ajo-ui 0.1.0

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 (103) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +126 -0
  3. package/dist/accordion.js +130 -0
  4. package/dist/avatar.js +54 -0
  5. package/dist/calendar.js +2 -0
  6. package/dist/carousel.js +239 -0
  7. package/dist/chart.js +790 -0
  8. package/dist/checkbox-group.js +70 -0
  9. package/dist/checkbox.js +77 -0
  10. package/dist/chunks/bar-CVafh6C1.js +99 -0
  11. package/dist/chunks/calendar-q8jZ8Cxr.js +1923 -0
  12. package/dist/chunks/collection-DtRB63U4.js +111 -0
  13. package/dist/chunks/data-table-DpkWv4y4.js +1039 -0
  14. package/dist/chunks/menu-B45IyHHC.js +704 -0
  15. package/dist/chunks/native-BJdhd9XJ.js +20 -0
  16. package/dist/chunks/popup-C8Bb3l_g.js +459 -0
  17. package/dist/chunks/popup-surface-BDCgtVU0.js +18 -0
  18. package/dist/chunks/position-D6_i_SRn.js +434 -0
  19. package/dist/chunks/virtual-list-B7hjGkjk.js +413 -0
  20. package/dist/collapsible.js +106 -0
  21. package/dist/command.js +263 -0
  22. package/dist/context-menu.js +112 -0
  23. package/dist/data-table.js +5 -0
  24. package/dist/dialog.js +207 -0
  25. package/dist/direction.js +22 -0
  26. package/dist/drawer.js +139 -0
  27. package/dist/field.js +26 -0
  28. package/dist/index.js +38 -0
  29. package/dist/input-date.js +994 -0
  30. package/dist/input-group.js +52 -0
  31. package/dist/input-otp.js +179 -0
  32. package/dist/menu.js +2 -0
  33. package/dist/menubar.js +236 -0
  34. package/dist/message-scroller.js +446 -0
  35. package/dist/navigation-menu.js +330 -0
  36. package/dist/popover.js +307 -0
  37. package/dist/progress.js +39 -0
  38. package/dist/radio-group.js +107 -0
  39. package/dist/resizable.js +172 -0
  40. package/dist/select.js +961 -0
  41. package/dist/sidebar.js +343 -0
  42. package/dist/slider.js +259 -0
  43. package/dist/switch.js +53 -0
  44. package/dist/tabs.js +182 -0
  45. package/dist/toast.js +492 -0
  46. package/dist/toggle-group.js +111 -0
  47. package/dist/toggle.js +52 -0
  48. package/dist/toolbar.js +127 -0
  49. package/dist/tooltip.js +196 -0
  50. package/dist/utils.js +104 -0
  51. package/dist/virtual-list.js +2 -0
  52. package/package.json +250 -0
  53. package/src/accordion.tsx +261 -0
  54. package/src/availability.ts +261 -0
  55. package/src/avatar.tsx +99 -0
  56. package/src/bar.ts +156 -0
  57. package/src/calendar.tsx +1441 -0
  58. package/src/carousel.tsx +424 -0
  59. package/src/chart.tsx +1194 -0
  60. package/src/checkbox-group.tsx +132 -0
  61. package/src/checkbox.tsx +130 -0
  62. package/src/collapsible.tsx +188 -0
  63. package/src/collection.ts +154 -0
  64. package/src/command.tsx +511 -0
  65. package/src/context-menu.tsx +233 -0
  66. package/src/data-table-contract.ts +143 -0
  67. package/src/data-table-model.ts +760 -0
  68. package/src/data-table.tsx +475 -0
  69. package/src/dialog.tsx +393 -0
  70. package/src/direction.tsx +45 -0
  71. package/src/drawer.tsx +251 -0
  72. package/src/field.tsx +61 -0
  73. package/src/index.ts +37 -0
  74. package/src/input-date.tsx +1539 -0
  75. package/src/input-group.tsx +142 -0
  76. package/src/input-otp.tsx +324 -0
  77. package/src/menu-cluster.ts +124 -0
  78. package/src/menu.tsx +1095 -0
  79. package/src/menubar.tsx +459 -0
  80. package/src/message-scroller.tsx +732 -0
  81. package/src/native.ts +26 -0
  82. package/src/navigation-menu.tsx +578 -0
  83. package/src/popover.tsx +519 -0
  84. package/src/popup-surface.tsx +31 -0
  85. package/src/popup.ts +569 -0
  86. package/src/position.ts +523 -0
  87. package/src/progress.tsx +70 -0
  88. package/src/radio-group.tsx +186 -0
  89. package/src/resizable.tsx +310 -0
  90. package/src/segments.ts +922 -0
  91. package/src/select.tsx +1501 -0
  92. package/src/sidebar.tsx +683 -0
  93. package/src/slider.tsx +424 -0
  94. package/src/switch.tsx +104 -0
  95. package/src/tabs.tsx +314 -0
  96. package/src/toast.tsx +923 -0
  97. package/src/toggle-group.tsx +249 -0
  98. package/src/toggle.tsx +91 -0
  99. package/src/toolbar.tsx +212 -0
  100. package/src/tooltip.tsx +359 -0
  101. package/src/utils.ts +204 -0
  102. package/src/virtual-list.tsx +205 -0
  103. package/src/virtual.ts +385 -0
package/src/select.tsx ADDED
@@ -0,0 +1,1501 @@
1
+ import type { Children, IntrinsicElements, Stateful, Stateless, WithChildren } from 'ajo'
2
+ import { announce, callHandler, callRef, controlled, dom, id, listen, roving, statefulRootAttrs as rootAttrs, typeahead } from 'ajo-cloves'
3
+ import { context } from 'ajo/context'
4
+ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from './input-group'
5
+ import { collection } from './collection'
6
+ import { contentAttrs, popup, type PopupView } from './popup'
7
+ import type { ReservedPositionArg } from './position'
8
+ import type { FixedArgs, OmitArg, PopupPosition } from './utils'
9
+ import { defaultResultsLabel, flag, matchesTokens, resolveFilter, text, triggerAttrs } from './utils'
10
+ export type { PopupPlacement, PopupPosition } from './utils'
11
+
12
+ /** Visual size supported by the built-in select trigger. */
13
+ export type SelectSize = 'default' | 'sm'
14
+
15
+ /** Predicate used to include an item in the current search results. */
16
+ export type SelectFilter<T = unknown> = (item: T, search: string, text: string) => boolean
17
+
18
+ /** Props for single- or multiple-selection state and search behavior. */
19
+ export type SelectArgs<T = string, Multiple extends boolean = false> = WithChildren<OmitArg<IntrinsicElements['div'], 'children' | 'defaultValue' | 'onchange' | ReservedPositionArg> & PopupPosition & {
20
+ /** Items available to SelectList render functions. */
21
+ items?: T[]
22
+ /** Controlled selection; null means controlled-empty. */
23
+ value?: (Multiple extends true ? T[] : T) | null
24
+ /** Initial selection for uncontrolled usage. */
25
+ defaultValue?: Multiple extends true ? T[] : T
26
+ /** Allow more than one selected item. */
27
+ multiple?: Multiple
28
+ /** Controlled search text. */
29
+ inputValue?: string
30
+ /** Initial search text for uncontrolled usage. */
31
+ defaultInputValue?: string
32
+ /** Called when the search text changes. */
33
+ onInputValueChange?: (value: string, event?: Event) => void
34
+ /** Controlled popup state. */
35
+ open?: boolean
36
+ /** Initial popup state for uncontrolled usage. */
37
+ defaultOpen?: boolean
38
+ /** Called when the popup opens or closes. */
39
+ onOpenChange?: (open: boolean, event?: Event) => void
40
+ /** Called when the selection changes; emits null (single) or [] (multiple) on clear. */
41
+ onValueChange?: (value: (Multiple extends true ? T[] : T) | null, event?: Event) => void
42
+ /** Called when the SelectCreate row is committed; the consumer owns creation. */
43
+ onCreate?: (text: string, event?: Event) => void
44
+ /** String view of an item: display, filter haystack, identity, and form value. */
45
+ itemToStringValue?: (item: T) => string
46
+ /** Item filter; null disables internal filtering for externally driven lists. */
47
+ filter?: SelectFilter<T> | null
48
+ /** Highlight the first visible item while filtering. */
49
+ autoHighlight?: boolean
50
+ /** Name for hidden form submission inputs. */
51
+ name?: string
52
+ /** Mark the selection as required for assistive technologies. */
53
+ required?: boolean
54
+ /** Disable the field, items, chips, and clear. */
55
+ disabled?: boolean
56
+ /** Screen-reader message for filtered result counts. */
57
+ resultsLabel?: (count: number) => string
58
+ /** Additional UnoCSS classes. */
59
+ class?: string
60
+ }> & FixedArgs<'onchange' | ReservedPositionArg>
61
+
62
+ /** Props for the button that opens the select popup. */
63
+ export type SelectTriggerArgs = WithChildren<OmitArg<IntrinsicElements['button'], 'size'> & {
64
+ /** Select trigger size. */
65
+ size?: SelectSize
66
+ /** Additional UnoCSS classes. */
67
+ class?: string
68
+ iconClass?: string
69
+ }>
70
+
71
+ /** Props for rendering the current selection or its placeholder. */
72
+ export type SelectValueArgs = WithChildren<IntrinsicElements['span'] & {
73
+ /** Fallback shown when no value is selected. */
74
+ placeholder?: Children
75
+ /** Additional UnoCSS classes. */
76
+ class?: string
77
+ }>
78
+
79
+ /** Props for a searchable select input and its optional controls. */
80
+ export type SelectInputArgs = WithChildren<OmitArg<IntrinsicElements['input'], 'children' | 'onchange'> & {
81
+ /** Render the dropdown trigger inside the input group. */
82
+ showTrigger?: boolean
83
+ /** Render a clear button inside the input group. */
84
+ showClear?: boolean
85
+ /** Accessible label for the clear button. */
86
+ clearLabel?: string
87
+ /** Accessible label for the dropdown trigger button. */
88
+ triggerLabel?: string
89
+ /** Called when input text changes. */
90
+ onValueChange?: (value: string, event: Event) => void
91
+ /** Additional UnoCSS classes for the input group. */
92
+ class?: string
93
+ addonClass?: string
94
+ buttonClass?: string
95
+ buttonIconClass?: string
96
+ clearButtonClass?: string
97
+ clearIconClass?: string
98
+ inputClass?: string
99
+ }> & FixedArgs<'onchange'>
100
+
101
+ /** Props for the button that clears the current selection. */
102
+ export type SelectClearArgs = WithChildren<IntrinsicElements['button'] & {
103
+ /** Additional UnoCSS classes. */
104
+ class?: string
105
+ iconClass?: string
106
+ }>
107
+
108
+ /** Props for the select popup; positioning and native semantics belong to Select. */
109
+ export type SelectContentArgs = WithChildren<OmitArg<IntrinsicElements['div'], 'hidden' | 'id' | 'popover' | 'tabindex' | 'tabIndex' | ReservedPositionArg> & {
110
+ /** Additional UnoCSS classes. */
111
+ class?: string
112
+ /** Inline CSS declarations composed with live positioning styles. */
113
+ style?: string
114
+ }> & FixedArgs<'gap' | 'hidden' | 'id' | 'placement' | 'popover' | 'tabindex' | 'tabIndex' | ReservedPositionArg>
115
+
116
+ /** Props for an option list, including item-renderer children. */
117
+ export type SelectListArgs<T = unknown> = WithChildren<OmitArg<IntrinsicElements['div'], 'children'> & {
118
+ children?: Children | ((item: T, index: number) => Children)
119
+ /** Additional UnoCSS classes. */
120
+ class?: string
121
+ }>
122
+
123
+ /** Props for a selectable option and its filtering metadata. */
124
+ export type SelectItemArgs<T = unknown> = WithChildren<OmitArg<IntrinsicElements['div'], 'value'> & {
125
+ /** Item value selected by this option. */
126
+ value?: T
127
+ /** Plain-text label for display, filtering, and typeahead when children are rich. */
128
+ textValue?: string
129
+ /** Extra searchable terms. */
130
+ keywords?: string[]
131
+ /** Keep mounted even when it does not match the search query. */
132
+ forceMount?: boolean
133
+ /** Disable activation. */
134
+ disabled?: boolean
135
+ /** Called when this option is selected. */
136
+ onSelect?: (value: T, event: Event) => void
137
+ /** Additional UnoCSS classes. */
138
+ class?: string
139
+ indicatorClass?: string
140
+ indicatorIconClass?: string
141
+ }>
142
+
143
+ /** Props for grouping related select options. */
144
+ export type SelectGroupArgs = WithChildren<IntrinsicElements['div'] & {
145
+ /** Additional UnoCSS classes. */
146
+ class?: string
147
+ }>
148
+
149
+ /** Props for a label associated with a select option group. */
150
+ export type SelectLabelArgs = WithChildren<IntrinsicElements['div'] & {
151
+ /** Additional UnoCSS classes. */
152
+ class?: string
153
+ }>
154
+
155
+ /** Props for a visual separator between select options or groups. */
156
+ export type SelectSeparatorArgs = IntrinsicElements['div'] & {
157
+ /** Additional UnoCSS classes. */
158
+ class?: string
159
+ }
160
+
161
+ /** Props for content shown when no options match. */
162
+ export type SelectEmptyArgs = WithChildren<IntrinsicElements['div'] & {
163
+ /** Additional UnoCSS classes. */
164
+ class?: string
165
+ }>
166
+
167
+ /** Props for an accessible select status message. */
168
+ export type SelectStatusArgs = WithChildren<IntrinsicElements['div'] & {
169
+ /** Additional UnoCSS classes. */
170
+ class?: string
171
+ }>
172
+
173
+ /** Props for the option that commits the current search as a new value. */
174
+ export type SelectCreateArgs = WithChildren<IntrinsicElements['div'] & {
175
+ /** Additional UnoCSS classes. */
176
+ class?: string
177
+ }>
178
+
179
+ /** Props for the container of a multiple select's value chips. */
180
+ export type SelectChipsArgs = WithChildren<IntrinsicElements['div'] & {
181
+ /** Additional UnoCSS classes. */
182
+ class?: string
183
+ }>
184
+
185
+ /** Props for a selected-value chip and its optional remove control. */
186
+ export type SelectChipArgs<T = unknown> = WithChildren<IntrinsicElements['span'] & {
187
+ /** Value removed by the built-in remove button. Defaults to chip text. */
188
+ value?: T
189
+ /** Show the built-in remove button. */
190
+ showRemove?: boolean
191
+ /** Accessible label for the remove button. */
192
+ removeLabel?: string
193
+ /** Additional UnoCSS classes. */
194
+ class?: string
195
+ removeClass?: string
196
+ removeIconClass?: string
197
+ }>
198
+
199
+ /** Props for the search input composed inside a chip collection. */
200
+ export type SelectChipsInputArgs = OmitArg<IntrinsicElements['input'], 'onchange'> & {
201
+ /** Called when input text changes. */
202
+ onValueChange?: (value: string, event: Event) => void
203
+ /** Additional UnoCSS classes. */
204
+ class?: string
205
+ } & FixedArgs<'onchange'>
206
+
207
+ /** Shared props for select viewport scroll buttons. */
208
+ export type SelectScrollButtonArgs = WithChildren<IntrinsicElements['button'] & {
209
+ /** Additional UnoCSS classes. */
210
+ class?: string
211
+ iconClass?: string
212
+ }>
213
+
214
+ type SelectContextValue = {
215
+ activeId: string
216
+ activeKey: string
217
+ clear: (event?: Event) => void
218
+ contentId: string
219
+ contentStyle: PopupView['contentStyle']
220
+ create: (event: Event) => void
221
+ createVisible: boolean
222
+ disabled: boolean
223
+ filteredItems: unknown[]
224
+ hasTrigger: boolean
225
+ inputId: string
226
+ itemId: (key: string) => string
227
+ listId: string
228
+ matches: (item: unknown, keywords?: string[]) => boolean
229
+ multiple: boolean
230
+ open: boolean
231
+ searchInPopup: boolean
232
+ registerLabel: (key: string, label: string) => void
233
+ remove: (item: unknown, event?: Event) => void
234
+ required: boolean
235
+ search: string
236
+ selectedKeys: Set<string>
237
+ selectedLabels: string[]
238
+ select: (item: unknown, event: Event) => void
239
+ setActive: (key: string) => void
240
+ setReference: (element: HTMLElement | null, previous?: HTMLElement | null) => void
241
+ setContent: (element: HTMLDivElement | null) => void
242
+ setInput: (element: HTMLInputElement | null, previous?: HTMLInputElement | null) => void
243
+ setOpen: (open: boolean, event?: Event) => void
244
+ setSearch: (value: string, event?: Event) => void
245
+ setTrigger: (element: HTMLButtonElement | null) => void
246
+ stringValue: (item: unknown) => string
247
+ triggerId: string
248
+ }
249
+
250
+ const SelectContext = context<SelectContextValue | null>(null)
251
+
252
+ const fallbackString = (item: unknown): string => {
253
+ if (item == null) return ''
254
+ if (typeof item === 'string' || typeof item === 'number' || typeof item === 'bigint') return String(item)
255
+ if (typeof item === 'object') {
256
+ const record = item as Record<string, unknown>
257
+ if (record.label != null) return text(record.label)
258
+ if (record.value != null) return text(record.value)
259
+ }
260
+ return String(item)
261
+ }
262
+
263
+ const defaultFilter: SelectFilter = (_item, search, label) => matchesTokens(search, label)
264
+
265
+ const array = (value: unknown) =>
266
+ Array.isArray(value) ? value : value == null ? [] : [value]
267
+
268
+ // The create row needs a stable collection key that no real item label can mint.
269
+ const CREATE_KEY = '\0create'
270
+
271
+ // Filtering happens through [hidden] and typeahead must resolve options while
272
+ // the popover is closed, so rendered-layout checks stay off.
273
+ const selectItems = collection('select', { rendered: false })
274
+
275
+ const SelectRoot: Stateful<SelectArgs<any, boolean>> = function* ({
276
+ defaultInputValue,
277
+ defaultOpen,
278
+ defaultValue,
279
+ inputValue,
280
+ open,
281
+ }) {
282
+ const selectId = id('select')
283
+ const ownerDocument = dom(this) ? this.ownerDocument : null
284
+ const fallbackInputId = `${selectId}-input`
285
+ const labels = new Map<string, string>()
286
+ let activeKey = ''
287
+ let disabled = false
288
+ let fieldReference: HTMLElement | null = null
289
+ let input: HTMLInputElement | null = null
290
+ let inputId = fallbackInputId
291
+ let announceResults = false
292
+ let closeFocus: HTMLElement | null = null
293
+ let lastResultCount = -1
294
+ let onCreate: SelectArgs<any, boolean>['onCreate']
295
+ let onInputValueChange: SelectArgs<any, boolean>['onInputValueChange']
296
+ let onOpenChange: SelectArgs<any, boolean>['onOpenChange']
297
+ let onValueChange: SelectArgs<any, boolean>['onValueChange']
298
+ let pendingFocus = false
299
+ let pendingReference: HTMLElement | null = null
300
+ let pendingSeed = ''
301
+ let pendingStep = 0
302
+ let pop: PopupView<HTMLButtonElement, HTMLDivElement>
303
+ const searchState = controlled<string>(this, {
304
+ fallback: String(inputValue ?? defaultInputValue ?? ''),
305
+ onChange: (next, event) => onInputValueChange?.(next, event),
306
+ })
307
+ const valueState = controlled<unknown>(this, {
308
+ fallback: defaultValue ?? null,
309
+ onChange: (next, event) => onValueChange?.(next as any, event),
310
+ })
311
+ const live = announce(this)
312
+
313
+ const itemDomId = (key: string) => `${selectId}-item-${encodeURIComponent(key)}`
314
+
315
+ // The in-popup search input must not reference the popup to itself; the
316
+ // external field, trigger, or field input always wins.
317
+ const inPopup = (element: HTMLElement | null) =>
318
+ Boolean(element && pop.content && pop.content.contains(element))
319
+
320
+ const clearFocusIntent = () => {
321
+ pendingFocus = false
322
+ pendingReference = null
323
+ pendingSeed = ''
324
+ pendingStep = 0
325
+ }
326
+
327
+ pop = popup<HTMLButtonElement, HTMLDivElement>(this, {
328
+ prefix: 'select',
329
+ profile: 'select',
330
+ initialOpen: Boolean(open ?? defaultOpen),
331
+ disabled: () => disabled,
332
+ onOpenChange: (next, event) => onOpenChange?.(next, event),
333
+ reference: view => view.reference,
334
+ source: view => dom(view.reference) ? view.reference as HTMLElement : null,
335
+ referenceHidden: 'close',
336
+ dismiss: {
337
+ escape: false,
338
+ outside: true,
339
+ inside: view => [dom(view.reference) ? view.reference : null, view.content],
340
+ onDismiss: event => setOpen(false, event),
341
+ },
342
+ onPosition: () => {
343
+ if (!pendingFocus || pop.reference !== pendingReference) return
344
+ pendingFocus = false
345
+ const popupInput = inPopup(input) ? input : null
346
+ if (popupInput) {
347
+ popupInput.focus()
348
+ if (pendingSeed) setSearch(pendingSeed)
349
+ } else {
350
+ const items = selectItems.items(pop.content)
351
+ let target = items.find(item => selectedKeySet.has(item.dataset.value ?? ''))
352
+ ?? items[0]
353
+ // Arrow-key opening lands one step past the selection, native-select style.
354
+ if (pendingStep && target) target = items[items.indexOf(target) + pendingStep] ?? target
355
+ selectItems.focusItem(pop.content, target)
356
+ }
357
+ pendingReference = null
358
+ pendingSeed = ''
359
+ pendingStep = 0
360
+ },
361
+ onSync: opened => {
362
+ if (!opened) finishClose()
363
+ },
364
+ })
365
+
366
+ // Focus returns to the field before the popover hides so it never drops to body.
367
+ const restoreFocus = () => {
368
+ const active = ownerDocument?.activeElement
369
+ const target = closeFocus ?? pop.trigger ?? input
370
+ closeFocus = null
371
+ if (!active || !pop.content?.contains(active)) return
372
+ target?.focus()
373
+ }
374
+
375
+ const finishClose = () => {
376
+ restoreFocus()
377
+ clearFocusIntent()
378
+ // Closing discards the search so reopening shows the full list.
379
+ if (!searchState.controlled && searchState.value) searchState.init('')
380
+ activeKey = ''
381
+ }
382
+
383
+ const setOpen = (next: boolean, event?: Event, focus?: HTMLElement | null) => {
384
+ if (disabled && next) return
385
+ if (next === pop.open) return
386
+ // The in-popup search input receives focus however the popup opens.
387
+ if (next) {
388
+ closeFocus = null
389
+ if (inPopup(input)) pendingFocus = true
390
+ }
391
+ if (!next) {
392
+ closeFocus = focus ?? null
393
+ clearFocusIntent()
394
+ }
395
+ else pendingReference = pop.reference as HTMLElement | null
396
+ pop.setOpen(next, event)
397
+ }
398
+
399
+ const effectiveReference = () =>
400
+ (inPopup(fieldReference) ? null : fieldReference)
401
+ ?? pop.trigger
402
+ ?? (inPopup(input) ? null : input)
403
+
404
+ const syncReference = () => {
405
+ const next = effectiveReference()
406
+ if (pop.reference === next) return
407
+ clearFocusIntent()
408
+ pop.setReference(next)
409
+ }
410
+
411
+ const setReference = (element: HTMLElement | null, previous?: HTMLElement | null) => {
412
+ if (!element && previous && fieldReference !== previous) return
413
+ fieldReference = element
414
+ syncReference()
415
+ }
416
+
417
+ const setInput = (element: HTMLInputElement | null, previous?: HTMLInputElement | null) => {
418
+ if (!element && previous && input !== previous) return
419
+ input = element
420
+ const nextInputId = element?.id || fallbackInputId
421
+ if (nextInputId !== inputId) {
422
+ inputId = nextInputId
423
+ queueMicrotask(() => this.next())
424
+ }
425
+ syncReference()
426
+ }
427
+
428
+ const setTrigger = (element: HTMLButtonElement | null) => {
429
+ const had = Boolean(pop.trigger)
430
+ pop.setTrigger(element)
431
+ // Trigger presence gates the listbox label; converge attributes post-mount.
432
+ if (Boolean(element) !== had) queueMicrotask(() => this.next())
433
+ syncReference()
434
+ }
435
+
436
+ const setContent = (element: HTMLDivElement | null) => {
437
+ pop.setContent(element)
438
+ syncReference()
439
+ }
440
+
441
+ const setSearch = (next: string, event?: Event) => {
442
+ searchState.set(next, event)
443
+ announceResults = true
444
+ // Typing (or deleting) keeps the list open; observation owns geometry.
445
+ if (!pop.open) setOpen(true, event)
446
+ }
447
+
448
+ const setActive = (key: string) => {
449
+ if (key === activeKey) return
450
+ // Button mode mirrors hover into real focus, menu-style.
451
+ if (!input) {
452
+ const target = selectItems.items(this).find(item => item.dataset.value === key)
453
+ if (target && target !== ownerDocument?.activeElement) selectItems.focusItem(this, target)
454
+ }
455
+ this.next(() => activeKey = key)
456
+ }
457
+
458
+ // Virtual navigation for input-owned focus; real focus roving lives in the
459
+ // button branch through collection.focusItem.
460
+ const nav = roving(this, {
461
+ items: () => selectItems.items(this),
462
+ loop: () => false,
463
+ current: () => selectItems.items(this).find(item => item.dataset.value === activeKey),
464
+ onMove: target => {
465
+ activeKey = target.dataset.value ?? ''
466
+ selectItems.highlight(this, target)
467
+ this.next()
468
+ queueMicrotask(() => target.scrollIntoView({ block: 'nearest' }))
469
+ },
470
+ })
471
+
472
+ const focusNav = roving(this, {
473
+ items: () => selectItems.items(this),
474
+ onMove: (target, event) => {
475
+ selectItems.focusItem(this, target)
476
+ void event
477
+ },
478
+ })
479
+
480
+ const ta = typeahead(this, {
481
+ items: () => selectItems.items(this).filter(item => item.dataset.value !== CREATE_KEY),
482
+ onMatch: (target, event) => {
483
+ if (pop.open) {
484
+ selectItems.focusItem(this, target)
485
+ } else {
486
+ // Closed typeahead commits directly, native-select style.
487
+ target.click()
488
+ event.preventDefault()
489
+ }
490
+ },
491
+ })
492
+
493
+ let selectedKeySet = new Set<string>()
494
+
495
+ const chipsOf = () => Array.from(this.querySelectorAll<HTMLElement>('[data-slot="select-chip"]'))
496
+
497
+ const rtl = () => getComputedStyle(this).direction === 'rtl'
498
+
499
+ listen(this, 'keydown', (event: KeyboardEvent) => {
500
+ const target = event.target as HTMLElement | null
501
+ if (!target?.closest('[data-slot^="select"]')) return
502
+
503
+ // Chip roving: real focus between chips, back to the input past the ends.
504
+ if (target.dataset.slot === 'select-chip') {
505
+ const chips = chipsOf()
506
+ const index = chips.indexOf(target)
507
+ const forward = rtl() ? 'ArrowLeft' : 'ArrowRight'
508
+ const backward = rtl() ? 'ArrowRight' : 'ArrowLeft'
509
+
510
+ if (event.key === 'Escape') {
511
+ if (!pop.open) return
512
+ event.preventDefault()
513
+ setOpen(false, event, input)
514
+ return
515
+ }
516
+ if (event.key === backward) {
517
+ event.preventDefault()
518
+ if (index > 0) chips[index - 1].focus()
519
+ else input?.focus()
520
+ return
521
+ }
522
+ if (event.key === forward) {
523
+ event.preventDefault()
524
+ if (index < chips.length - 1) chips[index + 1].focus()
525
+ else input?.focus()
526
+ return
527
+ }
528
+ if (event.key === 'Backspace' || event.key === 'Delete') {
529
+ event.preventDefault()
530
+ const neighbor = chips[index + 1] ?? chips[index - 1]
531
+ const key = target.dataset.value ?? ''
532
+ const item = array(valueState.value).find(candidate => itemKeyOf(candidate) === key)
533
+ if (item !== undefined) removeItem(item, event)
534
+ if (neighbor && neighbor !== target) neighbor.focus()
535
+ else input?.focus()
536
+ return
537
+ }
538
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
539
+ event.preventDefault()
540
+ input?.focus()
541
+ setOpen(true, event)
542
+ return
543
+ }
544
+ // Printable keys and everything else bail back to the input.
545
+ if (event.key.length === 1 || event.key === 'Enter') input?.focus()
546
+ return
547
+ }
548
+
549
+ // Field trigger: the closed-state combobox for button and popup-search modes.
550
+ if (target.closest('[data-slot="select-trigger"]')) {
551
+ if (event.key === 'Escape') {
552
+ if (!pop.open) return
553
+ event.preventDefault()
554
+ setOpen(false, event, pop.trigger)
555
+ return
556
+ }
557
+ if (event.key === 'Tab') {
558
+ if (pop.open) setOpen(false, event)
559
+ return
560
+ }
561
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ') {
562
+ event.preventDefault()
563
+ if (pop.open) {
564
+ // Mouse-opened popups keep focus on the trigger; keys still work.
565
+ if (event.key === 'Enter' || event.key === ' ') {
566
+ setOpen(false, event)
567
+ return
568
+ }
569
+ if (inPopup(input)) {
570
+ input?.focus()
571
+ return
572
+ }
573
+ const items = selectItems.items(this)
574
+ let target = items.find(item => selectedKeySet.has(item.dataset.value ?? '')) ?? items[0]
575
+ if (target) target = items[items.indexOf(target) + (event.key === 'ArrowDown' ? 1 : -1)] ?? target
576
+ selectItems.focusItem(this, target)
577
+ return
578
+ }
579
+ pendingFocus = true
580
+ pendingStep = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0
581
+ setOpen(true, event)
582
+ return
583
+ }
584
+ if (inPopup(input)) {
585
+ // Printable keys open and seed the in-popup search.
586
+ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey && event.key !== ' ') {
587
+ event.preventDefault()
588
+ pendingFocus = true
589
+ pendingSeed = event.key
590
+ setOpen(true, event)
591
+ }
592
+ return
593
+ }
594
+ if (!input) ta.handle(event)
595
+ return
596
+ }
597
+
598
+ // Any input (field, chips, in-popup): virtual focus model.
599
+ if (target === input || target.dataset.slot === 'select-chips-input' || target.dataset.slot === 'select-input') {
600
+ if (event.key === 'Tab') {
601
+ if (pop.open) setOpen(false, event)
602
+ return
603
+ }
604
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
605
+ if (!pop.open) setOpen(true, event)
606
+ // Without a live highlight the first move lands on the edge, not past it.
607
+ if (!selectItems.items(this).some(item => item.dataset.value === activeKey)) {
608
+ event.preventDefault()
609
+ nav.move(event.key === 'ArrowDown' ? 'first' : 'last', event)
610
+ return
611
+ }
612
+ nav.handle(event)
613
+ return
614
+ }
615
+ if ((event.key === 'Home' || event.key === 'End') && pop.open) {
616
+ nav.handle(event)
617
+ return
618
+ }
619
+ if (event.key === 'Enter' && pop.open && activeKey) {
620
+ // Visible items only: a stale highlight must never commit a filtered-out option.
621
+ const item = selectItems.items(this).find(candidate => candidate.dataset.value === activeKey)
622
+ if (!item) return
623
+ event.preventDefault()
624
+ item.click()
625
+ return
626
+ }
627
+ if (event.key === 'Escape') {
628
+ if (!pop.open) return
629
+ // Always consumed so an ancestor dialog does not also close.
630
+ event.preventDefault()
631
+ setOpen(false, event, inPopup(target) ? pop.trigger : null)
632
+ return
633
+ }
634
+ if (event.key === 'Backspace' && target.dataset.slot === 'select-chips-input' && !(target as HTMLInputElement).value) {
635
+ const selected = array(valueState.value)
636
+ if (selected.length) {
637
+ event.preventDefault()
638
+ removeItem(selected[selected.length - 1], event)
639
+ }
640
+ return
641
+ }
642
+ const backward = rtl() ? 'ArrowRight' : 'ArrowLeft'
643
+ if (event.key === backward && target.dataset.slot === 'select-chips-input' && (target as HTMLInputElement).selectionStart === 0) {
644
+ const chips = chipsOf()
645
+ if (chips.length) {
646
+ event.preventDefault()
647
+ chips[chips.length - 1].focus()
648
+ }
649
+ }
650
+ return
651
+ }
652
+
653
+ // Real-focus roving over the options (button mode, no input).
654
+ if (target.closest('[data-slot="select-content"]')) {
655
+ if (event.key === 'Tab') {
656
+ setOpen(false, event)
657
+ return
658
+ }
659
+ if (event.key === 'Escape') {
660
+ event.preventDefault()
661
+ setOpen(false, event, pop.trigger)
662
+ return
663
+ }
664
+ if (focusNav.handle(event)) return
665
+ if (event.key === 'Enter' || event.key === ' ') {
666
+ const item = selectItems.item(event)
667
+ if (!item) return
668
+ event.preventDefault()
669
+ item.click()
670
+ return
671
+ }
672
+ if (ta.handle(event)) event.preventDefault()
673
+ }
674
+ })
675
+
676
+ let itemKeyOf = (item: unknown) => fallbackString(item)
677
+
678
+ let removeItem = (_item: unknown, _event?: Event) => {}
679
+
680
+ for (const args of this) {
681
+ disabled = Boolean(args.disabled)
682
+ onCreate = args.onCreate
683
+ onInputValueChange = args.onInputValueChange
684
+ onOpenChange = args.onOpenChange
685
+ onValueChange = args.onValueChange
686
+ const wasOpen = pop.open
687
+ const opened = pop.sync(args.open == null ? undefined : Boolean(args.open), {
688
+ placement: args.placement,
689
+ gap: args.gap,
690
+ })
691
+ if (wasOpen && !opened) finishClose()
692
+ else if (!opened) clearFocusIntent()
693
+ else closeFocus = null
694
+ searchState.sync(args.inputValue === undefined ? undefined : String(args.inputValue))
695
+ valueState.sync(args.value)
696
+
697
+ const multiple = Boolean(args.multiple)
698
+ const stringValue = (item: unknown) => args.itemToStringValue?.(item) ?? fallbackString(item)
699
+ // Identity is the raw string: slugging collides on case, punctuation, and
700
+ // non-Latin labels, and these keys also mint DOM ids.
701
+ const itemKey = (item: unknown) => stringValue(item)
702
+ itemKeyOf = itemKey
703
+ const rawValue = valueState.value
704
+ // An empty string is "no selection" for uncontrolled string selects.
705
+ const selectedItems = multiple ? array(rawValue) : rawValue == null || rawValue === '' ? [] : [rawValue]
706
+ const selectedKeys = new Set(selectedItems.map(itemKey))
707
+ selectedKeySet = selectedKeys
708
+ const search = searchState.value
709
+ const filter = resolveFilter(args.filter, defaultFilter)
710
+ const filtering = Boolean(input) && args.filter !== null
711
+ const filteredItems = filtering
712
+ ? (args.items ?? []).filter(item => filter(item, search, stringValue(item)))
713
+ : args.items ?? []
714
+ const matches = (item: unknown, keywords: string[] = []) =>
715
+ filtering ? filter(item, search, [stringValue(item), ...keywords].join(' ')) : true
716
+ const label = (item: unknown) => {
717
+ const key = itemKey(item)
718
+ return labels.get(key) ?? stringValue(item)
719
+ }
720
+ const selectedLabels = selectedItems.map(label)
721
+ const query = search.trim().toLowerCase()
722
+ const createVisible = Boolean(onCreate) && Boolean(query) &&
723
+ ![...labels.values(), ...(args.items ?? []).map(stringValue)]
724
+ .some(candidate => candidate.trim().toLowerCase() === query)
725
+
726
+ if (args.autoHighlight && pop.open && filteredItems.length && !filteredItems.some(item => itemKey(item) === activeKey)) {
727
+ activeKey = itemKey(filteredItems[0])
728
+ }
729
+
730
+ const commitValue = (next: unknown, event?: Event) => {
731
+ valueState.set(next, event)
732
+ }
733
+
734
+ const select = (item: unknown, event: Event) => {
735
+ if (disabled) return
736
+ const key = itemKey(item)
737
+ if (multiple) {
738
+ const current = array(valueState.value)
739
+ const exists = current.some(candidate => itemKey(candidate) === key)
740
+ const next = exists ? current.filter(candidate => itemKey(candidate) !== key) : [...current, item]
741
+ commitValue(next, event)
742
+ live.polite(`${label(item)} ${exists ? 'deselected' : 'selected'}, ${next.length} selected`)
743
+ if (!searchState.controlled) searchState.init('')
744
+ this.next()
745
+ return
746
+ }
747
+
748
+ // Clicking the selected option again deselects it (null = the
749
+ // controlled-empty convention) unless the selection is required.
750
+ // Either way the click commits, and commits close.
751
+ if (selectedKeys.has(key) && !args.required) {
752
+ commitValue(null, event)
753
+ live.polite(`${label(item)} deselected`)
754
+ } else {
755
+ commitValue(item, event)
756
+ }
757
+ if (!searchState.controlled) searchState.init('')
758
+ setOpen(false, event)
759
+ }
760
+
761
+ const remove = (item: unknown, event?: Event) => {
762
+ if (disabled) return
763
+ const key = itemKey(item)
764
+ const current = array(valueState.value)
765
+ const next = current.filter(candidate => itemKey(candidate) !== key)
766
+ commitValue(multiple ? next : null, event)
767
+ live.polite(`${label(item)} removed, ${next.length} selected`)
768
+ this.next()
769
+ }
770
+ removeItem = remove
771
+
772
+ const clear = (event?: Event) => {
773
+ commitValue(multiple ? [] : null, event)
774
+ if (!searchState.controlled) searchState.init('')
775
+ this.next()
776
+ input?.focus()
777
+ }
778
+
779
+ const create = (event: Event) => {
780
+ if (disabled) return
781
+ const value = search.trim()
782
+ if (!value) return
783
+ onCreate?.(value, event)
784
+ if (!searchState.controlled) searchState.init('')
785
+ this.next()
786
+ }
787
+
788
+ SelectContext({
789
+ activeId: activeKey ? itemDomId(activeKey) : '',
790
+ activeKey,
791
+ clear,
792
+ contentId: pop.contentId,
793
+ contentStyle: pop.contentStyle,
794
+ create,
795
+ createVisible,
796
+ disabled,
797
+ filteredItems,
798
+ hasTrigger: Boolean(pop.trigger),
799
+ inputId,
800
+ itemId: itemDomId,
801
+ listId: `${selectId}-list`,
802
+ matches,
803
+ multiple,
804
+ open: pop.open,
805
+ searchInPopup: inPopup(input),
806
+ registerLabel: (key, itemLabel) => {
807
+ if (labels.get(key) === itemLabel) return
808
+ labels.set(key, itemLabel)
809
+ if (selectedKeys.has(key)) queueMicrotask(() => this.next())
810
+ },
811
+ remove,
812
+ required: Boolean(args.required),
813
+ search,
814
+ selectedKeys,
815
+ selectedLabels,
816
+ select,
817
+ setActive,
818
+ setReference,
819
+ setContent,
820
+ setInput,
821
+ setOpen,
822
+ setSearch,
823
+ setTrigger,
824
+ stringValue,
825
+ triggerId: pop.triggerId,
826
+ })
827
+
828
+ if (dom(this)) queueMicrotask(() => {
829
+ const visible = selectItems.sweep(this)
830
+ pop.content?.toggleAttribute('data-empty', visible.length === 0)
831
+
832
+ // With a search field present, the empty state is strictly a
833
+ // search result: while no query is typed (first open, or cleared
834
+ // and reopened) an empty list renders nothing instead of a
835
+ // misleading "not found".
836
+ if (input && !search) {
837
+ for (const empty of this.querySelectorAll<HTMLElement>('[data-slot="select-empty"]')) empty.hidden = true
838
+ }
839
+
840
+ // A highlight the filter hid must not linger in aria-activedescendant.
841
+ if (activeKey && !visible.some(item => item.dataset.value === activeKey)) {
842
+ this.next(() => activeKey = '')
843
+ }
844
+
845
+ // Stale labels (unmounted, unselected) would suppress the create row forever.
846
+ const mounted = new Set(selectItems.all(this).map(item => item.dataset.value ?? ''))
847
+ for (const key of labels.keys()) {
848
+ if (!mounted.has(key) && !selectedKeySet.has(key)) labels.delete(key)
849
+ }
850
+
851
+ // A rendered SelectStatus owns async/result announcements.
852
+ const status = this.querySelector('[data-slot="select-status"]')
853
+ if (pop.open && announceResults && !status && visible.length !== lastResultCount) {
854
+ live.polite((args.resultsLabel ?? defaultResultsLabel)(visible.length))
855
+ }
856
+ lastResultCount = pop.open ? visible.length : -1
857
+ announceResults = false
858
+ })
859
+
860
+ yield (
861
+ <>
862
+ {args.name && !multiple
863
+ ? <input disabled={disabled} name={args.name} set:value={selectedItems.length ? itemKey(selectedItems[0]) : ''} type="hidden" value={selectedItems.length ? itemKey(selectedItems[0]) : ''} />
864
+ : null}
865
+ {args.name && multiple
866
+ ? selectedItems.map(item => (
867
+ <input disabled={disabled} key={itemKey(item)} name={args.name} set:value={itemKey(item)} type="hidden" value={itemKey(item)} />
868
+ ))
869
+ : null}
870
+ {args.children}
871
+ </>
872
+ )
873
+ }
874
+ }
875
+
876
+
877
+ /** Unified select: single, multiple, searchable, editable, chips, and tagging by composition. */
878
+ const Select = <T = string, Multiple extends boolean = false>({
879
+ autoHighlight,
880
+ children,
881
+ class: classes,
882
+ defaultInputValue,
883
+ defaultOpen,
884
+ defaultValue,
885
+ disabled,
886
+ filter,
887
+ gap,
888
+ inputValue,
889
+ itemToStringValue,
890
+ items,
891
+ multiple,
892
+ name,
893
+ onCreate,
894
+ onInputValueChange,
895
+ onOpenChange,
896
+ onValueChange,
897
+ open,
898
+ placement,
899
+ required,
900
+ resultsLabel,
901
+ value,
902
+ ...attrs
903
+ }: SelectArgs<T, Multiple>) => (
904
+ <SelectRoot
905
+ {...rootAttrs(attrs as Record<string, unknown>)}
906
+ autoHighlight={autoHighlight}
907
+ defaultInputValue={defaultInputValue}
908
+ defaultOpen={defaultOpen}
909
+ defaultValue={defaultValue}
910
+ disabled={disabled}
911
+ filter={filter as SelectFilter | null | undefined}
912
+ gap={gap}
913
+ inputValue={inputValue}
914
+ itemToStringValue={itemToStringValue as ((item: unknown) => string) | undefined}
915
+ items={items}
916
+ multiple={multiple}
917
+ name={name}
918
+ onCreate={onCreate}
919
+ onInputValueChange={onInputValueChange}
920
+ onOpenChange={onOpenChange}
921
+ onValueChange={onValueChange as ((value: unknown, event?: Event) => void) | undefined}
922
+ open={open}
923
+ placement={placement}
924
+ required={required}
925
+ resultsLabel={resultsLabel}
926
+ value={value}
927
+ attr:class={classes}
928
+ attr:data-slot="select"
929
+ >
930
+ {children}
931
+ </SelectRoot>
932
+ )
933
+
934
+ /** Button field for a Select; the closed-state combobox. */
935
+ const SelectTrigger: Stateless<SelectTriggerArgs> = ({
936
+ children,
937
+ class: classes,
938
+ disabled,
939
+ iconClass,
940
+ id: idArg,
941
+ ref,
942
+ size = 'default',
943
+ type = 'button',
944
+ 'set:onclick': onClick,
945
+ ...attrs
946
+ }) => {
947
+ const select = SelectContext()
948
+ const disabledFlag = Boolean(disabled ?? select?.disabled)
949
+ const empty = !select?.selectedKeys.size
950
+
951
+ return (
952
+ <button
953
+ {...attrs}
954
+ {...triggerAttrs({
955
+ controls: select?.listId,
956
+ expanded: Boolean(select?.open),
957
+ haspopup: 'listbox',
958
+ id: idArg,
959
+ open: Boolean(select?.open),
960
+ ref,
961
+ setTrigger: select?.setTrigger,
962
+ triggerId: select?.triggerId,
963
+ })}
964
+ aria-required={flag(select?.required)}
965
+ class={classes}
966
+ data-placeholder={flag(empty)}
967
+ data-size={size}
968
+ data-slot="select-trigger"
969
+ disabled={disabledFlag}
970
+ role="combobox"
971
+ set:onclick={(event: Event) => {
972
+ callHandler(onClick, event)
973
+ if (event.defaultPrevented) return
974
+ select?.setOpen(!select.open, event)
975
+ }}
976
+ type={type}
977
+ >
978
+ {children}
979
+ <span aria-hidden="true" class={iconClass} data-slot="select-icon" />
980
+ </button>
981
+ )
982
+ }
983
+
984
+ /** Selected value text for a SelectTrigger. */
985
+ const SelectValue: Stateless<SelectValueArgs> = ({
986
+ children,
987
+ class: classes,
988
+ placeholder,
989
+ ...attrs
990
+ }) => {
991
+ const select = SelectContext()
992
+ const selected = select?.selectedLabels ?? []
993
+ const empty = !selected.length
994
+ const content = children ?? (empty ? placeholder : selected.join(', '))
995
+
996
+ return (
997
+ <span
998
+ {...attrs}
999
+ class={classes}
1000
+ data-placeholder={flag(empty)}
1001
+ data-slot="select-value"
1002
+ >
1003
+ {content}
1004
+ </span>
1005
+ )
1006
+ }
1007
+
1008
+ /** Input field or in-popup search box for a Select. */
1009
+ const SelectInput: Stateless<SelectInputArgs> = ({
1010
+ children,
1011
+ addonClass,
1012
+ buttonClass,
1013
+ buttonIconClass,
1014
+ class: classes,
1015
+ clearButtonClass,
1016
+ clearIconClass,
1017
+ clearLabel = 'Clear selection',
1018
+ disabled,
1019
+ id: idArg,
1020
+ inputClass,
1021
+ onValueChange,
1022
+ placeholder,
1023
+ ref,
1024
+ showClear,
1025
+ showTrigger = true,
1026
+ triggerLabel = 'Show options',
1027
+ value,
1028
+ 'set:oninput': onInput,
1029
+ ...attrs
1030
+ }) => {
1031
+ const select = SelectContext()
1032
+ const disabledFlag = Boolean(disabled ?? select?.disabled)
1033
+ const clearable = Boolean(select && (select.selectedKeys.size > 0 || select.search))
1034
+ // The in-popup search is not the field: the trigger stays the combobox.
1035
+ const searchbox = Boolean(select?.searchInPopup)
1036
+ const shown = value == null
1037
+ ? select?.open || select?.search
1038
+ ? select?.search ?? ''
1039
+ : select?.multiple
1040
+ ? ''
1041
+ : select?.selectedLabels[0] ?? ''
1042
+ : String(value)
1043
+ let mounted: HTMLInputElement | null = null
1044
+ let group: HTMLDivElement | null = null
1045
+ const reference = (element: HTMLInputElement | null) => {
1046
+ const previous = mounted
1047
+ mounted = element
1048
+ select?.setInput(element, previous)
1049
+ callRef(ref, element)
1050
+ }
1051
+
1052
+ return (
1053
+ <InputGroup
1054
+ class={classes}
1055
+ disabled={disabledFlag}
1056
+ ref={element => {
1057
+ const previous = group
1058
+ group = element
1059
+ if (!element || !element.closest('[data-slot="select-content"]')) {
1060
+ select?.setReference(element, previous)
1061
+ }
1062
+ }}
1063
+ >
1064
+ <InputGroupInput
1065
+ {...attrs}
1066
+ aria-activedescendant={select?.activeId || undefined}
1067
+ aria-autocomplete="list"
1068
+ aria-controls={select?.listId}
1069
+ aria-expanded={searchbox ? undefined : select?.open ? 'true' : 'false'}
1070
+ aria-haspopup={searchbox ? undefined : 'listbox'}
1071
+ aria-required={flag(select?.required)}
1072
+ class={inputClass}
1073
+ data-slot="select-input"
1074
+ disabled={disabledFlag}
1075
+ id={idArg ?? select?.inputId}
1076
+ placeholder={placeholder}
1077
+ ref={reference}
1078
+ role={searchbox ? 'searchbox' : 'combobox'}
1079
+ set:onclick={(event: Event) => select?.setOpen(true, event)}
1080
+ set:onfocus={(event: FocusEvent) => select?.setOpen(true, event)}
1081
+ set:oninput={(event: Event) => {
1082
+ const next = (event.target as HTMLInputElement).value
1083
+ callHandler(onInput, event)
1084
+ if (event.defaultPrevented) return
1085
+ onValueChange?.(next, event)
1086
+ select?.setSearch(next, event)
1087
+ }}
1088
+ set:value={shown}
1089
+ type="text"
1090
+ />
1091
+ {children}
1092
+ <InputGroupAddon align="inline-end" class={addonClass}>
1093
+ {showTrigger && !(showClear && clearable) ? (
1094
+ <InputGroupButton
1095
+ aria-controls={select?.listId}
1096
+ aria-expanded={select?.open ? 'true' : 'false'}
1097
+ aria-label={triggerLabel}
1098
+ class={buttonClass}
1099
+ data-slot="select-input-trigger"
1100
+ disabled={disabledFlag}
1101
+ set:onclick={(event: Event) => select?.setOpen(!select?.open, event)}
1102
+ type="button"
1103
+ >
1104
+ <span aria-hidden="true" class={buttonIconClass} />
1105
+ </InputGroupButton>
1106
+ ) : null}
1107
+ {showClear && clearable ? (
1108
+ <InputGroupButton
1109
+ aria-label={clearLabel}
1110
+ class={clearButtonClass}
1111
+ data-slot="select-clear"
1112
+ disabled={disabledFlag}
1113
+ set:onclick={(event: Event) => select?.clear(event)}
1114
+ type="button"
1115
+ >
1116
+ <span aria-hidden="true" class={clearIconClass} />
1117
+ </InputGroupButton>
1118
+ ) : null}
1119
+ </InputGroupAddon>
1120
+ </InputGroup>
1121
+ )
1122
+ }
1123
+
1124
+ /** Button that clears the current selection and search. */
1125
+ const SelectClear: Stateless<SelectClearArgs> = ({
1126
+ children,
1127
+ class: classes,
1128
+ disabled,
1129
+ iconClass,
1130
+ type = 'button',
1131
+ 'set:onclick': onClick,
1132
+ ...attrs
1133
+ }) => {
1134
+ const select = SelectContext()
1135
+ const disabledFlag = Boolean(disabled ?? select?.disabled)
1136
+
1137
+ // Nothing to clear, nothing to render.
1138
+ if (select && !select.selectedKeys.size && !select.search) return null
1139
+
1140
+ return (
1141
+ <button
1142
+ {...attrs}
1143
+ aria-label={attrs['aria-label'] ?? 'Clear selection'}
1144
+ class={classes}
1145
+ data-slot="select-clear"
1146
+ disabled={disabledFlag}
1147
+ set:onclick={(event: Event) => {
1148
+ callHandler(onClick, event)
1149
+ if (event.defaultPrevented) return
1150
+ select?.clear(event)
1151
+ }}
1152
+ type={type}
1153
+ >
1154
+ {children ?? <span aria-hidden="true" class={iconClass} />}
1155
+ </button>
1156
+ )
1157
+ }
1158
+
1159
+ /** Popup panel for Select options. */
1160
+ const SelectContent: Stateless<SelectContentArgs> = ({ children, class: classes, ref, style, ...attrs }) => {
1161
+ const select = SelectContext()
1162
+
1163
+ return (
1164
+ <div
1165
+ {...attrs}
1166
+ {...contentAttrs({
1167
+ id: select?.contentId,
1168
+ open: Boolean(select?.open),
1169
+ ref,
1170
+ setContent: select?.setContent,
1171
+ style: select?.contentStyle(style),
1172
+ tabindex: '-1',
1173
+ })}
1174
+ class={classes}
1175
+ data-slot="select-content"
1176
+ >
1177
+ {children}
1178
+ </div>
1179
+ )
1180
+ }
1181
+
1182
+ /** Listbox for Select options; required in every composition. */
1183
+ const SelectList: Stateless<SelectListArgs<any>> = ({ children, class: classes, ...attrs }) => {
1184
+ const select = SelectContext()
1185
+ const render = typeof children === 'function'
1186
+ ? (select?.filteredItems ?? []).map((item, index) => (children as (item: unknown, index: number) => Children)(item, index))
1187
+ : children
1188
+
1189
+ return (
1190
+ <div
1191
+ {...attrs}
1192
+ aria-labelledby={select?.hasTrigger ? select.triggerId : undefined}
1193
+ aria-multiselectable={select?.multiple ? 'true' : undefined}
1194
+ class={classes}
1195
+ data-slot="select-list"
1196
+ id={select?.listId}
1197
+ role="listbox"
1198
+ >
1199
+ {render}
1200
+ </div>
1201
+ )
1202
+ }
1203
+
1204
+ /** Selectable Select option. */
1205
+ const SelectItem: Stateless<SelectItemArgs<any>> = ({
1206
+ children,
1207
+ class: classes,
1208
+ disabled,
1209
+ forceMount,
1210
+ indicatorClass,
1211
+ indicatorIconClass,
1212
+ keywords = [],
1213
+ onSelect,
1214
+ textValue,
1215
+ value,
1216
+ ...attrs
1217
+ }) => {
1218
+ const select = SelectContext()
1219
+ const item = value ?? text(children)
1220
+ const key = select ? select.stringValue(item) : fallbackString(item)
1221
+ // Rich object items must not register concatenated children text as label;
1222
+ // their display string is itemToStringValue. textValue always wins.
1223
+ const label = textValue ?? (typeof item === 'string' ? text(children) : undefined)
1224
+ const selected = Boolean(select?.selectedKeys.has(key))
1225
+ const highlighted = select?.activeKey === key
1226
+ const disabledFlag = Boolean(disabled ?? select?.disabled)
1227
+ const hidden = !forceMount && select ? !select.matches(item, keywords) : false
1228
+
1229
+ if (label != null) select?.registerLabel(key, label)
1230
+
1231
+ return (
1232
+ <div
1233
+ {...attrs}
1234
+ {...selectItems.attrs({ disabled: disabledFlag, label: label ?? key, value: key })}
1235
+ aria-disabled={flag(disabledFlag)}
1236
+ aria-selected={selected ? 'true' : 'false'}
1237
+ class={classes}
1238
+ data-highlighted={flag(highlighted)}
1239
+ data-selected={flag(selected)}
1240
+ data-slot="select-item"
1241
+ data-state={selected ? 'checked' : 'unchecked'}
1242
+ hidden={hidden || undefined}
1243
+ id={select?.itemId(key)}
1244
+ role="option"
1245
+ set:onclick={(event: Event) => {
1246
+ if (disabledFlag) return
1247
+ onSelect?.(item, event)
1248
+ if (event.defaultPrevented) return
1249
+ select?.select(item, event)
1250
+ }}
1251
+ set:onfocus={() => select?.setActive(key)}
1252
+ set:onpointermove={() => {
1253
+ if (!disabledFlag) select?.setActive(key)
1254
+ }}
1255
+ tabindex={disabledFlag ? undefined : '-1'}
1256
+ >
1257
+ {children}
1258
+ <span aria-hidden="true" class={indicatorClass} data-selected={selected ? 'true' : 'false'} data-slot="select-item-indicator">
1259
+ <span class={indicatorIconClass} />
1260
+ </span>
1261
+ </div>
1262
+ )
1263
+ }
1264
+
1265
+ /** Group wrapper for Select options. */
1266
+ const SelectGroup: Stateless<SelectGroupArgs> = ({ children, class: classes, ...attrs }) => (
1267
+ <div {...attrs} class={classes} data-slot="select-group" role="group">
1268
+ {children}
1269
+ </div>
1270
+ )
1271
+
1272
+ /** Label for a SelectGroup. */
1273
+ const SelectLabel: Stateless<SelectLabelArgs> = ({ children, class: classes, ...attrs }) => (
1274
+ <div {...attrs} class={classes} data-slot="select-label">
1275
+ {children}
1276
+ </div>
1277
+ )
1278
+
1279
+ /** Visual separator between Select groups. */
1280
+ const SelectSeparator: Stateless<SelectSeparatorArgs> = ({ class: classes, ...attrs }) => (
1281
+ <div {...attrs} class={classes} data-slot="select-separator" role="separator" />
1282
+ )
1283
+
1284
+ /** Empty state shown when filtering hides every option. */
1285
+ const SelectEmpty: Stateless<SelectEmptyArgs> = ({ children, class: classes, ...attrs }) => (
1286
+ <div {...attrs} class={classes} data-slot="select-empty">
1287
+ {children}
1288
+ </div>
1289
+ )
1290
+
1291
+ /** Keep-mounted polite live region for async status; children swap. */
1292
+ const SelectStatus: Stateless<SelectStatusArgs> = ({ children, class: classes, ...attrs }) => (
1293
+ <div {...attrs} aria-live="polite" class={classes} data-slot="select-status" role="status">
1294
+ {children}
1295
+ </div>
1296
+ )
1297
+
1298
+ /** Create-tag row: visible while the search matches no option label exactly. */
1299
+ const SelectCreate: Stateless<SelectCreateArgs> = ({ children, class: classes, ...attrs }) => {
1300
+ const select = SelectContext()
1301
+ const highlighted = select?.activeKey === CREATE_KEY
1302
+
1303
+ return (
1304
+ <div
1305
+ {...attrs}
1306
+ {...selectItems.attrs({ label: select?.search ?? '', value: CREATE_KEY })}
1307
+ class={classes}
1308
+ data-highlighted={flag(highlighted)}
1309
+ data-slot="select-create"
1310
+ hidden={select?.createVisible ? undefined : true}
1311
+ id={select?.itemId(CREATE_KEY)}
1312
+ role="option"
1313
+ aria-selected="false"
1314
+ set:onclick={(event: Event) => select?.create(event)}
1315
+ set:onfocus={() => select?.setActive(CREATE_KEY)}
1316
+ set:onpointermove={() => select?.setActive(CREATE_KEY)}
1317
+ tabindex="-1"
1318
+ >
1319
+ {children ?? <>Create &laquo;{select?.search}&raquo;</>}
1320
+ </div>
1321
+ )
1322
+ }
1323
+
1324
+ /** Chip input wrapper for multiple Select selections. */
1325
+ const SelectChips: Stateless<SelectChipsArgs> = ({
1326
+ children,
1327
+ class: classes,
1328
+ ref,
1329
+ ...attrs
1330
+ }) => {
1331
+ const select = SelectContext()
1332
+ let mounted: HTMLDivElement | null = null
1333
+ const reference = (element: HTMLDivElement | null) => {
1334
+ const previous = mounted
1335
+ mounted = element
1336
+ select?.setReference(element, previous)
1337
+ callRef(ref, element)
1338
+ }
1339
+
1340
+ return (
1341
+ <div {...attrs} class={classes} data-slot="select-chips" ref={reference}>
1342
+ {children}
1343
+ </div>
1344
+ )
1345
+ }
1346
+
1347
+ /** Selected chip for multiple Select usage. */
1348
+ const SelectChip: Stateless<SelectChipArgs<any>> = ({
1349
+ children,
1350
+ class: classes,
1351
+ removeClass,
1352
+ removeLabel,
1353
+ removeIconClass,
1354
+ showRemove = true,
1355
+ value,
1356
+ ...attrs
1357
+ }) => {
1358
+ const select = SelectContext()
1359
+ const item = value ?? text(children)
1360
+ const key = select ? select.stringValue(item) : fallbackString(item)
1361
+
1362
+ return (
1363
+ <span {...attrs} class={classes} data-slot="select-chip" data-value={key} tabindex="-1">
1364
+ {children}
1365
+ {showRemove ? (
1366
+ <button
1367
+ aria-label={removeLabel ?? `Remove ${key}`}
1368
+ class={removeClass}
1369
+ data-slot="select-chip-remove"
1370
+ disabled={select?.disabled}
1371
+ tabindex="-1"
1372
+ type="button"
1373
+ set:onclick={(event: Event) => {
1374
+ event.stopPropagation()
1375
+ select?.remove(item, event)
1376
+ }}
1377
+ >
1378
+ <span aria-hidden="true" class={removeIconClass} />
1379
+ </button>
1380
+ ) : null}
1381
+ </span>
1382
+ )
1383
+ }
1384
+
1385
+ /** Input used inside SelectChips. */
1386
+ const SelectChipsInput: Stateless<SelectChipsInputArgs> = ({
1387
+ class: classes,
1388
+ disabled,
1389
+ onValueChange,
1390
+ placeholder,
1391
+ ref,
1392
+ value,
1393
+ 'set:oninput': onInput,
1394
+ ...attrs
1395
+ }) => {
1396
+ const select = SelectContext()
1397
+ const disabledFlag = Boolean(disabled ?? select?.disabled)
1398
+ let mounted: HTMLInputElement | null = null
1399
+ const reference = (element: HTMLInputElement | null) => {
1400
+ const previous = mounted
1401
+ mounted = element
1402
+ select?.setInput(element, previous)
1403
+ callRef(ref, element)
1404
+ }
1405
+
1406
+ return (
1407
+ <input
1408
+ {...attrs}
1409
+ aria-activedescendant={select?.activeId || undefined}
1410
+ aria-autocomplete="list"
1411
+ aria-controls={select?.listId}
1412
+ aria-expanded={select?.open ? 'true' : 'false'}
1413
+ aria-haspopup="listbox"
1414
+ aria-required={flag(select?.required)}
1415
+ class={classes}
1416
+ data-slot="select-chips-input"
1417
+ disabled={disabledFlag}
1418
+ placeholder={placeholder}
1419
+ ref={reference}
1420
+ role="combobox"
1421
+ set:onclick={(event: Event) => select?.setOpen(true, event)}
1422
+ set:onfocus={(event: FocusEvent) => select?.setOpen(true, event)}
1423
+ set:oninput={(event: Event) => {
1424
+ const next = (event.target as HTMLInputElement).value
1425
+ callHandler(onInput, event)
1426
+ if (event.defaultPrevented) return
1427
+ onValueChange?.(next, event)
1428
+ select?.setSearch(next, event)
1429
+ }}
1430
+ set:value={value == null ? select?.search ?? '' : String(value)}
1431
+ type="text"
1432
+ />
1433
+ )
1434
+ }
1435
+
1436
+ const scrollList = (event: Event, top: number) => {
1437
+ (event.currentTarget as HTMLElement)
1438
+ .closest<HTMLElement>('[data-slot="select-content"]')
1439
+ ?.querySelector<HTMLElement>('[data-slot="select-list"]')
1440
+ ?.scrollBy({ top })
1441
+ }
1442
+
1443
+ /** Scroll button for long Select lists. */
1444
+ const SelectScrollUpButton: Stateless<SelectScrollButtonArgs> = ({
1445
+ children,
1446
+ class: classes,
1447
+ iconClass,
1448
+ type = 'button',
1449
+ ...attrs
1450
+ }) => (
1451
+ <button
1452
+ {...attrs}
1453
+ class={classes}
1454
+ data-slot="select-scroll-up-button"
1455
+ set:onclick={(event: Event) => scrollList(event, -96)}
1456
+ type={type}
1457
+ >
1458
+ {children ?? <span aria-hidden="true" class={iconClass} />}
1459
+ </button>
1460
+ )
1461
+
1462
+ /** Scroll button for long Select lists. */
1463
+ const SelectScrollDownButton: Stateless<SelectScrollButtonArgs> = ({
1464
+ children,
1465
+ class: classes,
1466
+ iconClass,
1467
+ type = 'button',
1468
+ ...attrs
1469
+ }) => (
1470
+ <button
1471
+ {...attrs}
1472
+ class={classes}
1473
+ data-slot="select-scroll-down-button"
1474
+ set:onclick={(event: Event) => scrollList(event, 96)}
1475
+ type={type}
1476
+ >
1477
+ {children ?? <span aria-hidden="true" class={iconClass} />}
1478
+ </button>
1479
+ )
1480
+
1481
+ export {
1482
+ Select,
1483
+ SelectChip,
1484
+ SelectChips,
1485
+ SelectChipsInput,
1486
+ SelectClear,
1487
+ SelectContent,
1488
+ SelectCreate,
1489
+ SelectEmpty,
1490
+ SelectGroup,
1491
+ SelectInput,
1492
+ SelectItem,
1493
+ SelectLabel,
1494
+ SelectList,
1495
+ SelectScrollDownButton,
1496
+ SelectScrollUpButton,
1497
+ SelectSeparator,
1498
+ SelectStatus,
1499
+ SelectTrigger,
1500
+ SelectValue,
1501
+ }