simplesvelte 2.2.23 → 2.4.0

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