@acusti/dropdown
Version:
React component that renders a dropdown with a trigger and supports searching, keyboard access, and more
480 lines (410 loc) • 14.4 kB
Markdown
# /dropdown
[](https://www.npmjs.com/package/@acusti/dropdown)
[](https://npms.io/search?q=%40acusti%2Fdropdown)
[](https://www.npmjs.com/package/@acusti/dropdown)
[](https://bundlejs.com/?q=%40acusti%2Fdropdown)
`Dropdown` is a React component that renders a menu-like UI with a trigger
that the user clicks to disclose a dropdown anchored to that trigger. The
body of the dropdown can include any DOM, and many dropdowns can be
combined to form a multi-item menu, like the system menu in the top toolbar
of macOS.
The three primary design goals for the existence of this component:
1. **Best-in-class UX** (inspired by macOS native menus) with excellent
keyboard support
2. **Best-in-class DX** with the simplest possible API:
1. To create a dropdown with a `<button>` trigger, pass in a single
child element with the body of the dropdown
2. To create a dropdown with a custom trigger, pass in exactly two
child elements; the first is the trigger, the second is the body of
the dropdown
3. To create a dropdown with a list of items as the body, use the
`data-ukt-item` boolean to mark dropdown items, or use
`data-ukt-value="foo"` to specify that an element is a dropdown item
and the value of that item at the same time (otherwise, the value is
the text content of the dropdown item element)
4. To style your dropdowns, use CSS; there are a
[collection of CSS custom properties](https://github.com/acusti/uikit/blob/main/packages/dropdown/src/Dropdown.css)
used internally to style them if that works best for you, or just
override the minimal default CSS as appropriate
3. **Lightweight bundle size** with the bare minimum of dependencies (see
minzipped size above)
See the [storybook docs and demo][] to get a feel for what it can do.
[storybook docs and demo]:
https://uikit.acusti.ca/?path=/docs/uikit-controls-Dropdown--docs
## Installation
```bash
npm install /dropdown
# or
yarn add /dropdown
```
## Quick Start
```tsx
import Dropdown from '@acusti/dropdown';
// Simple dropdown with button trigger
function SimpleDropdown() {
return (
<Dropdown>
<ul>
<li>Option 1</li>
<li>Option 2</li>
<li>Option 3</li>
</ul>
</Dropdown>
);
}
// Custom trigger
function CustomTrigger() {
return (
<Dropdown>
<button>My Custom Button</button>
<ul>
<li>Option 1</li>
<li>Option 2</li>
</ul>
</Dropdown>
);
}
```
## Layout Model
`Dropdown` uses CSS anchor positioning for placement and prefers a
CSS-first sizing model:
- The trigger is the anchor
- The dropdown body is an anchored shell
- The inner content region becomes scrollable only when the content exceeds
the available space
- Placement fallbacks are handled with `position-try-order: most-height`
This means the dropdown tends to:
- stay content-sized when the contents are small
- expand to the available viewport space when more room is needed
- become scrollable when the contents exceed that space
Internally, the dropdown renders:
- `.uktdropdown-body` as the anchored outer shell
- `.uktdropdown-content` as the scrollable inner region with default
padding
Custom padding and overflow styling belongs on the content region, not the
outer shell. Note that `.uktdropdown-content` already applies default
padding (see the `--uktdd-body-pad-*` variables below), so your body
element does **not** need its own padding:
```tsx
// ✗ Don’t double-pad — the content region already has padding
<Dropdown>
<button>Open</button>
<div style={{ padding: 16 }}>…</div>
</Dropdown>
// ✓ Override default padding via CSS variables if needed
// .my-dropdown { --uktdd-body-pad-top: 16px; /* etc */ }
```
For the most reliable anchor-positioning behavior:
- pass exactly two children when you need a custom trigger
- ensure the trigger resolves to a stable DOM element
- keep the trigger first and the dropdown body second
- prefer CSS variable overrides over custom `top`/`left`/`right` inset
rules
For placement recipes, see
[Placement Customization](#placement-customization-with-css-variables)
below. If your trigger sits near the right edge of the viewport, the
[End-Aligned, Content-Sized Menu](#end-aligned-content-sized-menu) example
is the one you want.
## API Reference
### Props
```ts
type Props = {
/**
* Boolean indicating if the user can submit a value not already in the
* dropdown.
*/
allowCreate?: boolean;
/**
* Boolean indicating if the user can submit an empty value (i.e. clear
* the value). Defaults to true.
*/
allowEmpty?: boolean;
/**
* Can take a single React element or exactly two renderable children.
* - Single child: The dropdown body (trigger will be auto-generated button)
* - Two children: [trigger, body]
*/
children: ReactNode | [ReactNode, ReactNode];
className?: string;
disabled?: boolean;
/**
* Group identifier string links dropdowns together into a menu
* (like macOS top menubar).
*/
group?: string;
/**
* Whether the dropdown contains items that can be selected.
* Defaults to true if children contain elements with data-ukt-item or data-ukt-value.
*/
hasItems?: boolean;
/**
* Whether the dropdown should be open when first mounted.
*/
isOpenOnMount?: boolean;
/**
* Whether the dropdown should include a search input for filtering options.
*/
isSearchable?: boolean;
/**
* Whether the dropdown should remain open after selecting an item.
* Useful for multi-select scenarios.
*/
keepOpenOnSubmit?: boolean;
/**
* Label content for the trigger button (when using single child syntax).
*/
label?: ReactNode;
/**
* Minimum height for the dropdown body in pixels. Defaults to 30.
*/
minHeightBody?: number;
/**
* Minimum width for the dropdown body in pixels.
*/
minWidthBody?: number;
/**
* Name attribute for the search input (requires isSearchable: true).
*/
name?: string;
onClick?: (event: React.MouseEvent<HTMLElement>) => unknown;
onClose?: () => unknown;
onMouseDown?: (event: React.MouseEvent<HTMLElement>) => unknown;
onMouseUp?: (event: React.MouseEvent<HTMLElement>) => unknown;
onOpen?: () => unknown;
/**
* Called when an item is selected. The payload includes:
* - element: The DOM element that was clicked
* - event: The click or keyboard event
* - label: The visible text of the item
* - value: The value attribute or text content
*/
onSubmitItem?: (payload: Item) => void;
/**
* Placeholder text for the search input (requires isSearchable: true).
*/
placeholder?: string;
style?: React.CSSProperties;
/**
* Tab index for the search input (requires isSearchable: true).
*/
tabIndex?: number;
/**
* Current value of the search input (requires isSearchable: true).
* Used for controlled components and change detection.
*/
value?: string;
};
```
### Item Type
```ts
type Item = {
element: HTMLElement | null;
event: Event | React.SyntheticEvent<HTMLElement>;
label: string;
value: string;
};
```
## Usage Examples
### Basic List Dropdown
```tsx
import Dropdown from '@acusti/dropdown';
function StatesDropdown() {
const handleSelection = (item) => {
console.log('Selected:', item.value);
};
return (
<Dropdown onSubmitItem={handleSelection}>
<ul>
<li>California</li>
<li>New York</li>
<li>Texas</li>
<li>Florida</li>
</ul>
</Dropdown>
);
}
```
### Searchable Dropdown
```tsx
function SearchableDropdown() {
return (
<Dropdown
isSearchable
placeholder="Search states…"
label="Choose a state"
>
<ul>
<li>Alabama</li>
<li>Alaska</li>
<li>Arizona</li>
{/* ... more states */}
</ul>
</Dropdown>
);
}
```
### Custom Values with Data Attributes
```tsx
function FontWeightDropdown() {
return (
<Dropdown onSubmitItem={(item) => setFontWeight(item.value)}>
<ul>
<li data-ukt-value="100">Thin (100)</li>
<li data-ukt-value="400">Regular (400)</li>
<li data-ukt-value="700">Bold (700)</li>
<li data-ukt-value="900">Black (900)</li>
</ul>
</Dropdown>
);
}
```
### Allow Creating New Items
```tsx
function TagsDropdown() {
const [tags, setTags] = useState(['react', 'typescript', 'dropdown']);
const handleNewTag = (item) => {
if (!tags.includes(item.value)) {
setTags([...tags, item.value]);
}
};
return (
<Dropdown
isSearchable
allowCreate
placeholder="Add or select a tag…"
onSubmitItem={handleNewTag}
>
<ul>
{tags.map((tag) => (
<li key={tag}>{tag}</li>
))}
</ul>
</Dropdown>
);
}
```
### Multi-Select with Checkboxes
```tsx
function MultiSelectDropdown() {
return (
<Dropdown
keepOpenOnSubmit
onSubmitItem={({ label }) => {
console.log('Selected color:', label);
}}
>
<ul>
<li>
<label>
<input type="checkbox" /> Red
</label>
</li>
<li>
<label>
<input type="checkbox" /> Blue
</label>
</li>
</ul>
</Dropdown>
);
}
```
### Dropdown with Interactive Content
For dropdowns whose body is a form (inputs, date pickers, buttons that
aren’t meant to submit a value), pass `hasItems={false}`. This disables the
item-selection keyboard model and, importantly, prevents clicks inside the
body from closing the dropdown via `onSubmitItem`.
```tsx
function InteractiveDropdown() {
return (
<Dropdown hasItems={false}>
<button>Settings</button>
<div>
<label>
Full name:{' '}
<input
defaultValue=""
onChange={(value) =>
console.log('Full name:', value)
}
placeholder="Sally Ride"
type="text"
/>
</label>
<label>
Email:{' '}
<input
defaultValue=""
onChange={(value) => console.log('Email:', value)}
placeholder="sally@ride.com"
type="email"
/>
</label>
</div>
</Dropdown>
);
}
```
### Placement Customization with CSS Variables
Placement is best customized in CSS instead of props. The component exposes
CSS custom properties for the most common low-level placement controls:
- `--uktdd-body-position-area`
- `--uktdd-body-position-try-fallbacks`
- `--uktdd-body-translate`
- `--uktdd-body-min-height`
- `--uktdd-body-min-width`
- `--uktdd-body-max-height`
- `--uktdd-body-max-width`
Example:
```css
.settings-dropdown {
--uktdd-body-position-area: bottom span-left;
--uktdd-body-position-try-fallbacks:
--uktdd-top-right, --uktdd-bottom-left, --uktdd-top-left;
--uktdd-body-translate: -8px 0;
}
.settings-dropdown .uktdropdown-body {
inline-size: 18rem;
}
```
This approach keeps the public React API small while still allowing precise
placement and sizing control when a product surface needs it.
### End-Aligned, Content-Sized Menu
For menus attached to controls near the right edge of the viewport, such as
an avatar menu in a fixed header, prefer customizing alignment only and let
the menu size itself from its contents.
```css
.avatar-menu {
--uktdd-body-position-area: bottom span-left;
--uktdd-body-position-try-fallbacks:
--uktdd-top-right, --uktdd-top-left, --uktdd-bottom-right;
}
```
This keeps the menu:
- aligned to the end edge of the trigger
- content-sized by default
- constrained only by the component’s max available space rules
Avoid hardcoding width for this pattern unless the product explicitly needs
a fixed-size menu.
## Keyboard Navigation & Accessibility
The dropdown implements full keyboard navigation:
- **Enter/Space**: Open dropdown or select highlighted item
- **Escape**: Close dropdown
- **Arrow Up/Down**: Navigate between items
- **Home/End**: Jump to first/last item
- **Type characters**: Search for items (when searchable)
For accessibility, the component focuses on semantic HTML structure and
keyboard navigation. It works best when you use appropriate HTML elements
in your dropdown content (like `<ul>` and `<li>` for lists, `<button>`
elements for actions, etc.).
### ARIA attributes
The trigger automatically receives `aria-haspopup`, `aria-expanded`, and
`aria-controls` pointing to the open body. The popup role is chosen based
on the dropdown’s mode:
- `aria-haspopup="listbox"` when `isSearchable` is true (combobox pattern)
- `aria-haspopup="menu"` when `hasItems` is true (the default)
- `aria-haspopup="dialog"` when `hasItems={false}` (interactive content)
The open body element also receives a matching `role` and an `id` so screen
readers can associate the trigger with its popup. If your custom trigger
already specifies any of these ARIA props, your values win — the component
only fills in what you haven’t set.