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.
registry/behivetech/atoms/dropdown-menu at v0.4.1, snapshotted 2026-11-10
dropdown-menu-content.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-content.module.scss";56export 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}1617/**18 * DropdownMenu's popup. A block of its own (`BHT__DropdownMenuContent`) so the19 * open and closed animations are styled through modifiers instead of Radix's20 * `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 });3435 return (36 <RadixDropdownMenu.Content37 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 "@bht-component-registry/class-names";4import styles from "./dropdown-menu-item.module.scss";56export 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}1415/**16 * One item in DropdownMenu's popup. A block of its own (`BHT__DropdownMenuItem`)17 * so `disabled` and `highlighted` are styled through modifiers instead of18 * 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 and26 // 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 });3334 return (35 <RadixDropdownMenu.Item36 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";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 // 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;5051 const handleOpenChange = (next: boolean) => {52 setInternalOpen(next);53 onOpenChange?.(next);54 };5556 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 <DropdownMenuItemRow63 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.34.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 to16 // clear --z-modal (400) and --z-sticky (200), not sit under them.17 z-index: var(--z-dropdown, 450);1819 &--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 }2526 &--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}3334@keyframes dropdown-menu-in {35 from {36 opacity: 0;37 transform: translateY(-0.25rem);38 }3940 to {41 opacity: 1;42 transform: translateY(0);43 }44}4546@keyframes dropdown-menu-out {47 from {48 opacity: 1;49 transform: translateY(0);50 }5152 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` and2// `--highlighted` are real classes.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. `--highlighted` follows Radix's highlight, set for both pointer22 // 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 }3435 &--highlighted {36 &.BHT__DropdownMenuItem::after {37 opacity: var(--md-sys-state-hover-opacity, 0.08);38 }39 }4041 &:active::after {42 opacity: var(--md-sys-state-pressed-opacity, 0.1);43 }4445 // The state layer alone is a faint keyboard cue, so keyboard focus also46 // gets the ring. Inset because the content's `overflow: hidden` would clip47 // 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 }5253 &--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";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};