Dropdown Menu
v0.2.0TODO: Add component description.
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";56export 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}1415export 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}3132/**33 * A trigger element that opens a list of selectable items, built on Radix's34 * `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 });5152 return (53 <RadixDropdownMenu.Root open={open} onOpenChange={onOpenChange}>54 <RadixDropdownMenu.Trigger asChild>{trigger}</RadixDropdownMenu.Trigger>55 <RadixDropdownMenu.Portal>56 <RadixDropdownMenu.Content57 className={getChildClass("content")}58 align={align}59 sideOffset={4}60 >61 {items.map((item) => (62 <RadixDropdownMenu.Item63 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.34.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 to17 // clear --z-modal (400) and --z-sticky (200), not sit under them.18 z-index: var(--z-dropdown, 450);1920 &[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 }2425 &[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 }3031 &__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;4748 // State layer. Radix sets `data-highlighted` for both pointer hover and49 // 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 }6162 &[data-highlighted]::after {63 opacity: var(--md-sys-state-hover-opacity, 0.08);64 }6566 &:active::after {67 opacity: var(--md-sys-state-pressed-opacity, 0.1);68 }6970 // The state layer alone is a faint keyboard cue, so keyboard focus also71 // gets the ring. Inset because the content's `overflow: hidden` would clip72 // an outer ring on the first and last items.73 &:focus-visible {74 outline: 3px solid75 var(--md-sys-color-secondary, var(--color-border-focus));76 outline-offset: -3px;77 }7879 &[data-disabled] {80 opacity: 0.4;81 cursor: not-allowed;82 pointer-events: none;83 }84 }85}8687@keyframes dropdown-menu-in {88 from {89 opacity: 0;90 transform: translateY(-0.25rem);91 }9293 to {94 opacity: 1;95 transform: translateY(0);96 }97}9899@keyframes dropdown-menu-out {100 from {101 opacity: 1;102 transform: translateY(0);103 }104105 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";34const 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];910export const BasicDropdownMenu = () => {11 const [selected, setSelected] = useState("site-1");12 const selectedLabel = siteItems.find((item) => item.id === selected)?.label;1314 return (15 <DropdownMenu16 trigger={<button type="button">{selectedLabel} ▾</button>}17 items={siteItems}18 onSelect={setSelected}19 />20 );21};2223export 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;2728 return (29 <>30 <p>Selected: {selectedLabel}</p>31 <DropdownMenu32 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";45const items = [6 { id: "a", label: "Item A" },7 { id: "b", label: "Item B" },8 { id: "c", label: "Item C", disabled: true },9];1011describe("DropdownMenu", () => {12 it("renders the trigger", () => {13 render(14 <DropdownMenu15 trigger={<button>Open</button>}16 items={items}17 onSelect={() => {}}18 />,19 );20 expect(screen.getByRole("button", { name: "Open" })).toBeTruthy();21 });2223 it("is closed by default", () => {24 render(25 <DropdownMenu26 trigger={<button>Open</button>}27 items={items}28 onSelect={() => {}}29 />,30 );31 expect(screen.queryByText("Item A")).toBeNull();32 });3334 it("shows items when open is true", () => {35 render(36 <DropdownMenu37 trigger={<button>Open</button>}38 items={items}39 onSelect={() => {}}40 open41 onOpenChange={() => {}}42 />,43 );44 expect(screen.getByText("Item A")).toBeTruthy();45 expect(screen.getByText("Item B")).toBeTruthy();46 });4748 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 <DropdownMenu53 trigger={<button>Open</button>}54 items={items}55 onSelect={handleSelect}56 open57 onOpenChange={() => {}}58 />,59 );60 await user.click(screen.getByText("Item A"));61 expect(handleSelect).toHaveBeenCalledWith("a");62 });6364 it("does not call onSelect for a disabled item", async () => {65 const handleSelect = vi.fn();66 const user = userEvent.setup();67 render(68 <DropdownMenu69 trigger={<button>Open</button>}70 items={items}71 onSelect={handleSelect}72 open73 onOpenChange={() => {}}74 />,75 );76 await user.click(screen.getByText("Item C"));77 expect(handleSelect).not.toHaveBeenCalled();78 });79});