Dropdown Menu
v0.3.0StableA 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.
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";45export 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}1314/**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 });2829 return (30 <RadixDropdownMenu.Item31 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";67export 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}1516export 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}3233/**34 * A trigger element that opens a list of selectable items, built on Radix's35 * `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 });5253 return (54 <RadixDropdownMenu.Root open={open} onOpenChange={onOpenChange}>55 <RadixDropdownMenu.Trigger asChild>{trigger}</RadixDropdownMenu.Trigger>56 <RadixDropdownMenu.Portal>57 <RadixDropdownMenu.Content58 className={getChildClass("content")}59 align={align}60 sideOffset={4}61 >62 {items.map((item) => (63 <DropdownMenuItemRow64 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.34.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;2021 // State layer. Radix sets `data-highlighted` for both pointer hover and22 // 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 }3435 &[data-highlighted]::after {36 opacity: var(--md-sys-state-hover-opacity, 0.08);37 }3839 &:active::after {40 opacity: var(--md-sys-state-pressed-opacity, 0.1);41 }4243 // The state layer alone is a faint keyboard cue, so keyboard focus also44 // gets the ring. Inset because the content's `overflow: hidden` would clip45 // 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 }5051 &--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.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 }30}3132@keyframes dropdown-menu-in {33 from {34 opacity: 0;35 transform: translateY(-0.25rem);36 }3738 to {39 opacity: 1;40 transform: translateY(0);41 }42}4344@keyframes dropdown-menu-out {45 from {46 opacity: 1;47 transform: translateY(0);48 }4950 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";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 });7980 it("adds the --disabled modifier to a disabled item only", () => {81 render(82 <DropdownMenu83 trigger={<button>Open</button>}84 items={items}85 onSelect={() => {}}86 open87 onOpenChange={() => {}}88 />,89 );90 expect(screen.getByText("Item C").className).toContain("--disabled");91 expect(screen.getByText("Item A").className).not.toContain("--disabled");92 });93});