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-content.tsx
1import { type ReactNode } from "react";
2import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu";
3import { getClassName } from "@behivetech/get-class-name";
4import styles from "./dropdown-menu-content.module.scss";
5
6export interface DropdownMenuContentProps {
7 /** Whether the menu is open; adds `--open`, or `--closed` while it animates out */
8 open: boolean;
9 /** Alignment of the menu relative to the trigger */
10 align: "start" | "center" | "end";
11 /** Additional class names to merge with the content element */
12 className?: string;
13 /** The menu items */
14 children: ReactNode;
15}
16
17/**
18 * DropdownMenu's popup. A block of its own (`BHT__DropdownMenuContent`) so the
19 * open and closed animations are styled through modifiers instead of Radix's
20 * `data-state`.
21 */
22export const DropdownMenuContent = ({
23 open,
24 align,
25 className,
26 children,
27}: DropdownMenuContentProps) => {
28 const [rootClass] = getClassName({
29 className,
30 modifiers: { open, closed: !open },
31 rootClass: "BHT__DropdownMenuContent",
32 styles,
33 });
34
35 return (
36 <RadixDropdownMenu.Content
37 className={rootClass}
38 align={align}
39 sideOffset={4}
40 >
41 {children}
42 </RadixDropdownMenu.Content>
43 );
44};
dropdown-menu-item.tsx
1import { useState } from "react";
2import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu";
3import { getClassName } from "@behivetech/get-class-name";
4import styles from "./dropdown-menu-item.module.scss";
5
6export interface DropdownMenuItemProps {
7 /** Text shown for this item */
8 label: string;
9 /** Prevents this item from being selected; adds the `--disabled` modifier */
10 disabled?: boolean;
11 /** Called when the item is chosen */
12 onSelect: () => void;
13}
14
15/**
16 * One item in DropdownMenu's popup. A block of its own (`BHT__DropdownMenuItem`)
17 * so `disabled` and `highlighted` are styled through modifiers instead of
18 * Radix's `data-disabled` and `data-highlighted`.
19 */
20export const DropdownMenuItem = ({
21 label,
22 disabled = false,
23 onSelect,
24}: DropdownMenuItemProps) => {
25 // Radix highlights an item exactly while it has focus (pointer move and
26 // arrow keys both focus it), so focus and blur are the React signal.
27 const [highlighted, setHighlighted] = useState(false);
28 const [rootClass] = getClassName({
29 modifiers: { disabled, highlighted },
30 rootClass: "BHT__DropdownMenuItem",
31 styles,
32 });
33
34 return (
35 <RadixDropdownMenu.Item
36 disabled={disabled}
37 className={rootClass}
38 onSelect={onSelect}
39 onFocus={() => setHighlighted(true)}
40 onBlur={() => setHighlighted(false)}
41 >
42 {label}
43 </RadixDropdownMenu.Item>
44 );
45};
dropdown-menu.tsx
1import { type ReactNode, useState } from "react";
2import * as RadixDropdownMenu from "@radix-ui/react-dropdown-menu";
3import { DropdownMenuContent } from "./dropdown-menu-content.js";
4import { DropdownMenuItem as DropdownMenuItemRow } from "./dropdown-menu-item.js";
5
6export interface DropdownMenuItem {
7 /** Unique identifier for this item, passed to onSelect */
8 id: string;
9 /** Text shown for this item */
10 label: string;
11 /** Prevents this item from being selected */
12 disabled?: boolean;
13}
14
15export interface DropdownMenuProps {
16 /** Element that opens the menu when activated — receives the trigger's props via Radix's `asChild` */
17 trigger: ReactNode;
18 /** Items rendered in the menu */
19 items: DropdownMenuItem[];
20 /** Called with the selected item's id when an item is chosen */
21 onSelect: (id: string) => void;
22 /** Controlled open state */
23 open?: boolean;
24 /** Called when the open state changes (trigger click, item select, outside click, Escape) */
25 onOpenChange?: (open: boolean) => void;
26 /** Alignment of the menu relative to the trigger; defaults to "start" */
27 align?: "start" | "center" | "end";
28 /** Additional class names to merge with the menu content element */
29 className?: string;
30}
31
32/**
33 * A trigger element that opens a list of selectable items, built on Radix's
34 * `DropdownMenu` for focus management, keyboard navigation, and outside-click/
35 * Escape-to-close.
36 */
37export const DropdownMenu = ({
38 trigger,
39 items,
40 onSelect,
41 open,
42 onOpenChange,
43 align = "start",
44 className,
45}: DropdownMenuProps) => {
46 // Tracks the open state so the content can carry `--open` / `--closed`;
47 // a controlled `open` wins.
48 const [internalOpen, setInternalOpen] = useState(false);
49 const isOpen = open ?? internalOpen;
50
51 const handleOpenChange = (next: boolean) => {
52 setInternalOpen(next);
53 onOpenChange?.(next);
54 };
55
56 return (
57 <RadixDropdownMenu.Root open={isOpen} onOpenChange={handleOpenChange}>
58 <RadixDropdownMenu.Trigger asChild>{trigger}</RadixDropdownMenu.Trigger>
59 <RadixDropdownMenu.Portal>
60 <DropdownMenuContent open={isOpen} align={align} className={className}>
61 {items.map((item) => (
62 <DropdownMenuItemRow
63 key={item.id}
64 label={item.label}
65 disabled={item.disabled}
66 onSelect={() => onSelect(item.id)}
67 />
68 ))}
69 </DropdownMenuContent>
70 </RadixDropdownMenu.Portal>
71 </RadixDropdownMenu.Root>
72 );
73};
dropdown-menu-content.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__DropdownMenuContent {
5 min-width: 11.25rem;
6 background: var(
7 --md-sys-color-surface-container,
8 var(--color-surface-raised)
9 );
10 // No outline: MD3 menus separate from the page by tone and elevation.
11 border-radius: var(--md-sys-shape-corner-extra-small, 4px);
12 box-shadow: var(--md-sys-elevation-2, var(--shadow-md));
13 padding: var(--spacing-1, 0.25rem);
14 overflow: hidden;
15 // Portaled to document.body — see select-content.module.scss; the fallback has to
16 // clear --z-modal (400) and --z-sticky (200), not sit under them.
17 z-index: var(--z-dropdown, 450);
18
19 &--open {
20 &.BHT__DropdownMenuContent {
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
26 &--closed {
27 &.BHT__DropdownMenuContent {
28 animation: dropdown-menu-out var(--md-sys-motion-duration-short-2, 100ms)
29 var(--md-sys-motion-easing-standard-accelerate, ease-in);
30 }
31 }
32}
33
34@keyframes dropdown-menu-in {
35 from {
36 opacity: 0;
37 transform: translateY(-0.25rem);
38 }
39
40 to {
41 opacity: 1;
42 transform: translateY(0);
43 }
44}
45
46@keyframes dropdown-menu-out {
47 from {
48 opacity: 1;
49 transform: translateY(0);
50 }
51
52 to {
53 opacity: 0;
54 transform: translateY(-0.25rem);
55 }
56}
dropdown-menu-item.module.scss
1// Material Design 3 menu item. Its own block so `--disabled` and
2// `--highlighted` are real classes.
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. `--highlighted` follows Radix's highlight, set for both pointer
22 // hover and 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 &--highlighted {
36 &.BHT__DropdownMenuItem::after {
37 opacity: var(--md-sys-state-hover-opacity, 0.08);
38 }
39 }
40
41 &:active::after {
42 opacity: var(--md-sys-state-pressed-opacity, 0.1);
43 }
44
45 // The state layer alone is a faint keyboard cue, so keyboard focus also
46 // gets the ring. Inset because the content's `overflow: hidden` would clip
47 // an outer ring on the first and last items.
48 &:focus-visible {
49 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));
50 outline-offset: -3px;
51 }
52
53 &--disabled {
54 &.BHT__DropdownMenuItem {
55 opacity: 0.4;
56 cursor: not-allowed;
57 pointer-events: none;
58 }
59 }
60}
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, waitFor } 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
94 it("adds --open to the content while open, with no data-state selector needed", () => {
95 render(
96 <DropdownMenu
97 trigger={<button>Open</button>}
98 items={items}
99 onSelect={() => {}}
100 open
101 onOpenChange={() => {}}
102 />,
103 );
104 const content = screen.getByRole("menu");
105 expect(content.className).toContain("--open");
106 expect(content.className).not.toContain("--closed");
107 });
108
109 it("adds --highlighted to the focused item only", async () => {
110 render(
111 <DropdownMenu
112 trigger={<button>Open</button>}
113 items={items}
114 onSelect={() => {}}
115 open
116 onOpenChange={() => {}}
117 />,
118 );
119 await userEvent.hover(screen.getByText("Item B"));
120 expect(screen.getByText("Item B").className).toContain("--highlighted");
121 expect(screen.getByText("Item A").className).not.toContain("--highlighted");
122 });
123
124 it("tracks its own open state when uncontrolled: --open after click, closed after Escape", async () => {
125 render(
126 <DropdownMenu
127 trigger={<button>Open</button>}
128 items={items}
129 onSelect={() => {}}
130 />,
131 );
132 await userEvent.click(screen.getByRole("button", { name: "Open" }));
133 expect((await screen.findByRole("menu")).className).toContain("--open");
134
135 await userEvent.keyboard("{Escape}");
136 await waitFor(() => {
137 const menu = screen.queryByRole("menu");
138 expect(menu === null || menu.className.includes("--closed")).toBe(true);
139 });
140 });
141});