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.
@@ -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
- openDropdown()
300
- return
301
- }
302
- // Move to next option
303
- const currentPos = optionIndices.indexOf(highlightedIndex)
304
- const nextPos = currentPos + 1
305
- if (nextPos < optionIndices.length) {
306
- highlightedIndex = optionIndices[nextPos]
307
- scrollToHighlighted(highlightedIndex)
308
- }
309
- } else if (e.key === 'ArrowUp') {
310
- e.preventDefault()
311
- if (!dropdownOpen) {
312
- openDropdown()
313
- return
314
- }
315
- // Move to previous option
316
- const currentPos = optionIndices.indexOf(highlightedIndex)
317
- const prevPos = currentPos - 1
318
- if (prevPos >= 0) {
319
- highlightedIndex = optionIndices[prevPos]
320
- scrollToHighlighted(highlightedIndex)
321
- }
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
- }
338
- }
339
-
340
- // Virtual list implementation
341
- let scrollTop = $state(0)
342
- const itemHeight = 40 // Approximate height of each item in pixels
343
- const containerHeight = 320 // max-h-80 = 320px
344
- const visibleCount = Math.ceil(containerHeight / itemHeight) + 2 // Add buffer items
345
-
346
- // Calculate visible items based on scroll position (for flatList)
347
- let visibleItems = $derived.by(() => {
348
- const total = flatList.length
349
- const totalHeight = total * itemHeight
350
-
351
- // If content is shorter than container, show everything from start
352
- if (totalHeight <= containerHeight) {
353
- return {
354
- startIndex: 0,
355
- endIndex: total,
356
- items: flatList,
357
- total,
358
- }
359
- }
360
-
361
- const startIndex = Math.floor(scrollTop / itemHeight)
362
- const endIndex = Math.min(startIndex + visibleCount, total)
363
- return {
364
- startIndex: Math.max(0, startIndex),
365
- endIndex,
366
- items: flatList.slice(Math.max(0, startIndex), endIndex),
367
- total,
368
- }
369
- }) // Handle scroll events
370
- function handleScroll(e: Event) {
371
- const target = e.target as HTMLDivElement
372
- scrollTop = target.scrollTop
373
- }
374
-
375
- // Scroll to selected item when dropdown opens
376
- let hasScrolledOnOpen = false
377
- function scrollToSelected(node: HTMLDivElement) {
378
- const unsubscribe = $effect.root(() => {
379
- $effect(() => {
380
- if (dropdownOpen) {
381
- // Only scroll on initial open, not on subsequent selection changes
382
- if (hasScrolledOnOpen) return
383
- hasScrolledOnOpen = true
384
-
385
- // Find the index of the selected item in the flat list
386
- let selectedIndex = -1
387
-
388
- if (!multiple && selectedItem) {
389
- // For single select, find the selected item
390
- selectedIndex = flatList.findIndex(
391
- (entry) => entry.type === 'option' && entry.item.value === selectedItem.value,
392
- )
393
- } else if (multiple && selectedItems.length > 0) {
394
- // For multi select, find the first selected item
395
- selectedIndex = flatList.findIndex(
396
- (entry) =>
397
- entry.type === 'option' && selectedItems.some((selected) => selected.value === entry.item.value),
398
- )
399
- }
400
-
401
- if (selectedIndex >= 0) {
402
- // Calculate scroll position to center the selected item
403
- const targetScrollTop = Math.max(0, selectedIndex * itemHeight - containerHeight / 2 + itemHeight / 2)
404
- // Set scroll position directly on the DOM node
405
- node.scrollTop = targetScrollTop
406
- }
407
- } else {
408
- // Reset flag when dropdown closes
409
- hasScrolledOnOpen = false
410
- }
411
- })
412
- })
413
-
414
- return {
415
- destroy() {
416
- unsubscribe()
417
- },
418
- }
419
- }
420
-
421
- const errorText = $derived.by(() => {
422
- if (error) return error
423
- if (!name) return undefined
424
- if (zodErrors) return zodErrors.find((e) => e.path.includes(name))?.message
425
- return undefined
426
- })
427
-
428
- // Tooltip showing all selected items
429
- const tooltipText = $derived.by(() => {
430
- if (multiple && selectedItems.length > 0) {
431
- return selectedItems.map((item) => item.label).join(', ')
432
- } else if (!multiple && selectedItem) {
433
- return selectedItem.label
434
- }
435
- return undefined
436
- })
437
-
438
- function openDropdown() {
439
- if (!popoverEl) return
440
- try {
441
- if (!popoverEl.matches(':popover-open')) {
442
- popoverEl.showPopover()
443
- }
444
- } catch {
445
- // Ignore errors if popover is in an invalid state
446
- }
447
- }
448
-
449
- function closeDropdown() {
450
- if (!popoverEl) return
451
- try {
452
- // Check if open before hiding
453
- if (popoverEl.matches(':popover-open')) {
454
- popoverEl.hidePopover()
455
- }
456
- } catch {
457
- // Ignore errors if popover is in an invalid state
458
- }
459
- }
460
-
461
- // Handle popover toggle event to sync state
462
- function handlePopoverToggle(e: ToggleEvent) {
463
- dropdownOpen = e.newState === 'open'
464
- if (dropdownOpen) {
465
- searchEL?.focus()
466
- } else {
467
- hasScrolledOnOpen = false
468
- }
469
- }
470
- </script>
471
-
472
- <!-- Data inputs for form submission -->
473
- {#if multiple && Array.isArray(normalizedValue)}
474
- {#each normalizedValue as val, i (val + '-' + i)}
475
- <input type="hidden" {name} value={val} />
476
- {/each}
477
- {:else if !multiple && normalizedValue !== undefined && normalizedValue !== null && normalizedValue !== ''}
478
- <input type="hidden" {name} value={normalizedValue} />
479
- {/if}
480
-
481
- <Label {label} {name} optional={!required} class={className} error={errorText}>
482
- {#if !disabled}
483
- <!-- Trigger button with popover target and anchor positioning -->
484
- <button
485
- type="button"
486
- popovertarget={popoverId}
487
- role="combobox"
488
- aria-expanded={dropdownOpen}
489
- aria-haspopup="listbox"
490
- aria-controls={popoverId}
491
- tabindex="-1"
492
- class="select relative h-max min-h-10 w-full min-w-12 cursor-pointer bg-none! pr-1 text-left"
493
- style="anchor-name: {anchorName}"
494
- title={tooltipText}
495
- onclick={() => {
496
- searchEL?.focus()
497
- // Only clear filter in single-select mode; in multi-select, keep filter for continued searching
498
- if (!multiple) filterInput = ''
499
- }}>
500
- {#if multiple}
501
- <!-- Multi-select display with condensed chips -->
502
- <div class="flex min-h-8 flex-wrap gap-1 p-1">
503
- {#if selectedItems.length > 0}
504
- <!-- Show first selected item -->
505
- <div class="badge badge-neutral bg-base-200 text-base-content gap-1">
506
- <span class="max-w-50 truncate">{selectedItems[0].label}</span>
507
- <!-- svelte-ignore a11y_click_events_have_key_events -->
508
- <span
509
- role="button"
510
- tabindex="-1"
511
- class="btn btn-xs btn-circle btn-ghost hover:bg-base-300"
512
- onclick={(e) => {
513
- e.stopPropagation()
514
- removeSelectedItem(selectedItems[0].value)
515
- }}>
516
-
517
- </span>
518
- </div>
519
-
520
- {#if selectedItems.length > 1}
521
- <!-- Show count indicator for remaining items -->
522
- <div class="badge badge-ghost text-base-content/70">
523
- (+{selectedItems.length - 1} more)
524
- </div>
525
- {/if}
526
- {/if}
527
- <!-- Search input for filtering in multi-select -->
528
- <input
529
- type="text"
530
- class="h-full min-w-30 flex-1 outline-0 {dropdownOpen ? 'cursor-text' : 'cursor-pointer'}"
531
- bind:this={searchEL}
532
- bind:value={filterInput}
533
- onclick={() => openDropdown()}
534
- onkeydown={handleKeydown}
535
- placeholder="Search..."
536
- required={required && (!Array.isArray(normalizedValue) || normalizedValue.length === 0)} />
537
- </div>
538
- {:else}
539
- <!-- Single-select display -->
540
- <input
541
- type="text"
542
- class="h-full w-full outline-0 {dropdownOpen ? 'cursor-text' : 'cursor-pointer'}"
543
- bind:this={searchEL}
544
- value={filter}
545
- oninput={(e) => (filterInput = e.currentTarget.value)}
546
- onclick={() => {
547
- filterInput = ''
548
- openDropdown()
549
- }}
550
- onkeydown={handleKeydown}
551
- {placeholder}
552
- required={required && !normalizedValue} />
553
- {/if}
554
- {#if !required && ((multiple && Array.isArray(normalizedValue) && normalizedValue.length > 0) || (!multiple && normalizedValue))}
555
- <!-- svelte-ignore a11y_click_events_have_key_events -->
556
- <span
557
- role="button"
558
- tabindex="-1"
559
- class="btn btn-sm btn-circle btn-ghost bg-base-100 absolute top-1 right-1"
560
- onclick={(e) => {
561
- e.preventDefault()
562
- e.stopPropagation()
563
- clearAll()
564
- }}>
565
-
566
- </span>
567
- {/if}
568
- </button>
569
-
570
- <!-- Dropdown content using popover API -->
571
- <ul
572
- bind:this={popoverEl}
573
- id={popoverId}
574
- popover
575
- role="listbox"
576
- inert={!dropdownOpen}
577
- class="dropdown menu bg-base-100 rounded-box z-50 flex flex-col flex-nowrap gap-1 p-2 shadow outline m-0"
578
- 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;"
579
- ontoggle={handlePopoverToggle}>
580
- {#if multiple && filteredItems.length > 1}
581
- <!-- Select All / Clear All options for multi-select -->
582
- <div class="flex gap-2">
583
- <button
584
- type="button"
585
- class="btn btn-sm hover:bg-base-content/10 grow"
586
- onclick={() => {
587
- const allValues = filteredItems.map((item) => item.value)
588
- value = [...allValues]
589
- if (onchange) onchange(value)
590
- }}>
591
- Select All
592
- </button>
593
- <button
594
- type="button"
595
- class="btn btn-sm hover:bg-base-content/10 grow"
596
- onclick={() => {
597
- value = []
598
- if (onchange) onchange(value)
599
- }}>
600
- Clear All
601
- </button>
602
- </div>
603
- {/if}
604
- {#if isLoading}
605
- <li class="m-2 flex items-center justify-center gap-2 text-sm text-gray-500">
606
- <span class="loading loading-spinner loading-sm"></span>
607
- Loading...
608
- </li>
609
- {:else if fetchError}
610
- <li class="m-2 text-center text-sm text-error">{fetchError}</li>
611
- {:else if filteredItems.length === 0}
612
- <li class="m-2 text-center text-sm text-gray-500">No items found</li>
613
- {/if}
614
-
615
- {#if flatList.length > 0}
616
- <div class="relative max-h-80 overflow-y-auto pr-2" bind:this={scrollContainerEl} use:scrollToSelected onscroll={handleScroll}>
617
- <!-- Virtual spacer for items before visible range -->
618
- {#if visibleItems.startIndex > 0}
619
- <div style="height: {visibleItems.startIndex * itemHeight}px;"></div>
620
- {/if}
621
-
622
- <!-- Render only visible items (headers and options) -->
623
- {#each visibleItems.items as entry, idx (entry.type === 'header' ? 'header-' + entry.group + '-' + idx : 'option-' + entry.item.value + '-' + idx)}
624
- {#if entry.type === 'header'}
625
- <li
626
- class="bg-base-200 top-0 z-10 flex items-center justify-center px-2 text-lg font-bold text-gray-700"
627
- style="height: {itemHeight}px;">
628
- {entry.group}
629
- </li>
630
- {:else}
631
- {@const item = entry.item}
632
- {@const flatIdx = visibleItems.startIndex + idx}
633
- {@const isSelected = multiple
634
- ? Array.isArray(normalizedValue) && normalizedValue.includes(item.value)
635
- : item.value === normalizedValue}
636
- {@const isHighlighted = flatIdx === highlightedIndex}
637
- <li style="height: {itemHeight}px;" role="option" aria-selected={isSelected}>
638
- <button
639
- class="flex h-full w-full items-center gap-2 {isSelected
640
- ? ' bg-primary text-primary-content hover:bg-primary/70!'
641
- : ''} {isHighlighted && !isSelected ? 'bg-base-200' : ''}"
642
- type="button"
643
- onclick={(e) => {
644
- e.stopPropagation()
645
- toggleItemSelection(item.value)
646
- if (multiple) searchEL?.focus()
647
- }}>
648
- {#if multiple}
649
- <input
650
- type="checkbox"
651
- tabindex="-1"
652
- class="checkbox checkbox-sm text-primary-content! pointer-events-none"
653
- checked={isSelected}
654
- readonly />
655
- {/if}
656
- <span class="flex-1 overflow-hidden text-left text-nowrap text-ellipsis">{item.label}</span>
657
- </button>
658
- </li>
659
- {/if}
660
- {/each}
661
-
662
- <!-- Virtual spacer for items after visible range -->
663
- {#if visibleItems.endIndex < visibleItems.total}
664
- <div style="height: {(visibleItems.total - visibleItems.endIndex) * itemHeight}px;"></div>
665
- {/if}
666
- </div>
667
- {/if}
668
- </ul>
669
- {:else}
670
- <!-- Disabled state -->
671
- {#if multiple}
672
- <div class="flex min-h-12 flex-wrap gap-1 p-2">
673
- {#each selectedItems as item (item.value)}
674
- <div class="badge badge-ghost">{item.label}</div>
675
- {/each}
676
- {#if selectedItems.length === 0}
677
- <span class="text-gray-500">No items selected</span>
678
- {/if}
679
- </div>
680
- {:else}
681
- <Input
682
- type="text"
683
- class="h-full w-full outline-0"
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>