simplesvelte 2.4.6 → 2.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +214 -214
- package/dist/Input.svelte +0 -4
- package/dist/Label.svelte +43 -43
- package/dist/Modal.svelte +39 -39
- package/dist/Select.svelte +683 -689
- package/dist/styles.css +15 -15
- package/package.json +1 -1
package/dist/Select.svelte
CHANGED
|
@@ -1,689 +1,683 @@
|
|
|
1
|
-
<script lang="ts" module>
|
|
2
|
-
export type SelectOption = {
|
|
3
|
-
value: any
|
|
4
|
-
label: any
|
|
5
|
-
group?: string
|
|
6
|
-
[key: string]: any // Allow additional properties
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export type SelectFetchParams = { filter: string | undefined }
|
|
10
|
-
</script>
|
|
11
|
-
|
|
12
|
-
<script lang="ts">
|
|
13
|
-
import Input from './Input.svelte'
|
|
14
|
-
import Label from './Label.svelte'
|
|
15
|
-
|
|
16
|
-
type Props = {
|
|
17
|
-
value?: string | number | undefined | null | (string | number)[]
|
|
18
|
-
options?: SelectOption[]
|
|
19
|
-
fetchOptions?: (params: SelectFetchParams) => Promise<SelectOption[]>
|
|
20
|
-
debounceMs?: number
|
|
21
|
-
name?: string
|
|
22
|
-
label?: string
|
|
23
|
-
class?: string
|
|
24
|
-
required?: boolean
|
|
25
|
-
placeholder?: string
|
|
26
|
-
disabled?: boolean
|
|
27
|
-
multiple?: boolean
|
|
28
|
-
error?: string
|
|
29
|
-
zodErrors?: {
|
|
30
|
-
expected: string
|
|
31
|
-
code: string
|
|
32
|
-
path: string[]
|
|
33
|
-
message: string
|
|
34
|
-
}[]
|
|
35
|
-
onchange?: (value: string | number | undefined | null | (string | number)[]) => void
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
let {
|
|
39
|
-
value = $bindable(undefined),
|
|
40
|
-
options: staticOptions = [],
|
|
41
|
-
fetchOptions,
|
|
42
|
-
debounceMs = 300,
|
|
43
|
-
name,
|
|
44
|
-
label,
|
|
45
|
-
class: className = '',
|
|
46
|
-
required = false,
|
|
47
|
-
disabled = false,
|
|
48
|
-
multiple = false,
|
|
49
|
-
placeholder = 'Select an item...',
|
|
50
|
-
error,
|
|
51
|
-
zodErrors,
|
|
52
|
-
onchange,
|
|
53
|
-
}: Props = $props()
|
|
54
|
-
|
|
55
|
-
// Async fetch state
|
|
56
|
-
let asyncItems = $state<SelectOption[]>([])
|
|
57
|
-
let isLoading = $state(false)
|
|
58
|
-
let fetchError = $state<string | undefined>(undefined)
|
|
59
|
-
let debounceTimeout: ReturnType<typeof setTimeout> | undefined
|
|
60
|
-
|
|
61
|
-
// Use async items if fetchOptions is provided, otherwise use static options
|
|
62
|
-
let items = $derived(fetchOptions ? asyncItems : staticOptions)
|
|
63
|
-
|
|
64
|
-
// Fetch options when filter changes (debounced)
|
|
65
|
-
async function fetchWithFilter(filterValue: string | undefined) {
|
|
66
|
-
if (!fetchOptions) return
|
|
67
|
-
|
|
68
|
-
// Clear previous timeout
|
|
69
|
-
if (debounceTimeout) clearTimeout(debounceTimeout)
|
|
70
|
-
|
|
71
|
-
debounceTimeout = setTimeout(async () => {
|
|
72
|
-
isLoading = true
|
|
73
|
-
fetchError = undefined
|
|
74
|
-
try {
|
|
75
|
-
asyncItems = await fetchOptions({ filter: filterValue })
|
|
76
|
-
} catch (err) {
|
|
77
|
-
fetchError = err instanceof Error ? err.message : 'Failed to fetch options'
|
|
78
|
-
asyncItems = []
|
|
79
|
-
} finally {
|
|
80
|
-
isLoading = false
|
|
81
|
-
}
|
|
82
|
-
}, debounceMs)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Trigger fetch when dropdown opens or filter input changes (if using async)
|
|
86
|
-
$effect(() => {
|
|
87
|
-
if (fetchOptions && dropdownOpen) {
|
|
88
|
-
// Track filterInput to re-run when it changes
|
|
89
|
-
const currentFilter = filterInput
|
|
90
|
-
fetchWithFilter(currentFilter || undefined)
|
|
91
|
-
}
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
let dropdownOpen = $state(false)
|
|
95
|
-
|
|
96
|
-
// Generate unique ID for popover
|
|
97
|
-
const popoverId = `select-popover-${Math.random().toString(36).slice(2, 9)}`
|
|
98
|
-
const anchorName = `--anchor-${popoverId}`
|
|
99
|
-
|
|
100
|
-
// Ensure value is properly typed for single/multiple mode
|
|
101
|
-
// Normalize on access rather than maintaining separate state
|
|
102
|
-
let normalizedValue = $derived.by(() => {
|
|
103
|
-
if (multiple) {
|
|
104
|
-
// For multiple mode, always work with arrays
|
|
105
|
-
return Array.isArray(value) ? value : value ? [value] : []
|
|
106
|
-
} else {
|
|
107
|
-
// For single mode, extract first value if array
|
|
108
|
-
return Array.isArray(value) ? (value.length > 0 ? value[0] : undefined) : value
|
|
109
|
-
}
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
// For single select mode
|
|
113
|
-
let selectedItem = $derived.by(() => {
|
|
114
|
-
if (multiple) return null
|
|
115
|
-
const currentValue = normalizedValue
|
|
116
|
-
return items.find((item) => item.value === currentValue)
|
|
117
|
-
})
|
|
118
|
-
// For multi select mode
|
|
119
|
-
let selectedItems = $derived.by(() => {
|
|
120
|
-
if (!multiple) return []
|
|
121
|
-
const currentValue = normalizedValue
|
|
122
|
-
if (!Array.isArray(currentValue)) return []
|
|
123
|
-
return items.filter((item) => currentValue.includes(item.value))
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
// Remove specific item from multi-select
|
|
127
|
-
function toggleItemSelection(itemValue: any) {
|
|
128
|
-
if (!multiple) {
|
|
129
|
-
// Close dropdown and clear filter - the derived `filter` will show the label when closed
|
|
130
|
-
filterInput = ''
|
|
131
|
-
closeDropdown()
|
|
132
|
-
value = itemValue
|
|
133
|
-
if (onchange) onchange(value)
|
|
134
|
-
return
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// For multiple selection, work with current array state
|
|
138
|
-
const currentValue = Array.isArray(normalizedValue) ? normalizedValue : []
|
|
139
|
-
|
|
140
|
-
if (currentValue.includes(itemValue)) {
|
|
141
|
-
value = currentValue.filter((v) => v !== itemValue)
|
|
142
|
-
} else {
|
|
143
|
-
value = [...currentValue, itemValue]
|
|
144
|
-
}
|
|
145
|
-
if (onchange) onchange(value)
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Remove specific item from multi-select
|
|
149
|
-
function removeSelectedItem(itemValue: any) {
|
|
150
|
-
const currentValue = normalizedValue
|
|
151
|
-
if (Array.isArray(currentValue)) {
|
|
152
|
-
value = currentValue.filter((v) => v !== itemValue)
|
|
153
|
-
if (onchange) onchange(value)
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Clear all selections
|
|
158
|
-
function clearAll() {
|
|
159
|
-
value = multiple ? [] : null
|
|
160
|
-
filterInput = ''
|
|
161
|
-
closeDropdown()
|
|
162
|
-
if (onchange) onchange(value)
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// User's filter input - always editable
|
|
166
|
-
let filterInput = $state('')
|
|
167
|
-
|
|
168
|
-
// Display value in filter box for single-select when closed
|
|
169
|
-
let filter = $derived.by(() => {
|
|
170
|
-
// In single select mode when dropdown is closed, show selected item
|
|
171
|
-
if (!multiple && !dropdownOpen && selectedItem) {
|
|
172
|
-
return selectedItem.label
|
|
173
|
-
}
|
|
174
|
-
// Otherwise use the user's filter input
|
|
175
|
-
return filterInput
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
let filteredItems = $derived.by(() => {
|
|
179
|
-
// When using async fetch, server handles filtering - return items as-is
|
|
180
|
-
if (fetchOptions) return items
|
|
181
|
-
// Client-side filtering for static options
|
|
182
|
-
if (filter.length === 0) return items
|
|
183
|
-
return items.filter((item) => item.label.toLowerCase().includes(filter.toLowerCase()))
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
// Flatten filteredItems into a list with group headers and options for virtual scroll
|
|
187
|
-
type FlatListItem = { type: 'header'; group: string } | { type: 'option'; item: SelectOption }
|
|
188
|
-
let flatList = $derived.by(() => {
|
|
189
|
-
const result: FlatListItem[] = []
|
|
190
|
-
const groups: Record<string, SelectOption[]> = {}
|
|
191
|
-
const ungrouped: SelectOption[] = []
|
|
192
|
-
|
|
193
|
-
for (const item of filteredItems) {
|
|
194
|
-
if (item.group) {
|
|
195
|
-
if (!groups[item.group]) groups[item.group] = []
|
|
196
|
-
groups[item.group].push(item)
|
|
197
|
-
} else {
|
|
198
|
-
ungrouped.push(item)
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// In multiple mode, separate selected and unselected items
|
|
203
|
-
if (multiple && Array.isArray(normalizedValue) && normalizedValue.length > 0) {
|
|
204
|
-
const selectedUngrouped: SelectOption[] = []
|
|
205
|
-
const unselectedUngrouped: SelectOption[] = []
|
|
206
|
-
|
|
207
|
-
for (const item of ungrouped) {
|
|
208
|
-
if (normalizedValue.includes(item.value)) {
|
|
209
|
-
selectedUngrouped.push(item)
|
|
210
|
-
} else {
|
|
211
|
-
unselectedUngrouped.push(item)
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Add selected ungrouped items first
|
|
216
|
-
for (const item of selectedUngrouped) {
|
|
217
|
-
result.push({ type: 'option', item })
|
|
218
|
-
}
|
|
219
|
-
// Then unselected ungrouped items
|
|
220
|
-
for (const item of unselectedUngrouped) {
|
|
221
|
-
result.push({ type: 'option', item })
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Add grouped items with headers, also with selected first within each group
|
|
225
|
-
for (const groupName of Object.keys(groups)) {
|
|
226
|
-
const groupItems = groups[groupName]
|
|
227
|
-
const selectedGroupItems = groupItems.filter((item) => normalizedValue.includes(item.value))
|
|
228
|
-
const unselectedGroupItems = groupItems.filter((item) => !normalizedValue.includes(item.value))
|
|
229
|
-
|
|
230
|
-
result.push({ type: 'header', group: groupName })
|
|
231
|
-
for (const item of selectedGroupItems) {
|
|
232
|
-
result.push({ type: 'option', item })
|
|
233
|
-
}
|
|
234
|
-
for (const item of unselectedGroupItems) {
|
|
235
|
-
result.push({ type: 'option', item })
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
} else {
|
|
239
|
-
// Normal ordering when not in multiple mode or no selections
|
|
240
|
-
for (const item of ungrouped) {
|
|
241
|
-
result.push({ type: 'option', item })
|
|
242
|
-
}
|
|
243
|
-
for (const groupName of Object.keys(groups)) {
|
|
244
|
-
result.push({ type: 'header', group: groupName })
|
|
245
|
-
for (const item of groups[groupName]) {
|
|
246
|
-
result.push({ type: 'option', item })
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
return result
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
let searchEL: HTMLInputElement | undefined = $state(undefined)
|
|
255
|
-
let popoverEl: HTMLElement | undefined = $state(undefined)
|
|
256
|
-
let scrollContainerEl: HTMLDivElement | undefined = $state(undefined)
|
|
257
|
-
|
|
258
|
-
// Keyboard navigation
|
|
259
|
-
let highlightedIndex = $state(-1)
|
|
260
|
-
|
|
261
|
-
// Get only the option entries from flatList (skip headers)
|
|
262
|
-
let optionIndices = $derived.by(() => {
|
|
263
|
-
const indices: number[] = []
|
|
264
|
-
for (let i = 0; i < flatList.length; i++) {
|
|
265
|
-
if (flatList[i].type === 'option') indices.push(i)
|
|
266
|
-
}
|
|
267
|
-
return indices
|
|
268
|
-
})
|
|
269
|
-
|
|
270
|
-
// Reset highlight when filter changes or dropdown closes
|
|
271
|
-
$effect(() => {
|
|
272
|
-
// Track filterInput to reset highlight when user types
|
|
273
|
-
void filterInput
|
|
274
|
-
if (!dropdownOpen) {
|
|
275
|
-
highlightedIndex = -1
|
|
276
|
-
}
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
// Scroll the virtual list to keep highlighted item visible
|
|
280
|
-
function scrollToHighlighted(index: number) {
|
|
281
|
-
if (!scrollContainerEl || index < 0) return
|
|
282
|
-
const targetTop = index * itemHeight
|
|
283
|
-
const targetBottom = targetTop + itemHeight
|
|
284
|
-
const viewTop = scrollContainerEl.scrollTop
|
|
285
|
-
const viewBottom = viewTop + containerHeight
|
|
286
|
-
|
|
287
|
-
if (targetTop < viewTop) {
|
|
288
|
-
scrollContainerEl.scrollTop = targetTop
|
|
289
|
-
} else if (targetBottom > viewBottom) {
|
|
290
|
-
scrollContainerEl.scrollTop = targetBottom - containerHeight
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Keyboard handler for arrow navigation
|
|
295
|
-
function handleKeydown(e: KeyboardEvent) {
|
|
296
|
-
if (e.key === 'ArrowDown') {
|
|
297
|
-
e.preventDefault()
|
|
298
|
-
if (!dropdownOpen)
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if (
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
endIndex,
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
if (
|
|
423
|
-
if (
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
dropdownOpen
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
{
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
{
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
{
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
disabled
|
|
685
|
-
value={selectedItem ? selectedItem.label : ''}
|
|
686
|
-
readonly />
|
|
687
|
-
{/if}
|
|
688
|
-
{/if}
|
|
689
|
-
</Label>
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type SelectOption = {
|
|
3
|
+
value: any
|
|
4
|
+
label: any
|
|
5
|
+
group?: string
|
|
6
|
+
[key: string]: any // Allow additional properties
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type SelectFetchParams = { filter: string | undefined }
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<script lang="ts">
|
|
13
|
+
import Input from './Input.svelte'
|
|
14
|
+
import Label from './Label.svelte'
|
|
15
|
+
|
|
16
|
+
type Props = {
|
|
17
|
+
value?: string | number | undefined | null | (string | number)[]
|
|
18
|
+
options?: SelectOption[]
|
|
19
|
+
fetchOptions?: (params: SelectFetchParams) => Promise<SelectOption[]>
|
|
20
|
+
debounceMs?: number
|
|
21
|
+
name?: string
|
|
22
|
+
label?: string
|
|
23
|
+
class?: string
|
|
24
|
+
required?: boolean
|
|
25
|
+
placeholder?: string
|
|
26
|
+
disabled?: boolean
|
|
27
|
+
multiple?: boolean
|
|
28
|
+
error?: string
|
|
29
|
+
zodErrors?: {
|
|
30
|
+
expected: string
|
|
31
|
+
code: string
|
|
32
|
+
path: string[]
|
|
33
|
+
message: string
|
|
34
|
+
}[]
|
|
35
|
+
onchange?: (value: string | number | undefined | null | (string | number)[]) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let {
|
|
39
|
+
value = $bindable(undefined),
|
|
40
|
+
options: staticOptions = [],
|
|
41
|
+
fetchOptions,
|
|
42
|
+
debounceMs = 300,
|
|
43
|
+
name,
|
|
44
|
+
label,
|
|
45
|
+
class: className = '',
|
|
46
|
+
required = false,
|
|
47
|
+
disabled = false,
|
|
48
|
+
multiple = false,
|
|
49
|
+
placeholder = 'Select an item...',
|
|
50
|
+
error,
|
|
51
|
+
zodErrors,
|
|
52
|
+
onchange,
|
|
53
|
+
}: Props = $props()
|
|
54
|
+
|
|
55
|
+
// Async fetch state
|
|
56
|
+
let asyncItems = $state<SelectOption[]>([])
|
|
57
|
+
let isLoading = $state(false)
|
|
58
|
+
let fetchError = $state<string | undefined>(undefined)
|
|
59
|
+
let debounceTimeout: ReturnType<typeof setTimeout> | undefined
|
|
60
|
+
|
|
61
|
+
// Use async items if fetchOptions is provided, otherwise use static options
|
|
62
|
+
let items = $derived(fetchOptions ? asyncItems : staticOptions)
|
|
63
|
+
|
|
64
|
+
// Fetch options when filter changes (debounced)
|
|
65
|
+
async function fetchWithFilter(filterValue: string | undefined) {
|
|
66
|
+
if (!fetchOptions) return
|
|
67
|
+
|
|
68
|
+
// Clear previous timeout
|
|
69
|
+
if (debounceTimeout) clearTimeout(debounceTimeout)
|
|
70
|
+
|
|
71
|
+
debounceTimeout = setTimeout(async () => {
|
|
72
|
+
isLoading = true
|
|
73
|
+
fetchError = undefined
|
|
74
|
+
try {
|
|
75
|
+
asyncItems = await fetchOptions({ filter: filterValue })
|
|
76
|
+
} catch (err) {
|
|
77
|
+
fetchError = err instanceof Error ? err.message : 'Failed to fetch options'
|
|
78
|
+
asyncItems = []
|
|
79
|
+
} finally {
|
|
80
|
+
isLoading = false
|
|
81
|
+
}
|
|
82
|
+
}, debounceMs)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Trigger fetch when dropdown opens or filter input changes (if using async)
|
|
86
|
+
$effect(() => {
|
|
87
|
+
if (fetchOptions && dropdownOpen) {
|
|
88
|
+
// Track filterInput to re-run when it changes
|
|
89
|
+
const currentFilter = filterInput
|
|
90
|
+
fetchWithFilter(currentFilter || undefined)
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
let dropdownOpen = $state(false)
|
|
95
|
+
|
|
96
|
+
// Generate unique ID for popover
|
|
97
|
+
const popoverId = `select-popover-${Math.random().toString(36).slice(2, 9)}`
|
|
98
|
+
const anchorName = `--anchor-${popoverId}`
|
|
99
|
+
|
|
100
|
+
// Ensure value is properly typed for single/multiple mode
|
|
101
|
+
// Normalize on access rather than maintaining separate state
|
|
102
|
+
let normalizedValue = $derived.by(() => {
|
|
103
|
+
if (multiple) {
|
|
104
|
+
// For multiple mode, always work with arrays
|
|
105
|
+
return Array.isArray(value) ? value : value ? [value] : []
|
|
106
|
+
} else {
|
|
107
|
+
// For single mode, extract first value if array
|
|
108
|
+
return Array.isArray(value) ? (value.length > 0 ? value[0] : undefined) : value
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// For single select mode
|
|
113
|
+
let selectedItem = $derived.by(() => {
|
|
114
|
+
if (multiple) return null
|
|
115
|
+
const currentValue = normalizedValue
|
|
116
|
+
return items.find((item) => item.value === currentValue)
|
|
117
|
+
})
|
|
118
|
+
// For multi select mode
|
|
119
|
+
let selectedItems = $derived.by(() => {
|
|
120
|
+
if (!multiple) return []
|
|
121
|
+
const currentValue = normalizedValue
|
|
122
|
+
if (!Array.isArray(currentValue)) return []
|
|
123
|
+
return items.filter((item) => currentValue.includes(item.value))
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// Remove specific item from multi-select
|
|
127
|
+
function toggleItemSelection(itemValue: any) {
|
|
128
|
+
if (!multiple) {
|
|
129
|
+
// Close dropdown and clear filter - the derived `filter` will show the label when closed
|
|
130
|
+
filterInput = ''
|
|
131
|
+
closeDropdown()
|
|
132
|
+
value = itemValue
|
|
133
|
+
if (onchange) onchange(value)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// For multiple selection, work with current array state
|
|
138
|
+
const currentValue = Array.isArray(normalizedValue) ? normalizedValue : []
|
|
139
|
+
|
|
140
|
+
if (currentValue.includes(itemValue)) {
|
|
141
|
+
value = currentValue.filter((v) => v !== itemValue)
|
|
142
|
+
} else {
|
|
143
|
+
value = [...currentValue, itemValue]
|
|
144
|
+
}
|
|
145
|
+
if (onchange) onchange(value)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Remove specific item from multi-select
|
|
149
|
+
function removeSelectedItem(itemValue: any) {
|
|
150
|
+
const currentValue = normalizedValue
|
|
151
|
+
if (Array.isArray(currentValue)) {
|
|
152
|
+
value = currentValue.filter((v) => v !== itemValue)
|
|
153
|
+
if (onchange) onchange(value)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Clear all selections
|
|
158
|
+
function clearAll() {
|
|
159
|
+
value = multiple ? [] : null
|
|
160
|
+
filterInput = ''
|
|
161
|
+
closeDropdown()
|
|
162
|
+
if (onchange) onchange(value)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// User's filter input - always editable
|
|
166
|
+
let filterInput = $state('')
|
|
167
|
+
|
|
168
|
+
// Display value in filter box for single-select when closed
|
|
169
|
+
let filter = $derived.by(() => {
|
|
170
|
+
// In single select mode when dropdown is closed, show selected item
|
|
171
|
+
if (!multiple && !dropdownOpen && selectedItem) {
|
|
172
|
+
return selectedItem.label
|
|
173
|
+
}
|
|
174
|
+
// Otherwise use the user's filter input
|
|
175
|
+
return filterInput
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
let filteredItems = $derived.by(() => {
|
|
179
|
+
// When using async fetch, server handles filtering - return items as-is
|
|
180
|
+
if (fetchOptions) return items
|
|
181
|
+
// Client-side filtering for static options
|
|
182
|
+
if (filter.length === 0) return items
|
|
183
|
+
return items.filter((item) => item.label.toLowerCase().includes(filter.toLowerCase()))
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// Flatten filteredItems into a list with group headers and options for virtual scroll
|
|
187
|
+
type FlatListItem = { type: 'header'; group: string } | { type: 'option'; item: SelectOption }
|
|
188
|
+
let flatList = $derived.by(() => {
|
|
189
|
+
const result: FlatListItem[] = []
|
|
190
|
+
const groups: Record<string, SelectOption[]> = {}
|
|
191
|
+
const ungrouped: SelectOption[] = []
|
|
192
|
+
|
|
193
|
+
for (const item of filteredItems) {
|
|
194
|
+
if (item.group) {
|
|
195
|
+
if (!groups[item.group]) groups[item.group] = []
|
|
196
|
+
groups[item.group].push(item)
|
|
197
|
+
} else {
|
|
198
|
+
ungrouped.push(item)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// In multiple mode, separate selected and unselected items
|
|
203
|
+
if (multiple && Array.isArray(normalizedValue) && normalizedValue.length > 0) {
|
|
204
|
+
const selectedUngrouped: SelectOption[] = []
|
|
205
|
+
const unselectedUngrouped: SelectOption[] = []
|
|
206
|
+
|
|
207
|
+
for (const item of ungrouped) {
|
|
208
|
+
if (normalizedValue.includes(item.value)) {
|
|
209
|
+
selectedUngrouped.push(item)
|
|
210
|
+
} else {
|
|
211
|
+
unselectedUngrouped.push(item)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Add selected ungrouped items first
|
|
216
|
+
for (const item of selectedUngrouped) {
|
|
217
|
+
result.push({ type: 'option', item })
|
|
218
|
+
}
|
|
219
|
+
// Then unselected ungrouped items
|
|
220
|
+
for (const item of unselectedUngrouped) {
|
|
221
|
+
result.push({ type: 'option', item })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Add grouped items with headers, also with selected first within each group
|
|
225
|
+
for (const groupName of Object.keys(groups)) {
|
|
226
|
+
const groupItems = groups[groupName]
|
|
227
|
+
const selectedGroupItems = groupItems.filter((item) => normalizedValue.includes(item.value))
|
|
228
|
+
const unselectedGroupItems = groupItems.filter((item) => !normalizedValue.includes(item.value))
|
|
229
|
+
|
|
230
|
+
result.push({ type: 'header', group: groupName })
|
|
231
|
+
for (const item of selectedGroupItems) {
|
|
232
|
+
result.push({ type: 'option', item })
|
|
233
|
+
}
|
|
234
|
+
for (const item of unselectedGroupItems) {
|
|
235
|
+
result.push({ type: 'option', item })
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
// Normal ordering when not in multiple mode or no selections
|
|
240
|
+
for (const item of ungrouped) {
|
|
241
|
+
result.push({ type: 'option', item })
|
|
242
|
+
}
|
|
243
|
+
for (const groupName of Object.keys(groups)) {
|
|
244
|
+
result.push({ type: 'header', group: groupName })
|
|
245
|
+
for (const item of groups[groupName]) {
|
|
246
|
+
result.push({ type: 'option', item })
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return result
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
let searchEL: HTMLInputElement | undefined = $state(undefined)
|
|
255
|
+
let popoverEl: HTMLElement | undefined = $state(undefined)
|
|
256
|
+
let scrollContainerEl: HTMLDivElement | undefined = $state(undefined)
|
|
257
|
+
|
|
258
|
+
// Keyboard navigation
|
|
259
|
+
let highlightedIndex = $state(-1)
|
|
260
|
+
|
|
261
|
+
// Get only the option entries from flatList (skip headers)
|
|
262
|
+
let optionIndices = $derived.by(() => {
|
|
263
|
+
const indices: number[] = []
|
|
264
|
+
for (let i = 0; i < flatList.length; i++) {
|
|
265
|
+
if (flatList[i].type === 'option') indices.push(i)
|
|
266
|
+
}
|
|
267
|
+
return indices
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// Reset highlight when filter changes or dropdown closes
|
|
271
|
+
$effect(() => {
|
|
272
|
+
// Track filterInput to reset highlight when user types
|
|
273
|
+
void filterInput
|
|
274
|
+
if (!dropdownOpen) {
|
|
275
|
+
highlightedIndex = -1
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
// Scroll the virtual list to keep highlighted item visible
|
|
280
|
+
function scrollToHighlighted(index: number) {
|
|
281
|
+
if (!scrollContainerEl || index < 0) return
|
|
282
|
+
const targetTop = index * itemHeight
|
|
283
|
+
const targetBottom = targetTop + itemHeight
|
|
284
|
+
const viewTop = scrollContainerEl.scrollTop
|
|
285
|
+
const viewBottom = viewTop + containerHeight
|
|
286
|
+
|
|
287
|
+
if (targetTop < viewTop) {
|
|
288
|
+
scrollContainerEl.scrollTop = targetTop
|
|
289
|
+
} else if (targetBottom > viewBottom) {
|
|
290
|
+
scrollContainerEl.scrollTop = targetBottom - containerHeight
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Keyboard handler for arrow navigation
|
|
295
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
296
|
+
if (e.key === 'ArrowDown') {
|
|
297
|
+
e.preventDefault()
|
|
298
|
+
if (!dropdownOpen) openDropdown()
|
|
299
|
+
// Move to next option (or first option if none highlighted)
|
|
300
|
+
const currentPos = optionIndices.indexOf(highlightedIndex)
|
|
301
|
+
const nextPos = currentPos + 1
|
|
302
|
+
if (nextPos < optionIndices.length) {
|
|
303
|
+
highlightedIndex = optionIndices[nextPos]
|
|
304
|
+
scrollToHighlighted(highlightedIndex)
|
|
305
|
+
}
|
|
306
|
+
searchEL?.focus()
|
|
307
|
+
} else if (e.key === 'ArrowUp') {
|
|
308
|
+
e.preventDefault()
|
|
309
|
+
if (!dropdownOpen) openDropdown()
|
|
310
|
+
// Move to previous option (or last option if none highlighted)
|
|
311
|
+
const currentPos = optionIndices.indexOf(highlightedIndex)
|
|
312
|
+
const prevPos = currentPos >= 0 ? currentPos - 1 : optionIndices.length - 1
|
|
313
|
+
if (prevPos >= 0) {
|
|
314
|
+
highlightedIndex = optionIndices[prevPos]
|
|
315
|
+
scrollToHighlighted(highlightedIndex)
|
|
316
|
+
}
|
|
317
|
+
searchEL?.focus()
|
|
318
|
+
} else if (e.key === 'Enter') {
|
|
319
|
+
e.preventDefault()
|
|
320
|
+
if (!dropdownOpen) {
|
|
321
|
+
openDropdown()
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
if (highlightedIndex >= 0) {
|
|
325
|
+
const entry = flatList[highlightedIndex]
|
|
326
|
+
if (entry && entry.type === 'option') {
|
|
327
|
+
toggleItemSelection(entry.item.value)
|
|
328
|
+
if (multiple) searchEL?.focus()
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} else if (e.key === 'Escape') {
|
|
332
|
+
closeDropdown()
|
|
333
|
+
} else if (e.key === 'Tab') {
|
|
334
|
+
// Close dropdown on tab to allow normal tab navigation
|
|
335
|
+
closeDropdown()
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Virtual list implementation
|
|
340
|
+
let scrollTop = $state(0)
|
|
341
|
+
const itemHeight = 40 // Approximate height of each item in pixels
|
|
342
|
+
const containerHeight = 320 // max-h-80 = 320px
|
|
343
|
+
const visibleCount = Math.ceil(containerHeight / itemHeight) + 2 // Add buffer items
|
|
344
|
+
|
|
345
|
+
// Calculate visible items based on scroll position (for flatList)
|
|
346
|
+
let visibleItems = $derived.by(() => {
|
|
347
|
+
const total = flatList.length
|
|
348
|
+
const totalHeight = total * itemHeight
|
|
349
|
+
|
|
350
|
+
// If content is shorter than container, show everything from start
|
|
351
|
+
if (totalHeight <= containerHeight) {
|
|
352
|
+
return {
|
|
353
|
+
startIndex: 0,
|
|
354
|
+
endIndex: total,
|
|
355
|
+
items: flatList,
|
|
356
|
+
total,
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const startIndex = Math.floor(scrollTop / itemHeight)
|
|
361
|
+
const endIndex = Math.min(startIndex + visibleCount, total)
|
|
362
|
+
return {
|
|
363
|
+
startIndex: Math.max(0, startIndex),
|
|
364
|
+
endIndex,
|
|
365
|
+
items: flatList.slice(Math.max(0, startIndex), endIndex),
|
|
366
|
+
total,
|
|
367
|
+
}
|
|
368
|
+
}) // Handle scroll events
|
|
369
|
+
function handleScroll(e: Event) {
|
|
370
|
+
const target = e.target as HTMLDivElement
|
|
371
|
+
scrollTop = target.scrollTop
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Scroll to selected item when dropdown opens
|
|
375
|
+
let hasScrolledOnOpen = false
|
|
376
|
+
function scrollToSelected(node: HTMLDivElement) {
|
|
377
|
+
const unsubscribe = $effect.root(() => {
|
|
378
|
+
$effect(() => {
|
|
379
|
+
if (dropdownOpen) {
|
|
380
|
+
// Only scroll on initial open, not on subsequent selection changes
|
|
381
|
+
if (hasScrolledOnOpen) return
|
|
382
|
+
hasScrolledOnOpen = true
|
|
383
|
+
|
|
384
|
+
// Find the index of the selected item in the flat list
|
|
385
|
+
let selectedIndex = -1
|
|
386
|
+
|
|
387
|
+
if (!multiple && selectedItem) {
|
|
388
|
+
// For single select, find the selected item
|
|
389
|
+
selectedIndex = flatList.findIndex(
|
|
390
|
+
(entry) => entry.type === 'option' && entry.item.value === selectedItem.value,
|
|
391
|
+
)
|
|
392
|
+
} else if (multiple && selectedItems.length > 0) {
|
|
393
|
+
// For multi select, find the first selected item
|
|
394
|
+
selectedIndex = flatList.findIndex(
|
|
395
|
+
(entry) =>
|
|
396
|
+
entry.type === 'option' && selectedItems.some((selected) => selected.value === entry.item.value),
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (selectedIndex >= 0) {
|
|
401
|
+
// Calculate scroll position to center the selected item
|
|
402
|
+
const targetScrollTop = Math.max(0, selectedIndex * itemHeight - containerHeight / 2 + itemHeight / 2)
|
|
403
|
+
// Set scroll position directly on the DOM node
|
|
404
|
+
node.scrollTop = targetScrollTop
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
// Reset flag when dropdown closes
|
|
408
|
+
hasScrolledOnOpen = false
|
|
409
|
+
}
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
destroy() {
|
|
415
|
+
unsubscribe()
|
|
416
|
+
},
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const errorText = $derived.by(() => {
|
|
421
|
+
if (error) return error
|
|
422
|
+
if (!name) return undefined
|
|
423
|
+
if (zodErrors) return zodErrors.find((e) => e.path.includes(name))?.message
|
|
424
|
+
return undefined
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
// Tooltip showing all selected items
|
|
428
|
+
const tooltipText = $derived.by(() => {
|
|
429
|
+
if (multiple && selectedItems.length > 0) {
|
|
430
|
+
return selectedItems.map((item) => item.label).join(', ')
|
|
431
|
+
} else if (!multiple && selectedItem) {
|
|
432
|
+
return selectedItem.label
|
|
433
|
+
}
|
|
434
|
+
return undefined
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
function openDropdown() {
|
|
438
|
+
if (!popoverEl) return
|
|
439
|
+
try {
|
|
440
|
+
if (!popoverEl.matches(':popover-open')) {
|
|
441
|
+
popoverEl.showPopover()
|
|
442
|
+
}
|
|
443
|
+
} catch {
|
|
444
|
+
// Ignore errors if popover is in an invalid state
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function closeDropdown() {
|
|
449
|
+
if (!popoverEl) return
|
|
450
|
+
try {
|
|
451
|
+
// Check if open before hiding
|
|
452
|
+
if (popoverEl.matches(':popover-open')) {
|
|
453
|
+
popoverEl.hidePopover()
|
|
454
|
+
}
|
|
455
|
+
} catch {
|
|
456
|
+
// Ignore errors if popover is in an invalid state
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Handle popover toggle event to sync state
|
|
461
|
+
function handlePopoverToggle(e: ToggleEvent) {
|
|
462
|
+
dropdownOpen = e.newState === 'open'
|
|
463
|
+
if (dropdownOpen) {
|
|
464
|
+
searchEL?.focus()
|
|
465
|
+
} else {
|
|
466
|
+
hasScrolledOnOpen = false
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
</script>
|
|
470
|
+
|
|
471
|
+
<Label {label} {name} optional={!required} class={className} error={errorText}>
|
|
472
|
+
{#if !disabled}
|
|
473
|
+
<!-- Trigger button with popover target and anchor positioning -->
|
|
474
|
+
<button
|
|
475
|
+
type="button"
|
|
476
|
+
popovertarget={popoverId}
|
|
477
|
+
role="combobox"
|
|
478
|
+
aria-expanded={dropdownOpen}
|
|
479
|
+
aria-haspopup="listbox"
|
|
480
|
+
aria-controls={popoverId}
|
|
481
|
+
tabindex="-1"
|
|
482
|
+
class="select relative h-max min-h-10 w-full min-w-12 cursor-pointer bg-none! pr-1 text-left"
|
|
483
|
+
style="anchor-name: {anchorName}"
|
|
484
|
+
title={tooltipText}
|
|
485
|
+
onclick={() => {
|
|
486
|
+
searchEL?.focus()
|
|
487
|
+
// Only clear filter in single-select mode; in multi-select, keep filter for continued searching
|
|
488
|
+
if (!multiple) filterInput = ''
|
|
489
|
+
}}>
|
|
490
|
+
{#if multiple}
|
|
491
|
+
<!-- Multi-select display with condensed chips -->
|
|
492
|
+
<div class="flex min-h-8 flex-wrap gap-1 p-1">
|
|
493
|
+
{#if selectedItems.length > 0}
|
|
494
|
+
<!-- Show first selected item -->
|
|
495
|
+
<div class="badge badge-neutral bg-base-200 text-base-content gap-1">
|
|
496
|
+
<span class="max-w-50 truncate">{selectedItems[0].label}</span>
|
|
497
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
498
|
+
<span
|
|
499
|
+
role="button"
|
|
500
|
+
tabindex="-1"
|
|
501
|
+
class="btn btn-xs btn-circle btn-ghost hover:bg-base-300"
|
|
502
|
+
onclick={(e) => {
|
|
503
|
+
e.stopPropagation()
|
|
504
|
+
removeSelectedItem(selectedItems[0].value)
|
|
505
|
+
}}>
|
|
506
|
+
✕
|
|
507
|
+
</span>
|
|
508
|
+
</div>
|
|
509
|
+
|
|
510
|
+
{#if selectedItems.length > 1}
|
|
511
|
+
<!-- Show count indicator for remaining items -->
|
|
512
|
+
<div class="badge badge-ghost text-base-content/70">
|
|
513
|
+
(+{selectedItems.length - 1} more)
|
|
514
|
+
</div>
|
|
515
|
+
{/if}
|
|
516
|
+
{/if}
|
|
517
|
+
<!-- Search input for filtering in multi-select -->
|
|
518
|
+
<input
|
|
519
|
+
type="text"
|
|
520
|
+
class="h-full min-w-30 flex-1 outline-0 {dropdownOpen ? 'cursor-text' : 'cursor-pointer'}"
|
|
521
|
+
bind:this={searchEL}
|
|
522
|
+
bind:value={filterInput}
|
|
523
|
+
onclick={() => openDropdown()}
|
|
524
|
+
onkeydown={handleKeydown}
|
|
525
|
+
placeholder="Search..."
|
|
526
|
+
required={required && (!Array.isArray(normalizedValue) || normalizedValue.length === 0)} />
|
|
527
|
+
</div>
|
|
528
|
+
{:else}
|
|
529
|
+
<!-- Single-select display -->
|
|
530
|
+
<input
|
|
531
|
+
type="text"
|
|
532
|
+
class="h-full w-full outline-0 {dropdownOpen ? 'cursor-text' : 'cursor-pointer'}"
|
|
533
|
+
bind:this={searchEL}
|
|
534
|
+
value={filter}
|
|
535
|
+
oninput={(e) => (filterInput = e.currentTarget.value)}
|
|
536
|
+
onclick={() => {
|
|
537
|
+
filterInput = ''
|
|
538
|
+
openDropdown()
|
|
539
|
+
}}
|
|
540
|
+
onkeydown={handleKeydown}
|
|
541
|
+
{placeholder}
|
|
542
|
+
required={required && !normalizedValue} />
|
|
543
|
+
{/if}
|
|
544
|
+
{#if !required && ((multiple && Array.isArray(normalizedValue) && normalizedValue.length > 0) || (!multiple && normalizedValue))}
|
|
545
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
546
|
+
<span
|
|
547
|
+
role="button"
|
|
548
|
+
tabindex="-1"
|
|
549
|
+
class="btn btn-sm btn-circle btn-ghost bg-base-100 absolute top-1 right-1"
|
|
550
|
+
onclick={(e) => {
|
|
551
|
+
e.preventDefault()
|
|
552
|
+
e.stopPropagation()
|
|
553
|
+
clearAll()
|
|
554
|
+
}}>
|
|
555
|
+
✕
|
|
556
|
+
</span>
|
|
557
|
+
{/if}
|
|
558
|
+
</button>
|
|
559
|
+
|
|
560
|
+
<!-- Dropdown content using popover API -->
|
|
561
|
+
<ul
|
|
562
|
+
bind:this={popoverEl}
|
|
563
|
+
id={popoverId}
|
|
564
|
+
popover
|
|
565
|
+
role="listbox"
|
|
566
|
+
inert={!dropdownOpen}
|
|
567
|
+
class="dropdown menu bg-base-100 rounded-box z-50 m-0 flex flex-col flex-nowrap gap-1 p-2 shadow outline"
|
|
568
|
+
style="position-anchor: {anchorName}; position: fixed; top: anchor(bottom); left: anchor(left); width: anchor-size(width); margin-block: 0.5rem; position-try-fallbacks: flip-block;"
|
|
569
|
+
ontoggle={handlePopoverToggle}>
|
|
570
|
+
{#if multiple && filteredItems.length > 1}
|
|
571
|
+
<!-- Select All / Clear All options for multi-select -->
|
|
572
|
+
<div class="flex gap-2">
|
|
573
|
+
<button
|
|
574
|
+
type="button"
|
|
575
|
+
class="btn btn-sm hover:bg-base-content/10 grow"
|
|
576
|
+
onclick={() => {
|
|
577
|
+
const allValues = filteredItems.map((item) => item.value)
|
|
578
|
+
value = [...allValues]
|
|
579
|
+
if (onchange) onchange(value)
|
|
580
|
+
}}>
|
|
581
|
+
Select All
|
|
582
|
+
</button>
|
|
583
|
+
<button
|
|
584
|
+
type="button"
|
|
585
|
+
class="btn btn-sm hover:bg-base-content/10 grow"
|
|
586
|
+
onclick={() => {
|
|
587
|
+
value = []
|
|
588
|
+
if (onchange) onchange(value)
|
|
589
|
+
}}>
|
|
590
|
+
Clear All
|
|
591
|
+
</button>
|
|
592
|
+
</div>
|
|
593
|
+
{/if}
|
|
594
|
+
{#if isLoading}
|
|
595
|
+
<li class="m-2 flex items-center justify-center gap-2 text-sm text-gray-500">
|
|
596
|
+
<span class="loading loading-spinner loading-sm"></span>
|
|
597
|
+
Loading...
|
|
598
|
+
</li>
|
|
599
|
+
{:else if fetchError}
|
|
600
|
+
<li class="text-error m-2 text-center text-sm">{fetchError}</li>
|
|
601
|
+
{:else if filteredItems.length === 0}
|
|
602
|
+
<li class="m-2 text-center text-sm text-gray-500">No items found</li>
|
|
603
|
+
{/if}
|
|
604
|
+
|
|
605
|
+
{#if flatList.length > 0}
|
|
606
|
+
<div
|
|
607
|
+
class="relative max-h-80 overflow-y-auto pr-2"
|
|
608
|
+
bind:this={scrollContainerEl}
|
|
609
|
+
use:scrollToSelected
|
|
610
|
+
onscroll={handleScroll}>
|
|
611
|
+
<!-- Virtual spacer for items before visible range -->
|
|
612
|
+
{#if visibleItems.startIndex > 0}
|
|
613
|
+
<div style="height: {visibleItems.startIndex * itemHeight}px;"></div>
|
|
614
|
+
{/if}
|
|
615
|
+
|
|
616
|
+
<!-- Render only visible items (headers and options) -->
|
|
617
|
+
{#each visibleItems.items as entry, idx (entry.type === 'header' ? 'header-' + entry.group + '-' + idx : 'option-' + entry.item.value + '-' + idx)}
|
|
618
|
+
{#if entry.type === 'header'}
|
|
619
|
+
<li
|
|
620
|
+
class="bg-base-200 top-0 z-10 flex items-center justify-center px-2 text-lg font-bold text-gray-700"
|
|
621
|
+
style="height: {itemHeight}px;">
|
|
622
|
+
{entry.group}
|
|
623
|
+
</li>
|
|
624
|
+
{:else}
|
|
625
|
+
{@const item = entry.item}
|
|
626
|
+
{@const flatIdx = visibleItems.startIndex + idx}
|
|
627
|
+
{@const isSelected = multiple
|
|
628
|
+
? Array.isArray(normalizedValue) && normalizedValue.includes(item.value)
|
|
629
|
+
: item.value === normalizedValue}
|
|
630
|
+
{@const isHighlighted = flatIdx === highlightedIndex}
|
|
631
|
+
<li style="height: {itemHeight}px;" role="option" aria-selected={isSelected}>
|
|
632
|
+
<button
|
|
633
|
+
class="flex h-full w-full items-center gap-2 {isSelected
|
|
634
|
+
? ' bg-primary text-primary-content hover:bg-primary/70!'
|
|
635
|
+
: ''} {isHighlighted && !isSelected ? 'bg-base-200' : ''}"
|
|
636
|
+
type="button"
|
|
637
|
+
onclick={(e) => {
|
|
638
|
+
e.stopPropagation()
|
|
639
|
+
toggleItemSelection(item.value)
|
|
640
|
+
if (multiple) searchEL?.focus()
|
|
641
|
+
}}>
|
|
642
|
+
{#if multiple}
|
|
643
|
+
<input
|
|
644
|
+
type="checkbox"
|
|
645
|
+
tabindex="-1"
|
|
646
|
+
class="checkbox checkbox-sm text-primary-content! pointer-events-none"
|
|
647
|
+
checked={isSelected}
|
|
648
|
+
readonly />
|
|
649
|
+
{/if}
|
|
650
|
+
<span class="flex-1 overflow-hidden text-left text-nowrap text-ellipsis">{item.label}</span>
|
|
651
|
+
</button>
|
|
652
|
+
</li>
|
|
653
|
+
{/if}
|
|
654
|
+
{/each}
|
|
655
|
+
|
|
656
|
+
<!-- Virtual spacer for items after visible range -->
|
|
657
|
+
{#if visibleItems.endIndex < visibleItems.total}
|
|
658
|
+
<div style="height: {(visibleItems.total - visibleItems.endIndex) * itemHeight}px;"></div>
|
|
659
|
+
{/if}
|
|
660
|
+
</div>
|
|
661
|
+
{/if}
|
|
662
|
+
</ul>
|
|
663
|
+
{:else}
|
|
664
|
+
<!-- Disabled state -->
|
|
665
|
+
{#if multiple}
|
|
666
|
+
<div class="flex min-h-12 flex-wrap gap-1 p-2">
|
|
667
|
+
{#each selectedItems as item (item.value)}
|
|
668
|
+
<div class="badge badge-ghost">{item.label}</div>
|
|
669
|
+
{/each}
|
|
670
|
+
{#if selectedItems.length === 0}
|
|
671
|
+
<span class="text-gray-500">No items selected</span>
|
|
672
|
+
{/if}
|
|
673
|
+
</div>
|
|
674
|
+
{:else}
|
|
675
|
+
<Input
|
|
676
|
+
type="text"
|
|
677
|
+
class="h-full w-full outline-0"
|
|
678
|
+
disabled
|
|
679
|
+
value={selectedItem ? selectedItem.label : ''}
|
|
680
|
+
readonly />
|
|
681
|
+
{/if}
|
|
682
|
+
{/if}
|
|
683
|
+
</Label>
|