Behive Tech Registry

Dropdown Menu

v0.2.0

TODO: Add component description.

pnpm add @behivetech/atoms.dropdown-menu

registry/behivetech/atoms/dropdown-menu · depends on @behivetech/get-class-name

dropdown-menu.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.module.scss";
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 const [, getChildClass] = getClassName({
47 className,
48 rootClass: "BHT__DropdownMenu",
49 styles,
50 });
51
52 return (
53 <RadixDropdownMenu.Root open={open} onOpenChange={onOpenChange}>
54 <RadixDropdownMenu.Trigger asChild>{trigger}</RadixDropdownMenu.Trigger>
55 <RadixDropdownMenu.Portal>
56 <RadixDropdownMenu.Content
57 className={getChildClass("content")}
58 align={align}
59 sideOffset={4}
60 >
61 {items.map((item) => (
62 <RadixDropdownMenu.Item
63 key={item.id}
64 disabled={item.disabled}
65 className={getChildClass("item")}
66 onSelect={() => onSelect(item.id)}
67 >
68 {item.label}
69 </RadixDropdownMenu.Item>
70 ))}
71 </RadixDropdownMenu.Content>
72 </RadixDropdownMenu.Portal>
73 </RadixDropdownMenu.Root>
74 );
75};
dropdown-menu.module.scss
1// Material Design 3 menu. Each color falls back to the pre-MD3 `--color-*`
2// name, which `theme.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 &__item {
32 position: relative;
33 isolation: isolate;
34 display: flex;
35 align-items: center;
36 padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
37 border-radius: var(--md-sys-shape-corner-extra-small, 4px);
38 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));
39 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);
40 font-weight: var(--md-sys-typescale-label-large-weight, 500);
41 line-height: var(--md-sys-typescale-label-large-line-height, 1.25rem);
42 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);
43 color: var(--md-sys-color-on-surface, var(--color-on-surface));
44 cursor: pointer;
45 user-select: none;
46 outline: none;
47
48 // State layer. Radix sets `data-highlighted` for both pointer hover and
49 // keyboard navigation, so it stands in for :hover here.
50 &::after {
51 content: "";
52 position: absolute;
53 inset: 0;
54 border-radius: inherit;
55 background: currentColor;
56 opacity: 0;
57 pointer-events: none;
58 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)
59 var(--md-sys-motion-easing-standard, ease);
60 }
61
62 &[data-highlighted]::after {
63 opacity: var(--md-sys-state-hover-opacity, 0.08);
64 }
65
66 &:active::after {
67 opacity: var(--md-sys-state-pressed-opacity, 0.1);
68 }
69
70 // The state layer alone is a faint keyboard cue, so keyboard focus also
71 // gets the ring. Inset because the content's `overflow: hidden` would clip
72 // an outer ring on the first and last items.
73 &:focus-visible {
74 outline: 3px solid
75 var(--md-sys-color-secondary, var(--color-border-focus));
76 outline-offset: -3px;
77 }
78
79 &[data-disabled] {
80 opacity: 0.4;
81 cursor: not-allowed;
82 pointer-events: none;
83 }
84 }
85}
86
87@keyframes dropdown-menu-in {
88 from {
89 opacity: 0;
90 transform: translateY(-0.25rem);
91 }
92
93 to {
94 opacity: 1;
95 transform: translateY(0);
96 }
97}
98
99@keyframes dropdown-menu-out {
100 from {
101 opacity: 1;
102 transform: translateY(0);
103 }
104
105 to {
106 opacity: 0;
107 transform: translateY(-0.25rem);
108 }
109}
index.ts
1export { DropdownMenu } from "./dropdown-menu.js";
2export type { DropdownMenuProps, DropdownMenuItem } from "./dropdown-menu.js";
dropdown-menu.composition.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});