Dropdown
A generic action menu built on the WAI-ARIA APG menu button pattern, with real roving-tabindex keyboard focus.
Import
import { Dropdown } from "@hyzer-labs/ui"Demo
Dropdown vs Nav
Dropdown when the entries are actions: an action menu or kebab
menu that does something (edit, duplicate, delete). For navigation, meaning
links a reader clicks through to another page or section, use Nav's built-in dropdown instead. Its menu items are real
navigable links rather than action buttons.Every item carries its own onselect. Click one, or focus it and press Enter/Space, to fire its action, close the menu, and return focus
to the trigger.
Last action: –
let lastAction = $state("–");
const roundItems: DropdownEntry[] = [
{ id: 'view', label: 'View scorecard', onselect: () => (lastAction = 'View scorecard') },
{ id: 'edit', label: 'Edit round', onselect: () => (lastAction = 'Edit round') },
{ id: 'share', label: 'Share round', onselect: () => (lastAction = 'Share round') }
];
<Dropdown label="Round actions" items={roundItems} />
<p>Last action: {lastAction}</p>Check in is disabled. It stays focusable, so Arrow, Home, End,
and typeahead all reach it and screen-reader users can still discover it, but activating
it does nothing. Delete round is danger: styled destructively,
never by color alone, since its label already says "Delete."
const cardItems: DropdownEntry[] = [
{ id: 'edit', label: 'Edit tee time' },
{ id: 'checkin', label: 'Check in', disabled: true }, // round already started
{ separator: true },
{ id: 'delete', label: 'Delete round', danger: true }
];
<Dropdown label="Card actions" items={cardItems} />An item's icon snippet renders before the label and is purely decorative (aria-hidden), so the label text alone is the accessible name. A { separator: true } entry renders a non-interactive divider, skipped by every
navigation path.
{#snippet completeIcon()}<IconCheck />{/snippet}
{#snippet addIcon()}<IconPlus />{/snippet}
{#snippet removeIcon()}<IconX />{/snippet}
const iconItems: DropdownEntry[] = [
{ id: 'complete', label: 'Mark complete', icon: completeIcon },
{ id: 'add', label: 'Add to bag', icon: addIcon },
{ separator: true },
{ id: 'remove', label: 'Remove from bag', icon: removeIcon, danger: true }
];
<Dropdown label="Disc actions" items={iconItems} />align picks which trigger edge the menu hangs from. start (the
default) lines up the leading edges. end lines up the trailing edges, the
right-aligned kebab-menu form, useful near the edge of a container. center centers the menu under the trigger. Start and end follow reading direction.
<Dropdown label="Start" items={roundItems} />
<Dropdown label="Center" items={roundItems} align="center" />
<Dropdown label="End" items={roundItems} align="end" />With no label, the trigger renders as an icon-only Button circle, and triggerLabel becomes its accessible name. It is required: leave
it out and Button logs its own dev warning. triggerIcon replaces
the default chevron glyph.
<Dropdown
items={roundItems}
triggerLabel="Round actions"
/>
<!-- triggerIcon defaults to IconChevronDown; pass one to replace it, e.g.: -->
<Dropdown items={roundItems} triggerLabel="Round actions">
{#snippet triggerIcon()}<IconMenu />{/snippet}
</Dropdown>Props
| Name | Type | Default | Note |
|---|---|---|---|
items | DropdownEntry[] | — | Required. A DropdownEntry is either a DropdownItem or a DropdownSeparator, both below. |
label | string | — | Visible trigger text (and the accessible name, unless triggerLabel overrides it). |
triggerLabel | string | — | Accessible-name override; required for an icon-only trigger (no label). |
triggerProps | DropdownTriggerProps | {} | Trigger appearance pass-through. Resolves to variant: 'outline', intent: 'neutral'. |
triggerIcon | Snippet | — (⇒ IconChevronDown) | Decorative; the labeled trigger renders it trailing, the icon-only trigger renders it alone. |
align | 'start' | 'center' | 'end' | 'start' | |
onselect | (id: string, item: DropdownItem) => void | — | Fires on every activation, after the item's own onselect. |
disabled | boolean | false | |
class | string | — | Merged after the hz-dropdown class. |
DropdownItem
| Name | Type | Default | Note |
|---|---|---|---|
id | string | — | Required. Stable identity — keys the item, its DOM id, and the onselect callback. |
label | string | — | Required. |
disabled | boolean | — | Stays focusable — reachable by Arrow / Home / End / typeahead — but inert on activation. |
danger | boolean | — | Destructive action — surfaces the data-danger styling hook. |
icon | Snippet | — | Decorative leading glyph; the label owns the accessible name. |
onselect | () => void | — | Per-item action, fired before the component's onselect. |
DropdownSeparator
| Name | Type | Default | Note |
|---|---|---|---|
separator | true | — | A non-interactive divider — discriminates it from an actionable item. |
Theme hooks
What this component promises your CSS. The reference theme styles exactly these — from @layer hz-theme, so your unlayered rules win. See Styling Components for the how.
Root class: .hz-dropdown
Data attributes
| Hook | Values | Styles |
|---|---|---|
data-open | present while open | Hides the closed menu and rotates the chevron. |
data-side | 'bottom' | 'top' | On .hz-dropdown-menu — the resolved side, bottom unless flipped for lack of room below. See Positioning for the caret recipe. |
data-align | 'start' | 'center' | 'end' | On .hz-dropdown-menu — which edge the menu hangs from (or centered), RTL-aware. |
data-danger | present on danger items | Per-item. The label text carries the meaning too — colour alone never does. |
data-disabled | present on disabled items | Per-item. Items stay focusable (aria-disabled, not the native attribute), so the disabled state is still discoverable. |
Part classes
| Hook | Values | Styles |
|---|---|---|
.hz-dropdown-trigger | on a Button | Merged onto the composed Button, so the trigger carries .hz-button and its full data-attr set as well. |
.hz-dropdown-menu | child element | The menu surface. |
.hz-dropdown-item | child element | One item. There is no data-active — the active item is real DOM focus, so style :focus / :focus-visible. |
.hz-dropdown-item-icon | child element | An item’s icon slot. |
.hz-dropdown-separator | child element | A separator row. |
Accessibility
The trigger is a real button (the library's own Button) with aria-haspopup=menu, aria-expanded, and aria-controls. It opens a role=menu popup of role=menuitem buttons, named by the trigger via aria-labelledby.
Unlike Combobox, which keeps DOM focus on its text input and tracks a virtually-focused option with aria-activedescendant, Dropdown moves real DOM focus into the menu. Opening it lands focus on the first menuitem (the last, on ArrowUp). A roving tabindex (0 on the focused item, -1 on the rest) keeps the menu a single tab stop, so the active item is styled with native :focus rather than a data-active hook.
Keyboard: ArrowDown/ArrowUp move focus and wrap, including over disabled items. Home/End jump to the first and last item. Typing a character cycles focus to the next item whose label starts with it. Enter/Space activate the focused item through its own native button click, with no separate handling. Escape closes the menu and returns focus to the trigger. Tab/Shift+Tab close the menu and let focus move on, since there is no focus trap.
A disabled item keeps aria-disabled=true rather than the native disabled attribute, so it stays focusable and screen-reader users can discover it. Activating it does nothing.
References: APG Menu Button pattern