# Dropdown

**When to use:** Single-value selection from a list — country pickers, status selectors, category filters, any select-style input.

**Keywords:** combobox, bottom sheet, action sheet, mobile, touch

**Import:** `import { Dropdown, Presentation } from '@ahrowe/ui'`  
**Types:** `import type { DropdownItem, DropdownProps } from '@ahrowe/ui'`

```tsx
import { Dropdown, FormValidator, Validators } from '@ahrowe/ui';
import type { DropdownItem } from '@ahrowe/ui';

const items: DropdownItem[] = [
  { key: 'draft', label: 'Draft' },
  { key: 'published', label: 'Published' },
  { key: 'archived', label: 'Archived', disabled: true },
];

// Controlled
<Dropdown
  label="Status"
  value={status}
  items={items}
  onChange={(value) => setStatus(value as string)}
  isRequired
/>

// With FormValidator
const statusValidator = new FormValidator('', [Validators.required()]);
<Dropdown label="Status" items={items} formValidator={statusValidator} />

// Row layout (label left, input right)
<Dropdown label="Status" items={items} value={status} onChange={onChange} isRow />

// Bottom sheet on small touch screens, anchored to the input everywhere else
<Dropdown label="Status" items={items} value={status} onChange={onChange} presentation={Presentation.Auto} />

// Infinite scroll: more items as the end of the open list comes near
<Dropdown
  items={items}
  onLoadMore={hasMore ? loadMoreItems : undefined}
/>
```

Loading on scroll suits a list people browse. To find one entry among hundreds, typing beats
scrolling: InputDropdown filters as the user types.

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `label` | `string` | Floating label |
| `value` | `string \| number` | Selected key |
| `items` | `DropdownItem[]` | `{ key, label, disabled? }`, where `key` is a `string` or a `number` |
| `aria-label` | `string` | Names a field that has no `label`, such as one in a table row. It lands on the trigger, the combobox |
| `onChange` | `(value, customProps?) => void` | Selection callback. `value` is the picked item's `key`, typed `unknown`: cast it to your key type (`value as Role`) |
| `formValidator` | `FormValidator` | Connects to validation |
| `isRequired` | `boolean` | |
| `isRow` | `boolean` | Horizontal layout |
| `disabled` | `boolean` | Greys the field out and prevents interaction |
| `readOnly` | `boolean` | Locks the selection without the disabled styling — the field keeps its normal appearance and stays focusable, but the menu won't open and the value can't change (the chevron is hidden). Default `false` |
| `placeholder` | `string` | Placeholder when no selection |
| `onLoadMore` | `() => Promise<void>` | Loads more items as the end of the open list comes near, with a spinner meanwhile. Append what it loads. Called again while the end stays near, until a load appends nothing; pass `undefined` once there are no more to save that last call. A rejected load is tried again after three seconds |
| `useMatLabelStyle` | `boolean` | Material-style floating label |
| `presentation` | `Presentation` | Where the list opens: anchored to the input (`Default`), always a bottom sheet, or a sheet only on a small touch screen. Imported from the same package: `import { Presentation } from '@ahrowe/ui'` |
| `alwaysFloatLabel` | `boolean` | Keep the material label floated in the border notch even when nothing is selected, so an empty dropdown reads as blank instead of showing the label as if it were the selected value. Only applies with `useMatLabelStyle` (default) |

The list is rendered through a portal and tracks the trigger across scroll, so it always escapes clipping ancestors (cards, scroll containers, virtualized lists). The panel itself is a [Popover](Popover.md), so it flips, clamps and presents as a bottom sheet the same way every other anchored surface in the library does. It flips to open upward automatically when there's no room below, and shifts horizontally to stay within the viewport when the trigger sits near the left or right edge of the screen. It hides (without closing) if the trigger itself scrolls behind a clipping ancestor, reappearing once it's back in view. It also re-measures when its own content changes size while open, so a list that shrinks (e.g. filtered down to fewer entries) stays anchored to the trigger instead of hanging above it.

**Bottom sheet:** `presentation={Presentation.Sheet}` drops the anchoring entirely and pins the list to the bottom of the screen instead, full width up to 560px, behind a backdrop, scrolling internally past 70% of the viewport height and lifting itself above an open on-screen keyboard, shrinking to fit the space that leaves rather than running off the top of the screen. Tapping the backdrop closes the list without also pressing whatever sits behind it. It is the same list with the same keyboard handling, so selection, highlighting and infinite scroll are unchanged. `Presentation.Auto` does that only when the pointer is coarse *and* the screen is under 768px wide. The default is `Presentation.Default`. A whole app can opt in with `<ConfigProvider defaultProps={{ Dropdown: { presentation: Presentation.Auto } }}>`, or for every sheet-capable component at once with `<ConfigProvider presentation={Presentation.Auto}>` — see [ConfigProvider.md](ConfigProvider.md).

**Sheet dimensions:** as a sheet it spans the full width up to `--sheet-max-width` (560px) and centres beyond it, caps its height at `--sheet-max-height` (70dvh) of the space actually visible, and clears a phone's home indicator with `--sheet-safe-padding` (8px, or the device's own safe-area inset where that is larger). Set any of them per instance via `style` or theme-wide via `ThemeProvider`.

**Keyboard:** `ArrowDown` / `ArrowUp` open the list and move the highlight, wrapping at the ends and
skipping disabled items. `Home` / `End` jump to the first and last. `Enter` and `Space` open the list,
then pick the highlighted item. `Escape` and `Tab` close it without selecting. Typing a letter jumps to
the next item whose label starts with it, as a native `<select>` does; typing while the list is closed
selects that item outright. Keystrokes within half a second of each other match as one prefix.

**Screen readers:** the trigger is a `combobox` named by the field's `label`, or by `aria-label` for a
field without one, with the selected item as its value, `aria-expanded`, and `aria-activedescendant` pointing at the highlighted option while the
list is open. The list is a `listbox` of `option`s carrying `aria-selected` and `aria-disabled`. The
chevron is `aria-hidden`, because it only repeats what the trigger already offers. The focus stays on
the trigger the whole time the list is open.

Nothing highlights on open unless `scrollToSelected` is set, which starts on the current value.

**Width:** the control fills its container, the way `Input` and `MultiSelect` do. Constrain it from the
outside (a wrapper, a grid column, `style={{ maxWidth: 240 }}`) rather than expecting it to size itself
to the label: everything inside is absolutely positioned, so it has no width of its own to fall back on.
Where the container sizes to its content instead (a ListItem's `endContent`, an inline row), give it a
`width`: a `maxWidth` alone lets it collapse to little more than its chevron.

**Slots:** `root` `label` `field` `control` `icon` `placeholder` `list` `option`

## Migration from `onScroll`

Before 0.51 the load-more callback was `onScroll`, and it was never called. TypeScript points at
every use:

| Before | After |
|--------|-------|
| `onScroll={…}` | `onLoadMore={…}` |

Loading now actually happens, so pass `undefined` once there is nothing more to load.
