svelte-5-select 1.0.0-beta.1 → 1.0.2

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.
@@ -3,7 +3,7 @@
3
3
  import { onDestroy, onMount, untrack } from 'svelte';
4
4
  import { offset, flip, shift } from 'svelte-floating-ui/dom';
5
5
  import { createFloatingActions } from 'svelte-floating-ui';
6
- import type { FloatingConfig, SelectProps } from './types';
6
+ import type { FloatingConfig, SelectProps, SelectValue, ErrorEvent as SelectErrorEvent } from './types';
7
7
  import { useAriaHandlers } from './aria-handlers.svelte';
8
8
 
9
9
  import _filter from './filter';
@@ -14,12 +14,15 @@
14
14
  import type { SelectItem } from './types';
15
15
  import type { HTMLInputAttributes } from 'svelte/elements';
16
16
  import { useKeyboardNavigation } from './keyboard-navigation.svelte';
17
- import { areItemsEqual, hasValueChanged, isItemSelectableCheck, createGroupHeaderItem as _createGroupHeaderItem } from './utils';
17
+ import { useHover } from './use-hover.svelte';
18
+ import { useValue } from './use-value.svelte';
19
+ import { useLoadOptions } from './use-load-options.svelte';
20
+ import { areItemsEqual, isItemSelectableCheck, createGroupHeaderItem as _createGroupHeaderItem } from './utils';
18
21
 
19
22
  const defaultItemFilter = (label: string, filterText: string, option: SelectItem): boolean =>
20
23
  `${label}`.toLowerCase().includes(filterText?.toLowerCase());
21
24
 
22
- const defaultOnError = (error: any): void => {};
25
+ const defaultOnError = (error: SelectErrorEvent): void => {};
23
26
  const defaultOnLoaded = (options: SelectItem[]): void => {};
24
27
  const defaultHandleClear = (e?: MouseEvent): void => {};
25
28
 
@@ -92,6 +95,7 @@
92
95
  loadOptions = undefined,
93
96
 
94
97
  // ARIA props
98
+ ariaLabel = undefined,
95
99
  ariaFocused = () => {
96
100
  return `Select is focused, type to refine list, press down to open the menu.`;
97
101
  },
@@ -146,6 +150,9 @@
146
150
  : value
147
151
  );
148
152
 
153
+ const _generatedId = `svelte-select-${Math.random().toString(36).slice(2, 9)}`;
154
+ let _id = $derived(id ?? _generatedId);
155
+
149
156
  const DEFAULT_INPUT_ATTRS = {
150
157
  autocapitalize: 'none',
151
158
  autocomplete: 'off',
@@ -164,7 +171,7 @@
164
171
 
165
172
  handleClear = (e?: MouseEvent): void => {
166
173
  clearState = true;
167
- onclear(value);
174
+ onclear(value as SelectValue);
168
175
  value = undefined;
169
176
  closeList();
170
177
  handleFocus();
@@ -173,10 +180,12 @@
173
180
  let list = $state<HTMLDivElement | undefined>();
174
181
  let _inputAttributes = $derived<HTMLInputAttributes>({
175
182
  ...DEFAULT_INPUT_ATTRS,
176
- role: multiple ? 'combobox' : 'combobox',
177
- 'aria-controls': listOpen ? `listbox-${id}` : undefined,
183
+ role: 'combobox',
184
+ 'aria-controls': listOpen ? `listbox-${_id}` : undefined,
178
185
  'aria-expanded': listOpen,
179
186
  'aria-haspopup': 'listbox',
187
+ 'aria-activedescendant': listOpen ? `listbox-${_id}-item-${hoverItemIndex}` : undefined,
188
+ 'aria-label': ariaLabel,
180
189
  tabindex: 0,
181
190
  readonly: !searchable,
182
191
  id: id ? id : undefined,
@@ -184,9 +193,8 @@
184
193
  });
185
194
  let activeValue = $state<number | undefined>(undefined);
186
195
  let prev_value = $state<SelectItem | SelectItem[] | string | null | undefined>();
187
- let prev_filterText = $state();
196
+ let prev_filterText: string | undefined = $state();
188
197
  let prev_multiple = $state();
189
- let isScrollingTimer = $state<ReturnType<typeof setTimeout>>();
190
198
  let isScrolling = $state(false);
191
199
  let prefloat = $state(true);
192
200
  let hasValue = $state(false);
@@ -233,6 +241,78 @@
233
241
  autoUpdate: false,
234
242
  });
235
243
 
244
+ // Initialize composables
245
+ const valueManager = useValue({
246
+ getState: () => ({
247
+ value,
248
+ prevValue: prev_value,
249
+ items,
250
+ multiple,
251
+ itemId,
252
+ label,
253
+ hasValue,
254
+ normalizedValue,
255
+ useJustValue,
256
+ justValue,
257
+ clearState,
258
+ closeListOnChange,
259
+ }),
260
+ setValue: (v) => value = v,
261
+ setJustValue: (v) => justValue = v,
262
+ setPrevValue: (v) => prev_value = v,
263
+ setClearState: (v) => clearState = v,
264
+ setActiveValue: (v) => activeValue = v,
265
+ setFilterText: (v) => filterText = v,
266
+ closeList,
267
+ oninput: (v) => oninput?.(v as SelectValue),
268
+ onchange: (v) => onchange?.(v as SelectValue),
269
+ onclear: (v) => onclear(v as SelectValue),
270
+ onselect: (s) => onselect?.(s),
271
+ });
272
+
273
+ const hoverManager = useHover({
274
+ getState: () => ({
275
+ listOpen,
276
+ filteredItems,
277
+ hoverItemIndex,
278
+ multiple,
279
+ value,
280
+ isScrolling,
281
+ groupBy,
282
+ itemId,
283
+ }),
284
+ setHoverItemIndex: (v) => hoverItemIndex = v,
285
+ setIsScrolling: (v) => isScrolling = v,
286
+ onhoveritem: (i) => onhoveritem?.(i),
287
+ });
288
+
289
+ const loadOptionsManager = useLoadOptions({
290
+ getState: () => ({
291
+ filterText,
292
+ prevFilterText: prev_filterText,
293
+ loadOptionsDeps,
294
+ loadOptions,
295
+ disabled,
296
+ multiple,
297
+ value,
298
+ items,
299
+ itemId,
300
+ useJustValue,
301
+ justValue,
302
+ listOpen,
303
+ debounceWait,
304
+ }),
305
+ setItems: (v) => items = v,
306
+ setValue: (v) => value = v,
307
+ setJustValue: (v) => justValue = v,
308
+ setLoading: (v) => loading = v,
309
+ setListOpen: (v) => listOpen = v,
310
+ debounce,
311
+ convertStringItemsToObjects: valueManager.convertStringItemsToObjects,
312
+ onloaded: (opts) => onloaded(opts),
313
+ onerror: (err) => onerror(err),
314
+ });
315
+
236
316
  const keyboardNav = useKeyboardNavigation({
237
317
  getState: () => ({
238
318
  listOpen,
@@ -249,9 +329,9 @@
249
329
  setHoverItemIndex: (v) => hoverItemIndex = v,
250
330
  setActiveValue: (v) => activeValue = v,
251
331
  closeList,
252
- setHoverIndex,
332
+ setHoverIndex: hoverManager.setHoverIndex,
253
333
  handleSelect,
254
- handleMultiItemClear,
334
+ handleMultiItemClear: valueManager.handleMultiItemClear,
255
335
  });
256
336
  const handleKeyDown = keyboardNav.handleKeyDown;
257
337
 
@@ -261,6 +341,8 @@
261
341
  if (listOpen) focused = true;
262
342
  if (focused && input) input.focus();
263
343
  });
344
+
345
+ // Filter items
264
346
  $effect.pre(() => {
265
347
  filterText;
266
348
  value;
@@ -268,7 +350,7 @@
268
350
  untrack(
269
351
  () =>
270
352
  (filteredItems = filter({
271
- loadOptions: undefined, // Don't pass loadOptions - items are already loaded
353
+ loadOptions: undefined,
272
354
  filterText,
273
355
  items,
274
356
  multiple,
@@ -278,57 +360,82 @@
278
360
  label,
279
361
  filterSelectedItems,
280
362
  itemFilter,
281
- convertStringItemsToObjects,
363
+ convertStringItemsToObjects: valueManager.convertStringItemsToObjects,
282
364
  filterGroupedItems,
283
365
  })),
284
366
  );
285
367
  });
368
+
369
+ // Set value on hasValue change
286
370
  $effect(() => {
287
371
  hasValue;
288
372
  untrack(() => {
289
- if (items) setValue();
373
+ if (items) valueManager.setValue();
290
374
  });
291
375
  });
376
+
377
+ // Setup multi when multiple changes
292
378
  $effect(() => {
293
- // Used when multiple is dynamically set
294
379
  if (multiple) {
295
- untrack(() => setupMulti());
380
+ untrack(() => valueManager.setupMulti());
296
381
  }
297
382
  });
383
+
384
+ // Clear value when switching from multiple to single
298
385
  $effect(() => {
299
- // Check BEFORE updating prev_multiple
300
386
  if (prev_multiple && !multiple && value) {
301
387
  value = null;
302
388
  }
303
- // Update prev_multiple AFTER the check
304
389
  prev_multiple = multiple;
305
390
  });
391
+
392
+ // Check for duplicates
306
393
  $effect(() => {
307
- if (multiple && value && value.length > 1) checkValueForDuplicates();
394
+ if (multiple && value && value.length > 1) {
395
+ untrack(() => valueManager.checkValueForDuplicates());
396
+ }
308
397
  });
398
+
399
+ // Dispatch selected item
309
400
  $effect(() => {
310
- if (value) dispatchSelectedItem();
401
+ if (value) {
402
+ untrack(() => valueManager.dispatchSelectedItem());
403
+ }
311
404
  });
405
+
406
+ // Value cleared notification
312
407
  $effect(() => {
313
408
  if (prev_value && !value) {
314
- oninput?.(value || []);
409
+ oninput?.((value || []) as SelectValue);
315
410
  }
316
411
  });
412
+
413
+ // Close list on unfocus
317
414
  $effect(() => {
318
415
  if (!focused && input) closeList();
319
416
  });
417
+
418
+ // Setup filter text
320
419
  $effect(() => {
321
420
  filterText;
322
421
  untrack(() => {
323
422
  if (filterText !== prev_filterText) setupFilterText();
324
423
  });
325
424
  });
425
+
426
+ // Set value index as hover when list opens
326
427
  $effect(() => {
327
- if (!multiple && listOpen && value && filteredItems) setValueIndexAsHoverIndex();
428
+ if (!multiple && listOpen && value && filteredItems) {
429
+ untrack(() => hoverManager.setValueIndexAsHoverIndex());
430
+ }
328
431
  });
432
+
433
+ // Fire onhoveritem
329
434
  $effect(() => {
330
435
  onhoveritem?.(hoverItemIndex);
331
436
  });
437
+
438
+ // Compute hasValue
332
439
  $effect(() => {
333
440
  multiple;
334
441
  value;
@@ -338,15 +445,18 @@
338
445
  : !!value;
339
446
  });
340
447
  });
448
+
449
+ // Compute justValue
341
450
  $effect(() => {
342
451
  multiple;
343
452
  itemId;
344
453
  value;
345
454
  untrack(() => {
346
- justValue = computeJustValue();
455
+ justValue = valueManager.computeJustValue();
347
456
  });
348
457
  });
349
458
 
459
+ // Check hover selectable
350
460
  $effect(() => {
351
461
  filteredItems;
352
462
  value;
@@ -355,21 +465,25 @@
355
465
  untrack(() => {
356
466
  if (listOpen && filteredItems.length > 0) {
357
467
  if (!isItemSelectableCheck(filteredItems[hoverItemIndex])) {
358
- checkHoverSelectable();
359
- }
360
- // Or always check when list opens with groupBy
361
- else if (groupBy && hoverItemIndex === 0) {
362
- checkHoverSelectable();
468
+ hoverManager.checkHoverSelectable();
469
+ } else if (groupBy && hoverItemIndex === 0) {
470
+ hoverManager.checkHoverSelectable();
363
471
  }
364
472
  }
365
473
  });
366
474
  });
475
+
476
+ // Fire onfilter
367
477
  $effect(() => {
368
478
  if (filteredItems && listOpen) onfilter?.(filteredItems);
369
479
  });
480
+
481
+ // Floating UI config
370
482
  $effect(() => {
371
483
  if (container && floatingConfig) floatingUpdate({..._floatingConfig, ...floatingConfig});
372
484
  });
485
+
486
+ // List mounted
373
487
  $effect(() => {
374
488
  listOpen;
375
489
  untrack(() => {
@@ -377,196 +491,53 @@
377
491
  if (listOpen && container && list) setListWidth();
378
492
  });
379
493
  });
494
+
495
+ // Focus when list opens
380
496
  $effect(() => {
381
497
  if (input && listOpen && !focused) handleFocus();
382
498
  });
499
+
500
+ // Reset hover on filterText change
383
501
  $effect(() => {
384
502
  if (filterText) {
385
503
  untrack(() => {
386
- hoverItemIndex = getFirstSelectableIndex();
504
+ hoverItemIndex = hoverManager.getFirstSelectableIndex();
387
505
  });
388
506
  }
389
507
  });
508
+
509
+ // Auto update floating config
390
510
  $effect(() => {
391
511
  if (container && floatingConfig?.autoUpdate === undefined) {
392
512
  _floatingConfig.autoUpdate = true;
393
513
  }
394
514
  });
515
+
516
+ // Disabled state
395
517
  $effect(() => {
396
518
  if (disabled) {
397
519
  listOpen = false;
398
520
  filterText = '';
399
521
  }
400
522
  });
523
+
524
+ // Load options handler
401
525
  $effect(() => {
402
- // Watch both filterText and loadOptionsDeps for changes
403
526
  const currentFilterText = filterText;
404
- // Properly track loadOptionsDeps by creating a new array (forces reading each value)
405
527
  const currentDeps = [...loadOptionsDeps];
406
528
 
407
- if (loadOptions && !disabled) {
408
- // Use untrack to prevent infinite loops when setting items/value/loading
529
+ if (loadOptions) {
409
530
  untrack(() => {
410
- loading = true;
411
-
412
- // Determine if this is a filterText change (needs debounce) or deps change (immediate)
413
- const isFilterTextChange = currentFilterText !== prev_filterText;
414
-
415
- const executeLoad = async () => {
416
- try {
417
- const result = await loadOptions(currentFilterText);
418
-
419
- // Check if result is string array and convert to SelectItem objects
420
- if (result && result.length > 0 && typeof result[0] === 'string') {
421
- items = convertStringItemsToObjects(result as string[]);
422
- } else {
423
- // Force reactivity with a new reference and proper typing
424
- items = result ? (result.slice() as typeof items) : null;
425
- }
426
-
427
- // Clear value if it's not in the new items list
428
- if (value && items && items.length > 0) {
429
- const valueExists = multiple
430
- ? Array.isArray(value) && value.every((v: any) =>
431
- items?.some((item: any) =>
432
- (typeof item === 'string' ? item : item[itemId]) === (typeof v === 'string' ? v : v[itemId])
433
- )
434
- )
435
- : items.some((item: any) =>
436
- (typeof item === 'string' ? item : item[itemId]) === (typeof value === 'string' ? value : (value as any)[itemId])
437
- );
438
-
439
- if (!valueExists) {
440
- value = multiple ? [] : undefined;
441
- // Also clear justValue when using useJustValue mode
442
- if (useJustValue) {
443
- justValue = multiple ? [] : '';
444
- }
445
- }
446
- } else if (value && (!items || items.length === 0)) {
447
- // Clear value if items is empty
448
- value = multiple ? [] : undefined;
449
- }
450
-
451
- loading = false;
452
- // Ensure items is SelectItem[] for onloaded callback
453
- onloaded((items || []) as SelectItem[]);
454
- } catch (err) {
455
- console.error('loadOptions error:', err);
456
- // Pass the raw error to match expected behavior
457
- onerror(err as any);
458
- items = null;
459
- loading = false;
460
- }
461
- };
462
-
463
- // Use debounce for filterText changes, immediate for deps changes
464
- if (isFilterTextChange) {
465
- debounce(executeLoad, debounceWait);
466
- } else {
467
- executeLoad();
468
- }
531
+ loadOptionsManager.handleLoadOptions(currentFilterText, currentDeps);
469
532
  });
470
533
 
471
- // Set listOpen outside untrack so it triggers reactivity
472
- if (currentFilterText.length > 0 && !listOpen) {
534
+ // Keep outside untrack for proper reactivity
535
+ if (!disabled && currentFilterText.length > 0 && !listOpen) {
473
536
  listOpen = true;
474
537
  }
475
- } else if (loadOptions && disabled) {
476
- // When disabled and using loadOptions, clear the value and items
477
- untrack(() => {
478
- if (value || (useJustValue && justValue)) {
479
- value = multiple ? [] : undefined;
480
- if (useJustValue) {
481
- justValue = multiple ? [] : '';
482
- }
483
- }
484
- items = null;
485
- });
486
538
  }
487
539
  });
488
540
 
489
- function getFirstSelectableIndex(): number {
490
- if (!groupBy || filteredItems.length === 0) return 0;
491
-
492
- if (!isItemSelectableCheck(filteredItems[0])) {
493
- const firstSelectable = filteredItems.findIndex(isItemSelectableCheck);
494
- return firstSelectable >= 0 ? firstSelectable : 0;
495
- }
496
-
497
- return 0;
498
- }
499
-
500
- function findItemByValue(id: any): SelectItem | undefined {
501
- return (items as SelectItem[])?.find(item => item[itemId] === id);
502
- }
503
-
504
- function itemSelected(selection: SelectItem) {
505
- if (selection) {
506
- filterText = '';
507
- const item = Object.assign({}, selection);
508
-
509
- if (item.groupHeader && !item.selectable) return;
510
- setValue();
511
- updateValueDisplay(items);
512
- value = multiple ? (value ? value.concat([item]) : [item]) : (value = item);
513
-
514
- if (closeListOnChange) closeList();
515
- activeValue = undefined;
516
- onchange?.(value);
517
- onselect?.(selection);
518
- }
519
- }
520
-
521
- function setValue() {
522
- prev_value = value;
523
- if (typeof value === 'string') {
524
- let item = findItemByValue(value);
525
- value = item || {
526
- [itemId]: value,
527
- label: value,
528
- };
529
- } else if (multiple && Array.isArray(value) && value.length > 0) {
530
- value = value.map((val) => {
531
- if (typeof val === 'string') {
532
- // Look up each string value in items
533
- let item = findItemByValue(val);
534
- return item || { value: val, label: val };
535
- }
536
- return val;
537
- });
538
- }
539
- }
540
-
541
- function updateValueDisplay(items?: SelectItem[] | string[] | null): void {
542
- if (!items || items.length === 0 || items.some((item) => typeof item !== 'object')) return;
543
- if (!value) return; // Change back to value
544
-
545
- if (Array.isArray(value)) { // Change back to value
546
- if (value.some((selection: SelectItem) => !selection || !(selection as Record<string, any>)[itemId])) return;
547
- value = value.map((selection) => findItem(selection) || selection);
548
- } else if (typeof value === 'object') {
549
- if (!(value as Record<string, any>)[itemId]) return;
550
- value = findItem() || value;
551
- }
552
- }
553
-
554
- function assignInputAttributes() {
555
- _inputAttributes = { ...DEFAULT_INPUT_ATTRS, ...inputAttributes };
556
- if (id) _inputAttributes['id'] = id;
557
- if (!searchable) _inputAttributes['readonly'] = true;
558
- }
559
-
560
- function convertStringItemsToObjects(_items: string[]): SelectItem[] {
561
- return _items.map((item, index) => {
562
- return {
563
- index,
564
- value: item,
565
- label: `${item}`,
566
- };
567
- });
568
- }
569
-
570
541
  function filterGroupedItems(_items: SelectItem[]): SelectItem[] {
571
542
  if (!groupBy) return _items;
572
543
 
@@ -602,56 +573,8 @@
602
573
  return sortedGroupedItems;
603
574
  }
604
575
 
605
- function dispatchSelectedItem() {
606
- if (multiple) {
607
- if (hasValueChanged(value, prev_value)) {
608
- if (checkValueForDuplicates()) {
609
- oninput?.(value || []);
610
- }
611
- }
612
- return;
613
- }
614
-
615
- if (!prev_value || hasValueChanged((value as SelectItem)[itemId], (prev_value as SelectItem)[itemId])) {
616
- oninput?.(value);
617
- }
618
- }
619
-
620
- function setupMulti() {
621
- if (value) {
622
- if (Array.isArray(value)) {
623
- value = [...value];
624
- } else {
625
- value = [value];
626
- }
627
- }
628
- }
629
-
630
- function setValueIndexAsHoverIndex() {
631
- if (!normalizedValue || Array.isArray(normalizedValue)) return;
632
-
633
- const singleValue: SelectItem = normalizedValue;
634
-
635
- const valueIndex = filteredItems.findIndex((i: SelectItem) => {
636
- return (i as Record<string, any>)[itemId] === (singleValue as Record<string, any>)[itemId];
637
- });
638
-
639
- checkHoverSelectable(valueIndex, true);
640
- }
641
-
642
- function checkHoverSelectable(startingIndex = 0, ignoreGroup?: boolean) {
643
- hoverItemIndex = startingIndex < 0 ? 0 : startingIndex;
644
- if (!ignoreGroup && groupBy && filteredItems[hoverItemIndex] && !filteredItems[hoverItemIndex].selectable) {
645
- setHoverIndex(1);
646
- }
647
- }
648
-
649
576
  function setupFilterText() {
650
- // When loadOptions is defined, the unified $effect handles everything
651
- // This function now only handles non-loadOptions cases
652
577
  if (loadOptions) {
653
- // loadOptions is handled by the unified $effect that watches filterText and loadOptionsDeps
654
- // Just ensure the list opens if there's filter text
655
578
  if (filterText.length > 0 && !listOpen) {
656
579
  listOpen = true;
657
580
  }
@@ -660,7 +583,6 @@
660
583
 
661
584
  if (filterText.length === 0) return;
662
585
 
663
- // Non-loadOptions case: just open the list
664
586
  listOpen = true;
665
587
 
666
588
  if (multiple) {
@@ -668,86 +590,6 @@
668
590
  }
669
591
  }
670
592
 
671
- function computeJustValue(): any {
672
- const hasJustValue = multiple
673
- ? (Array.isArray(justValue) && justValue.length > 0)
674
- : (justValue !== '' && justValue != null);
675
-
676
- if (useJustValue && !value && !clearState && hasJustValue) {
677
- // Lookup justValue in items and set value
678
- const typedItems = (items as SelectItem[]) || [];
679
- if (multiple) {
680
- value = typedItems.filter((item: SelectItem) =>
681
- justValue.includes((item as Record<string, any>)[itemId])
682
- );
683
- } else {
684
- value = typedItems.filter((item: SelectItem) =>
685
- (item as Record<string, any>)[itemId] === justValue
686
- )[0];
687
- }
688
- }
689
-
690
- // Capture clearState before resetting it
691
- const wasClearing = clearState;
692
- clearState = false;
693
-
694
- // Preserve justValue when in useJustValue mode with no value
695
- // BUT allow clearing when user clicked clear button
696
- if (useJustValue && !value && !wasClearing) {
697
- return justValue;
698
- }
699
-
700
- // Handle multiple selection
701
- if (multiple && Array.isArray(value)) {
702
- return value.map((item: SelectItem) => item[itemId]);
703
- }
704
-
705
- // Handle single selection
706
- if (!value || typeof value === 'string' || Array.isArray(value)) {
707
- return value;
708
- }
709
-
710
- return value[itemId];
711
- }
712
-
713
- function checkValueForDuplicates(): boolean {
714
- if (!Array.isArray(value) || value.length === 0) return true;
715
-
716
- const seen = new Set();
717
- const uniqueValues = value.filter((val: SelectItem) => {
718
- const id = val[itemId];
719
- if (seen.has(id)) return false;
720
- seen.add(id);
721
- return true;
722
- });
723
-
724
- const noDuplicates = uniqueValues.length === value.length;
725
- if (!noDuplicates) value = uniqueValues;
726
-
727
- return noDuplicates;
728
- }
729
-
730
- function findItem(selection?: SelectItem): SelectItem | undefined {
731
- let matchTo = selection ? selection[itemId] : (normalizedValue as SelectItem)[itemId];
732
- return (items as SelectItem[])?.find(item => item[itemId] === matchTo);
733
- }
734
-
735
- async function handleMultiItemClear(i:number): Promise<void> {
736
- if (!Array.isArray(value)) return;
737
-
738
- const itemToRemove = value[i];
739
-
740
- clearState = true;
741
- if (value.length === 1) {
742
- value = undefined;
743
- } else {
744
- value = value.filter((item: SelectItem) => {
745
- return item !== itemToRemove;
746
- });
747
- }
748
- onclear(itemToRemove);
749
- }
750
-
751
593
  function handleFocus(e?: FocusEvent): void {
752
594
  if (focused && input === document?.activeElement) return;
753
595
  if (e) {
@@ -782,13 +624,6 @@
782
624
  listOpen = false;
783
625
  }
784
626
 
785
- function handleListScroll() {
786
- clearTimeout(isScrollingTimer);
787
- isScrollingTimer = setTimeout(() => {
788
- isScrolling = false;
789
- }, 100);
790
- }
791
-
792
627
  function handleClickOutside(event: MouseEvent): void {
793
628
  const target = event.target as Node;
794
629
  if (!listOpen && !focused && container && !container.contains(target) && !list?.contains(target)) {
@@ -802,69 +637,18 @@
802
637
 
803
638
  function handleSelect(item: SelectItem): void {
804
639
  if (!item || item.selectable === false) return;
805
- itemSelected(item);
806
- }
807
-
808
- function handleHover(i: number): void {
809
- if (isScrolling) return;
810
- hoverItemIndex = i;
640
+ valueManager.itemSelected(item);
811
641
  }
812
642
 
813
643
  function handleItemClick(item: SelectItem, i: number): void {
814
644
  if (item?.selectable === false) return;
815
645
  if (!multiple && areItemsEqual(normalizedValue, item, itemId)) return closeList();
816
- if (isItemSelectable(item)) {
646
+ if (hoverManager.isItemSelectable(item)) {
817
647
  hoverItemIndex = i;
818
648
  handleSelect(item);
819
649
  }
820
650
  }
821
651
 
822
- function setHoverIndex(increment: number) {
823
- let selectableFilteredItems = filteredItems.filter(
824
- (item) => !Object.hasOwn(item, 'selectable') || item.selectable === true,
825
- );
826
-
827
- if (selectableFilteredItems.length === 0) {
828
- return (hoverItemIndex = 0);
829
- }
830
-
831
- // Get current item
832
- const currentItem = filteredItems[hoverItemIndex];
833
- const isCurrentSelectable = isItemSelectableCheck(currentItem);
834
-
835
- // Find position in selectable items array
836
- let currentSelectableIndex;
837
-
838
- if (isCurrentSelectable) {
839
- currentSelectableIndex = selectableFilteredItems.findIndex(item => item === currentItem);
840
- } else {
841
- // Starting from non-selectable item - treat as before first/after last depending on direction
842
- currentSelectableIndex = increment > 0 ? -1 : selectableFilteredItems.length;
843
- }
844
-
845
- // Calculate new position with wrapping
846
- let newSelectableIndex;
847
- if (increment > 0) {
848
- newSelectableIndex = (currentSelectableIndex + 1) % selectableFilteredItems.length;
849
- } else {
850
- newSelectableIndex = (currentSelectableIndex - 1 + selectableFilteredItems.length) % selectableFilteredItems.length;
851
- }
852
-
853
- // Map back to filteredItems
854
- const newItem = selectableFilteredItems[newSelectableIndex];
855
- hoverItemIndex = filteredItems.findIndex(item => item === newItem);
856
- }
857
-
858
- function isItemActive(item: SelectItem, val: SelectItem | SelectItem[] | null | undefined, itemId: string): boolean | undefined {
859
- if (multiple) return;
860
- const normalized = !val ? null : typeof val === 'string' ? { value: val, label: val } : val;
861
- return areItemsEqual(normalized, item, itemId);
862
- }
863
-
864
- function isItemSelectable(item: SelectItem) {
865
- return (item.groupHeader && item.selectable) || isItemSelectableCheck(item);
866
- }
867
-
868
652
  function setListWidth():void {
869
653
  if (!container || !list) return;
870
654
  const { width } = container.getBoundingClientRect();
@@ -884,6 +668,17 @@
884
668
  prev_filterText = filterText;
885
669
  filterText = target.value;
886
670
  }
671
+
672
+ export function reset(): void {
673
+ clearState = true;
674
+ value = undefined;
675
+ filterText = '';
676
+ listOpen = false;
677
+ hoverItemIndex = 0;
678
+ activeValue = undefined;
679
+ justValue = undefined;
680
+ focused = false;
681
+ }
887
682
  </script>
888
683
 
889
684
  <svelte:window onclick={handleClickOutside} onkeydown={handleKeyDown} />
@@ -908,16 +703,18 @@
908
703
  class="svelte-select-list"
909
704
  class:prefloat
910
705
  style={listStyle}
911
- onscroll={handleListScroll}
706
+ onscroll={hoverManager.handleListScroll}
707
+ onscrollend={hoverManager.handleListScrollEnd}
912
708
  onpointerup={(ev) => {
913
- ev.preventDefault();
709
+ if (ev.pointerType === 'mouse') ev.preventDefault();
914
710
  ev.stopPropagation();
915
711
  }}
916
712
  onmousedown={(ev) => {
917
713
  ev.preventDefault();
918
714
  ev.stopPropagation();
919
715
  }}
920
- role="none">
716
+ role="listbox"
717
+ id="listbox-{_id}">
921
718
  {#if listPrependSnippet}
922
719
  {@render listPrependSnippet()}
923
720
  {/if}
@@ -926,8 +723,8 @@
926
723
  {:else if filteredItems?.length > 0}
927
724
  {#each filteredItems as item, i}
928
725
  <div
929
- onmouseover={() => handleHover(i)}
930
- onfocus={() => handleHover(i)}
726
+ onmouseover={() => hoverManager.handleHover(i)}
727
+ onfocus={() => hoverManager.handleHover(i)}
931
728
  onclick={(ev) => {
932
729
  ev.stopPropagation();
933
730
  handleItemClick(item, i);
@@ -938,15 +735,19 @@
938
735
  }}
939
736
  class="list-item"
940
737
  tabindex="-1"
941
- role="none">
738
+ role={item.groupHeader ? 'presentation' : 'option'}
739
+ id="listbox-{_id}-item-{i}"
740
+ aria-selected={item.groupHeader ? undefined : hoverManager.isItemActive(item, normalizedValue, itemId) || false}>
942
741
  <div
943
742
  class="item"
944
743
  class:list-group-title={item.groupHeader}
945
- class:active={isItemActive(item, normalizedValue, itemId)}
744
+ class:active={hoverManager.isItemActive(item, normalizedValue, itemId)}
946
745
  class:first={i === 0}
947
746
  class:hover={hoverItemIndex === i}
948
747
  class:group-item={item.groupItem}
949
- class:not-selectable={item?.selectable === false}>
748
+ class:not-selectable={item?.selectable === false}
749
+ role={item.groupHeader ? 'group' : undefined}
750
+ aria-label={item.groupHeader ? item[label] : undefined}>
950
751
  {#if itemSnippet}
951
752
  {@render itemSnippet(item, i)}
952
753
  {:else}
@@ -995,7 +796,7 @@
995
796
  class:disabled
996
797
  onclick={(ev) => {
997
798
  ev.preventDefault();
998
- return multiFullItemClearable ? handleMultiItemClear(i) : {};
799
+ return multiFullItemClearable ? valueManager.handleMultiItemClear(i) : {};
999
800
  }}
1000
801
  onkeydown={(ev) => {
1001
802
  ev.preventDefault();
@@ -1014,9 +815,9 @@
1014
815
  <div
1015
816
  class="multi-item-clear"
1016
817
  onpointerup={(ev) => {
1017
- ev.preventDefault();
818
+ if (ev.pointerType === 'mouse') ev.preventDefault();
1018
819
  ev.stopPropagation();
1019
- handleMultiItemClear(i);
820
+ valueManager.handleMultiItemClear(i);
1020
821
  }}>
1021
822
  {#if multiClearIconSnippet}
1022
823
  {@render multiClearIconSnippet()}
@@ -1030,7 +831,7 @@
1030
831
  {:else}
1031
832
  <div class="selected-item" class:hide-selected-item={hideSelectedItem}>
1032
833
  {#if selectionSnippet}
1033
- {@render selectionSnippet(value)}
834
+ {@render selectionSnippet(value as SelectItem)}
1034
835
  {:else}
1035
836
  {!Array.isArray(normalizedValue) ? normalizedValue?.[label] : ''}
1036
837
  {/if}
@@ -1084,14 +885,18 @@
1084
885
  {/if}
1085
886
  </div>
1086
887
  {#if inputHiddenSnippet}
1087
- {@render inputHiddenSnippet(value)}
1088
- {:else}
1089
- <input {name} type="hidden" value={useJustValue ? justValue : value ? JSON.stringify(value) : null} />
888
+ {@render inputHiddenSnippet(value as SelectValue)}
889
+ {:else if multiple && Array.isArray(value) && value.length > 0}
890
+ {#each value as item}
891
+ <input {name} type="hidden" value={useJustValue ? item[itemId] : JSON.stringify(item)} />
892
+ {/each}
893
+ {:else if !multiple}
894
+ <input {name} type="hidden" value={value ? (useJustValue ? justValue : JSON.stringify(value)) : ''} />
1090
895
  {/if}
1091
896
 
1092
897
  {#if required && (!value || value.length === 0)}
1093
898
  {#if requiredSnippet}
1094
- {@render requiredSnippet(value)}
899
+ {@render requiredSnippet(value as SelectValue)}
1095
900
  {:else}
1096
901
  <select class="required" required tabindex="-1" aria-hidden="true"></select>
1097
902
  {/if}
@@ -1485,4 +1290,4 @@ input:focus {
1485
1290
  bottom: 0;
1486
1291
  right: 0;
1487
1292
  }
1488
- </style>
1293
+ </style>