simplesvelte 2.5.0 → 2.5.1

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