216 lines
7.4 KiB
TypeScript
216 lines
7.4 KiB
TypeScript
import {
|
|
Button,
|
|
DropdownMenu,
|
|
DropdownMenuCheckboxItem,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuRadioGroup,
|
|
DropdownMenuRadioItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuShortcut,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubContent,
|
|
DropdownMenuSubTrigger,
|
|
DropdownMenuTrigger
|
|
} from "@ai-ui/ui";
|
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
|
|
async function waitForCondition(
|
|
predicate: () => boolean,
|
|
message: string,
|
|
timeoutMs = 1500
|
|
) {
|
|
const startedAt = Date.now();
|
|
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
if (predicate()) {
|
|
return;
|
|
}
|
|
|
|
await new Promise((resolve) => window.setTimeout(resolve, 16));
|
|
}
|
|
|
|
throw new Error(message);
|
|
}
|
|
|
|
type ReleaseMenuProps = {
|
|
triggerLabel?: string;
|
|
};
|
|
|
|
function ReleaseMenu({ triggerLabel = "Open menu" }: ReleaseMenuProps) {
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="secondary">{triggerLabel}</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<DropdownMenuLabel>Launch actions</DropdownMenuLabel>
|
|
<DropdownMenuItem>
|
|
Review summary
|
|
<DropdownMenuShortcut>R</DropdownMenuShortcut>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem>
|
|
Share preview
|
|
<DropdownMenuShortcut>S</DropdownMenuShortcut>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem disabled>
|
|
Retry checks
|
|
<DropdownMenuShortcut>⌘R</DropdownMenuShortcut>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuCheckboxItem checked>Notify stakeholders</DropdownMenuCheckboxItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuRadioGroup value="staged">
|
|
<DropdownMenuRadioItem value="staged">Staged rollout</DropdownMenuRadioItem>
|
|
<DropdownMenuRadioItem value="global">Global rollout</DropdownMenuRadioItem>
|
|
</DropdownMenuRadioGroup>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger inset>More actions</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent>
|
|
<DropdownMenuItem>Duplicate release</DropdownMenuItem>
|
|
<DropdownMenuItem variant="destructive">Archive release</DropdownMenuItem>
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
const meta = {
|
|
title: "Components/DropdownMenu",
|
|
component: DropdownMenu,
|
|
parameters: {
|
|
docs: {
|
|
description: {
|
|
component:
|
|
"DropdownMenu is the compact action surface for contextual commands, quick toggles, and short decision trees. It supports labels, separators, nested submenus, checkbox and radio items, destructive emphasis, and keyboard-first navigation without introducing a separate API style."
|
|
}
|
|
},
|
|
layout: "centered"
|
|
},
|
|
tags: ["autodocs"]
|
|
} satisfies Meta<typeof DropdownMenu>;
|
|
|
|
export default meta;
|
|
|
|
type Story = StoryObj<typeof meta>;
|
|
|
|
export const Playground: Story = {
|
|
render: () => <ReleaseMenu />,
|
|
play: async ({ canvasElement }) => {
|
|
const trigger = [...canvasElement.querySelectorAll("button")].find((element) =>
|
|
element.textContent?.includes("Open menu")
|
|
);
|
|
|
|
if (!(trigger instanceof HTMLButtonElement)) {
|
|
throw new Error("Expected the dropdown trigger to render.");
|
|
}
|
|
|
|
trigger.click();
|
|
|
|
await waitForCondition(
|
|
() => document.body.querySelector('[role="menu"]') instanceof HTMLElement,
|
|
"Expected the dropdown menu to open."
|
|
);
|
|
|
|
const item = [...document.body.querySelectorAll('[role="menuitem"]')].find((element) =>
|
|
element.textContent?.includes("Review summary")
|
|
);
|
|
|
|
if (!(item instanceof HTMLElement)) {
|
|
throw new Error("Expected the Review summary item to render.");
|
|
}
|
|
|
|
item.click();
|
|
|
|
await waitForCondition(
|
|
() => document.body.querySelector('[role="menu"]') === null,
|
|
"Expected the dropdown menu to close after selection."
|
|
);
|
|
}
|
|
};
|
|
|
|
export const States: Story = {
|
|
parameters: {
|
|
docs: {
|
|
description: {
|
|
story:
|
|
"Open the menu to inspect the checked checkbox item, the selected radio item, a disabled action, the inset submenu trigger, and the destructive nested action."
|
|
}
|
|
}
|
|
},
|
|
render: () => (
|
|
<div className="grid w-[680px] gap-3 sm:grid-cols-2">
|
|
<ReleaseMenu triggerLabel="Review lane menu" />
|
|
<ReleaseMenu triggerLabel="Launch action menu" />
|
|
</div>
|
|
)
|
|
};
|
|
|
|
export const Anatomy: Story = {
|
|
render: () => (
|
|
<div className="w-[720px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
|
|
<div className="space-y-3">
|
|
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
|
Dropdown menu anatomy
|
|
</p>
|
|
<ReleaseMenu triggerLabel="Inspect menu structure" />
|
|
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
<p>
|
|
<code className="text-[var(--color-foreground)]">data-slot="content"</code> frames
|
|
the floating panel and exposes sizing for denser menus.
|
|
</p>
|
|
<p>
|
|
<code className="text-[var(--color-foreground)]">data-slot="item"</code>,{" "}
|
|
<code className="text-[var(--color-foreground)]">data-slot="trigger"</code>, and{" "}
|
|
<code className="text-[var(--color-foreground)]">data-slot="shortcut"</code> map the
|
|
action rows, nested trigger, and keyboard hint.
|
|
</p>
|
|
<p>
|
|
<code className="text-[var(--color-foreground)]">data-slot="label"</code>,{" "}
|
|
<code className="text-[var(--color-foreground)]">data-slot="separator"</code>, and{" "}
|
|
<code className="text-[var(--color-foreground)]">data-slot="icon"</code> support
|
|
grouping, dividers, and selection markers.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
};
|
|
|
|
export const Accessibility: Story = {
|
|
parameters: {
|
|
docs: {
|
|
description: {
|
|
story:
|
|
"Dropdown menus are optimized for keyboard and pointer parity. Focus moves with arrow keys, typeahead remains available through Radix semantics, and destructive options should stay visually distinct from neutral commands."
|
|
}
|
|
}
|
|
},
|
|
render: () => (
|
|
<div className="grid w-[760px] gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
|
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
|
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)]">
|
|
Keyboard guidance
|
|
</h3>
|
|
<div className="mt-4 grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
<p>Use labels and separators to group commands into short scannable clusters.</p>
|
|
<p>
|
|
Keep checkbox and radio items in menus only when the state change is immediate
|
|
and local to the current context.
|
|
</p>
|
|
<p>
|
|
Prefer concise labels. Long explanatory copy belongs in a dialog or popover,
|
|
not in a menu row.
|
|
</p>
|
|
</div>
|
|
</article>
|
|
<div className="flex items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
|
|
<ReleaseMenu triggerLabel="Open keyboard-friendly menu" />
|
|
</div>
|
|
</div>
|
|
)
|
|
};
|