From 52a9e536fb65254b64417302a6af0f5b445156b3 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Mon, 8 Jun 2026 21:02:13 +0700 Subject: [PATCH 1/8] Add Dropdown (menu) component Compound menu built on @base-ui/react/menu: Dropdown, DropdownTrigger, DropdownContent (Portal+Positioner+Popup), DropdownItem, DropdownCheckboxItem, DropdownGroup, DropdownLabel, DropdownSeparator. Item variant axis default | with-description | with-value (Figma 27-1057); selected/disabled are primitive state. propel tokens via cva+cx, no className/style props. --- .../components/dropdown/dropdown.stories.tsx | 153 ++++++++++ .../propel/src/components/dropdown/index.tsx | 262 ++++++++++++++++++ 2 files changed, 415 insertions(+) create mode 100644 packages/propel/src/components/dropdown/dropdown.stories.tsx create mode 100644 packages/propel/src/components/dropdown/index.tsx 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..f01623ec --- /dev/null +++ b/packages/propel/src/components/dropdown/dropdown.stories.tsx @@ -0,0 +1,153 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Copy, Pencil, Settings, Trash2, UserPlus } from "lucide-react"; +import * as React from "react"; +import { expect, userEvent, waitFor } from "storybook/test"; +import { + Dropdown, + DropdownCheckboxItem, + DropdownContent, + DropdownGroup, + DropdownItem, + DropdownLabel, + DropdownSeparator, + DropdownTrigger, +} from "./index"; + +const meta = { + title: "Components/Dropdown", + component: Dropdown, + // Dropdown is a compound component; document the menu parts 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, + DropdownLabel, + }, + 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 rounded-md border border-subtle bg-surface-1 px-3 text-13 text-secondary outline-none"; + +/** + * A trigger plus a menu mixing every item shape: a plain item, items with leading + * icons, an item with a trailing value, a `with-description` item, a separator, and + * a disabled item. + */ +export const Default: Story = { + render: () => ( + + }> + Actions + + + } label="Edit" /> + } label="Duplicate" variant="with-value" value="⌘D" /> + } + label="Invite members" + variant="with-description" + description="Add people to this project" + /> + + } label="Settings" disabled /> + } label="Delete" /> + + + ), + play: async ({ canvas, step }) => { + await step("opening the trigger reveals the menu", async () => { + await userEvent.click(canvas.getByRole("button", { name: "Actions" })); + // The popup renders in a portal, so query the document body and wait for it. + const menu = await waitFor(() => document.body.querySelector('[role="menu"]')); + await expect(menu).toBeInTheDocument(); + }); + + await step("all items are present", async () => { + await waitFor(() => + expect(document.body.querySelectorAll('[role="menuitem"]')).toHaveLength(5), + ); + const edit = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find((el) => + el.textContent?.includes("Edit"), + ); + await expect(edit).toBeDefined(); + }); + + await step("selecting an item closes the menu", async () => { + const edit = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find((el) => + el.textContent?.includes("Edit"), + ) as HTMLElement; + await userEvent.click(edit); + await waitFor(() => expect(document.body.querySelector('[role="menu"]')).toBeNull()); + }); + }, +}; + +/** + * A multi-select menu built from `DropdownCheckboxItem`s. Each row toggles its own + * checked state and the menu stays open so several can be picked in one pass. + */ +export const WithCheckboxes: Story = { + render: function WithCheckboxesStory() { + const [filters, setFilters] = React.useState({ + assigned: true, + created: false, + subscribed: false, + }); + return ( + + }> + Filters + + + + Show items + setFilters((f) => ({ ...f, assigned: checked }))} + /> + setFilters((f) => ({ ...f, created: checked }))} + /> + setFilters((f) => ({ ...f, subscribed: checked }))} + /> + + + + ); + }, + play: async ({ canvas, step }) => { + await step("open the menu", async () => { + await userEvent.click(canvas.getByRole("button", { name: "Filters" })); + await waitFor(() => expect(document.body.querySelector('[role="menu"]')).toBeInTheDocument()); + }); + + await step("toggling a checkbox item keeps the menu open", async () => { + const created = (await waitFor(() => + Array.from(document.body.querySelectorAll('[role="menuitemcheckbox"]')).find((el) => + el.textContent?.includes("Created by me"), + ), + )) as HTMLElement; + await expect(created).toHaveAttribute("aria-checked", "false"); + await userEvent.click(created); + await waitFor(() => expect(created).toHaveAttribute("aria-checked", "true")); + // Still open after a checkbox toggle — multi-select stays put. + await expect(document.body.querySelector('[role="menu"]')).toBeInTheDocument(); + }); + }, +}; diff --git a/packages/propel/src/components/dropdown/index.tsx b/packages/propel/src/components/dropdown/index.tsx new file mode 100644 index 00000000..32f52baf --- /dev/null +++ b/packages/propel/src/components/dropdown/index.tsx @@ -0,0 +1,262 @@ +import { Menu } from "@base-ui/react/menu"; +import { cva, cx, type VariantProps } from "class-variance-authority"; +import { Check } from "lucide-react"; +import * as React from "react"; + +/** + * 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 ` + ); + })} + + + Group by + setGroupBy(String(v))}> + {["Priority", "State", "Cycle", "Labels"].map((g) => ( + + ))} + + +
+ setToggles((t) => ({ ...t, sub: next }))} /> - setFilters((f) => ({ ...f, created: checked }))} + setToggles((t) => ({ ...t, empty: next }))} /> - setFilters((f) => ({ ...f, subscribed: checked }))} +
+ + + ); + }, + 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" ? ( + 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 }) => { - await step("open the menu", async () => { - await userEvent.click(canvas.getByRole("button", { name: "Filters" })); + 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()); }); + }, +}; - await step("toggling a checkbox item keeps the menu open", async () => { - const created = (await waitFor(() => - Array.from(document.body.querySelectorAll('[role="menuitemcheckbox"]')).find((el) => - el.textContent?.includes("Created by me"), - ), - )) as HTMLElement; - await expect(created).toHaveAttribute("aria-checked", "false"); - await userEvent.click(created); - await waitFor(() => expect(created).toHaveAttribute("aria-checked", "true")); - // Still open after a checkbox toggle — multi-select stays put. - await expect(document.body.querySelector('[role="menu"]')).toBeInTheDocument(); +/** + * 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 index f4d3cf51..63f363b9 100644 --- a/packages/propel/src/components/dropdown/index.tsx +++ b/packages/propel/src/components/dropdown/index.tsx @@ -1,7 +1,8 @@ import { Menu } from "@base-ui/react/menu"; import { cva, cx, type VariantProps } from "class-variance-authority"; -import { Check } from "lucide-react"; +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 @@ -32,20 +33,36 @@ export function DropdownTrigger(props: DropdownTriggerProps) { return ; } -// The floating surface: Base UI `Popup` on white `surface-1` with the overlay -// shadow, a subtle hairline border, and a small radius — the Figma menu container. -// `origin-(--transform-origin)` + the open/closed data attributes drive a quick -// scale/fade so the menu grows from the side it's anchored to. -const dropdownContentVariants = cva( +// The visible floating surface (`Positioner` child): white `surface-1`, overlay +// shadow, hairline border, small radius, and the scale/fade transition. The sticky +// search/footer and the scrollable `role="menu"` list (`Menu.Popup`) all live inside +// it, so non-menuitem chrome stays outside the menu role and the surface is +// ARIA-valid. +const dropdownSurfaceVariants = cva( cx( - "min-w-(--anchor-width) max-h-(--available-height) overflow-y-auto", - "rounded-md border border-subtle bg-surface-1 p-1 shadow-overlay-200 outline-none", + "flex max-h-(--available-height) flex-col overflow-hidden", + "rounded-md border border-subtle bg-surface-1 shadow-overlay-200 outline-none", "origin-(--transform-origin) transition-[transform,opacity]", "data-[starting-style]:scale-95 data-[starting-style]:opacity-0", "data-[ending-style]:scale-95 data-[ending-style]:opacity-0", ), + { + variants: { + // Fixed menu widths from the Figma demos: `anchor` matches the trigger + // (`default`), the rest are the popup widths the design uses for picker menus. + width: { + anchor: "min-w-(--anchor-width)", + sm: "w-64", // 256px — the standard picker menu (Status / Labels / …) + md: "w-72", // 288px — display-options menus + lg: "w-96", // 384px — the wide two-line "Description" menu + }, + }, + defaultVariants: { width: "anchor" }, + }, ); +type DropdownContentWidth = NonNullable["width"]>; + export type DropdownContentProps = Omit< React.ComponentProps, "className" | "style" @@ -56,27 +73,51 @@ export type DropdownContentProps = Omit< sideOffset?: React.ComponentProps["sideOffset"]; /** Alignment of the menu relative to the trigger along `side`. @default "start" */ align?: React.ComponentProps["align"]; + /** + * Fixed menu width. `anchor` matches the trigger; `sm`/`md`/`lg` are the picker + * widths from Figma (256 / 288 / 384px). @default "anchor" + */ + width?: DropdownContentWidth; + /** + * A sticky `DropdownSearch` pinned above the list, *outside* the `role="menu"` + * element so the menu only contains menu items (ARIA `aria-required-children`). + */ + search?: React.ReactNode; + /** + * A sticky `DropdownFooter` pinned below the list, *outside* the `role="menu"` + * element (same ARIA reasoning as `search`). + */ + footer?: React.ReactNode; }; /** - * The menu surface. Bundles Base UI's `Portal` + `Positioner` + `Popup` so the - * menu renders in a portal, is positioned against the trigger, and carries propel's - * popover styling. Place `DropdownItem`/`DropdownCheckboxItem`/`DropdownSeparator`/ - * `DropdownLabel` as children. + * The menu surface. Bundles Base UI's `Portal` + `Positioner` + `Popup` so the menu + * renders in a portal, positioned against the trigger, with propel's popover styling. + * Place `DropdownItem`/`DropdownCheckboxItem`/`DropdownSeparator`/`DropdownGroup` as + * children — they become the `role="menu"` list. Non-menuitem chrome (a search input, + * a footer note) must go through the `search`/`footer` props so it sits outside the + * menu role and keeps the surface ARIA-valid. */ export function DropdownContent({ children, side = "bottom", sideOffset = 4, align = "start", + width, + search, + footer, ...props }: DropdownContentProps) { return ( - - {children} - +
+ {search} + + {children} + + {footer} +
); @@ -109,7 +150,7 @@ type DropdownItemVariant = NonNullable export type DropdownItemProps = Omit< React.ComponentProps, - "className" | "style" + "className" | "style" | "label" > & { /** * Row layout. `default` is a single line; `with-description` stacks a muted @@ -120,47 +161,87 @@ export type DropdownItemProps = Omit< variant?: DropdownItemVariant; /** Leading content, typically an icon. Rendered before the label. */ icon?: React.ReactNode; + /** + * Leading control rendered *before* the icon at full size (no icon box) — use + * for a composed propel control such as a `Checkbox`, `Radio`, `Avatar`, or a + * color swatch. Single-select rows should use `selected` instead. + */ + leading?: React.ReactNode; /** The primary text of the row. */ label?: React.ReactNode; /** Muted secondary line under the label (use with `variant="with-description"`). */ description?: React.ReactNode; + /** + * Muted text shown inline after the label (e.g. a language's English name). Sits + * between the label and any trailing value, on the same line. + */ + secondaryText?: React.ReactNode; /** Trailing value text (use with `variant="with-value"`). */ value?: React.ReactNode; + /** + * Trailing content after the value slot (e.g. a `Badge` count, a chevron, or a + * keyboard shortcut). Use instead of (or alongside) `value` for rich content. + */ + trailing?: React.ReactNode; /** Trailing content after the value slot, typically an icon or shortcut. */ endIcon?: React.ReactNode; + /** + * Single-select selected state: renders a leading checkmark in the icon column on + * the selected row only (no per-row control on the others). Distinct from the + * multi-select `DropdownCheckboxItem`, which shows a `Checkbox` on every row. + */ + selected?: boolean; }; /** * A selectable menu row. Closes the menu when clicked (Base UI default). An item is - * an optional leading icon + label (+ optional description / trailing value / end - * icon) — all of it content, laid out by `variant`. + * an optional leading control/icon + label (+ optional description / inline + * secondary text / trailing value / badge / end icon) — all of it content, laid out + * by `variant`. Pass `selected` for the single-select leading-checkmark pattern. */ export function DropdownItem({ variant = "default", icon, + leading, label, description, + secondaryText, value, + trailing, endIcon, + selected, children, ...props }: DropdownItemProps) { return ( - {icon ? ( + {leading != null ? {leading} : null} + {selected != null || icon ? ( - {icon} + {selected ? ( + ) : null} - {label ?? children} + + {label ?? children} + {secondaryText != null ? ( + + {secondaryText} + + ) : null} + {description != null ? ( {description} ) : null} - {value != null ? {value} : null} + {value != null ? {value} : null} + {trailing != null ? {trailing} : null} {endIcon ? ( {endIcon} @@ -172,9 +253,13 @@ export function DropdownItem({ export type DropdownCheckboxItemProps = Omit< React.ComponentProps, - "className" | "style" + "className" | "style" | "label" > & { - /** Leading content shown after the checkbox, typically an icon. */ + /** + * Leading content shown after the checkbox — typically an icon, a color swatch, + * an `Avatar`, or a priority glyph. Use it to compose the propel components a + * multi-select demo calls for (Avatar for assignees, swatch for labels, etc.). + */ icon?: React.ReactNode; /** The primary text of the row. */ label?: React.ReactNode; @@ -183,17 +268,30 @@ export type DropdownCheckboxItemProps = Omit< }; /** - * A toggleable menu row with a leading checkbox indicator. Use for multi-select - * menus; it stays open on click (Base UI's checkbox-item default). Control it with - * `checked` + `onCheckedChange`, or leave it uncontrolled with `defaultChecked`. + * A toggleable multi-select menu row. The leading control is the propel `Checkbox` + * component (driven by this row's Base UI checkbox-item state, so the box reflects + * `checked`/`indeterminate`/`disabled` without owning its own state). The row keeps + * the `role="menuitemcheckbox"` a11y semantics and stays open on click. Control it + * with `checked` + `onCheckedChange`, or leave it uncontrolled with `defaultChecked`. */ export function DropdownCheckboxItem({ icon, label, value, + checked, + defaultChecked, + onCheckedChange, children, ...props }: DropdownCheckboxItemProps) { + // Mirror the row's checked state so the visual propel Checkbox stays in sync for + // both controlled (`checked`) and uncontrolled (`defaultChecked`) usage. When + // controlled, the prop is the source of truth; when uncontrolled, we track it + // locally and forward changes through `onCheckedChange`. + const isControlled = checked !== undefined; + const [internalChecked, setInternalChecked] = React.useState(defaultChecked ?? false); + const isChecked = isControlled ? checked : internalChecked; + return ( { + if (!isControlled) setInternalChecked(next); + onCheckedChange?.(next, details); + }} {...props} > - - - + {/* + Compose the propel Checkbox *appearance* via its presentational sibling + `CheckboxVisual` (a non-interactive ``), so the row keeps a single + interactive control (`role="menuitemcheckbox"`) and stays ARIA-valid — a real + Checkbox here would be a `nested-interactive` violation. The row owns the + toggle; the box just mirrors `isChecked`. + */} + + {icon ? ( @@ -215,7 +324,7 @@ export function DropdownCheckboxItem({ ) : null} {label ?? children} - {value != null ? {value} : null} + {value != null ? {value} : null} ); } @@ -246,17 +355,175 @@ export function DropdownGroup(props: DropdownGroupProps) { export type DropdownLabelProps = Omit< React.ComponentProps, "className" | "style" ->; +> & { + /** + * Optional trailing slot on the heading row — e.g. a "View all" link or a count. + * Sits at the end of the label line (the Figma "Dropdown header" trailing slot). + */ + action?: React.ReactNode; + children?: React.ReactNode; +}; /** - * A non-interactive heading for a group of items. Must be rendered inside a - * `DropdownGroup`, as the first child, to label the items that follow it. + * A non-interactive section heading for a group of items (the Figma "Dropdown + * header": `text/12`, `text/tertiary`, title-case). Must be rendered inside a + * `DropdownGroup`, as the first child, to label the items that follow it. Pass + * `action` for a trailing "View all" link or count. */ -export function DropdownLabel(props: DropdownLabelProps) { +export function DropdownLabel({ action, children, ...props }: DropdownLabelProps) { return ( + {children} + {action != null ? {action} : null} + + ); +} + +// A sticky search header for filterable menus — the Figma "Search-Dropdown" row. +// Pinned to the top of the popup (so it stays put while the list scrolls) on the +// surface-1 background with a hairline bottom border. Owns no state; pass `value` +// + `onValueChange` to filter the list in the parent story/component. +export type DropdownSearchProps = Omit< + React.ComponentProps<"input">, + "className" | "style" | "onChange" | "value" | "type" +> & { + /** Current search text. */ + value?: string; + /** Called with the new text on each keystroke. */ + onValueChange?: (value: string) => void; + /** Placeholder text. @default "Search" */ + placeholder?: string; +}; + +/** + * A sticky search input pinned to the top of a `DropdownContent`. Use it as the + * first child of the content for filterable menus (Status, Labels, Assignees, …). + * It keeps focus inside the menu and does not steal Base UI's typeahead because it + * is a real text input. Drive filtering with `value` + `onValueChange`. + */ +export function DropdownSearch({ + value, + onValueChange, + placeholder = "Search", + ...props +}: DropdownSearchProps) { + return ( +
+
+ ); +} + +export type DropdownFooterProps = Omit, "className" | "style">; + +/** + * A non-interactive footer pinned to the bottom of a `DropdownContent` — e.g. + * "Type to add a new label." Render it as the last child of the content. + */ +export function DropdownFooter(props: DropdownFooterProps) { + return ( +
); } + +/** + * A submenu root. Wrap a `DropdownSubTrigger` + `DropdownSubContent` in it to nest + * a second menu that opens from a row. Built on Base UI `Menu.SubmenuRoot`. + */ +export const DropdownSub = Menu.SubmenuRoot; +export type DropdownSubProps = React.ComponentProps; + +export type DropdownSubTriggerProps = Omit< + React.ComponentProps, + "className" | "style" | "label" +> & { + /** Leading content, typically an icon. */ + icon?: React.ReactNode; + /** The primary text of the row. */ + label?: React.ReactNode; + /** Trailing content before the chevron — e.g. a `Badge` count. */ + trailing?: React.ReactNode; +}; + +/** + * The row that opens a submenu. Looks like a `DropdownItem` with a trailing chevron; + * pass `trailing` for a count `Badge` before the chevron. Must be rendered inside a + * `DropdownSub`, paired with a `DropdownSubContent`. + */ +export function DropdownSubTrigger({ + icon, + label, + trailing, + children, + ...props +}: DropdownSubTriggerProps) { + return ( + + {icon ? ( + + {icon} + + ) : null} + {label ?? children} + {trailing != null ? {trailing} : null} + + ); +} + +/** + * The floating surface for a submenu. Same styling as `DropdownContent` but defaults + * to opening to the right (`side="right"`). Place submenu items inside it. + */ +export function DropdownSubContent({ + children, + side = "right", + sideOffset = 4, + align = "start", + width, + search, + footer, + ...props +}: DropdownContentProps) { + return ( + + +
+ {search} + + {children} + + {footer} +
+
+
+ ); +} From 65812692c4affc1cab82c11c08c94634e8e70c65 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 9 Jun 2026 16:21:48 +0700 Subject: [PATCH 4/8] Dropdown: remove cva defaultVariants; make item variant required Drop defaultVariants from both cva blocks in dropdown/index.tsx. The essential item layout axis (variant: default/with-description/with-value) is now a required prop; the additive surface width axis stays optional with no default. Update all 12 demo stories to pass variant explicitly. --- .../components/dropdown/dropdown.stories.tsx | 33 +++++++++++++++---- .../propel/src/components/dropdown/index.tsx | 11 +++---- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/propel/src/components/dropdown/dropdown.stories.tsx b/packages/propel/src/components/dropdown/dropdown.stories.tsx index 3461f561..5a9635ef 100644 --- a/packages/propel/src/components/dropdown/dropdown.stories.tsx +++ b/packages/propel/src/components/dropdown/dropdown.stories.tsx @@ -227,6 +227,7 @@ export const Status: Story = { {visible.map((s) => ( - } label="Edit" /> - } label="Make a copy" /> - } label="Open in new tab" /> - } label="Copy link" value="⌘L" /> + } label="Edit" /> + } label="Make a copy" /> + } label="Open in new tab" /> + } label="Copy link" value="⌘L" /> } @@ -323,6 +324,7 @@ export const ActionMenu: Story = { /> } label={Delete} /> @@ -456,6 +458,7 @@ export const LanguagePicker: Story = { {visible.map((l) => ( View all} closeOnClick={false} /> @@ -863,7 +867,9 @@ export const EmptyState: Story = { search={} > {visible.length > 0 ? ( - visible.map((s) => ) + visible.map((s) => ( + + )) ) : (
No matching results
)} @@ -912,7 +918,13 @@ export const Submenu: Story = { 5} /> {PRIORITIES.map((p) => ( - + ))} @@ -920,7 +932,13 @@ export const Submenu: Story = { 5} /> {STATUSES.map((s) => ( - + ))} @@ -930,6 +948,7 @@ export const Submenu: Story = { {ASSIGNEES.map((a) => ( } label={a.name} closeOnClick={false} diff --git a/packages/propel/src/components/dropdown/index.tsx b/packages/propel/src/components/dropdown/index.tsx index 63f363b9..dfc814c6 100644 --- a/packages/propel/src/components/dropdown/index.tsx +++ b/packages/propel/src/components/dropdown/index.tsx @@ -57,7 +57,6 @@ const dropdownSurfaceVariants = cva( lg: "w-96", // 384px — the wide two-line "Description" menu }, }, - defaultVariants: { width: "anchor" }, }, ); @@ -142,7 +141,6 @@ const dropdownItemVariants = cva( "with-value": "h-8", }, }, - defaultVariants: { variant: "default" }, }, ); @@ -153,12 +151,11 @@ export type DropdownItemProps = Omit< "className" | "style" | "label" > & { /** - * Row layout. `default` is a single line; `with-description` stacks a muted - * second line under the label; `with-value` reserves a trailing value slot. + * Row layout (required). `default` is a single line; `with-description` stacks a + * muted second line under the label; `with-value` reserves a trailing value slot. * Selected/disabled are state props, not variants. - * @default "default" */ - variant?: DropdownItemVariant; + variant: DropdownItemVariant; /** Leading content, typically an icon. Rendered before the label. */ icon?: React.ReactNode; /** @@ -200,7 +197,7 @@ export type DropdownItemProps = Omit< * by `variant`. Pass `selected` for the single-select leading-checkmark pattern. */ export function DropdownItem({ - variant = "default", + variant, icon, leading, label, From 103cc4f1c10552e259388853f54d4a19766b86fc Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 9 Jun 2026 16:45:33 +0700 Subject: [PATCH 5/8] Dropdown: add Figma design link to story meta parameters --- .../propel/src/components/dropdown/dropdown.stories.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/propel/src/components/dropdown/dropdown.stories.tsx b/packages/propel/src/components/dropdown/dropdown.stories.tsx index 5a9635ef..1990b125 100644 --- a/packages/propel/src/components/dropdown/dropdown.stories.tsx +++ b/packages/propel/src/components/dropdown/dropdown.stories.tsx @@ -45,6 +45,12 @@ import { 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). From 8da28a1dae13d25bab299d4b2f1efc000ade79be Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 9 Jun 2026 17:54:10 +0700 Subject: [PATCH 6/8] Dropdown: mirror submenu chevron in RTL (rtl:-scale-x-100) In RTL submenus open to the inline-start (left) side; flip the DropdownSubTrigger ChevronRight so it points toward the submenu. Rest of the component already uses logical utilities (gap, px, -mx), so no other physical-direction conversions were needed. --- packages/propel/src/components/dropdown/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/propel/src/components/dropdown/index.tsx b/packages/propel/src/components/dropdown/index.tsx index dfc814c6..a51bee07 100644 --- a/packages/propel/src/components/dropdown/index.tsx +++ b/packages/propel/src/components/dropdown/index.tsx @@ -489,7 +489,7 @@ export function DropdownSubTrigger({ {label ?? children} {trailing != null ? {trailing} : null}