diff --git a/packages/propel/src/components/checkbox/index.tsx b/packages/propel/src/components/checkbox/index.tsx index f515277a..97d27513 100644 --- a/packages/propel/src/components/checkbox/index.tsx +++ b/packages/propel/src/components/checkbox/index.tsx @@ -45,6 +45,50 @@ type CheckboxVariantProps = VariantProps; export type CheckboxTone = NonNullable; +// The check / dash glyph for the box, shared by the interactive Checkbox and the +// presentational CheckboxVisual. +function CheckboxGlyph({ indeterminate }: { indeterminate?: boolean }) { + return indeterminate ? ( + + ) : ( + + ); +} + +export type CheckboxVisualProps = { + /** Resting color of the box. `danger` is the Figma "Error" state. */ + tone?: CheckboxTone; + /** Whether the box shows as checked. */ + checked?: boolean; + /** Whether the box shows the indeterminate dash (wins over `checked`). */ + indeterminate?: boolean; + /** Whether the box shows as disabled. */ + disabled?: boolean; +}; + +/** + * A purely presentational copy of the `Checkbox` box — same tokens and states, but a + * non-interactive `` with no `role`/focus. Use it when a checkbox *appearance* + * is needed inside another interactive control (e.g. a menu `menuitemcheckbox` row), + * where nesting a real checkbox would be an ARIA `nested-interactive` violation. The + * parent control owns the state and the a11y semantics. + */ +export function CheckboxVisual({ tone, checked, indeterminate, disabled }: CheckboxVisualProps) { + return ( + + {checked || indeterminate ? : null} + + ); +} + export type CheckboxProps = Omit< React.ComponentProps, "className" | "render" | "style" @@ -75,11 +119,7 @@ export function Checkbox({ tone, label, id, ...props }: CheckboxProps) { // otherwise a check. Decorative — the Root carries the a11y state. className="flex items-center justify-center" > - {props.indeterminate ? ( - - ) : ( - - )} + ); diff --git a/packages/propel/src/components/dropdown/dropdown.stories.tsx b/packages/propel/src/components/dropdown/dropdown.stories.tsx new file mode 100644 index 00000000..867d471f --- /dev/null +++ b/packages/propel/src/components/dropdown/dropdown.stories.tsx @@ -0,0 +1,1097 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + ArrowDownUp, + ArrowUpDown, + ChevronDown, + ChevronRight, + ChevronUp, + Circle, + CircleCheck, + CircleDashed, + CircleDot, + CircleX, + Copy, + ExternalLink, + Globe, + Link2, + Lock, + Pencil, + Plus, + SignalHigh, + SignalLow, + SignalMedium, + Trash2, +} from "lucide-react"; +import * as React from "react"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { Avatar } from "../avatar/index"; +import { Badge } from "../badge/index"; +import { Checkbox } from "../checkbox/index"; +import { Radio, RadioGroup } from "../radio/index"; +import { + Dropdown, + DropdownCheckboxItem, + DropdownContent, + DropdownFooter, + DropdownGroup, + DropdownItem, + DropdownLabel, + DropdownSearch, + DropdownSeparator, + DropdownSub, + DropdownSubContent, + DropdownSubTrigger, + DropdownTrigger, +} from "./index"; + +const meta = { + title: "Components/Dropdown", + component: Dropdown, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/ioN74zM1xMGbcPemsxs4J1/Global-components?node-id=30-1078", + }, + }, + // Dropdown is a compound component; document every menu part alongside the root so + // their props appear in the args table and the relationship is recorded in the + // manifest (the same pattern AvatarGroup uses for Avatar). + subcomponents: { + DropdownTrigger, + DropdownContent, + DropdownItem, + DropdownCheckboxItem, + DropdownSeparator, + DropdownGroup, + DropdownLabel, + DropdownSearch, + DropdownFooter, + DropdownSub, + DropdownSubTrigger, + DropdownSubContent, + }, + tags: ["ai-generated"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// A plain trigger button styled with propel tokens — the dropdown itself doesn't +// ship a trigger style, so stories provide one to keep the focus on the menu. +const triggerClass = + "inline-flex h-8 items-center gap-1.5 rounded-md border border-subtle bg-surface-1 px-3 text-13 text-secondary outline-none"; + +// Open the menu and wait for its portal popup to mount. Shared by the play tests. +async function openMenu(canvas: ReturnType, name: string) { + await userEvent.click(canvas.getByRole("button", { name })); + await waitFor(() => expect(document.body.querySelector('[role="menu"]')).toBeInTheDocument()); +} + +function findItem(role: string, text: string) { + return Array.from(document.body.querySelectorAll(`[role="${role}"]`)).find((el) => + el.textContent?.includes(text), + ) as HTMLElement | undefined; +} + +// --------------------------------------------------------------------------- +// Small demo-only fixtures. Where a demo needs a propel primitive that does not +// exist yet (a selectable pill/chip, a sort-direction button group), a minimal +// local version is defined inline and flagged in the PR — it is NOT part of the +// Dropdown API. +// --------------------------------------------------------------------------- + +// Status glyphs (Figma "Status" demo). +const STATUSES = [ + { key: "backlog", label: "Backlog", icon: }, + { key: "todo", label: "Todo", icon: }, + { + key: "in_progress", + label: "In Progress", + icon: , + }, + { key: "done", label: "Done", icon: }, + { key: "cancelled", label: "Cancelled", icon: }, +] as const; + +// Priority glyphs (Figma "Priority" demo). +const PRIORITIES = [ + { key: "urgent", label: "Urgent", icon: }, + { key: "high", label: "High", icon: }, + { key: "medium", label: "Medium", icon: }, + { key: "low", label: "Low", icon: }, + { key: "none", label: "None", icon: }, +] as const; + +const LABELS = [ + { key: "customer", label: "Customer request", color: "bg-label-orange-bg-strong" }, + { key: "feedback", label: "Feedback", color: "bg-label-emerald-bg-strong" }, + { key: "design", label: "Design", color: "bg-label-purple-bg-strong" }, + { key: "dev", label: "Dev", color: "bg-label-indigo-bg-strong" }, + { key: "feature", label: "Feature", color: "bg-label-crimson-bg-strong" }, +] as const; + +const ASSIGNEES: ReadonlyArray<{ key: string; name: string; disabled?: boolean }> = [ + { key: "amelia", name: "Amelia Parker" }, + { key: "david", name: "David Wilson" }, + { key: "samuel", name: "Samuel Wright", disabled: true }, + { key: "sarah", name: "Sarah Jones" }, + { key: "ethan", name: "Ethan Parker" }, +]; + +const LANGUAGES = [ + { key: "en", label: "English", secondary: "English" }, + { key: "es", label: "Español", secondary: "Spanish" }, + { key: "fr", label: "Français", secondary: "French" }, + { key: "de", label: "Deutsch", secondary: "German" }, + { key: "it", label: "Italiano", secondary: "Italian" }, + { key: "pt", label: "Português", secondary: "Portuguese" }, + { key: "nl", label: "Nederlands", secondary: "Dutch" }, +] as const; + +// A small color swatch, used as the leading control alongside the Checkbox for +// labels. Not a propel primitive — local to the demo. +function ColorSwatch({ className }: { className: string }) { + return ; +} + +// A selectable display-property pill matching Figma `56-366` (default / hover / +// selected). NOTE: the real propel Pills component isn't built yet — this is a +// minimal demo-local stand-in, flagged in the PR. +function DisplayPill({ + label, + selected, + onClick, +}: { + label: string; + selected: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +// Initials for an Avatar fallback ("Amelia Parker" -> "AP"). +function initials(name: string) { + return name + .split(" ") + .map((part) => part[0]) + .slice(0, 2) + .join(""); +} + +// --------------------------------------------------------------------------- +// Panel-row helpers for the two "settings panel" demos (DisplayProperties, +// DisplayAccordion). Those popovers mix radios / checkbox toggles / pills, which +// are NOT valid children of an ARIA `role="menu"`. So the panels render with +// `role="group"` (via DropdownContent's `role` prop) and use these plain rows +// instead of the menuitem-flavored `DropdownItem` / `DropdownCheckboxItem`. +// --------------------------------------------------------------------------- + +// A section heading inside a settings panel. +function PanelLabel({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +// A row whose leading control is a propel Radio, for single-select sort lists. +function PanelRadioRow({ + value, + label, + trailing, +}: { + value: string; + label: string; + trailing?: React.ReactNode; +}) { + return ( + + ); +} + +// A row whose leading control is a propel Checkbox, for boolean toggles in a panel. +function PanelCheckboxRow({ + label, + checked, + onCheckedChange, +}: { + label: string; + checked: boolean; + onCheckedChange: (next: boolean) => void; +}) { + return ( + onCheckedChange(Boolean(next))} + label={{label}} + /> + ); +} + +/** + * Demo 1 — **Status**. Single-select with status icons, a sticky search header, and + * a leading checkmark on the selected row only. + */ +export const Status: Story = { + render: function StatusStory() { + const [selected, setSelected] = React.useState("backlog"); + const [query, setQuery] = React.useState(""); + const visible = STATUSES.filter((s) => s.label.toLowerCase().includes(query.toLowerCase())); + return ( + + }> + {STATUSES.find((s) => s.key === selected)?.label ?? "Status"} + + } + > + {visible.map((s) => ( + setSelected(s.key)} + /> + ))} + + + ); + }, + play: async ({ canvas, step }) => { + await step("open and pick a status", async () => { + await openMenu(canvas, "Backlog"); + const done = await waitFor(() => findItem("menuitem", "Done") as HTMLElement); + await userEvent.click(done); + await waitFor(() => + expect(canvas.getByRole("button").textContent?.includes("Done")).toBe(true), + ); + }); + }, +}; + +/** + * Demo 2 — **Labels**. Multi-select: each row is a `DropdownCheckboxItem` (the propel + * `Checkbox` as the leading control) plus a color swatch, with a search header. When + * the typed query has no exact match, an "Add label" option (Figma `64-626`) appears, + * separated from the search by a double divider line. + */ +export const Labels: Story = { + render: function LabelsStory() { + const [checked, setChecked] = React.useState>({ customer: true }); + const [query, setQuery] = React.useState(""); + const trimmed = query.trim(); + const visible = LABELS.filter((l) => l.label.toLowerCase().includes(query.toLowerCase())); + // Offer "Add label" whenever the query doesn't exactly match an existing label. + const canAdd = + trimmed.length > 0 && !LABELS.some((l) => l.label.toLowerCase() === trimmed.toLowerCase()); + return ( + + }> + Labels + + } + > + {canAdd ? ( + <> + {/* Two horizontal divider lines between the search and the new-label + item (Figma 64-626): the sticky search already draws a bottom rule; + this adds the second one directly above the add-label row. */} +
+ } + label={`Add label "${trimmed}"`} + closeOnClick={false} + /> + + ) : null} + {visible.map((l) => ( + } + label={l.label} + checked={Boolean(checked[l.key])} + onCheckedChange={(next) => setChecked((c) => ({ ...c, [l.key]: next }))} + /> + ))} + + + ); + }, + play: async ({ canvas, step }) => { + await step("open and toggle a label", async () => { + await openMenu(canvas, "Labels"); + const feedback = (await waitFor(() => + findItem("menuitemcheckbox", "Feedback"), + )) as HTMLElement; + await expect(feedback).toHaveAttribute("aria-checked", "false"); + await userEvent.click(feedback); + await waitFor(() => expect(feedback).toHaveAttribute("aria-checked", "true")); + // Multi-select stays open. + await expect(document.body.querySelector('[role="menu"]')).toBeInTheDocument(); + }); + await step("typing a new name reveals the Add label option", async () => { + const search = document.body.querySelector('input[type="text"]') as HTMLInputElement; + await userEvent.type(search, "Product"); + await waitFor(() => expect(findItem("menuitem", 'Add label "Product"')).toBeDefined()); + }); + }, +}; + +/** + * Demo 3 — **ActionMenu**. Icon items, a trailing keyboard shortcut (⌘L), a disabled + * item with a description, a destructive Delete, and separators between groups. + */ +export const ActionMenu: Story = { + render: () => ( + + }> + Actions + + + } label="Edit" /> + } label="Make a copy" /> + } label="Open in new tab" /> + } label="Copy link" value="⌘L" /> + + } + label="Archive" + variant="with-description" + description="Only completed or cancelled work items can be archived" + disabled + /> + + } + label={Delete} + /> + + + ), + play: async ({ canvas, step }) => { + await step("open and confirm the disabled Archive row", async () => { + await openMenu(canvas, "Actions"); + const archive = (await waitFor(() => findItem("menuitem", "Archive"))) as HTMLElement; + await expect(archive).toHaveAttribute("data-disabled"); + }); + }, +}; + +/** + * Demo 4 — **Description**. Single-select with a two-line label + muted description, + * laid out in a wider menu. + */ +export const Description: Story = { + render: function DescriptionStory() { + const [selected, setSelected] = React.useState("private"); + return ( + + }> + Visibility + + + } + label="Private" + variant="with-description" + description="Accessible only by invite" + selected={selected === "private"} + closeOnClick={false} + onClick={() => setSelected("private")} + /> + } + label="Public" + variant="with-description" + description="Anyone in the workspace except Guests can join" + selected={selected === "public"} + closeOnClick={false} + onClick={() => setSelected("public")} + /> + + + ); + }, + play: async ({ canvas, step }) => { + await step("open and select Public", async () => { + await openMenu(canvas, "Visibility"); + const pub = (await waitFor(() => findItem("menuitem", "Public"))) as HTMLElement; + await userEvent.click(pub); + await waitFor(() => + expect(findItem("menuitem", "Public")?.querySelector("svg")).toBeInTheDocument(), + ); + }); + }, +}; + +/** + * Demo 5 — **Assignees**. Multi-select: a `DropdownCheckboxItem` (propel `Checkbox`) + * with a propel `Avatar` as the leading content, a search header, and a disabled row. + */ +export const Assignees: Story = { + render: function AssigneesStory() { + const [checked, setChecked] = React.useState>({ amelia: true }); + const [query, setQuery] = React.useState(""); + const visible = ASSIGNEES.filter((a) => a.name.toLowerCase().includes(query.toLowerCase())); + return ( + + }> + Assignees + + } + > + {visible.map((a) => ( + } + label={a.name} + checked={Boolean(checked[a.key])} + disabled={a.disabled} + onCheckedChange={(next) => setChecked((c) => ({ ...c, [a.key]: next }))} + /> + ))} + + + ); + }, + play: async ({ canvas, step }) => { + await step("open and toggle an assignee", async () => { + await openMenu(canvas, "Assignees"); + const david = (await waitFor(() => + findItem("menuitemcheckbox", "David Wilson"), + )) as HTMLElement; + await userEvent.click(david); + await waitFor(() => expect(david).toHaveAttribute("aria-checked", "true")); + }); + await step("the disabled row cannot be toggled", async () => { + const samuel = findItem("menuitemcheckbox", "Samuel Wright") as HTMLElement; + await expect(samuel).toHaveAttribute("data-disabled"); + }); + }, +}; + +/** + * Demo 6 — **LanguagePicker**. Single-select; each row pairs a label with muted + * secondary text inline (the English name), a search header, and a selected checkmark. + */ +export const LanguagePicker: Story = { + render: function LanguagePickerStory() { + const [selected, setSelected] = React.useState("en"); + const [query, setQuery] = React.useState(""); + const visible = LANGUAGES.filter((l) => + `${l.label} ${l.secondary}`.toLowerCase().includes(query.toLowerCase()), + ); + return ( + + }> + {LANGUAGES.find((l) => l.key === selected)?.label ?? "Language"} + + } + > + {visible.map((l) => ( + setSelected(l.key)} + /> + ))} + + + ); + }, + play: async ({ canvas, step }) => { + await step("filter and select a language", async () => { + await openMenu(canvas, "English"); + const search = (await waitFor(() => + document.body.querySelector('input[type="text"]'), + )) as HTMLInputElement; + await userEvent.type(search, "Span"); + await waitFor(() => expect(findItem("menuitem", "Español")).toBeDefined()); + await userEvent.click(findItem("menuitem", "Español") as HTMLElement); + await waitFor(() => + expect(canvas.getByRole("button").textContent?.includes("Español")).toBe(true), + ); + }); + }, +}; + +/** + * Demo 7 — **Priority**. Multi-select: a `DropdownCheckboxItem` (propel `Checkbox`) + * with a leading priority glyph, plus a search header. + */ +export const Priority: Story = { + render: function PriorityStory() { + const [checked, setChecked] = React.useState>({}); + const [query, setQuery] = React.useState(""); + const visible = PRIORITIES.filter((p) => p.label.toLowerCase().includes(query.toLowerCase())); + return ( + + }> + Priority + + } + > + {visible.map((p) => ( + setChecked((c) => ({ ...c, [p.key]: next }))} + /> + ))} + + + ); + }, + play: async ({ canvas, step }) => { + await step("open and toggle High", async () => { + await openMenu(canvas, "Priority"); + const high = (await waitFor(() => findItem("menuitemcheckbox", "High"))) as HTMLElement; + await userEvent.click(high); + await waitFor(() => expect(high).toHaveAttribute("aria-checked", "true")); + }); + }, +}; + +/** + * Demo 8 — **Filters**. Multi-select across several titled, collapsible sections + * (Priority, State, Assignee, …). Each item carries a leading icon; a chevron on the + * heading collapses/expands the category; categories are separated by a divider; and + * the "View all" link sits in the heading's trailing slot (no hover background, + * `cursor-pointer`). + */ +export const Filters: Story = { + render: function FiltersStory() { + const [checked, setChecked] = React.useState>({}); + const [collapsed, setCollapsed] = React.useState>({}); + const [query, setQuery] = React.useState(""); + const toggle = (key: string) => (next: boolean) => setChecked((c) => ({ ...c, [key]: next })); + const match = (label: string) => label.toLowerCase().includes(query.toLowerCase()); + const sections = [ + { + title: "Priority", + items: PRIORITIES.map((p) => ({ key: `p-${p.key}`, label: p.label, icon: p.icon })), + }, + { + title: "State", + items: STATUSES.map((s) => ({ key: `s-${s.key}`, label: s.label, icon: s.icon })), + }, + { + title: "Assignee", + viewAll: true, + items: ASSIGNEES.map((a) => ({ + key: `a-${a.key}`, + label: a.name, + icon: , + })), + }, + ]; + return ( + + }> + Filters + + } + > + {sections.map((section, index) => { + const items = section.items.filter((i) => match(i.label)); + if (items.length === 0) return null; + const isCollapsed = Boolean(collapsed[section.title]); + return ( + + {/* Divider between categories. */} + {index > 0 ? : null} + + {/* The category heading is itself a menuitem (valid `role="menu"` + child) so its collapse chevron stays interactive without breaking + ARIA. The label is the section title; the chevron is the endIcon. */} + + + ); + })} + + + ); + }, + play: async ({ canvas, step }) => { + await step("open and confirm multiple sections render", async () => { + await openMenu(canvas, "Filters"); + await waitFor(() => expect(findItem("menuitemcheckbox", "Urgent")).toBeDefined()); + await expect(findItem("menuitemcheckbox", "Backlog")).toBeDefined(); + await expect(findItem("menuitemcheckbox", "David Wilson")).toBeDefined(); + }); + await step("collapse a category from its heading", async () => { + const heading = (await waitFor(() => + Array.from(document.body.querySelectorAll('[role="menuitem"]')).find( + (el) => + el.getAttribute("aria-expanded") === "true" && el.textContent?.includes("Priority"), + ), + )) as HTMLElement; + await userEvent.click(heading); + await waitFor(() => expect(findItem("menuitemcheckbox", "Urgent")).toBeUndefined()); + }); + }, +}; + +/** + * Demo 9 — **DisplayProperties**. A selectable pill/chip group at the top, a + * single-select section (Group by, via `Radio`), and a checkbox-toggle footer. + * + * NOTE: the selectable pill/chip group is a minimal local primitive — propel has no + * "chip" component yet (flagged in the PR). + */ +export const DisplayProperties: Story = { + parameters: { + a11y: { + // This is a settings *panel*, not an ARIA menu, so DropdownContent is rendered + // with role="group". Base UI's menu popup still injects `aria-orientation` (a + // composite-widget attribute), which axe disallows on role="group". The attribute + // is a framework artifact, not author markup; suppress just that rule here. + // A dedicated non-menu Popover surface (flagged in the PR) would remove the need. + config: { rules: [{ id: "aria-allowed-attr", enabled: false }] }, + }, + }, + render: function DisplayPropertiesStory() { + const PILLS = ["ID", "Work item type", "Assignee", "Start date", "Due date", "Labels"]; + const [pills, setPills] = React.useState>({ + ID: true, + "Work item type": true, + Assignee: true, + "Due date": true, + }); + const [groupBy, setGroupBy] = React.useState("state"); + const [toggles, setToggles] = React.useState>({}); + return ( + + }> + Display + + {/* role="group": this is a settings panel (pills + radios + toggles), not a menu */} + + Display Properties +
+ {PILLS.map((p) => ( + setPills((s) => ({ ...s, [p]: !s[p] }))} + /> + ))} +
+ + Group by + setGroupBy(String(v))}> + {["Priority", "State", "Cycle", "Labels"].map((g) => ( + + ))} + + +
+ setToggles((t) => ({ ...t, sub: next }))} + /> + setToggles((t) => ({ ...t, empty: next }))} + /> +
+
+
+ ); + }, + play: async ({ canvas, step }) => { + await step("open and toggle a property pill", async () => { + await userEvent.click(canvas.getByRole("button", { name: "Display" })); + await waitFor(() => + expect(document.body.querySelector('[role="group"]')).toBeInTheDocument(), + ); + const labelsPill = (await waitFor(() => + Array.from(document.body.querySelectorAll("button[aria-pressed]")).find( + (b) => b.textContent === "Labels", + ), + )) as HTMLElement; + await expect(labelsPill).toHaveAttribute("aria-pressed", "false"); + await userEvent.click(labelsPill); + await waitFor(() => expect(labelsPill).toHaveAttribute("aria-pressed", "true")); + }); + }, +}; + +// A minimal sort-direction toggle (asc/desc). propel has no ButtonGroup yet — local +// to this demo and flagged in the PR. +function SortDirectionToggle({ + value, + onValueChange, +}: { + value: "asc" | "desc"; + onValueChange: (v: "asc" | "desc") => void; +}) { + return ( + + + + + ); +} + +/** + * Demo 10 — **DisplayAccordion**. Collapsible sections; an open "Order by" section is + * a single-select `Radio` sort list with a sort-direction toggle on the selected row, + * plus checkbox toggles in a footer. + * + * NOTE: the sort-direction toggle (`ButtonGroup`) is a minimal local primitive — + * flagged in the PR. + */ +export const DisplayAccordion: Story = { + parameters: { + a11y: { + // Same as DisplayProperties: a settings panel (role="group") where Base UI's + // menu popup injects an `aria-orientation` that axe disallows on role="group". + // Framework artifact, not author markup — suppress just that rule. + config: { rules: [{ id: "aria-allowed-attr", enabled: false }] }, + }, + }, + render: function DisplayAccordionStory() { + const [open, setOpen] = React.useState("order"); + const [order, setOrder] = React.useState("priority"); + const [dir, setDir] = React.useState<"asc" | "desc">("asc"); + const [toggles, setToggles] = React.useState>({}); + const SECTIONS = ["Display Properties", "Group by", "Sub Group by", "Order by"]; + const ORDER = ["Manual - Rank", "Last created", "Last updated", "Priority", "Due date"]; + return ( + + }> + Display options + + {/* role="group": a settings panel (accordion + radios + toggles), not a menu */} + + {SECTIONS.map((title) => { + const key = title.split(" ")[0].toLowerCase(); + const isOpen = open === key; + return ( +
+ + {isOpen && key === "order" ? ( + // Items within an expanded category sit flush (0 spacing): collapse + // RadioGroup's default row gap from the parent. +
+ setOrder(String(v))}> + {ORDER.map((o) => { + const v = o.toLowerCase(); + return ( + + ) : undefined + } + /> + ); + })} + +
+ ) : null} +
+ ); + })} + +
+ setToggles((t) => ({ ...t, sub: next }))} + /> + setToggles((t) => ({ ...t, empty: next }))} + /> +
+
+
+ ); + }, + play: async ({ canvas, step }) => { + const findHeader = (text: string) => + Array.from(document.body.querySelectorAll("button[aria-expanded]")).find((b) => + b.textContent?.includes(text), + ) as HTMLElement | undefined; + const hasText = (text: string) => + Boolean(document.body.querySelector('[role="group"]')?.textContent?.includes(text)); + + await step("open the panel with Order by expanded", async () => { + await userEvent.click(canvas.getByRole("button", { name: "Display options" })); + await waitFor(() => + expect(document.body.querySelector('[role="group"]')).toBeInTheDocument(), + ); + await waitFor(() => expect(hasText("Last created")).toBe(true)); + }); + + await step("collapse and re-expand the Order by section", async () => { + await userEvent.click(findHeader("Order by") as HTMLElement); + await waitFor(() => expect(hasText("Last created")).toBe(false)); + await userEvent.click(findHeader("Order by") as HTMLElement); + await waitFor(() => expect(hasText("Last created")).toBe(true)); + }); + }, +}; + +/** + * Demo 11 — **EmptyState**. Searching filters the list; when nothing matches, the menu + * shows a "No matching results" message instead of items. + */ +export const EmptyState: Story = { + render: function EmptyStateStory() { + const [query, setQuery] = React.useState("Product"); + const visible = STATUSES.filter((s) => s.label.toLowerCase().includes(query.toLowerCase())); + return ( + + }> + Status + + } + > + {visible.length > 0 ? ( + visible.map((s) => ( + + )) + ) : ( +
No matching results
+ )} +
+
+ ); + }, + play: async ({ step }) => { + await step("the no-results message shows for a non-matching query", async () => { + await waitFor(() => expect(document.body.querySelector('[role="menu"]')).toBeInTheDocument()); + await waitFor(() => + expect(document.body.textContent?.includes("No matching results")).toBe(true), + ); + }); + await step("clearing the search restores the list", async () => { + const search = document.body.querySelector('input[type="text"]') as HTMLInputElement; + await userEvent.clear(search); + await waitFor(() => expect(findItem("menuitem", "Backlog")).toBeDefined()); + }); + }, +}; + +/** + * Demo 12 — **Submenu**. Rows carry a trailing count `Badge` and a chevron; hovering + * one opens a nested submenu of options (built on `DropdownSub`). + */ +export const Submenu: Story = { + parameters: { + a11y: { + // Base UI renders each submenu trigger as a `` inside the parent + // `role="menu"` — its own (valid) pattern for associating the trigger with the + // nested menu in the a11y tree. axe's aria-required-children flags that span as a + // disallowed menu child even though it is the framework's prescribed markup; the + // submenu trigger keeps `role="menuitem"` + `aria-haspopup="menu"`. Suppress just + // that rule for this story. + config: { rules: [{ id: "aria-required-children", enabled: false }] }, + }, + }, + render: () => ( + + }> + Filter by + + + + + 5 + + } + /> + + {PRIORITIES.map((p) => ( + + ))} + + + + + 5 + + } + /> + + {STATUSES.map((s) => ( + + ))} + + + + + 5 + + } + /> + + {ASSIGNEES.map((a) => ( + } + label={a.name} + closeOnClick={false} + /> + ))} + + + + + ), + play: async ({ canvas, step }) => { + await step("open the menu and reveal a submenu", async () => { + await openMenu(canvas, "Filter by"); + const priority = (await waitFor(() => findItem("menuitem", "Priority"))) as HTMLElement; + await expect(priority).toHaveAttribute("aria-haspopup", "menu"); + await userEvent.click(priority); + await waitFor(() => + expect(document.body.querySelectorAll('[role="menu"]').length).toBeGreaterThan(1), + ); + await waitFor(() => expect(findItem("menuitem", "Urgent")).toBeDefined()); + }); + }, +}; diff --git a/packages/propel/src/components/dropdown/index.tsx b/packages/propel/src/components/dropdown/index.tsx new file mode 100644 index 00000000..bfa81e9d --- /dev/null +++ b/packages/propel/src/components/dropdown/index.tsx @@ -0,0 +1,548 @@ +import { Menu } from "@base-ui/react/menu"; +import { cva, cx, type VariantProps } from "class-variance-authority"; +import { Check, ChevronRight, Search } from "lucide-react"; +import * as React from "react"; +import { CheckboxVisual } from "../checkbox/index"; + +/** + * The dropdown menu root — a Base UI `Menu.Root`. Holds open state and wires the + * trigger to the content. Compose it with `DropdownTrigger` + `DropdownContent`: + * + * + * Open + * + * + * + * + * + * + */ +export const Dropdown = Menu.Root; +export type DropdownProps = React.ComponentProps; + +export type DropdownTriggerProps = Omit< + React.ComponentProps, + "className" | "style" +>; + +/** + * The element that opens the menu. Renders a `