@antadesign/anta 0.3.17 → 0.3.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +38 -242
  2. package/dist/components/Input.d.ts +4 -1
  3. package/dist/components/Input.js +2 -0
  4. package/dist/components/InputDate.css +1 -0
  5. package/dist/components/InputDate.d.ts +1 -0
  6. package/dist/components/InputDate.js +5 -5
  7. package/dist/components/Loader.d.ts +39 -0
  8. package/dist/components/Loader.js +33 -0
  9. package/dist/components/Menu.d.ts +5 -1
  10. package/dist/components/Menu.js +3 -2
  11. package/dist/components/Progress.d.ts +17 -8
  12. package/dist/components/Progress.js +12 -9
  13. package/dist/components/Select.d.ts +1 -0
  14. package/dist/components/Select.js +9 -13
  15. package/dist/components/SelectFaceted.d.ts +1 -0
  16. package/dist/components/SelectFaceted.js +29 -23
  17. package/dist/components/select-parts.css +1 -0
  18. package/dist/elements/a-button.css +1 -1
  19. package/dist/elements/a-icon.d.ts +1 -0
  20. package/dist/elements/a-icon.js +1 -0
  21. package/dist/elements/a-input.css +1 -1
  22. package/dist/elements/a-loader.css +1 -0
  23. package/dist/elements/a-loader.d.ts +12 -0
  24. package/dist/elements/a-loader.js +15 -0
  25. package/dist/elements/a-menu.js +12 -0
  26. package/dist/elements/a-progress.css +1 -1
  27. package/dist/elements/a-progress.js +32 -2
  28. package/dist/elements/a-slider.css +1 -1
  29. package/dist/elements/a-tabs.css +1 -1
  30. package/dist/elements/a-tabs.d.ts +3 -0
  31. package/dist/elements/a-tabs.js +40 -1
  32. package/dist/elements/index.d.ts +1 -0
  33. package/dist/elements/index.js +3 -0
  34. package/dist/general_types.d.ts +27 -2
  35. package/dist/index.d.ts +6 -1
  36. package/dist/index.js +2 -0
  37. package/dist/jsx-runtime.d.ts +11 -1
  38. package/dist/reset.css +1 -1
  39. package/dist/theme-anta.css +1 -1
  40. package/docs/accessibility.md +34 -0
  41. package/docs/changelog.md +551 -0
  42. package/docs/colors.md +42 -0
  43. package/docs/comparison.md +28 -0
  44. package/docs/components/banner.md +267 -0
  45. package/docs/components/button.md +603 -0
  46. package/docs/components/card.md +322 -0
  47. package/docs/components/checkbox.md +297 -0
  48. package/docs/components/dialog.md +314 -0
  49. package/docs/components/expander.md +529 -0
  50. package/docs/components/icon.md +155 -0
  51. package/docs/components/input-autocomplete.md +101 -0
  52. package/docs/components/input-date.md +406 -0
  53. package/docs/components/input-time.md +174 -0
  54. package/docs/components/input.md +610 -0
  55. package/docs/components/loader.md +139 -0
  56. package/docs/components/menu.md +447 -0
  57. package/docs/components/progress.md +182 -0
  58. package/docs/components/radio.md +369 -0
  59. package/docs/components/select-faceted.md +456 -0
  60. package/docs/components/select.md +931 -0
  61. package/docs/components/slider.md +142 -0
  62. package/docs/components/switch.md +198 -0
  63. package/docs/components/tabs.md +471 -0
  64. package/docs/components/tag.md +185 -0
  65. package/docs/components/text.md +148 -0
  66. package/docs/components/title.md +146 -0
  67. package/docs/components/toaster.md +313 -0
  68. package/docs/components/tooltip.md +177 -0
  69. package/docs/credits.md +22 -0
  70. package/docs/index.md +52 -0
  71. package/docs/install-config.md +240 -0
  72. package/docs/normalization.md +105 -0
  73. package/docs/overview.md +10 -0
  74. package/docs/packages/stickers.md +187 -0
  75. package/docs/packages/table.md +153 -0
  76. package/docs/theming.md +13 -0
  77. package/package.json +10 -6
@@ -0,0 +1,931 @@
1
+ # Select
2
+
3
+ A single- or multi-select dropdown: a read-only field showing the chosen value(s)
4
+ over a menu of options. Click to open; it anchors to the field, dismisses on
5
+ outside-click or `Esc`, and highlights the chosen row.
6
+
7
+ It's a **composed component**, an [Input](./input.md) trigger stitched to a
8
+ [Menu](./menu.md). Options are a JSON array of strings or objects, like
9
+ [RadioGroup](./radio.md) and [Tabs](./tabs.md); changes arrive
10
+ through one **`onValueChange`**. Controlled via `value`, uncontrolled via
11
+ `defaultValue`.
12
+
13
+ ## Options
14
+
15
+ ```tsx
16
+ <Select
17
+ label="Field"
18
+ options={[
19
+ { value: 'output_text', hint: 'Raw log message or event payload', icon: 'file' },
20
+ { value: 'stream', hint: 'Log level: error · info · internal', icon: 'braces' },
21
+ { value: 'container', hint: 'Host where the event occurred', icon: 'cube' },
22
+ { value: 'archived', hint: 'Read-only snapshot', icon: 'folder-close', disabled: true },
23
+ { value: 'flagged', hint: 'Needs review', icon: 'warning-triangle', tone: 'warning' },
24
+ { value: 'custom', hint: 'Your own field path', icon: 'asterisk', tone: '#c026d3' },
25
+ ]}
26
+ />
27
+ ```
28
+
29
+ `options` takes bare strings or objects. A string is shorthand for
30
+ `{ value, label: value }`. An object adds a `label`, a `hint` (the secondary line),
31
+ a leading `icon`, a per-option `tone` (a named tone or a custom CSS color), or
32
+ `disabled`. Open the field: the chosen row is highlighted, and the disabled row is
33
+ skipped by the keyboard.
34
+
35
+ ## Selection modes
36
+
37
+ ```tsx
38
+ // Single-select marks (single value)
39
+ <Select indicator="check" options={OPTIONS} value={one} onValueChange={setOne} /> // trailing ✓ + tint
40
+ <Select indicator="radio" options={OPTIONS} value={one} onValueChange={setOne} /> // leading radio
41
+ <Select options={OPTIONS} value={one} onValueChange={setOne} /> // tint only (default)
42
+
43
+ // Tone the selected row: named tone or custom colour (the indicator adopts it)
44
+ <Select indicator="check" toneSelected="brand" options={OPTIONS} value={one} onValueChange={setOne} />
45
+ <Select indicator="radio" toneSelected="#c026d3" options={OPTIONS} value={one} onValueChange={setOne} />
46
+
47
+ // Multiple: checkboxes, count summary, Select-all row (default); value is an array
48
+ <Select
49
+ selection="multiple"
50
+ options={OPTIONS}
51
+ value={many}
52
+ onValueChange={setMany}
53
+ />
54
+ ```
55
+
56
+ `selection` sets **behaviour**, `indicator` sets the single-select **mark**.
57
+
58
+ `selection="single"` (the default) keeps `value` a single value and closes on pick.
59
+ `indicator` then picks the row mark: **`"none"`** (default; a tint-only highlight),
60
+ **`"check"`** (a trailing checkmark on the chosen row that keeps the tint, the
61
+ canonical Select look), or **`"radio"`** (a leading radio on every row).
62
+
63
+ `selection="multiple"` makes `value` an **array**, puts a **checkbox** on
64
+ every row, keeps the menu open as you toggle, and summarises the selection in the
65
+ field: **`All`** when every option is on, the single label for one, else **"N
66
+ selected"** (pass **`verbose`** to spell the picks out — `3 selected: A, B, C`; or
67
+ take over the text entirely with [`renderSummary`](#selection-summary); an empty
68
+ selection shows the `placeholder`). A
69
+ **Select all** top row shows by default; it toggles everything, and
70
+ its box goes mixed when only some are on. Pass **`selectAll={false}`** to drop it.
71
+
72
+ On a multi-select, **`Alt`+Click** a row (⌥+Click on macOS) to *isolate* it: clear
73
+ the rest and select only that one. A cursor-following hint teaches the accelerator;
74
+ override it per row with `SelectOption.tooltip` (or `''` to suppress). This rides on
75
+ the Select all row, so `selectAll={false}` drops it too.
76
+
77
+ Each checkable row is the control itself (`role="menuitemcheckbox"` /
78
+ `menuitemradio`, `aria-checked`); the checkbox / radio / check is a passive indicator
79
+ the row drives.
80
+
81
+ **`toneSelected`** tones the selected row(s): the whole row takes the tone, including
82
+ label, icon, indicator, and tint. Pass a named tone or any CSS color (a custom color
83
+ keeps its hue, with lightness pinned to the brand text). It reads strongest with
84
+ `indicator` `none` / `check`, which show the row tint; `radio` / `checkbox` have no
85
+ row tint, so it tones the label and indicator only.
86
+
87
+ ## Filter
88
+
89
+ ```tsx
90
+ // Built-in matcher: case-insensitive substring of value / label / hint
91
+ <Select filter indicator="check" options={OPTIONS} value={v} onValueChange={setV} />
92
+
93
+ // Custom matcher: a per-option predicate
94
+ <Select
95
+ filter={(option, query) => option.value.startsWith(query)}
96
+ options={OPTIONS}
97
+ value={v}
98
+ onValueChange={setV}
99
+ />
100
+ ```
101
+
102
+ Add **`filter`** for a search field at the top of the menu that narrows the options
103
+ as you type. `true` uses the built-in matcher: a case-insensitive substring of the
104
+ option's **value, label, or hint**. It's **whitespace-flexible** (a typed space
105
+ matches any run of whitespace) and **bolds the matched substring** in the results.
106
+ Pass a **function** `(option, query) => boolean` for custom matching (no highlight).
107
+
108
+ The menu becomes a **combobox**: opening focuses the search field, `↑`/`↓` move a
109
+ cursor through the matches (focus stays in the field, so you keep typing), `Enter`
110
+ picks the active one, and `Esc` closes. The field stays **pinned** while the matches
111
+ scroll beneath it, and the menu re-anchors to the trigger as the list shrinks. No
112
+ matches shows a "No matches" row, and the query resets when the menu closes. In
113
+ `multiple` mode, `selectAll` acts on the currently-visible matches.
114
+
115
+ ## Value & changes
116
+
117
+ ```tsx
118
+ const [field, setField] = useState('stream')
119
+
120
+ <Select
121
+ label="Field"
122
+ options={['output_text', 'stream', 'container', 'vtime', 'custom']}
123
+ value={field}
124
+ onValueChange={(v) => setField(v)}
125
+ />
126
+ ```
127
+
128
+ The value is the **option's `value`** — `value="stream"` in single mode, an array
129
+ like `value={['stream', 'container']}` in `multiple`. It's the option's identity:
130
+ what `value` / `defaultValue` name and what `onValueChange` reports, so keep each
131
+ option's `value` **unique across the whole tree**. Selection is value-keyed and
132
+ global across groups / submenus, so a value repeated in two sections is one logical
133
+ pick (both rows toggle together; the trigger resolves to the last). Dev builds
134
+ `console.warn` on a duplicate.
135
+
136
+ Values aren't limited to strings. `Select` **infers its value type from `options`**:
137
+ pass `options={[{ value: 365 }, { value: 90 }]}` and `value`, `onValueChange`'s value,
138
+ and `attrs.value` are all `number` (the same for `boolean`). Values compare with `===`,
139
+ so `365` and `"365"` stay distinct and no string conversion is needed. The exported
140
+ `OptionValue` type is `string | number | boolean`; for anything richer (a date, an
141
+ object), give `value` a **stable primitive key** (a date's ISO string, a record's id)
142
+ and read the full value back off `attrs.option`.
143
+
144
+ ```tsx
145
+ // Rich value: id is the key, the full record rides on the option.
146
+ <Select
147
+ options={runs.map((r) => ({ value: r.id, label: r.name, run: r }))}
148
+ onValueChange={(id, attrs) => { if (!('all' in attrs)) openRun(attrs.option.run) }}
149
+ />
150
+
151
+ // Date filter: the ISO string is the value — round-trips as text, rebuild on read.
152
+ <Select
153
+ options={days.map((d) => ({ value: d.toISOString(), label: fmt(d) }))}
154
+ onValueChange={(iso) => setDay(new Date(iso))}
155
+ />
156
+ ```
157
+
158
+ Leave `value` off and pass `defaultValue` for an **uncontrolled** select (the
159
+ wrapper owns the value). Pass `value` to **control** it: the field follows the prop,
160
+ and a pick only requests a change that you apply in `onValueChange`.
161
+
162
+ **`onValueChange(value, attrs)`** is the single selection callback. The new value
163
+ comes first (a single value, or an array in `multiple` mode), with a snapshot
164
+ second: `{ value, option }` for the changed row, plus `selected` in `multiple` mode
165
+ (or `{ all: true }` for the Select-all row). There's **no `onStateChange`**: Select
166
+ has no discrete element state to veto (Checkbox and RadioGroup do), so it follows
167
+ Input's model of one value callback, and a controlled consumer rejects a pick by not
168
+ updating `value`. See the [event-model note on Input](./input.md#events) for
169
+ the rationale.
170
+
171
+ ### Deriving the selection tree
172
+
173
+ The callback hands you the new value (and the one row that changed); to read the
174
+ whole selection back against your hierarchy, use **`optionsWithSelection(options,
175
+ values)`**. It returns your `options` tree with every leaf marked `selected` and
176
+ every group / submenu carrying a rolled-up `selectionState` — `'all'`, `'none'`, or
177
+ `'some'` of its descendants. It's a pure function (no `Select` instance, reads
178
+ nothing off the event), so it behaves the same controlled or uncontrolled: hand it
179
+ your current `value` and render a grouped summary, a section indicator, or a diff.
180
+
181
+ The panel on the right is that projection, re-derived on every pick. Toggle scopes and
182
+ watch the leaf checks and the section `Tag`s (`some` / `all`) update:
183
+
184
+ ```tsx
185
+ import { Select, optionsWithSelection } from '@antadesign/anta'
186
+
187
+ const [values, setValues] = useState<string[]>(['repo.read', 'billing.view'])
188
+
189
+ // `values` drives the Select; the tree is derived from it and rendered read-only.
190
+ const tree = optionsWithSelection(scopes, values)
191
+ // tree: [ { label: 'Repositories', selectionState: 'some', options: [
192
+ // { value: 'repo.read', label: 'Read', selected: true },
193
+ // { value: 'repo.write', label: 'Write', selected: false }, … ] },
194
+ // { label: 'Members', selectionState: 'some', options: [ …,
195
+ // { label: 'Billing', selectionState: 'all', submenu: [
196
+ // { value: 'billing.view', label: 'View invoices', selected: true },
197
+ // { value: 'billing.manage', label: 'Manage plan', selected: true } ] } ] },
198
+ // { value: 'audit.read', label: 'Audit log', selected: false } ]
199
+
200
+ <Select selection="multiple" options={scopes} value={values} onValueChange={setValues} />
201
+ {/* render `tree` however you like — a summary, section indicators, a diff */}
202
+ ```
203
+
204
+ **Don't feed the tree back into `options` to control selection.** The projection
205
+ flows one way: `value` controls the `Select`, and the tree is a derived, read-only
206
+ view of it. `Select` resolves selection purely from `value` and never reads a
207
+ `selected` flag off `options`, so a `selected: true` in the tree changes nothing — it
208
+ rides along as inert data. Control the `Select` with the value array; keep the
209
+ annotated tree for rendering.
210
+
211
+ ```tsx
212
+ // ✗ Wrong — `selected` is ignored, so the Select isn't controlled by this
213
+ <Select options={optionsWithSelection(scopes, values)} />
214
+
215
+ // ✓ Right — selection is value-keyed; drive it with `value`
216
+ <Select options={scopes} value={values} onValueChange={setValues} />
217
+ ```
218
+
219
+ Bare-string options normalize to `{ value, label }`; a value under more than one
220
+ section marks the leaf everywhere it occurs (selection is value-keyed).
221
+
222
+ ## Size & status
223
+
224
+ ```tsx
225
+ <Select label="Small · info" size="small" status="info" hint="Lowercase & dashes only" options={OPTIONS} defaultValue="stream" />
226
+ <Select label="Medium · success" size="medium" status="success" hint="Field is valid" options={OPTIONS} defaultValue="stream" />
227
+ <Select label="Large · critical" size="large" status="critical" hint="Pick a field" options={OPTIONS} placeholder="Required…" />
228
+ {/* statusIcon overrides the per-status glyph, or `false` drops it, as on Input */}
229
+ <Select status="info" statusIcon="sparkles" hint="Custom glyph" options={OPTIONS} defaultValue="stream" />
230
+ ```
231
+
232
+ The trigger is an [Input](./input.md), so it inherits the field props.
233
+ **`size`** (`small` · `medium` · `large`) sets the height and type scale.
234
+ **`status`** paints a validation tone on the border, `hint`, and chevron, and
235
+ prefixes a glyph (`info` / `success` / `warning` / `critical`); **`statusIcon`**
236
+ overrides that glyph (any shape, or `false` to drop it), as on Input. The message
237
+ rides in `hint`.
238
+
239
+ `status` is a validation *state* over the same palette as `tone`: it also carries
240
+ the glyph and, for `critical`, validity. See the
241
+ [Input status note](./input.md#status). The field also takes `label`,
242
+ `placeholder`, `round`, and `disabled`, and fills its container; set a width via
243
+ `style` / `className` (both forwarded to the field).
244
+
245
+ ## Customization
246
+
247
+ Select is a composed wrapper with open seams. Rebuild it from the raw elements, or keep the wrapper and swap a row's **content** (`renderOption`) or its **selection mark** (`renderIndicator`) for your own nodes.
248
+
249
+ Pass `placement` to control where the options menu opens relative to its field. It
250
+ uses the same values as `Menu` and still flips or clamps when space is limited.
251
+ `offset` sets the gap in pixels between the field and the menu:
252
+
253
+ ```tsx
254
+ <Select placement="top-end" offset={8} options={OPTIONS} label="Field" />
255
+ ```
256
+
257
+ ### Components composition
258
+
259
+ `Select` has no `<a-select>` element; the wrapper *is* the coordinator. It renders
260
+ `<Input readOnly>` + `<Menu>`, reflects the value into the field and each option's
261
+ `selected`, and turns a pick into `onValueChange`. Positioning, dismissal, and
262
+ in-menu keyboard come from `Menu`. The same result is reachable **without React**:
263
+ the menu anchors to its previous sibling, so a field, a menu, and a little glue make
264
+ a working select:
265
+
266
+ ```html
267
+ <!-- `.select` is your own wrapper class; everything below is scoped to it. -->
268
+ <div class="select" style="width: 240px">
269
+ <!-- Read-only field trigger; `dim-actions` dims the chevron at rest. The
270
+ trailing slot supplies the inline inset (--input-trailing-inset); the
271
+ transition on the chevron matches <Select>. -->
272
+ <a-input readonly dim-actions value="stream" aria-haspopup="menu">
273
+ <a-icon slot="trailing" shape="chevron-down" style="transition: transform 150ms ease"></a-icon>
274
+ </a-input>
275
+ <a-menu role="menu">
276
+ <a-menu-item role="menuitemradio" tabindex="0" aria-checked="false" value="output_text"><a-menu-item-label>output_text</a-menu-item-label></a-menu-item>
277
+ <a-menu-item role="menuitemradio" tabindex="0" aria-checked="true" value="stream" selected><a-menu-item-label>stream</a-menu-item-label></a-menu-item>
278
+ <a-menu-item role="menuitemradio" tabindex="0" aria-checked="false" value="container"><a-menu-item-label>container</a-menu-item-label></a-menu-item>
279
+ </a-menu>
280
+ </div>
281
+ <script type="module">
282
+ import '@antadesign/anta/elements'
283
+ const root = document.querySelector('.select')
284
+ const field = root.querySelector('a-input')
285
+ const menu = root.querySelector('a-menu')
286
+ const chevron = field.querySelector('a-icon')
287
+ // Flip the chevron with the menu's open state (as <Select> does).
288
+ menu.addEventListener('statechange', (e) => {
289
+ chevron.style.transform = e.detail.next === 'open' ? 'rotate(180deg)' : ''
290
+ })
291
+ // On pick: mark the row selected + reflect its label into the field.
292
+ menu.addEventListener('click', (e) => {
293
+ const item = e.target.closest('a-menu-item')
294
+ if (!item) return
295
+ menu.querySelectorAll('a-menu-item').forEach((el) => {
296
+ const selected = el === item
297
+ el.toggleAttribute('selected', selected)
298
+ el.setAttribute('aria-checked', String(selected))
299
+ })
300
+ field.value = item.querySelector('a-menu-item-label').textContent
301
+ })
302
+ </script>
303
+ ```
304
+
305
+ That glue (tracking `selected`, flipping the chevron, updating the field) is what
306
+ the React `Select` packages.
307
+
308
+ ### Custom option rendering
309
+
310
+ ```tsx
311
+ const TONE = { in_progress: 'info', completed: 'neutral', incomplete: 'critical' }
312
+ const LABEL = { in_progress: 'In progress', completed: 'Completed', incomplete: 'Incomplete' }
313
+
314
+ // The field fills its container, so the wrapper's width bounds the trigger.
315
+ // (<Select style={{ width: 260 }} …> works too; style forwards to the field.)
316
+ <div style={{ width: 260 }}>
317
+ <Select
318
+ label="Test run"
319
+ filter // name / date stay searchable
320
+ options={runs} // { value, label, name, ranAt, status }[]
321
+ value={run}
322
+ onValueChange={setRun}
323
+ renderOption={(option) => {
324
+ const run = option as Run // index-signature fields are `unknown`
325
+ return (
326
+ <div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, gap: 2 }}>
327
+ {/* The ellipsized name is the anchor; the nested Tooltip (its children are the
328
+ bubble) reveals the full name when the name is clipped. */}
329
+ <span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
330
+ {run.name}
331
+ <Tooltip truncatedOnly>{run.name}</Tooltip>
332
+ </span>
333
+ <div style={{ display: 'flex', alignItems: 'center' }}>
334
+ <span style={{ color: 'var(--text-3)', fontSize: 12, fontVariantNumeric: 'tabular-nums' }}>{run.ranAt}</span>
335
+ <Tag size="small" tone={TONE[run.status]} style={{ marginInlineStart: 'auto' }}>{LABEL[run.status]}</Tag>
336
+ </div>
337
+ </div>
338
+ )
339
+ }}
340
+ />
341
+ </div>
342
+ ```
343
+
344
+ **`renderOption(option, state)`** replaces a row's **content** (the
345
+ `label`/`hint`/`icon` layout) with your own node. Select still supplies the row box,
346
+ click, ARIA, and the selection indicator; you return what goes *inside*. It styles
347
+ the menu **rows**. Set each option's `label` for the closed trigger text.
348
+
349
+ Attach your own fields to each option (`SelectOption` carries an index signature) and
350
+ read them back off `option`; those extra fields are typed `unknown`, so cast once at
351
+ the top. `state` gives you `value` / `selected` / `disabled` for the row. Give your
352
+ root `flex: 1; min-width: 0` so it fills the row (an inner `margin-inline-start: auto`
353
+ right-aligns a trailing element, like the status `Tag` here).
354
+
355
+ It **composes** with everything else: `filter` still matches the option's
356
+ `value` / `label` / `hint` (match-highlighting is skipped, since your content owns
357
+ its display), and `indicator` / `selection` still draw the mark. Replace that mark
358
+ too with `renderIndicator` (see [Custom indicators](#custom-indicators)).
359
+
360
+ ### Limiting the width
361
+
362
+ ```tsx
363
+ // Same ellipsis-ready row for both; it truncates once the menu is capped.
364
+ const renderPath = (o) => (
365
+ <span style={{ display: 'block', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
366
+ {o.label}
367
+ <Tooltip truncatedOnly>{o.label}</Tooltip> {/* reveals the full path when clipped */}
368
+ </span>
369
+ )
370
+
371
+ // First: no cap, so the widest row stretches the menu past the trigger.
372
+ <div style={{ width: 220 }}>
373
+ <Select label="No cap · grows to fit" options={paths} value={a} onValueChange={setA} renderOption={renderPath} />
374
+ </div>
375
+
376
+ // Second: cap the popover so the same rows truncate to the trigger width.
377
+ <style>{`.path-capped a-menu::part(menu) { max-width: 220px }`}</style>
378
+ <div className="path-capped" style={{ width: 220 }}>
379
+ <Select label="Capped · ellipsizes" options={paths} value={b} onValueChange={setB} renderOption={renderPath} />
380
+ </div>
381
+ ```
382
+
383
+ A menu **grows to fit its widest row**. The trigger width sets the floor; the
384
+ viewport sets the ceiling. Built-in `label`s stay bounded (they wrap), but **custom
385
+ `renderOption` content does whatever your CSS says**, so wide content stretches the
386
+ whole menu. Open both dropdowns: same rows, yet the first grows past its 220px field
387
+ while the second (capped) stays put and ellipsizes.
388
+
389
+ Enforce the limit on the **menu**: cap it with **`::part(menu) { max-width }`**. Only
390
+ then does ellipsis-ready content truncate, so give the row a shrinkable box
391
+ (`min-width: 0`) and single-line ellipsis (`overflow: hidden; text-overflow:
392
+ ellipsis; white-space: nowrap`). Nest a **`Tooltip truncatedOnly`** in that element
393
+ to reveal the full text on hover, only when it's clipped. `min-width: 0` alone won't
394
+ bound it: a shrink-to-fit popover grows to its content's max-content width, so the
395
+ cap is what does the work.
396
+
397
+ ### Custom indicators
398
+
399
+ ```tsx
400
+ <Select
401
+ label="Log level"
402
+ indicator="check" // keeps the semantics (role + aria-checked)
403
+ options={levels} // { value, label, dot }[]
404
+ value={level}
405
+ onValueChange={setLevel}
406
+ renderIndicator={(state) => {
407
+ const dot = levels.find((l) => l.value === state.value).dot
408
+ return (
409
+ <span style={{
410
+ width: 10, height: 10, borderRadius: '50%', display: 'inline-block',
411
+ boxShadow: `inset 0 0 0 2px ${dot}`,
412
+ background: state.selected ? dot : 'transparent',
413
+ }} />
414
+ )
415
+ }}
416
+ />
417
+ ```
418
+
419
+ **`renderIndicator(state)`** replaces the row's selection **mark** with your own
420
+ node, drawn at the **leading** edge. The row stays the control: keep an `indicator`
421
+ (`'check'` / `'radio'`) or `selection="multiple"` for the `role` + `aria-checked`
422
+ semantics, and only the drawn glyph changes. `state` gives you `value` / `selected` /
423
+ `disabled`, so the mark can reflect selection (here, an outline ring that fills when
424
+ chosen). It composes with `renderOption`, so you can override the content, the mark,
425
+ both, or neither.
426
+
427
+ ### Selection summary
428
+
429
+ ```tsx
430
+ const teams = ['Engineering', 'Design', 'Operations', 'Sales', 'Support']
431
+ const [a, setA] = useState(['eng', 'design', 'ops'])
432
+ const [b, setB] = useState(['eng', 'design'])
433
+
434
+ // verbose: the count summary lists the picks
435
+ <Select selection="multiple" placeholder="Pick teams" options={teams} value={a} onValueChange={setA} verbose />
436
+
437
+ // renderSummary: build the text yourself
438
+ <Select
439
+ selection="multiple"
440
+ placeholder="Pick teams"
441
+ options={teams}
442
+ value={b}
443
+ onValueChange={setB}
444
+ // one label; a count past that; every option → "All teams"
445
+ renderSummary={(selected) =>
446
+ selected.length === teams.length
447
+ ? 'All teams'
448
+ : selected.length > 1
449
+ ? `${selected.length} teams`
450
+ : undefined // fall back to the single label
451
+ }
452
+ />
453
+ ```
454
+
455
+ **`renderSummary(selected)`** builds the multi-select field text in place of the
456
+ built-in "`All` / one label / `N selected`". It runs while something is selected — an
457
+ empty selection still shows the `placeholder` — and takes the resolved `selected`
458
+ options (`selected.length` is the count). Return `undefined` for any case you'd rather
459
+ leave to the default, as above for the single-label case.
460
+
461
+ Return a **string**: it flows into the same read-only field the default uses, so a
462
+ long summary **ellipsizes at the field's width** the way a long value does
463
+ (`Engineering, Design, … `). For rich content — chips, several nodes — reach for
464
+ `renderTrigger` below, which replaces the whole field.
465
+
466
+ For the common case of just listing the picks, skip `renderSummary` and pass
467
+ **`verbose`**: the count summary becomes `3 selected: A, B, C` (labels comma-joined),
468
+ ellipsizing at the field width like any long value. `renderSummary` overrides it.
469
+
470
+ ### Custom trigger
471
+
472
+ ```tsx
473
+ const [value, setValue] = useState(['stream', 'message'])
474
+
475
+ <Select
476
+ selection="multiple"
477
+ options={fields}
478
+ value={value}
479
+ onValueChange={setValue}
480
+ renderTrigger={({ open, selected }) => (
481
+ // one focusable element the menu anchors to; it carries its own ARIA
482
+ <Button icon="filter" label="Filter" priority="secondary"
483
+ aria-haspopup="menu" aria-expanded={open ? 'true' : 'false'}>
484
+ {selected.length > 0 && (
485
+ <Tag size="small" priority="primary" tone="brand">
486
+ {selected.length === fields.length ? 'All' : selected.length}
487
+ </Tag>
488
+ )}
489
+ </Button>
490
+ )}
491
+ />
492
+ ```
493
+
494
+ ```tsx
495
+ const columns = ['Name', 'Status', 'Owner', 'Created', 'Duration', 'Environment', 'Branch', 'Commit']
496
+ const [hidden, setHidden] = useState([])
497
+
498
+ <Select
499
+ selection="multiple"
500
+ options={columns}
501
+ value={hidden}
502
+ onValueChange={setHidden}
503
+ // selecting a column hides it: eye by default, eye-closed once hidden
504
+ renderIndicator={({ selected }) => (
505
+ <Icon shape={selected ? 'eye-closed' : 'eye'} />
506
+ )}
507
+ renderTrigger={({ open, selected }) => (
508
+ <Button icon="columns-3-cog" priority="tertiary" aria-label="Configure columns"
509
+ aria-haspopup="menu" aria-expanded={open ? 'true' : 'false'}>
510
+ {selected.length > 0 && (
511
+ <Tag size="small" priority="secondary" tone="neutral">{selected.length}</Tag>
512
+ )}
513
+ </Button>
514
+ )}
515
+ />
516
+ ```
517
+
518
+ **`renderTrigger(state)`** swaps the default field for your own trigger. Return
519
+ **exactly one focusable element** (an Anta `Button` here) — the menu anchors to it (its
520
+ own previous DOM sibling) and opens it on click, so a fragment or a wrapping `<div>`
521
+ misanchors (the menu logs a console warning if the trigger has no focusable element).
522
+ It leaves ARIA to you, so add `aria-haspopup="menu"` and `aria-expanded={state.open}`.
523
+
524
+ `state` carries `open`, `value`, `selected`, `disabled`, and `icon`. `selected` is the
525
+ resolved option list, so the multi-select count is `selected.length` (rendered here
526
+ in a `Tag`, or `All` once every option is chosen). The field props (`label`, `size`,
527
+ `status`, …) don't apply, since you own the element — except `icon`, which is handed
528
+ through as `state.icon` so a custom trigger can place the same leading glyph (e.g. a
529
+ `Button`'s `icon`).
530
+
531
+ The second example pairs a custom trigger with `renderIndicator` for a **column
532
+ visibility** control: an icon-only `Button` (`columns-3-cog`, tertiary), and options
533
+ that are bare column names. Here selection means *hidden* — the mark reads `eye` while
534
+ a column shows and flips to `eye-closed` once it's selected off. The trigger reads its count off the
535
+ same `selected` list `renderTrigger` hands it, so the `Tag` stays empty until you turn a column off.
536
+
537
+ ### Empty state
538
+
539
+ ```tsx
540
+ const [options, setOptions] = useState(['bug', 'feature', 'chore', 'docs'])
541
+ const [value, setValue] = useState()
542
+ const [loading, setLoading] = useState(false) // your own fetch state
543
+
544
+ <Select
545
+ label="Tag"
546
+ filter
547
+ placeholder="Filter or create…"
548
+ options={loading ? [] : options}
549
+ value={value}
550
+ onValueChange={setValue}
551
+ renderEmpty={({ query }) =>
552
+ loading ? (
553
+ <MenuSeparator>Loading…</MenuSeparator>
554
+ ) : (
555
+ <>
556
+ <MenuSeparator>No options are matching the filter</MenuSeparator>
557
+ <MenuItem
558
+ icon="plus"
559
+ label={`Create "${query}"`}
560
+ onSelect={() => { setOptions((o) => [...o, query]); setValue(query) }}
561
+ />
562
+ </>
563
+ )
564
+ }
565
+ />
566
+ ```
567
+
568
+ **`renderEmpty({ query })`** fills the menu body when the option list is empty after
569
+ the filter runs — a "no results" line, a loading indicator, or a create-from-query
570
+ row. `query` is the current filter text: non-empty means the filter hid everything,
571
+ empty means there were no options to begin with.
572
+
573
+ There's **no built-in empty message** — omit `renderEmpty` and an empty list shows
574
+ nothing. Loading isn't a Select prop either: you already own the fetch, so gate a
575
+ `'Loading…'` branch on your own state (here a checkbox stands in for it).
576
+
577
+ Whatever you return renders where the rows would. Wrap a message in a `MenuSeparator`
578
+ — given text it renders a small muted caption and becomes an `aria-live="polite"`
579
+ region, so the message is announced. Return a `MenuItem` — like the `Create "…"` row
580
+ here, which arrow-keys and Enter reach like any option — to make it selectable.
581
+
582
+ ### Groups & submenus
583
+
584
+ ```tsx
585
+ // The same tree drives both modes — selection is global, so `selection` is the
586
+ // only difference between the two selects below.
587
+ const fields = [
588
+ 'output_text', // a plain option
589
+ { label: 'Log', options: ['stream', 'message'] }, // an inline group
590
+ {
591
+ label: 'Metadata', // a submenu (flyout)
592
+ icon: 'braces',
593
+ submenu: [
594
+ 'metadata.host',
595
+ { label: 'Tags', options: ['tag.env', 'tag.team'] }, // group inside submenu
596
+ { label: 'Custom', submenu: ['custom.a', 'custom.b'] }, // submenu inside submenu
597
+ ],
598
+ },
599
+ { label: 'Time', submenu: ['vtime'], disabled: true }, // disabled → cascades
600
+ ]
601
+
602
+ <Select label="Field · single" filter options={fields} value={one} onValueChange={setOne} />
603
+
604
+ <Select label="Fields · multiple" selection="multiple" filter options={fields} value={many} onValueChange={setMany} />
605
+ ```
606
+
607
+ An `options` entry is a plain option, a **group** (`{ label, options }`, an inline
608
+ titled section), or a **submenu** (`{ label, submenu }`, a flyout). They nest and mix
609
+ freely. Discriminated by shape — an `options` array is a group, a `submenu` array is a
610
+ submenu, everything else is an option (so those two keys are reserved on an option).
611
+
612
+ **Selection stays global.** The Select's `selection` (single / multiple) applies to
613
+ every leaf wherever it sits; group headings and submenu parents organize but aren't
614
+ selectable. One `value`, one `onValueChange`, one count — unchanged. The two selects
615
+ above share the exact same `fields` tree and differ only by `selection`. `disabled` on
616
+ a group or submenu cascades to all its descendants. Because selection is global, a
617
+ leaf's `value` must be **unique across the whole tree** — the same value under two
618
+ sections is one pick (dev builds warn).
619
+
620
+ **A query flattens the tree.** With `filter`, typing collapses submenus into inline
621
+ groups (their label becomes the heading) and drops empty ones, so results read as one
622
+ scannable list instead of hiding matches behind flyouts.
623
+
624
+ ### Props
625
+
626
+ | Prop | Type | Default | Description |
627
+ |------|------|---------|-------------|
628
+ | `options` | SelectItem[] | — | The options to choose from — bare strings, `SelectOption` objects, `SelectGroup`s
629
+ (inline titled sections), or `SelectSubmenu`s (flyout branches). Groups and
630
+ submenus nest and mix with plain options. Selection stays global (one `value`,
631
+ leaf options only); a filter query flattens the tree into grouped results.
632
+
633
+ `Select` infers its value type `V` from these options: `{ value: 365 }` makes
634
+ `onValueChange` report `number`. A mix of value types widens `V` to the union.
635
+
636
+ Each leaf `value` is the option's identity and must be **unique across the whole
637
+ tree** (selection is value-keyed, so a value repeated in two sections is one logical
638
+ pick: both rows toggle together, the trigger resolves to the last). Values that
639
+ stringify alike (`365` and `"365"`) also collide as row keys; dev builds
640
+ `console.warn` on either. |
641
+ | `clearable?` | boolean | — | Add a "Clear" row pinned in the menu **footer** that empties the selection
642
+ (single → none, multiple → `[]`). Shown only while something is selected, so
643
+ it never scrolls away in a long or filtered list. |
644
+ | `clearLabel?` | string | Clear | Label for the `clearable` footer row. |
645
+ | `defaultValue?` | V \| V[] | — | Initial value (an option's `value`) for the uncontrolled case — the
646
+ wrapper then owns it. |
647
+ | `disabled?` | boolean | — | Disable the whole select. |
648
+ | `filter?` | boolean \| (option, query) => boolean | — | Add a search field at the top of the menu that filters the options as you
649
+ type. `true` uses the built-in matcher — a case-insensitive substring of the
650
+ option's **value / label / hint**. Pass a **function** `(option, query) =>
651
+ boolean` for custom matching (called per option; return `true` to keep it). |
652
+ | `hint?` | string | — | Helper text under the field (Input's `hint`). |
653
+ | `icon?` | IconShape | — | Leading icon shown at the left of the field (the default trigger's `Input`
654
+ `leading` slot). With a custom `renderTrigger`, it's passed through as
655
+ `state.icon` instead — the consumer places it. |
656
+ | `indicator?` | 'none' \| 'check' \| 'radio' | none | The per-row mark for **single**-select: `'none'` (a tint-only highlight),
657
+ `'check'` (a trailing checkmark on the selected row, keeping the tint — the
658
+ canonical Select look), or `'radio'` (a leading radio on every row).
659
+ Multi-select always uses checkboxes. |
660
+ | `label?` | string | — | Field label, above the trigger (Input's `label`). |
661
+ | `leading?` | ReactNode | — | Arbitrary content for the default trigger's leading slot (piped straight to
662
+ `Input`'s `leading`) — e.g. a key prefix before the value. Overrides the
663
+ `icon`-derived glyph when both are set; include your own `<Icon>` if you want
664
+ one alongside. Ignored with a custom `renderTrigger`. |
665
+ | `offset?` | number | 4 | Gap in pixels between the trigger and the options menu. |
666
+ | `onValueChange?` | (value, attrs) => void | — | Fires after the selection changes, with the new value and a
667
+ `{ value, option }` snapshot. Select has no discrete element state, so
668
+ there is no cancelable `onStateChange` (see the Input event-model note). |
669
+ | `placeholder?` | string | — | Text shown when nothing is selected. |
670
+ | `placement?` | 'left' \| 'right' \| 'bottom' \| 'top' \| 'bottom-start' \| 'bottom-end' \| 'top-start' \| 'top-end' \| 'right-start' \| 'right-end' \| 'left-start' \| 'left-end' | bottom-start | Preferred placement of the options menu relative to the trigger. The menu
671
+ auto-flips vertically and clamps horizontally when needed. |
672
+ | `renderEmpty?` | (state) => ReactNode | — | Render content in the menu body when the (filtered) option list is empty —
673
+ a "no results" message, a loading indicator (gated on your own external
674
+ loading state), or a "create from the query" row. Receives an `EmptyState`
675
+ (`query`, trimmed). There is no built-in empty message: when omitted, an empty
676
+ list renders nothing. Whatever you return goes where the option rows would —
677
+ a plain node is inert; return a `MenuItem` (e.g. a "Create" row) to make it
678
+ focusable and selectable. |
679
+ | `renderIndicator?` | (state) => ReactNode | — | Replace each row's selection **mark** with your own node, drawn at the
680
+ leading edge. The row stays the control (`role` + `aria-checked` from
681
+ `indicator` / `selection`); only the drawn mark changes, so pair it with an
682
+ `indicator` (`'check'` / `'radio'`) or `selection="multiple"` for the
683
+ semantics. Composes with `renderOption`. |
684
+ | `renderOption?` | (option, state) => ReactNode | — | Render the **content** of each option row yourself, replacing the built-in
685
+ `label`/`hint`/`icon` layout. Select still supplies the row box, click, ARIA,
686
+ and the selection indicator — you return only what goes *inside*. Read extra
687
+ fields off the option (see `SelectOption`'s index signature) plus an
688
+ `OptionState` (`value`/`selected`/`disabled`). Filtering still works (it
689
+ matches the option's `value`/`label`/`hint`), but match-highlighting is
690
+ skipped — your content owns its own display. |
691
+ | `renderSummary?` | (selected) => string \| undefined | — | `multiple` only: build the trigger's selection summary text yourself,
692
+ replacing the built-in "`All` / one label / `N selected`" logic. Receives
693
+ the resolved selected options (`selected.length` is the count) and runs only
694
+ while something is selected — an empty selection still shows the
695
+ `placeholder`. Return a **string**: it flows into the default trigger's
696
+ read-only field, so a long summary ellipsizes at the field's width just
697
+ like a long value (`Engineering, Design, … `). Return `undefined` to fall
698
+ back to the default for that case (e.g. customize only the count, keeping
699
+ the single-label case built-in). For rich content (chips, multiple nodes)
700
+ use `renderTrigger`, which replaces the whole field. |
701
+ | `renderTrigger?` | (state) => ReactNode | — | Render your own trigger in place of the default field. Receives a
702
+ `TriggerState` (`open` / `value` / `selected` / `disabled` / `icon`) to drive
703
+ its look. **Return exactly one focusable element** (an Anta `Button`, say) —
704
+ the menu anchors to it (its own previous DOM sibling) and opens it on click,
705
+ so a fragment, multiple siblings, or a non-focusable wrapper will misanchor
706
+ (the menu warns in the console if the trigger has no focusable element). Give
707
+ the element `aria-haspopup="menu"` plus `aria-expanded={state.open}`. The custom
708
+ trigger owns its own styling: the field props (`label`, `hint`, `size`, `status`,
709
+ `placeholder`, `round`) and `className` / `style` apply to the *default* trigger
710
+ only — put your own on the element you return. |
711
+ | `round?` | boolean \| number \| string | — | Round the field corners — `true` for fully round, or a number / CSS length. |
712
+ | `selectAll?` | boolean | true | `multiple` only: a "Select all" row at the top that toggles every enabled
713
+ option (the currently-visible ones when a `filter` query is active); its box
714
+ shows the mixed state when only some are selected. On by default in
715
+ `multiple` mode — set `false` to drop the row (and with it the Alt/⌥-click
716
+ isolate accelerator it gates). |
717
+ | `selectAllLabel?` | string | Select all | Label for the `selectAll` row. |
718
+ | `selection?` | 'single' \| 'multiple' | single | Selection mode. `'single'` (the default) keeps `value` a single value and
719
+ closes the menu on pick. Switch to `'multiple'` for checkboxes + an
720
+ array value. |
721
+ | `size?` | 'small' \| 'medium' \| 'large' | medium | Field size. |
722
+ | `status?` | 'neutral' \| 'brand' \| 'info' \| 'success' \| 'warning' \| 'critical' | neutral | Validation/feedback tone for the field (Input's `status`). |
723
+ | `statusIcon?` | (string & {}) \| false \| IconShape | — | Glyph shown before the `hint` when `status` is set (Input's `statusIcon`).
724
+ Each status has a default; pass a shape to override, or `false` to drop it. |
725
+ | `toneSelected?` | 'neutral' \| 'brand' \| 'info' \| 'success' \| 'warning' \| 'critical' \| (string & {}) | — | Tone applied to the **selected** row(s) — the whole row takes this tone
726
+ (label, icon, indicator, and the background tint), like passing `tone` to just
727
+ the chosen option. A named tone or a custom CSS color. Most visible with the
728
+ tint-based marks (`indicator` `'none'` / `'check'`); with `'radio'` /
729
+ `'checkbox'` it tones the label + indicator (those modes have no row tint). |
730
+ | `value?` | V \| V[] | — | Controlled value — the `value` of the selected option. When provided, the
731
+ consumer owns selection: the field follows this prop and a pick only
732
+ *requests* a change via `onValueChange` (reject by not updating). Leave
733
+ undefined for uncontrolled. |
734
+ | `verbose?` | boolean | — | `multiple` only: spell the picks out in the count summary — `3 selected:
735
+ A, B, C` (labels comma-joined) in place of the bare `3 selected`. Applies
736
+ to the multi-count case only: `All` stays `All`, a single pick stays its
737
+ own label, and an empty selection stays the `placeholder`. The list flows
738
+ into the read-only field, so it ellipsizes at the field's width when long
739
+ (`3 selected: Engineering, Des… `). `renderSummary` overrides this. |
740
+
741
+ ### The `SelectItem` type
742
+
743
+ `options` takes a `SelectItem[]`. Each entry is one of four shapes, discriminated by
744
+ its keys — an `options` array is a group, a `submenu` array is a submenu, and a bare
745
+ string or a plain object is a single option:
746
+
747
+ | Shape | Renders as |
748
+ |---|---|
749
+ | `string` | One option; shorthand for `{ value: s, label: s }`. |
750
+ | `SelectOption` | One option with a label, hint, icon, tone, tooltip, or your own fields. |
751
+ | `SelectGroup` | A titled group of items, **inline** under a heading. |
752
+ | `SelectSubmenu` | A titled branch of items behind a **flyout**. |
753
+
754
+ `value` is unique across the whole tree — selection is value-keyed and global, so a
755
+ group or submenu only organizes (its heading isn't selectable), and a `disabled`
756
+ group or submenu cascades to every descendant.
757
+
758
+ The smallest form is three options, each carrying only a `value` (a bare string is
759
+ the same thing):
760
+
761
+ ```tsx
762
+ <Select
763
+ label="Priority"
764
+ options={[
765
+ { value: 'low' },
766
+ { value: 'medium' },
767
+ { value: 'high' },
768
+ ]}
769
+ />
770
+ // equivalent: options={['low', 'medium', 'high']}
771
+ ```
772
+
773
+ **`SelectOption`** — one selectable row.
774
+
775
+ | Field | Type | Description |
776
+ |---|---|---|
777
+ | `value` | `OptionValue` | The option's identity: `string`, `number`, or `boolean` (`Select` infers the type from your `options`, compared with `===`). What `value` / `defaultValue` name and `onValueChange` reports. Required, unique across the tree. See [Value & changes](#value--changes) for non-string values and rich data. |
778
+ | `label` | `string` | Visible label. Defaults to `value`. |
779
+ | `hint` | `string` | Secondary line under the label. |
780
+ | `icon` | `IconShape` | Leading icon, after the selection indicator. |
781
+ | `disabled` | `boolean` | Disable just this option. |
782
+ | `tone` | `string` | The row's color across label, icon, hint, selected tint, and indicator. A named tone (`neutral` / `brand` / `info` / `success` / `warning` / `critical`) or any CSS color. |
783
+ | `tooltip` | `React.ReactNode` | Row tooltip. In a `multiple` select with `selectAll`, a row with no `tooltip` shows the default "select only this" (Alt/⌥-click) hint; set it to override, or `''` to suppress. |
784
+ | `[key: string]` | `unknown` | Your own data — attach anything and read it back in `renderOption`. |
785
+
786
+ **`SelectGroup`** — a heading with items rendered inline beneath it.
787
+
788
+ | Field | Type | Description |
789
+ |---|---|---|
790
+ | `label` | `string` | The section heading (non-interactive). |
791
+ | `options` | `SelectItem[]` | The grouped items — options, or nested groups / submenus. |
792
+ | `disabled` | `boolean` | Disable the whole group (cascades to all descendants). |
793
+
794
+ **`SelectSubmenu`** — a parent row whose items live behind a flyout.
795
+
796
+ | Field | Type | Description |
797
+ |---|---|---|
798
+ | `label` | `string` | The parent row's label, and the group heading when a filter query flattens the tree. |
799
+ | `icon` | `IconShape` | Leading icon on the parent row. |
800
+ | `submenu` | `SelectItem[]` | The branch's items — options, or nested groups / submenus. |
801
+ | `disabled` | `boolean` | Disable the whole branch (cascades to all descendants). |
802
+
803
+ All four shapes mix in one `options` array — a bare string, a full `SelectOption`, an
804
+ inline `SelectGroup`, and a flyout `SelectSubmenu` (whose items are themselves
805
+ `SelectItem`s, so groups and submenus nest freely):
806
+
807
+ ```tsx
808
+ <Select
809
+ label="Field"
810
+ options={[
811
+ 'output_text', // string shorthand
812
+ { value: 'stream', label: 'Stream', hint: 'Log level', icon: 'braces' }, // SelectOption
813
+ { // SelectGroup — inline heading
814
+ label: 'Metadata',
815
+ options: [
816
+ { value: 'container', icon: 'cube' },
817
+ { value: 'file_path', icon: 'file' },
818
+ ],
819
+ },
820
+ { // SelectSubmenu — flyout branch
821
+ label: 'Advanced',
822
+ icon: 'folder-close',
823
+ submenu: [
824
+ 'raw_bytes',
825
+ { value: 'custom', hint: 'Your own field path', icon: 'asterisk', disabled: true },
826
+ ],
827
+ },
828
+ ]}
829
+ />
830
+ ```
831
+
832
+ Use this when you are not using the React or Preact wrapper and a native HTML control does not fit: construct the equivalent Anta web component from the elements below.
833
+
834
+ `Select` has no host element. Compose a read-only `<a-input>` trigger with an
835
+ `<a-menu>` of options. The menu owns opening and dismissal; the short controller
836
+ reflects a picked option into the field and selected row.
837
+
838
+ ```html
839
+ <div data-anta-composition="select" style="display: grid; gap: 4px; width: 280px">
840
+ <a-input readonly value="stream">
841
+ <span slot="label">Output</span>
842
+ <span slot="trailing"><a-icon shape="chevron-down" aria-hidden="true"></a-icon></span>
843
+ </a-input>
844
+ <a-menu role="menu">
845
+ <a-menu-item role="menuitem" tabindex="0" value="output_text"><a-menu-item-label>output_text</a-menu-item-label></a-menu-item>
846
+ <a-menu-item role="menuitem" tabindex="0" value="stream" selected><a-menu-item-label>stream</a-menu-item-label></a-menu-item>
847
+ </a-menu>
848
+ </div>
849
+ <script type="module">
850
+ import '@antadesign/anta/elements'
851
+
852
+ const root = document.querySelector('[data-anta-composition="select"]')
853
+ const field = root.querySelector('a-input')
854
+ const menu = root.querySelector('a-menu')
855
+
856
+ menu.addEventListener('click', (event) => {
857
+ const item = event.target instanceof Element ? event.target.closest('a-menu-item') : null
858
+ if (!item || item.closest('a-menu') !== menu) return
859
+ menu.querySelectorAll(':scope > a-menu-item').forEach((option) => {
860
+ const selected = option === item
861
+ option.toggleAttribute('selected', selected)
862
+ })
863
+ field.value = item.querySelector('a-menu-item-label').textContent.trim()
864
+ })
865
+ </script>
866
+ ```
867
+
868
+ ### Native HTML select
869
+
870
+ When a native single-value select is enough, add `data-anta` to `<select>`. The
871
+ modern customizable-select model keeps native selection, keyboard behavior, and
872
+ form submission while exposing its trigger `button`, `selectedcontent`, options,
873
+ and optgroups for styling. Anta styles every supported part; browsers without that
874
+ model retain their regular native select. Use `Select` for filtering, multiple
875
+ selection, nested menus, or custom option coordination.
876
+
877
+ Use `data-anta-size="small"` or `"large"` for the field size. Native selects
878
+ own their `size` attribute, and `round` gives the trigger matching rounded
879
+ corners.
880
+
881
+ ```html
882
+ <select data-anta data-anta-size="small" round name="output">
883
+ <!-- The native trigger; selectedcontent mirrors the chosen option. -->
884
+ <button>
885
+ <selectedcontent></selectedcontent>
886
+ <a-icon shape="chevron-down" aria-hidden="true"></a-icon>
887
+ </button>
888
+
889
+ <option value="" selected disabled>Choose an output</option>
890
+ <option value="stream">Stream</option>
891
+ <option value="output_text">Output text</option>
892
+ <optgroup label="Metadata">
893
+ <option value="container">Container</option>
894
+ <option value="file_path">File path</option>
895
+ </optgroup>
896
+ </select>
897
+ ```
898
+
899
+ Select is an [Input](./input.md) plus a [Menu](./menu.md), so it
900
+ inherits both surfaces' hooks. Reach for props first, then plain CSS or `::part` for
901
+ the rest. Don't override an element's internal `--*` output tokens.
902
+
903
+ **Selection colour** routes through the props: a per-option `tone`, or `toneSelected`
904
+ for the chosen row(s). Both take a named tone or any CSS color (a custom color keeps
905
+ its hue, with lightness pinned to the brand text).
906
+
907
+ **The field** is styled through Input's props (`size`, `status`, `round`) and its
908
+ `::part`s; set its width with `style` / `className` (forwarded to the field). **The
909
+ popover** takes Menu's `::part(menu)`.
910
+
911
+ ```tsx
912
+ // Tone the selection; give the trigger a width.
913
+ <Select toneSelected="brand" style={{ width: '220px' }} options={OPTIONS} />
914
+ ```
915
+
916
+ **Borderless trigger.** The field's border is an inset `box-shadow`, so you can drop
917
+ it *at rest* and let the standard hover / focus shadow return on its own; no need to
918
+ re-declare it. Pair that with hiding the chevron for a field that reads as plain text
919
+ until you reach for it. Hide the chevron by targeting the trailing `a-icon` (its slot
920
+ wrapper is inline-`display:contents`, so hit the icon itself).
921
+
922
+ ```css
923
+ /* Drop the resting border-shadow; the standard hover / focus shadow still shows. */
924
+ .ghost-select a-input:not(:hover):not(:focus-within)::part(field) {
925
+ box-shadow: none;
926
+ }
927
+ /* Centre the chosen label in the field. */
928
+ .ghost-select a-input::part(input) { text-align: center; }
929
+ /* The chevron's slot wrapper is inline display:contents; hide the icon itself. */
930
+ .ghost-select a-input [slot="trailing"] a-icon { display: none; }
931
+ ```