@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.
Files changed (59) 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/Menu.d.ts +5 -1
  8. package/dist/components/Menu.js +3 -2
  9. package/dist/components/Select.d.ts +1 -0
  10. package/dist/components/Select.js +9 -13
  11. package/dist/components/SelectFaceted.d.ts +1 -0
  12. package/dist/components/SelectFaceted.js +29 -23
  13. package/dist/components/select-parts.css +1 -0
  14. package/dist/elements/a-input.css +1 -1
  15. package/dist/elements/a-menu.js +12 -0
  16. package/dist/elements/a-slider.css +1 -1
  17. package/dist/elements/a-tabs.css +1 -1
  18. package/dist/elements/a-tabs.d.ts +3 -0
  19. package/dist/elements/a-tabs.js +40 -1
  20. package/dist/general_types.d.ts +2 -0
  21. package/dist/jsx-runtime.d.ts +9 -0
  22. package/docs/accessibility.md +34 -0
  23. package/docs/changelog.md +551 -0
  24. package/docs/colors.md +42 -0
  25. package/docs/comparison.md +28 -0
  26. package/docs/components/banner.md +267 -0
  27. package/docs/components/button.md +603 -0
  28. package/docs/components/card.md +322 -0
  29. package/docs/components/checkbox.md +297 -0
  30. package/docs/components/dialog.md +314 -0
  31. package/docs/components/expander.md +529 -0
  32. package/docs/components/icon.md +155 -0
  33. package/docs/components/input-autocomplete.md +101 -0
  34. package/docs/components/input-date.md +406 -0
  35. package/docs/components/input-time.md +174 -0
  36. package/docs/components/input.md +610 -0
  37. package/docs/components/loader.md +139 -0
  38. package/docs/components/menu.md +447 -0
  39. package/docs/components/progress.md +182 -0
  40. package/docs/components/radio.md +369 -0
  41. package/docs/components/select-faceted.md +456 -0
  42. package/docs/components/select.md +931 -0
  43. package/docs/components/slider.md +142 -0
  44. package/docs/components/switch.md +198 -0
  45. package/docs/components/tabs.md +471 -0
  46. package/docs/components/tag.md +185 -0
  47. package/docs/components/text.md +148 -0
  48. package/docs/components/title.md +146 -0
  49. package/docs/components/toaster.md +313 -0
  50. package/docs/components/tooltip.md +177 -0
  51. package/docs/credits.md +22 -0
  52. package/docs/index.md +52 -0
  53. package/docs/install-config.md +240 -0
  54. package/docs/normalization.md +105 -0
  55. package/docs/overview.md +10 -0
  56. package/docs/packages/stickers.md +187 -0
  57. package/docs/packages/table.md +153 -0
  58. package/docs/theming.md +13 -0
  59. package/package.json +10 -6
@@ -0,0 +1,456 @@
1
+ # SelectFaceted
2
+
3
+ A faceted filter: one trigger opens a menu of **facets** (dimensions), each a
4
+ submenu flyout with its own editor. The value is a `Record<facetKey, value>`, so
5
+ the same option value under two facets — assignee "Alice" and owner "Alice" —
6
+ stays distinct. Each facet declares a `kind`: `single`, `multiple` (with a
7
+ Select all row by default; `selectAll: false` drops it), `text` (free-form
8
+ substring, applied on Enter / blur), or `custom` (bring your own editor and
9
+ value, including an object).
10
+
11
+ It's a **composed component** built from [Menu](./menu.md), [Select](./select.md)'s
12
+ option model, [Input](./input.md), and [Tag](./tag.md) — no new element. Controlled via
13
+ `value`, uncontrolled via `defaultValue`; changes arrive through one
14
+ **`onValueChange`**.
15
+
16
+ Pass `placement` to control where the root facet menu opens relative to its
17
+ trigger. It uses the same values as `Menu` and still flips or clamps when space
18
+ is limited. `offset` sets the gap in pixels between the trigger and the menu:
19
+
20
+ ```tsx
21
+ <SelectFaceted placement="top-end" offset={8} facets={FACETS} />
22
+ ```
23
+
24
+ ```tsx
25
+ const FACETS = [
26
+ { key: 'assignee', label: 'Assignee', kind: 'multiple', filter: true, options: people },
27
+ { key: 'owner', label: 'Owner', kind: 'single', filter: true, options: people },
28
+ { key: 'status', label: 'Status', kind: 'single', options: [{ value: 'open', label: 'Open' }, /* … */] },
29
+ { key: 'title', label: 'Title contains', kind: 'text' },
30
+ { key: 'duration', label: 'Min duration', kind: 'custom', summary: (v) => `≥ ${v.min}s`, render: /* … */ },
31
+ ]
32
+
33
+ const isSet = (v) => !(v == null || v === '' || (Array.isArray(v) && v.length === 0))
34
+
35
+ function Demo() {
36
+ const [value, setValue] = useState({ assignee: ['Alice Nguyen'], status: 'open', title: 'crash' })
37
+ const setFacet = (key, v) =>
38
+ setValue((p) => (isSet(v) ? { ...p, [key]: v } : (({ [key]: _, ...rest }) => rest)(p)))
39
+
40
+ // Keep a text/custom chip mounted while it's focused, even when emptied — so
41
+ // backspacing to empty doesn't yank the field (only Clear or blur-when-empty does).
42
+ const [focusedKey, setFocusedKey] = useState(null)
43
+ const active = FACETS.filter((f) => isSet(value[f.key]) || f.key === focusedKey)
44
+
45
+ return (
46
+ <div style={{
47
+ display: 'flex',
48
+ flexDirection: 'row',
49
+ flexWrap: 'wrap',
50
+ gap: 12,
51
+ alignItems: 'center',
52
+ width: '100%',
53
+ }}>
54
+ <SelectFaceted facets={FACETS} value={value} onValueChange={setValue} searchable />
55
+
56
+ {active.length > 0 && (
57
+ <div style={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
58
+ {/* Each active facet's result is its own control, the key piped into `leading`. */}
59
+ {active.map((facet) => {
60
+ // Options facets → an editable Select (single shows the trailing check;
61
+ // both are filterable + have a clearable footer).
62
+ if (facet.kind === 'single' || facet.kind === 'multiple')
63
+ return (
64
+ <Select
65
+ key={facet.key}
66
+ selection={facet.kind}
67
+ indicator={facet.kind === 'single' ? 'check' : undefined}
68
+ options={facet.options}
69
+ value={value[facet.key]}
70
+ onValueChange={(v) => setFacet(facet.key, v)}
71
+ leading={`${facet.label}:`}
72
+ filter={facet.filter}
73
+ clearable
74
+ // Chip sizes to its value and caps at a max-width (ellipsizing past it).
75
+ style={{ width: 'fit-content', maxWidth: '240px' }}
76
+ />
77
+ )
78
+
79
+ // Free-form text / custom → an editable Input.
80
+ const val = facet.kind === 'text' ? value[facet.key] : value[facet.key]?.min
81
+ const write = (raw) =>
82
+ setFacet(facet.key, facet.kind === 'text' ? raw || undefined : raw ? { min: raw } : undefined)
83
+ return (
84
+ <Input
85
+ key={facet.key}
86
+ value={val ?? ''}
87
+ leading={`${facet.label}:`}
88
+ clearable
89
+ dimActions
90
+ onFocus={() => setFocusedKey(facet.key)}
91
+ onBlur={() => setFocusedKey(null)}
92
+ onInput={(e) => write(e.currentTarget.value)}
93
+ onClearInput={() => { setFocusedKey(null); setFacet(facet.key, undefined) }}
94
+ // Size the chip to its value, capped at a max-width.
95
+ style={{ width: 'fit-content', maxWidth: '240px' }}
96
+ />
97
+ )
98
+ })}
99
+ {/* Once more than one result is showing, a single control resets them
100
+ all — pushed to the right of the last chip. */}
101
+ {active.length > 1 && (
102
+ <Button
103
+ priority="tertiary"
104
+ icon="filter-x"
105
+ label="Clear all"
106
+ style={{ marginLeft: 'auto' }}
107
+ onClick={() => { setFocusedKey(null); setValue({}) }}
108
+ />
109
+ )}
110
+ </div>
111
+ )}
112
+ </div>
113
+ )
114
+ }
115
+ ```
116
+
117
+ The trigger opens the facet menu; each **active** facet's result renders below as its
118
+ own [Select](./select.md) — editable in place, with the facet key piped into the trigger's
119
+ `leading` slot as a prefix.
120
+
121
+ ### Value & changes
122
+
123
+ The value is a **record keyed by facet key** — `{ [facetKey]: value }` — so each facet's
124
+ selection lives under its own `key`. The shape per facet follows its `kind`: an option
125
+ value for `single`, an array for `multiple`, a string for `text`, your own `V` for
126
+ `custom`. A **cleared** facet is absent from the record (not `undefined`), and the
127
+ trigger's badge counts the present keys. Control it with `value` (+ `onValueChange`) or
128
+ seed `defaultValue` for the uncontrolled case.
129
+
130
+ Because one control mixes facets that hold different value types, the record is typed
131
+ `Record<string, unknown>`; narrow per facet when you read it.
132
+
133
+ **`onValueChange(value, attrs)`** fires after any change:
134
+
135
+ - **`value`** — the whole new record, e.g. `{ assignee: ['Alice'], status: 'open', title: 'crash' }`.
136
+ - **`attrs`** — what changed: `{ facet, kind, value }` for a single facet edit (the
137
+ facet's `key`, its `kind`, and its new value, `undefined` when that facet was cleared),
138
+ or `{ all: true }` for the **Clear all** row. Narrow on `'all' in attrs` before reading
139
+ `facet`.
140
+
141
+ ```tsx
142
+ <SelectFaceted
143
+ facets={FACETS}
144
+ onValueChange={(value, attrs) => {
145
+ if ('all' in attrs) return clearFilters() // the Clear all row
146
+ // `value` is the full record; `attrs.facet` / `attrs.value` is the one that changed
147
+ applyFilter(attrs.facet, attrs.value)
148
+ }}
149
+ />
150
+ ```
151
+
152
+ ### Value types
153
+
154
+ A `single` / `multiple` facet's option values follow [Select](./select.md#value--changes):
155
+ `string`, `number`, or `boolean` (the `OptionValue` type), compared with `===` and
156
+ returned through the value record unchanged. For a value that isn't a primitive, give the
157
+ option `value` a **stable primitive key** (a date's ISO string, a record's id) and read
158
+ the full value back by that key. When the whole facet is object-shaped (a date range, a
159
+ numeric comparator), use a **`custom`** facet: it owns its editor and value type `V`, so
160
+ it holds any shape and reports it back untouched.
161
+
162
+ ### Custom facet
163
+
164
+ A `custom` facet has **no option list**: you draw the editor and choose the value it
165
+ holds — a number, a date, a range, any object. Beyond the base `key` / `label` / `icon`,
166
+ provide two functions:
167
+
168
+ - **`render(ctx)`** — your editor, and the **only** place the value is produced.
169
+ `ctx.value` is the current value, `ctx.onChange(next)` sets it (`undefined` clears the
170
+ facet), `ctx.close()` dismisses the menu. Whatever you pass to `onChange` lands in
171
+ `value[key]` unchanged and comes back as `ctx.value` next render.
172
+ - **`summary(value)`** — the chip on the facet's row while a value is set. It's
173
+ **display-only** and decoupled from the value: the value round-trips through
174
+ `onChange` / `ctx.value`, so `summary` can return any node (or `null`) without changing
175
+ what you read from `value[key]`. The chip caps its width and ellipsizes a long
176
+ summary so it can't stretch the menu.
177
+
178
+ Nothing is compared or filtered inside the component; it stores the value and reports it
179
+ via `onValueChange`, and applying it to your rows is your code. Type the facet
180
+ `SelectFacetCustom<V>` so `ctx.value` and `summary` are typed.
181
+
182
+ A number, "min duration": the editor is one `Input`, which has no popup of its own, so it
183
+ sits directly in the flyout.
184
+
185
+ ```tsx
186
+ import type { SelectFacetCustom } from '@antadesign/anta'
187
+
188
+ const duration: SelectFacetCustom<string> = {
189
+ key: 'duration',
190
+ label: 'Min duration',
191
+ kind: 'custom',
192
+ render: (ctx) => (
193
+ <Input
194
+ // Controlled numeric field: type="text" + inputMode, not type="number"
195
+ // (which sanitizes "1." / "1.0" to empty). Keep the raw string; parse it below.
196
+ type="text"
197
+ inputMode="decimal"
198
+ size="small"
199
+ placeholder="seconds"
200
+ value={ctx.value ?? ''}
201
+ onInput={(e) => ctx.onChange(e.currentTarget.value || undefined)}
202
+ />
203
+ ),
204
+ summary: (v) => `≥ ${v}s`,
205
+ }
206
+
207
+ <SelectFaceted facets={[duration]} value={value} onValueChange={setValue} />
208
+
209
+ // user types 1.5 → value.duration === "1.5" (the raw string, returned unchanged)
210
+ // parse it where you apply the filter, so partial input ("1.", "1.0") types cleanly:
211
+ rows.filter((r) => value.duration == null || r.seconds >= Number(value.duration))
212
+ ```
213
+
214
+ A date, cast through a helper. Editors with **their own popup** (the inline `Calendar`,
215
+ `InputDate`, `Select`) work in the flyout too: each opens its menu stacked on top while
216
+ the flyout stays open. This one uses the inline `Calendar` grid; two helpers bridge its
217
+ ISO strings and the `Date` you store.
218
+
219
+ ```tsx
220
+ import type { SelectFacetCustom } from '@antadesign/anta'
221
+
222
+ const toISO = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` // Date → "2026-01-01"
223
+ const fromISO = (s: string | null) => (s ? new Date(`${s}T00:00:00`) : undefined) // ISO → Date | undefined
224
+
225
+ const since: SelectFacetCustom<Date> = {
226
+ key: 'since',
227
+ label: 'Created after',
228
+ kind: 'custom',
229
+ icon: 'calendar',
230
+ render: (ctx) => (
231
+ <Calendar
232
+ size="small"
233
+ value={ctx.value ? toISO(ctx.value) : ''}
234
+ // Controlled: apply the pick in onStateChange (onValueChange fires only after
235
+ // value changes, so it can't drive a controlled Calendar).
236
+ onStateChange={(_e, { next }) => ctx.onChange(fromISO(next))}
237
+ />
238
+ ),
239
+ summary: (d) => d.toLocaleDateString(),
240
+ }
241
+
242
+ <SelectFaceted facets={[since]} value={value} onValueChange={setValue} />
243
+
244
+ // user picks a day → value.since is a Date, returned unchanged
245
+ // you apply it:
246
+ rows.filter((r) => value.since == null || r.createdAt >= value.since)
247
+ ```
248
+
249
+ A `custom` facet holds the value whole, so a `Date` works here: it isn't compared by
250
+ `===` like a `single` / `multiple` option value, so there's no identity pitfall.
251
+
252
+ #### A recency filter: presets plus a range
253
+
254
+ A compound editor in one flyout: a `RadioGroup` of presets (Today, Yesterday, Last
255
+ 14 / 30 days) and a **Custom range** that reveals two `InputDate` fields. The facet
256
+ value is either `{ preset }` or `{ from, to }`, and a `resolveRange` helper turns
257
+ whichever one into the concrete `{ from, to }` you filter rows with (shown in the
258
+ readout under the trigger). Each `InputDate` opens its calendar stacked on top of the
259
+ flyout, which stays open throughout.
260
+
261
+ ```tsx
262
+ type Recency = { preset: 'today' | 'yesterday' | 'last14' | 'last30' } | { from: string; to: string }
263
+
264
+ // Whichever shape the facet holds, resolve it to a concrete range to filter with.
265
+ const resolveRange = (r: Recency): { from: string; to: string } => {
266
+ if ('from' in r) return r
267
+ const today = isoDay(daysAgo(0))
268
+ switch (r.preset) {
269
+ case 'today': return { from: today, to: today }
270
+ case 'yesterday': { const y = isoDay(daysAgo(1)); return { from: y, to: y } }
271
+ case 'last14': return { from: isoDay(daysAgo(13)), to: today }
272
+ case 'last30': return { from: isoDay(daysAgo(29)), to: today }
273
+ }
274
+ }
275
+
276
+ import type { SelectFacetCustom } from '@antadesign/anta'
277
+
278
+ const recency: SelectFacetCustom<Recency> = {
279
+ key: 'recency',
280
+ label: 'Recency',
281
+ kind: 'custom',
282
+ icon: 'calendar',
283
+ summary: (v) => ('preset' in v ? PRESET_LABELS[v.preset] : `${v.from} → ${v.to}`),
284
+ render: ({ value, onChange }) => {
285
+ const mode = value == null ? '' : 'preset' in value ? value.preset : 'custom'
286
+ const range = value && 'from' in value ? value : { from: '', to: '' }
287
+ return (
288
+ <div style={{ display: 'grid', gap: 8 }}>
289
+ <RadioGroup
290
+ size="small"
291
+ options={PRESETS} // Today / Yesterday / Last 14 / Last 30 / Custom range
292
+ value={mode}
293
+ // Controlled group: apply the pick in onStateChange (onValueChange fires
294
+ // only after `value` changes, so it can't drive a controlled RadioGroup).
295
+ onStateChange={(_e, { next }) => onChange(next === 'custom' ? range : { preset: next })}
296
+ />
297
+ {mode === 'custom' && (
298
+ <>
299
+ <InputDate size="small" label="From" value={range.from}
300
+ onValueChange={(from) => onChange({ from, to: range.to })} />
301
+ <InputDate size="small" label="To" value={range.to} min={range.from || undefined}
302
+ onValueChange={(to) => onChange({ from: range.from, to })} />
303
+ </>
304
+ )}
305
+ </div>
306
+ )
307
+ },
308
+ }
309
+
310
+ <SelectFaceted facets={[recency]} value={value} onValueChange={setValue} />
311
+ // value.recency is { preset: 'last14' } or { from, to } — apply resolveRange(value.recency) to your rows
312
+ ```
313
+
314
+ #### Menu-item presets, calendar on the right
315
+
316
+ The same filter with the presets as `MenuItem` rows (a check marks the active one) and
317
+ a single **Custom day** that reveals one `InputDate`, its calendar opening to the
318
+ **right** via `placement="right-start"`. That helps when the trigger sits on the left of
319
+ a wide layout. `placement` (and `offset`) forward to the calendar's `Menu`; `right` /
320
+ `left` are new alongside the existing `top` / `bottom`.
321
+
322
+ ```tsx
323
+ type Recency = { preset: 'today' | 'yesterday' | 'last14' | 'last30' } | { day: string }
324
+
325
+ const recency: SelectFacetCustom<Recency> = {
326
+ key: 'recency',
327
+ label: 'Recency',
328
+ kind: 'custom',
329
+ icon: 'calendar',
330
+ summary: (v) => ('preset' in v ? PRESET_LABELS[v.preset] : v.day || 'Custom day'),
331
+ render: ({ value, onChange }) => {
332
+ const mode = value == null ? '' : 'preset' in value ? value.preset : 'custom'
333
+ const day = value && 'day' in value ? value.day : ''
334
+ return (
335
+ <>
336
+ {DAY_PRESETS.map((p) => ( // Today / Yesterday / Last 14 / Last 30 / Custom day
337
+ <MenuItem
338
+ key={p.value}
339
+ label={p.label}
340
+ selectionIndicator="check"
341
+ selected={mode === p.value}
342
+ data-menu-open
343
+ onSelect={() => onChange(p.value === 'custom' ? { day } : { preset: p.value })}
344
+ />
345
+ ))}
346
+ {mode === 'custom' && (
347
+ <div data-menu-open style={{ padding: 8 }}>
348
+ {/* opens the calendar to the right of the field */}
349
+ <InputDate size="small" label="Day" placement="right-start" value={day}
350
+ onValueChange={(d) => onChange({ day: d })} />
351
+ </div>
352
+ )}
353
+ </>
354
+ )
355
+ },
356
+ }
357
+
358
+ <SelectFaceted facets={[recency]} value={value} onValueChange={setValue} />
359
+ // value.recency is { preset: 'last14' } or { day: '2026-07-20' } — resolve a preset to a
360
+ // concrete date (or take the day as-is) and filter your rows with it
361
+ ```
362
+
363
+ ### Props
364
+
365
+ | Prop | Type | Default | Description |
366
+ |------|------|---------|-------------|
367
+ | `facets` | SelectFacet[] | — | The facets (dimensions) to filter across, each with its own editor kind. |
368
+ | `clearable?` | boolean | true | Show the per-facet "Clear" row and the "Clear all" row. |
369
+ | `clearAllLabel?` | string | Clear all | Label for the "Clear all" row. |
370
+ | `defaultValue?` | SelectFacetedValue | — | Initial value for the uncontrolled case (the wrapper then owns it). |
371
+ | `disabled?` | boolean | — | Disable the whole control. |
372
+ | `icon?` | IconShape | filter | Default trigger's leading icon. |
373
+ | `label?` | string | Filter | Default trigger's button label. |
374
+ | `offset?` | number | 4 | Gap in pixels between the trigger and the filter menu. |
375
+ | `onValueChange?` | (value, attrs) => void | — | Fires after any facet changes. `value` is the whole new record (a facet key →
376
+ that facet's value; a cleared facet is absent). `attrs` says what changed:
377
+ `{ facet, kind, value }` for a single facet edit, or `{ all: true }` for the
378
+ "Clear all" row — narrow on `'all' in attrs` before reading `facet`. |
379
+ | `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 root filter menu relative to its trigger. The
380
+ menu auto-flips vertically and clamps horizontally when needed. |
381
+ | `priority?` | 'primary' \| 'secondary' | secondary | Default trigger's button priority. |
382
+ | `renderTrigger?` | (state) => ReactNode | — | Render your own trigger in place of the default `Button`. Receives a
383
+ `SelectFacetedTriggerState`; **return exactly one focusable element** (the
384
+ menu anchors to it as its previous sibling and opens it on click). Give it
385
+ `aria-haspopup="menu"` plus `aria-expanded={state.open}`. The custom trigger
386
+ owns its own styling — `className` / `style` / other props apply to the
387
+ *default* trigger only, so put your own on the element you return. |
388
+ | `searchable?` | boolean | — | Add a single search field to the top of the root menu that flattens every
389
+ `single` / `multiple` facet's options into one list and searches across them
390
+ — typing "alice" surfaces it under both an Assignee and an Owner facet, each
391
+ selectable in place. `text` / `custom` facets are reachable when the search is
392
+ empty. Matching uses each facet's `filter` function if it has one, else the
393
+ built-in substring match. |
394
+ | `searchPlaceholder?` | string | Filter… | Placeholder for the global search field. |
395
+ | `size?` | 'small' \| 'medium' \| 'large' | medium | Default trigger's button size. |
396
+ | `toneSelected?` | 'neutral' \| 'brand' \| 'info' \| 'success' \| 'warning' \| 'critical' \| (string & {}) | — | Tone applied to a selected option row in the facet flyouts (label, selected
397
+ tint, and the check / checkbox indicator). A named tone or a custom CSS
398
+ colour, matching `Select`'s `toneSelected`. Defaults to a neutral selection. |
399
+ | `value?` | SelectFacetedValue | — | Controlled value — the facet-keyed record. When provided, the consumer
400
+ owns state: a pick only *requests* a change via `onValueChange` (reject by
401
+ not updating). Leave undefined for uncontrolled. |
402
+
403
+ ### The `SelectFacet` type
404
+
405
+ `facets` takes a `SelectFacet[]`. Each facet is discriminated by **`kind`** and shares a
406
+ base of `key` / `label` / `icon`:
407
+
408
+ A `single` / `multiple` facet is [Select](./select.md)'s option model under a `key` — the same
409
+ `options` (`SelectItem[]`), `filter`, and `selectAll` you'd pass to a `Select`, with `kind`
410
+ where Select has `selection`. Know `Select` and you know these two facets; `text` and
411
+ `custom` are the kinds with no `Select` counterpart. The value each facet stores follows
412
+ the same rule: a `single`/`multiple` facet reports the chosen option's `value` (or an array
413
+ of them), exactly as [Select](./select.md#value--changes) does under its own key.
414
+
415
+ | Field | Type | Description |
416
+ |---|---|---|
417
+ | `key` | `string` | The facet's namespace — the key its value is stored under in the record, and what `attrs.facet` reports. Unique across `facets`. |
418
+ | `label` | `string` | The facet's row label in the menu. |
419
+ | `icon` | `IconShape` | Leading icon on the facet's row. |
420
+
421
+ **`single`** (`SelectFacetSingle`) — pick one. Value: the chosen option's `value`, or
422
+ absent when cleared (re-picking the selected option clears it).
423
+
424
+ | Field | Type | Description |
425
+ |---|---|---|
426
+ | `kind` | `'single'` | Discriminant. |
427
+ | `options` | `SelectItem[]` | The choices — bare strings or `SelectOption`s (groups / submenus flatten to their leaves). Option `value`s may be `string` / `number` / `boolean`. |
428
+ | `filter` | `FacetFilter` | Search field over this facet's options: `true` for the built-in substring match, or `(option, query) => boolean`. |
429
+
430
+ **`multiple`** (`SelectFacetMultiple`) — pick any number. Value: an array of the chosen
431
+ `value`s (empty array clears it).
432
+
433
+ | Field | Type | Description |
434
+ |---|---|---|
435
+ | `kind` | `'multiple'` | Discriminant. |
436
+ | `options` | `SelectItem[]` | As `single`. |
437
+ | `filter` | `FacetFilter` | As `single`. |
438
+ | `selectAll` | `boolean` | A "Select all" row toggling every visible option. Default `true`. |
439
+ | `selectAllLabel` | `string` | Label for that row. Default `Select all`. |
440
+
441
+ **`text`** (`SelectFacetText`) — free-form substring. Value: the typed string, applied on
442
+ Enter / blur (absent when empty).
443
+
444
+ | Field | Type | Description |
445
+ |---|---|---|
446
+ | `kind` | `'text'` | Discriminant. |
447
+ | `placeholder` | `string` | Placeholder for the text field. |
448
+
449
+ **`custom`** (`SelectFacetCustom<V>`) — bring your own editor and value type `V`, including
450
+ object-shaped values (a date range, a numeric comparator).
451
+
452
+ | Field | Type | Description |
453
+ |---|---|---|
454
+ | `kind` | `'custom'` | Discriminant. |
455
+ | `render` | `(ctx) => ReactNode` | Your editor. `ctx.value` is the current `V`, `ctx.onChange(next)` sets it (`undefined` clears the facet), `ctx.close()` dismisses the menu. |
456
+ | `summary` | `(value: V) => ReactNode` | The chip on the facet's row while a value is set. |