Behive Tech Component Registry

Dropdown Menu

v0.3.0Stable

A trigger that opens a list of selectable items, built on Radix's DropdownMenu for focus management, keyboard navigation and close on outside click or Escape.

pnpm add @behivetech/atoms.dropdown-menu

registry/behivetech/atoms/dropdown-menu

dropdown-menu-item.tsx
1import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu";
2import { getClassName } from "@bht-component-registry/class-names";
3import styles from "./dropdown-menu-item.module.scss";
4
5export interface DropdownMenuItemProps {
6 /** Text shown for this item */
7 label: string;
8 /** Prevents this item from being selected; adds the `--disabled` modifier */
9 disabled?: boolean;
10 /** Called when the item is chosen */
11 onSelect: () => void;
12}
13
14/**
15 * One item in DropdownMenu's popup. A block of its own (`BHT__DropdownMenuItem`)
16 * so `disabled` is styled through a modifier instead of Radix's `data-disabled`.
17 */
18export const DropdownMenuItem = ({
19 label,
20 disabled = false,
21 onSelect,
22}: DropdownMenuItemProps) => {
23 const [rootClass] = getClassName({
24 modifiers: { disabled },
25 rootClass: "BHT__DropdownMenuItem",
26 styles,
27 });
28
29 return (
30 <RadixDropdownMenu.Item
31 disabled={disabled}
32 className={rootClass}
33 onSelect={onSelect}
34 >
35 {label}
36 </RadixDropdownMenu.Item>
37 );
38};
dropdown-menu.tsx
1import { type ReactNode } from "react";
2import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu";
3import { getClassName } from "@bht-component-registry/class-names";
4import styles from "./dropdown-menu.module.scss";
5import { DropdownMenuItem as DropdownMenuItemRow } from "./dropdown-menu-item.js";
6
7export interface DropdownMenuItem {
8 /** Unique identifier for this item, passed to onSelect */
9 id: string;
10 /** Text shown for this item */
11 label: string;
12 /** Prevents this item from being selected */
13 disabled?: boolean;
14}
15
16export interface DropdownMenuProps {
17 /** Element that opens the menu when activated — receives the trigger's props via Radix's `asChild` */
18 trigger: ReactNode;
19 /** Items rendered in the menu */
20 items: DropdownMenuItem[];
21 /** Called with the selected item's id when an item is chosen */
22 onSelect: (id: string) => void;
23 /** Controlled open state */
24 open?: boolean;
25 /** Called when the open state changes (trigger click, item select, outside click, Escape) */
26 onOpenChange?: (open: boolean) => void;
27 /** Alignment of the menu relative to the trigger; defaults to "start" */
28 align?: "start" | "center" | "end";
29 /** Additional class names to merge with the menu content element */
30 className?: string;
31}
32
33/**
34 * A trigger element that opens a list of selectable items, built on Radix's
35 * `DropdownMenu` for focus management, keyboard navigation, and outside-click/
36 * Escape-to-close.
37 */
38export const DropdownMenu = ({
39 trigger,
40 items,
41 onSelect,
42 open,
43 onOpenChange,
44 align = "start",
45 className,
46}: DropdownMenuProps) => {
47 const [, getChildClass] = getClassName({
48 className,
49 rootClass: "BHT__DropdownMenu",
50 styles,
51 });
52
53 return (
54 <RadixDropdownMenu.Root open={open} onOpenChange={onOpenChange}>
55 <RadixDropdownMenu.Trigger asChild>{trigger}</RadixDropdownMenu.Trigger>
56 <RadixDropdownMenu.Portal>
57 <RadixDropdownMenu.Content
58 className={getChildClass("content")}
59 align={align}
60 sideOffset={4}
61 >
62 {items.map((item) => (
63 <DropdownMenuItemRow
64 key={item.id}
65 label={item.label}
66 disabled={item.disabled}
67 onSelect={() => onSelect(item.id)}
68 />
69 ))}
70 </RadixDropdownMenu.Content>
71 </RadixDropdownMenu.Portal>
72 </RadixDropdownMenu.Root>
73 );
74};
dropdown-menu-item.module.scss
1// Material Design 3 menu item. Its own block so `--disabled` is a real class;
2// `data-highlighted` is Radix runtime state and stays an attribute selector.
3
4.BHT__DropdownMenuItem {
5 position: relative;
6 isolation: isolate;
7 display: flex;
8 align-items: center;
9 padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
10 border-radius: var(--md-sys-shape-corner-extra-small, 4px);
11 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));
12 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);
13 font-weight: var(--md-sys-typescale-label-large-weight, 500);
14 line-height: var(--md-sys-typescale-label-large-line-height, 1.25rem);
15 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);
16 color: var(--md-sys-color-on-surface, var(--color-on-surface));
17 cursor: pointer;
18 user-select: none;
19 outline: none;
20
21 // State layer. Radix sets `data-highlighted` for both pointer hover and
22 // keyboard navigation, so it stands in for :hover here.
23 &::after {
24 content: "";
25 position: absolute;
26 inset: 0;
27 border-radius: inherit;
28 background: currentColor;
29 opacity: 0;
30 pointer-events: none;
31 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)
32 var(--md-sys-motion-easing-standard, ease);
33 }
34
35 &[data-highlighted]::after {
36 opacity: var(--md-sys-state-hover-opacity, 0.08);
37 }
38
39 &:active::after {
40 opacity: var(--md-sys-state-pressed-opacity, 0.1);
41 }
42
43 // The state layer alone is a faint keyboard cue, so keyboard focus also
44 // gets the ring. Inset because the content's `overflow: hidden` would clip
45 // an outer ring on the first and last items.
46 &:focus-visible {
47 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));
48 outline-offset: -3px;
49 }
50
51 &--disabled {
52 &.BHT__DropdownMenuItem {
53 opacity: 0.4;
54 cursor: not-allowed;
55 pointer-events: none;
56 }
57 }
58}
dropdown-menu.module.scss
1// Material Design 3 menu. Each color falls back to the pre-MD3 `--color-*`
2// name, which `cms.base-styles` aliases to the matching MD3 role.
3
4.BHT__DropdownMenu {
5 &__content {
6 min-width: 11.25rem;
7 background: var(
8 --md-sys-color-surface-container,
9 var(--color-surface-raised)
10 );
11 // No outline: MD3 menus separate from the page by tone and elevation.
12 border-radius: var(--md-sys-shape-corner-extra-small, 4px);
13 box-shadow: var(--md-sys-elevation-2, var(--shadow-md));
14 padding: var(--spacing-1, 0.25rem);
15 overflow: hidden;
16 // Portaled to document.body — see select.module.scss; the fallback has to
17 // clear --z-modal (400) and --z-sticky (200), not sit under them.
18 z-index: var(--z-dropdown, 450);
19
20 &[data-state="open"] {
21 animation: dropdown-menu-in var(--md-sys-motion-duration-short-3, 150ms)
22 var(--md-sys-motion-easing-standard-decelerate, ease-out);
23 }
24
25 &[data-state="closed"] {
26 animation: dropdown-menu-out var(--md-sys-motion-duration-short-2, 100ms)
27 var(--md-sys-motion-easing-standard-accelerate, ease-in);
28 }
29 }
30}
31
32@keyframes dropdown-menu-in {
33 from {
34 opacity: 0;
35 transform: translateY(-0.25rem);
36 }
37
38 to {
39 opacity: 1;
40 transform: translateY(0);
41 }
42}
43
44@keyframes dropdown-menu-out {
45 from {
46 opacity: 1;
47 transform: translateY(0);
48 }
49
50 to {
51 opacity: 0;
52 transform: translateY(-0.25rem);
53 }
54}
index.ts
1export { DropdownMenu } from "./dropdown-menu.js";
2export type { DropdownMenuProps, DropdownMenuItem } from "./dropdown-menu.js";
dropdown-menu.example.tsx
1import { useState } from "react";
2import { DropdownMenu } from "./dropdown-menu.js";
3
4const siteItems = [
5 { id: "site-1", label: "My Behive Site" },
6 { id: "site-2", label: "Second Site" },
7 { id: "site-3", label: "Archived Site", disabled: true },
8];
9
10export const BasicDropdownMenu = () => {
11 const [selected, setSelected] = useState("site-1");
12 const selectedLabel = siteItems.find((item) => item.id === selected)?.label;
13
14 return (
15 <DropdownMenu
16 trigger={<button type="button">{selectedLabel} ▾</button>}
17 items={siteItems}
18 onSelect={setSelected}
19 />
20 );
21};
22
23export const ControlledDropdownMenu = () => {
24 const [open, setOpen] = useState(false);
25 const [selected, setSelected] = useState("site-1");
26 const selectedLabel = siteItems.find((item) => item.id === selected)?.label;
27
28 return (
29 <>
30 <p>Selected: {selectedLabel}</p>
31 <DropdownMenu
32 trigger={<button type="button">Toggle menu</button>}
33 items={siteItems}
34 open={open}
35 onOpenChange={setOpen}
36 onSelect={(id) => {
37 setSelected(id);
38 setOpen(false);
39 }}
40 />
41 </>
42 );
43};
dropdown-menu.spec.tsx
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { DropdownMenu } from "./dropdown-menu.js";
4
5const items = [
6 { id: "a", label: "Item A" },
7 { id: "b", label: "Item B" },
8 { id: "c", label: "Item C", disabled: true },
9];
10
11describe("DropdownMenu", () => {
12 it("renders the trigger", () => {
13 render(
14 <DropdownMenu
15 trigger={<button>Open</button>}
16 items={items}
17 onSelect={() => {}}
18 />,
19 );
20 expect(screen.getByRole("button", { name: "Open" })).toBeTruthy();
21 });
22
23 it("is closed by default", () => {
24 render(
25 <DropdownMenu
26 trigger={<button>Open</button>}
27 items={items}
28 onSelect={() => {}}
29 />,
30 );
31 expect(screen.queryByText("Item A")).toBeNull();
32 });
33
34 it("shows items when open is true", () => {
35 render(
36 <DropdownMenu
37 trigger={<button>Open</button>}
38 items={items}
39 onSelect={() => {}}
40 open
41 onOpenChange={() => {}}
42 />,
43 );
44 expect(screen.getByText("Item A")).toBeTruthy();
45 expect(screen.getByText("Item B")).toBeTruthy();
46 });
47
48 it("calls onSelect with the item id when an item is clicked", async () => {
49 const handleSelect = vi.fn();
50 const user = userEvent.setup();
51 render(
52 <DropdownMenu
53 trigger={<button>Open</button>}
54 items={items}
55 onSelect={handleSelect}
56 open
57 onOpenChange={() => {}}
58 />,
59 );
60 await user.click(screen.getByText("Item A"));
61 expect(handleSelect).toHaveBeenCalledWith("a");
62 });
63
64 it("does not call onSelect for a disabled item", async () => {
65 const handleSelect = vi.fn();
66 const user = userEvent.setup();
67 render(
68 <DropdownMenu
69 trigger={<button>Open</button>}
70 items={items}
71 onSelect={handleSelect}
72 open
73 onOpenChange={() => {}}
74 />,
75 );
76 await user.click(screen.getByText("Item C"));
77 expect(handleSelect).not.toHaveBeenCalled();
78 });
79
80 it("adds the --disabled modifier to a disabled item only", () => {
81 render(
82 <DropdownMenu
83 trigger={<button>Open</button>}
84 items={items}
85 onSelect={() => {}}
86 open
87 onOpenChange={() => {}}
88 />,
89 );
90 expect(screen.getByText("Item C").className).toContain("--disabled");
91 expect(screen.getByText("Item A").className).not.toContain("--disabled");
92 });
93});