simplesvelte 2.2.22 → 2.3.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,472 +1,493 @@
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 text-left">{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">
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
+ let hasScrolledOnOpen = false
239
+ function scrollToSelected(node: HTMLDivElement) {
240
+ const unsubscribe = $effect.root(() => {
241
+ $effect(() => {
242
+ if (detailsOpen) {
243
+ // Only scroll on initial open, not on subsequent selection changes
244
+ if (hasScrolledOnOpen) return
245
+ hasScrolledOnOpen = true
246
+
247
+ // Find the index of the selected item in the flat list
248
+ let selectedIndex = -1
249
+
250
+ if (!multiple && selectedItem) {
251
+ // For single select, find the selected item
252
+ selectedIndex = flatList.findIndex(
253
+ (entry) => entry.type === 'option' && entry.item.value === selectedItem.value,
254
+ )
255
+ } else if (multiple && selectedItems.length > 0) {
256
+ // For multi select, find the first selected item
257
+ selectedIndex = flatList.findIndex(
258
+ (entry) =>
259
+ entry.type === 'option' && selectedItems.some((selected) => selected.value === entry.item.value),
260
+ )
261
+ }
262
+
263
+ if (selectedIndex >= 0) {
264
+ // Calculate scroll position to center the selected item
265
+ const targetScrollTop = Math.max(0, selectedIndex * itemHeight - containerHeight / 2 + itemHeight / 2)
266
+ // Set scroll position directly on the DOM node
267
+ node.scrollTop = targetScrollTop
268
+ }
269
+ } else {
270
+ // Reset flag when dropdown closes
271
+ hasScrolledOnOpen = false
272
+ }
273
+ })
274
+ })
275
+
276
+ return {
277
+ destroy() {
278
+ unsubscribe()
279
+ },
280
+ }
281
+ }
282
+
283
+ const errorText = $derived.by(() => {
284
+ if (error) return error
285
+ if (!name) return undefined
286
+ if (zodErrors) return zodErrors.find((e) => e.path.includes(name))?.message
287
+ return undefined
288
+ })
289
+
290
+ // Tooltip showing all selected items
291
+ const tooltipText = $derived.by(() => {
292
+ if (multiple && selectedItems.length > 0) {
293
+ return selectedItems.map((item) => item.label).join(', ')
294
+ } else if (!multiple && selectedItem) {
295
+ return selectedItem.label
296
+ }
297
+ return undefined
298
+ })
299
+ </script>
300
+
301
+ <!-- Data inputs for form submission -->
302
+ {#if multiple && Array.isArray(normalizedValue)}
303
+ {#each normalizedValue as val, i (val + '-' + i)}
304
+ <input type="hidden" {name} value={val} />
305
+ {/each}
306
+ {:else if !multiple && normalizedValue !== undefined && normalizedValue !== null && normalizedValue !== ''}
307
+ <input type="hidden" {name} value={normalizedValue} />
308
+ {/if}
309
+
310
+ <Label {label} {name} optional={!required} class={className} error={errorText}>
311
+ {#if !disabled}
312
+ <details
313
+ class="dropdown w-full"
314
+ bind:open={detailsOpen}
315
+ use:clickOutside={() => {
316
+ if (!detailsOpen) return
317
+ detailsOpen = false
318
+ }}>
319
+ <summary
320
+ class="select h-max min-h-10 w-full min-w-12 cursor-pointer bg-none! pr-1"
321
+ title={tooltipText}
322
+ onclick={() => {
323
+ searchEL?.focus()
324
+ // Only clear filter in single-select mode; in multi-select, keep filter for continued searching
325
+ if (!multiple) filterInput = ''
326
+ }}>
327
+ {#if multiple}
328
+ <!-- Multi-select display with condensed chips -->
329
+ <div class="flex min-h-8 flex-wrap gap-1 p-1">
330
+ {#if selectedItems.length > 0}
331
+ <!-- Show first selected item -->
332
+ <div class="badge badge-neutral bg-base-200 text-base-content gap-1">
333
+ <span class="max-w-[200px] truncate">{selectedItems[0].label}</span>
334
+ <button
335
+ type="button"
336
+ class="btn btn-xs btn-circle btn-ghost hover:bg-base-300"
337
+ onclick={(e) => {
338
+ e.stopPropagation()
339
+ removeSelectedItem(selectedItems[0].value)
340
+ }}>
341
+
342
+ </button>
343
+ </div>
344
+
345
+ {#if selectedItems.length > 1}
346
+ <!-- Show count indicator for remaining items -->
347
+ <div class="badge badge-ghost text-base-content/70">
348
+ (+{selectedItems.length - 1} more)
349
+ </div>
350
+ {/if}
351
+ {/if}
352
+ <!-- Search input for filtering in multi-select -->
353
+ <input
354
+ type="text"
355
+ class="h-full min-w-[120px] flex-1 outline-0 {detailsOpen ? 'cursor-text' : 'cursor-pointer'}"
356
+ bind:this={searchEL}
357
+ bind:value={filterInput}
358
+ onclick={() => {
359
+ detailsOpen = true
360
+ }}
361
+ placeholder="Search..."
362
+ required={required && (!Array.isArray(normalizedValue) || normalizedValue.length === 0)} />
363
+ </div>
364
+ {:else}
365
+ <!-- Single-select display -->
366
+ <input
367
+ type="text"
368
+ class="h-full w-full outline-0 {detailsOpen ? 'cursor-text' : 'cursor-pointer'}"
369
+ bind:this={searchEL}
370
+ value={filter}
371
+ oninput={(e) => (filterInput = e.currentTarget.value)}
372
+ onclick={() => {
373
+ detailsOpen = true
374
+ }}
375
+ {placeholder}
376
+ required={required && !normalizedValue} />
377
+ {/if}
378
+ {#if !required && ((multiple && Array.isArray(normalizedValue) && normalizedValue.length > 0) || (!multiple && normalizedValue))}
379
+ <button
380
+ type="button"
381
+ class="btn btn-sm btn-circle btn-ghost absolute top-1 right-1"
382
+ onclick={(e) => {
383
+ e.stopPropagation()
384
+ clearAll()
385
+ }}>
386
+
387
+ </button>
388
+ {/if}
389
+ </summary>
390
+ <ul
391
+ 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">
392
+ {#if multiple && filteredItems.length > 1}
393
+ <!-- Select All / Clear All options for multi-select -->
394
+
395
+ <div class="flex gap-2">
396
+ <button
397
+ type="button"
398
+ class="btn btn-sm hover:bg-base-content/10 grow"
399
+ onclick={() => {
400
+ const allValues = filteredItems.map((item) => item.value)
401
+ value = [...allValues]
402
+ if (onchange) onchange(value)
403
+ }}>
404
+ Select All
405
+ </button>
406
+ <button
407
+ type="button"
408
+ class="btn btn-sm hover:bg-base-content/10 grow"
409
+ onclick={() => {
410
+ value = []
411
+ if (onchange) onchange(value)
412
+ }}>
413
+ Clear All
414
+ </button>
415
+ </div>
416
+ {/if}
417
+ {#if filteredItems.length === 0}
418
+ <li class="m-2 text-center text-sm text-gray-500">No items found</li>
419
+ {/if}
420
+
421
+ {#if flatList.length > 0}
422
+ <div class="relative max-h-80 overflow-y-auto pr-2" use:scrollToSelected onscroll={handleScroll}>
423
+ <!-- Virtual spacer for items before visible range -->
424
+ {#if visibleItems.startIndex > 0}
425
+ <div style="height: {visibleItems.startIndex * itemHeight}px;"></div>
426
+ {/if}
427
+
428
+ <!-- Render only visible items (headers and options) -->
429
+ {#each visibleItems.items as entry, idx (entry.type === 'header' ? 'header-' + entry.group + '-' + idx : 'option-' + entry.item.value + '-' + idx)}
430
+ {#if entry.type === 'header'}
431
+ <li
432
+ class="bg-base-200 top-0 z-10 flex items-center justify-center px-2 text-lg font-bold text-gray-700"
433
+ style="height: {itemHeight}px;">
434
+ {entry.group}
435
+ </li>
436
+ {:else}
437
+ {@const item = entry.item}
438
+ {@const isSelected = multiple
439
+ ? Array.isArray(normalizedValue) && normalizedValue.includes(item.value)
440
+ : item.value === normalizedValue}
441
+ <li style="height: {itemHeight}px;">
442
+ <button
443
+ class="flex h-full w-full items-center gap-2 {isSelected
444
+ ? ' bg-primary text-primary-content hover:bg-primary/70!'
445
+ : ''}"
446
+ type="button"
447
+ onclick={(e) => {
448
+ e.stopPropagation()
449
+ toggleItemSelection(item.value)
450
+ searchEL?.focus()
451
+ }}>
452
+ {#if multiple}
453
+ <input
454
+ type="checkbox"
455
+ class="checkbox checkbox-sm text-primary-content! pointer-events-none"
456
+ checked={isSelected}
457
+ readonly />
458
+ {/if}
459
+ <span class="flex-1 overflow-hidden text-left text-nowrap text-ellipsis">{item.label}</span>
460
+ </button>
461
+ </li>
462
+ {/if}
463
+ {/each}
464
+
465
+ <!-- Virtual spacer for items after visible range -->
466
+ {#if visibleItems.endIndex < visibleItems.total}
467
+ <div style="height: {(visibleItems.total - visibleItems.endIndex) * itemHeight}px;"></div>
468
+ {/if}
469
+ </div>
470
+ {/if}
471
+ </ul>
472
+ </details>
473
+ {:else}
474
+ <!-- Disabled state -->
475
+ {#if multiple}
476
+ <div class="flex min-h-12 flex-wrap gap-1 p-2">
477
+ {#each selectedItems as item (item.value)}
478
+ <div class="badge badge-ghost">{item.label}</div>
479
+ {/each}
480
+ {#if selectedItems.length === 0}
481
+ <span class="text-gray-500">No items selected</span>
482
+ {/if}
483
+ </div>
484
+ {:else}
485
+ <Input
486
+ type="text"
487
+ class="h-full w-full outline-0"
488
+ disabled
489
+ value={selectedItem ? selectedItem.label : ''}
490
+ readonly />
491
+ {/if}
492
+ {/if}
493
+ </Label>