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.
- package/README.md +214 -214
- package/dist/AG_GRID_SERVER_API.md +373 -373
- package/dist/Grid.svelte +129 -129
- package/dist/Input.svelte +142 -142
- package/dist/Label.svelte +43 -43
- package/dist/Modal.svelte +39 -39
- package/dist/Select.svelte +493 -472
- package/dist/TextArea.svelte +43 -43
- package/dist/styles.css +10 -10
- package/package.json +1 -1
package/dist/Select.svelte
CHANGED
|
@@ -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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
// For
|
|
252
|
-
selectedIndex = flatList.findIndex(
|
|
253
|
-
(entry) =>
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
</
|
|
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>
|