Dropdowns
View on GitHubAdd a dropdown
Worked example: choosing which Dropdown variant to reach for, and how
to wire each one. Never hand-build a floating panel — a new dropdown
should look and behave exactly like the existing ones.
Pick a variant
| You need… | Variant |
|---|---|
| Pick exactly one value | select |
| Toggle several values | multiselect |
| A list of actions (no selection state) | menu |
| Your own content in the same surface (forms, summaries) | panel |
The trigger is any element — a FilterButton, an avatar button, a
pill. Dropdown makes it open the panel and adds the ARIA attributes;
pass a function (({ isOpen }) => …) if the trigger changes while open.
Single select
import Dropdown from '@/Components/Molecules/Dropdown/Dropdown';
import { useProjectPriorities } from '@/context/ProjectPrioritiesContext';
// A project's priorities are its own (see ../priorities/README.md), so the
// options come from the provider rather than a hardcoded list.
const { priorities } = useProjectPriorities();
<Dropdown
variant="select"
title="Priority"
options={priorities.map((priority) => ({
value: priority.name,
label: priority.name,
}))}
selectedValues={[priority]}
onSelect={(value) => setPriority(value)}
trigger={<button type="button">{priority}</button>}
/>
The search box appears by itself once there are more than six options
(searchable="auto"); force it with searchable or hide it with
searchable={false}. For rich labels (avatars, badges) set
searchLabel so search still has text to match. Add onClear to show
a "Clear" action while something is selected.
Multiselect
<Dropdown
variant="multiselect"
title="Labels"
showCount
options={labels}
selectedValues={selectedLabels}
onSelect={toggleLabel}
onClear={() => setSelectedLabels([])}
onSelectAll={toggleAll}
trigger={<FilterButton label="Labels" hasMenu />}
/>
It stays open between picks. onSelectAll adds the "Select all"
footer; showCount adds the "N results" line the filters use.
Menu
<Dropdown
variant="menu"
placement="top"
options={[
{ value: 'settings', label: 'Settings', icon: 'Settings' },
{ value: 'logout', label: 'Log out', icon: 'LogOut', tone: 'danger' },
]}
onSelect={(action) => handle(action)}
trigger={<button type="button">Account</button>}
/>
Rows have no selection indicator. For a menu that mixes sections, use
kind: 'heading' / kind: 'separator' rows and per-option role
(menuitemradio, menuitemcheckbox), indicator: 'check' and
closeOnSelect: false — see MemberRoleDropdown.
Panel
<Dropdown variant="panel" ariaLabel="Saved views" width={288} trigger={…}>
{({ close }) => <MyForm onDone={close} />}
</Dropdown>
You get the surface, positioning and Escape / outside-click handling; the content is yours. Tab doesn't close a panel, so forms keep working.
Controlled state and special anchors
open+onOpenChangehand the open state to the parent (the filter bar closes one dropdown when another opens).anchorPoint={{ x, y }}opens it at a point instead of at the trigger —ListRowuses it for the right-click context menu.align="end",placement="top"andwidth(a number, or'trigger') tune the panel; it flips above the trigger and stays inside the viewport.
Behaviors worth knowing
- Clicks and key presses inside the panel, and clicks on the trigger, don't propagate to ancestors in the component tree (a row, a card), even though the panel is portaled. The panel closes if its trigger scrolls out of the window.
- ArrowUp/Down, Home/End move between rows, Escape closes and returns focus to the trigger, and Tab closes the list and moves on.
- The panel renders above modals (inline
zIndex: 9999).
Test it
resources/js/Components/Molecules/Dropdown/Dropdown.test.tsxcovers every variant; add a case there when you extendDropdownitself.- In a wrapper's tests, query rows by role:
optionforselect/multiselect,menuitem/menuitemradio/menuitemcheckboxformenu.
