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