@antadesign/anta 0.3.18 → 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.
- package/README.md +38 -242
- package/dist/components/Input.d.ts +4 -1
- package/dist/components/Input.js +2 -0
- package/dist/components/InputDate.css +1 -0
- package/dist/components/InputDate.d.ts +1 -0
- package/dist/components/InputDate.js +5 -5
- package/dist/components/Menu.d.ts +5 -1
- package/dist/components/Menu.js +3 -2
- package/dist/components/Select.d.ts +1 -0
- package/dist/components/Select.js +9 -13
- package/dist/components/SelectFaceted.d.ts +1 -0
- package/dist/components/SelectFaceted.js +29 -23
- package/dist/components/select-parts.css +1 -0
- package/dist/elements/a-input.css +1 -1
- package/dist/elements/a-menu.js +12 -0
- package/dist/elements/a-slider.css +1 -1
- package/dist/elements/a-tabs.css +1 -1
- package/dist/elements/a-tabs.d.ts +3 -0
- package/dist/elements/a-tabs.js +40 -1
- package/dist/general_types.d.ts +2 -0
- package/dist/jsx-runtime.d.ts +9 -0
- package/docs/accessibility.md +34 -0
- package/docs/changelog.md +551 -0
- package/docs/colors.md +42 -0
- package/docs/comparison.md +28 -0
- package/docs/components/banner.md +267 -0
- package/docs/components/button.md +603 -0
- package/docs/components/card.md +322 -0
- package/docs/components/checkbox.md +297 -0
- package/docs/components/dialog.md +314 -0
- package/docs/components/expander.md +529 -0
- package/docs/components/icon.md +155 -0
- package/docs/components/input-autocomplete.md +101 -0
- package/docs/components/input-date.md +406 -0
- package/docs/components/input-time.md +174 -0
- package/docs/components/input.md +610 -0
- package/docs/components/loader.md +139 -0
- package/docs/components/menu.md +447 -0
- package/docs/components/progress.md +182 -0
- package/docs/components/radio.md +369 -0
- package/docs/components/select-faceted.md +456 -0
- package/docs/components/select.md +931 -0
- package/docs/components/slider.md +142 -0
- package/docs/components/switch.md +198 -0
- package/docs/components/tabs.md +471 -0
- package/docs/components/tag.md +185 -0
- package/docs/components/text.md +148 -0
- package/docs/components/title.md +146 -0
- package/docs/components/toaster.md +313 -0
- package/docs/components/tooltip.md +177 -0
- package/docs/credits.md +22 -0
- package/docs/index.md +52 -0
- package/docs/install-config.md +240 -0
- package/docs/normalization.md +105 -0
- package/docs/overview.md +10 -0
- package/docs/packages/stickers.md +187 -0
- package/docs/packages/table.md +153 -0
- package/docs/theming.md +13 -0
- 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
|
+
```
|