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.
- package/LICENSE +15 -0
- package/README.md +126 -0
- package/dist/accordion.js +130 -0
- package/dist/avatar.js +54 -0
- package/dist/calendar.js +2 -0
- package/dist/carousel.js +239 -0
- package/dist/chart.js +790 -0
- package/dist/checkbox-group.js +70 -0
- package/dist/checkbox.js +77 -0
- package/dist/chunks/bar-CVafh6C1.js +99 -0
- package/dist/chunks/calendar-q8jZ8Cxr.js +1923 -0
- package/dist/chunks/collection-DtRB63U4.js +111 -0
- package/dist/chunks/data-table-DpkWv4y4.js +1039 -0
- package/dist/chunks/menu-B45IyHHC.js +704 -0
- package/dist/chunks/native-BJdhd9XJ.js +20 -0
- package/dist/chunks/popup-C8Bb3l_g.js +459 -0
- package/dist/chunks/popup-surface-BDCgtVU0.js +18 -0
- package/dist/chunks/position-D6_i_SRn.js +434 -0
- package/dist/chunks/virtual-list-B7hjGkjk.js +413 -0
- package/dist/collapsible.js +106 -0
- package/dist/command.js +263 -0
- package/dist/context-menu.js +112 -0
- package/dist/data-table.js +5 -0
- package/dist/dialog.js +207 -0
- package/dist/direction.js +22 -0
- package/dist/drawer.js +139 -0
- package/dist/field.js +26 -0
- package/dist/index.js +38 -0
- package/dist/input-date.js +994 -0
- package/dist/input-group.js +52 -0
- package/dist/input-otp.js +179 -0
- package/dist/menu.js +2 -0
- package/dist/menubar.js +236 -0
- package/dist/message-scroller.js +446 -0
- package/dist/navigation-menu.js +330 -0
- package/dist/popover.js +307 -0
- package/dist/progress.js +39 -0
- package/dist/radio-group.js +107 -0
- package/dist/resizable.js +172 -0
- package/dist/select.js +961 -0
- package/dist/sidebar.js +343 -0
- package/dist/slider.js +259 -0
- package/dist/switch.js +53 -0
- package/dist/tabs.js +182 -0
- package/dist/toast.js +492 -0
- package/dist/toggle-group.js +111 -0
- package/dist/toggle.js +52 -0
- package/dist/toolbar.js +127 -0
- package/dist/tooltip.js +196 -0
- package/dist/utils.js +104 -0
- package/dist/virtual-list.js +2 -0
- package/package.json +250 -0
- package/src/accordion.tsx +261 -0
- package/src/availability.ts +261 -0
- package/src/avatar.tsx +99 -0
- package/src/bar.ts +156 -0
- package/src/calendar.tsx +1441 -0
- package/src/carousel.tsx +424 -0
- package/src/chart.tsx +1194 -0
- package/src/checkbox-group.tsx +132 -0
- package/src/checkbox.tsx +130 -0
- package/src/collapsible.tsx +188 -0
- package/src/collection.ts +154 -0
- package/src/command.tsx +511 -0
- package/src/context-menu.tsx +233 -0
- package/src/data-table-contract.ts +143 -0
- package/src/data-table-model.ts +760 -0
- package/src/data-table.tsx +475 -0
- package/src/dialog.tsx +393 -0
- package/src/direction.tsx +45 -0
- package/src/drawer.tsx +251 -0
- package/src/field.tsx +61 -0
- package/src/index.ts +37 -0
- package/src/input-date.tsx +1539 -0
- package/src/input-group.tsx +142 -0
- package/src/input-otp.tsx +324 -0
- package/src/menu-cluster.ts +124 -0
- package/src/menu.tsx +1095 -0
- package/src/menubar.tsx +459 -0
- package/src/message-scroller.tsx +732 -0
- package/src/native.ts +26 -0
- package/src/navigation-menu.tsx +578 -0
- package/src/popover.tsx +519 -0
- package/src/popup-surface.tsx +31 -0
- package/src/popup.ts +569 -0
- package/src/position.ts +523 -0
- package/src/progress.tsx +70 -0
- package/src/radio-group.tsx +186 -0
- package/src/resizable.tsx +310 -0
- package/src/segments.ts +922 -0
- package/src/select.tsx +1501 -0
- package/src/sidebar.tsx +683 -0
- package/src/slider.tsx +424 -0
- package/src/switch.tsx +104 -0
- package/src/tabs.tsx +314 -0
- package/src/toast.tsx +923 -0
- package/src/toggle-group.tsx +249 -0
- package/src/toggle.tsx +91 -0
- package/src/toolbar.tsx +212 -0
- package/src/tooltip.tsx +359 -0
- package/src/utils.ts +204 -0
- package/src/virtual-list.tsx +205 -0
- 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 «{select?.search}»</>}
|
|
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
|
+
}
|