Skip to content

Commit bf64381

Browse files
alexcarpenterclaude
andcommitted
feat(headless): add Select primitive
Dropdown with typeahead search, item-to-trigger alignment, FloatingList + useListNavigation for keyboard nav, controllable open/value state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 24adffa commit bf64381

6 files changed

Lines changed: 1509 additions & 0 deletions

File tree

packages/headless/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
"private": true,
55
"type": "module",
66
"exports": {
7+
"./select": {
8+
"import": "./dist/primitives/select/index.js",
9+
"types": "./dist/primitives/select/index.d.ts"
10+
},
711
"./popover": {
812
"import": "./dist/primitives/popover/index.js",
913
"types": "./dist/primitives/popover/index.d.ts"
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# Select
2+
3+
A dropdown select component with keyboard navigation, typeahead, and optional item-to-trigger alignment. Replaces native `<select>` with a fully styled, accessible alternative.
4+
5+
## When to Use
6+
7+
- Picking a single value from a predefined list of options.
8+
- When you need typeahead, keyboard navigation, and full styling control.
9+
- Prefer Select over Autocomplete when the user should choose from a fixed list without typing to filter.
10+
11+
## Usage
12+
13+
```tsx
14+
import { Select } from '@/primitives/select';
15+
16+
<Select>
17+
<Select.Trigger>
18+
<Select.Value placeholder='Choose a fruit...' />
19+
</Select.Trigger>
20+
<Select.Positioner>
21+
<Select.Popup>
22+
<Select.Option
23+
value='apple'
24+
label='Apple'
25+
/>
26+
<Select.Option
27+
value='banana'
28+
label='Banana'
29+
/>
30+
<Select.Option
31+
value='cherry'
32+
label='Cherry'
33+
/>
34+
</Select.Popup>
35+
</Select.Positioner>
36+
</Select>;
37+
```
38+
39+
### Controlled
40+
41+
```tsx
42+
const [value, setValue] = useState('apple');
43+
44+
<Select
45+
value={value}
46+
onValueChange={setValue}
47+
>
48+
{/* ... */}
49+
</Select>;
50+
```
51+
52+
### With `items` for SSR label resolution
53+
54+
The `items` prop allows label resolution before options mount (useful for server rendering or deferred lists):
55+
56+
```tsx
57+
const items = [
58+
{ value: 'apple', label: 'Apple' },
59+
{ value: 'banana', label: 'Banana' },
60+
];
61+
62+
<Select
63+
items={items}
64+
defaultValue='apple'
65+
>
66+
{/* Select.Value will display "Apple" even before Options mount */}
67+
</Select>;
68+
```
69+
70+
### Disable item-to-trigger alignment
71+
72+
By default, the selected option visually aligns with the trigger. Disable this for standard dropdown positioning:
73+
74+
```tsx
75+
<Select alignItemWithTrigger={false}>{/* Uses standard Floating UI positioning */}</Select>
76+
```
77+
78+
## Parts
79+
80+
| Part | Default Element | Description |
81+
| ------------------- | --------------- | ------------------------------------------ |
82+
| `Select` || Root context provider |
83+
| `Select.Trigger` | `<button>` | Toggles the dropdown on click |
84+
| `Select.Value` | `<span>` | Displays the selected label or placeholder |
85+
| `Select.Portal` || Portals children (accepts `root` prop) |
86+
| `Select.Positioner` | `<div>` | Floating positioned container |
87+
| `Select.Popup` | `<div>` | Visual wrapper for the option list |
88+
| `Select.Option` | `<button>` | A selectable option |
89+
| `Select.Arrow` | `<svg>` | Optional floating arrow |
90+
91+
## Props
92+
93+
### `Select` (root)
94+
95+
| Prop | Type | Default | Description |
96+
| ---------------------- | ------------------------- | ---------------- | ------------------------------------------------------------------ |
97+
| `value` | `string` || Controlled selected value |
98+
| `defaultValue` | `string` || Initial selected value (uncontrolled) |
99+
| `onValueChange` | `(value: string) => void` || Called when selection changes |
100+
| `open` | `boolean` || Controlled open state |
101+
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
102+
| `onOpenChange` | `(open: boolean) => void` || Called when open state changes |
103+
| `items` | `SelectItem[]` || `{ label, value }` pairs for label resolution before options mount |
104+
| `alignItemWithTrigger` | `boolean` | `true` | Visually align selected option over the trigger |
105+
| `placement` | `Placement` | `"bottom-start"` | Floating UI placement |
106+
| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) |
107+
108+
### `Select.Value`
109+
110+
| Prop | Type | Default | Description |
111+
| ------------- | ----------- | ------- | ------------------------------- |
112+
| `placeholder` | `ReactNode` || Shown when no value is selected |
113+
114+
### `Select.Option`
115+
116+
| Prop | Type | Default | Description |
117+
| ---------- | --------- | --------------------- | -------------------------------------- |
118+
| `value` | `string` | **required** | The option's value |
119+
| `label` | `string` | falls back to `value` | Display label, also used for typeahead |
120+
| `disabled` | `boolean` || Prevents selection |
121+
122+
### `Select.Trigger`, `Select.Positioner`, `Select.Popup`
123+
124+
No additional props beyond standard HTML attributes and the `render` prop.
125+
126+
### `Select.Arrow`
127+
128+
Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically.
129+
130+
## Keyboard Navigation
131+
132+
| Key | Action |
133+
| ----------------- | ------------------------------------- |
134+
| `ArrowDown` | Move to next option |
135+
| `ArrowUp` | Move to previous option |
136+
| `Enter` / `Space` | Select the active option, close popup |
137+
| `Escape` | Close the popup |
138+
| Type a character | Jump to matching option (typeahead) |
139+
140+
Typeahead also works while the popup is closed — it changes the selected value directly.
141+
142+
## Data Attributes
143+
144+
| Attribute | Applies To | Description |
145+
| --------------------------------- | ---------- | ---------------------------------------- |
146+
| `data-cl-slot` | All parts | Part identifier (e.g. `"select-option"`) |
147+
| `data-cl-open` / `data-cl-closed` | Trigger | Popup open state |
148+
| `data-cl-selected` | Option | The currently selected option |
149+
| `data-cl-active` | Option | The keyboard-highlighted option |
150+
| `data-cl-disabled` | Option | Disabled option |
151+
| `data-cl-side` | Positioner | Resolved placement side |
152+
153+
## Important Notes
154+
155+
- **`label` on `Select.Option`** drives both display in `Select.Value` and typeahead matching. If omitted, `value` is used for both.
156+
- **`items` prop** is only for label resolution — it does not control which options render. You still render `Select.Option` children yourself.
157+
- **Disabled options** can still receive keyboard focus but cannot be selected.
158+
159+
## ARIA
160+
161+
- Popup: `role="listbox"`
162+
- Option: `role="option"`, `aria-selected`, `aria-disabled`
163+
- Trigger: `aria-expanded`, `aria-haspopup="listbox"`, `aria-controls`
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export type {
2+
SelectArrowProps,
3+
SelectItem,
4+
SelectOptionProps,
5+
SelectPopupProps,
6+
SelectPortalProps,
7+
SelectPositionerProps,
8+
SelectProps,
9+
SelectTriggerProps,
10+
SelectValueProps,
11+
} from './select';
12+
export { Select } from './select';

0 commit comments

Comments
 (0)