svelte-5-select 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +21 -11
  2. package/dist/ChevronIcon.svelte +6 -0
  3. package/dist/ChevronIcon.svelte.d.ts +6 -14
  4. package/dist/ClearIcon.svelte +6 -0
  5. package/dist/ClearIcon.svelte.d.ts +6 -14
  6. package/dist/LoadingIcon.svelte +6 -0
  7. package/dist/LoadingIcon.svelte.d.ts +6 -14
  8. package/dist/Select.svelte +104 -16
  9. package/dist/Select.svelte.d.ts +2 -2
  10. package/dist/aria-handlers.svelte.d.ts +1 -1
  11. package/dist/aria-handlers.svelte.js +12 -1
  12. package/dist/filter.d.ts +1 -1
  13. package/dist/filter.js +1 -1
  14. package/dist/index.d.ts +3 -3
  15. package/dist/index.js +2 -2
  16. package/dist/keyboard-navigation.svelte.d.ts +1 -1
  17. package/dist/keyboard-navigation.svelte.js +9 -1
  18. package/dist/no-styles/ChevronIcon.svelte +6 -0
  19. package/dist/no-styles/ChevronIcon.svelte.d.ts +6 -14
  20. package/dist/no-styles/ClearIcon.svelte +6 -0
  21. package/dist/no-styles/ClearIcon.svelte.d.ts +6 -14
  22. package/dist/no-styles/LoadingIcon.svelte +6 -0
  23. package/dist/no-styles/LoadingIcon.svelte.d.ts +6 -14
  24. package/dist/no-styles/Select.svelte +85 -16
  25. package/dist/no-styles/Select.svelte.d.ts +2 -2
  26. package/dist/select-state.svelte.d.ts +1 -1
  27. package/dist/styles/default.css +19 -0
  28. package/dist/tailwind.css +19 -0
  29. package/dist/types.d.ts +42 -13
  30. package/dist/use-hover.svelte.d.ts +1 -1
  31. package/dist/use-hover.svelte.js +24 -5
  32. package/dist/use-load-options.svelte.d.ts +1 -1
  33. package/dist/use-load-options.svelte.js +31 -2
  34. package/dist/use-value.svelte.d.ts +1 -1
  35. package/dist/use-value.svelte.js +13 -1
  36. package/dist/utils.d.ts +1 -1
  37. package/package.json +6 -2
@@ -1,3 +1,9 @@
1
+ <script lang="ts">
2
+ // Required even though empty: script-less components make svelte2tsx emit a
3
+ // legacy d.ts referencing `SvelteComponent` without importing it, which breaks
4
+ // consumers that type-check with skipLibCheck: false.
5
+ </script>
6
+
1
7
  <svg class="loading" viewBox="25 25 50 50" aria-hidden="true" focusable="false">
2
8
  <circle
3
9
  class="circle_path"
@@ -1,18 +1,5 @@
1
- export default LoadingIcon;
2
- type LoadingIcon = SvelteComponent<{
3
- [x: string]: never;
4
- }, {
5
- [evt: string]: CustomEvent<any>;
6
- }, {}> & {
7
- $$bindings?: string | undefined;
8
- };
9
- declare const LoadingIcon: $$__sveltets_2_IsomorphicComponent<{
10
- [x: string]: never;
11
- }, {
12
- [evt: string]: CustomEvent<any>;
13
- }, {}, {}, string>;
14
1
  interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
15
- new (options: import("svelte").ComponentConstructorOptions<Props>): import("svelte").SvelteComponent<Props, Events, Slots> & {
2
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
16
3
  $$bindings?: Bindings;
17
4
  } & Exports;
18
5
  (internal: unknown, props: {
@@ -24,3 +11,8 @@ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> =
24
11
  };
25
12
  z_$$bindings?: Bindings;
26
13
  }
14
+ declare const LoadingIcon: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
+ [evt: string]: CustomEvent<any>;
16
+ }, {}, {}, string>;
17
+ type LoadingIcon = InstanceType<typeof LoadingIcon>;
18
+ export default LoadingIcon;
@@ -20,16 +20,16 @@ Bind `value` (and optionally `justValue`, `filterText`, `listOpen`, `focused`).
20
20
  SelectValue,
21
21
  SelectClearValue,
22
22
  SelectErrorEvent,
23
- } from '../types';
23
+ } from '../types.js';
24
24
  import { createFloatingActions } from 'svelte-floating-ui';
25
25
  import { useAriaHandlers } from '../aria-handlers.svelte';
26
26
 
27
- import _filter from '../filter';
27
+ import _filter from '../filter.js';
28
28
 
29
29
  import ChevronIcon from './ChevronIcon.svelte';
30
30
  import ClearIcon from './ClearIcon.svelte';
31
31
  import LoadingIcon from './LoadingIcon.svelte';
32
- import type { SelectItem } from '../types';
32
+ import type { SelectItem } from '../types.js';
33
33
  import type { HTMLInputAttributes } from 'svelte/elements';
34
34
  import { createSelectState } from '../select-state.svelte';
35
35
  import { useKeyboardNavigation } from '../keyboard-navigation.svelte';
@@ -43,7 +43,7 @@ Bind `value` (and optionally `justValue`, `filterText`, `listOpen`, `focused`).
43
43
  isItemSelectableCheck,
44
44
  normalizeItem,
45
45
  createGroupHeaderItem as _createGroupHeaderItem,
46
- } from '../utils';
46
+ } from '../utils.js';
47
47
 
48
48
  const defaultItemFilter = (label: string, filterText: string, _option: SelectItem): boolean =>
49
49
  `${label}`.toLowerCase().includes(filterText?.toLowerCase());
@@ -118,6 +118,8 @@ Bind `value` (and optionally `justValue`, `filterText`, `listOpen`, `focused`).
118
118
  // ARIA props
119
119
  ariaLabel = undefined,
120
120
  ariaErrorMessage = undefined,
121
+ ariaActiveTag = (label: string) =>
122
+ `${label} is active. Press Backspace to remove, or left and right arrow keys to move between selected options.`,
121
123
  ariaClearSelectLabel = 'Clear selection',
122
124
  ariaRemoveItemLabel = (label: string) => `Remove ${label}`,
123
125
  ariaCleared = () => {
@@ -335,6 +337,15 @@ Bind `value` (and optionally `justValue`, `filterText`, `listOpen`, `focused`).
335
337
  (consumer as (e: Event) => unknown)(e);
336
338
  };
337
339
  }
340
+ // aria-describedby composes the same way: the attribute takes a
341
+ // space-separated id list, so a consumer description must extend the
342
+ // selection description, not silently replace it (later-spread-wins
343
+ // dropped the selection from browse-mode reading).
344
+ const consumerDescribedby = inputAttributes?.['aria-describedby'];
345
+ const ownDescribedby = !multiple && value ? `selected-${_id}` : undefined;
346
+ if (consumerDescribedby && ownDescribedby) {
347
+ attrs['aria-describedby'] = `${ownDescribedby} ${consumerDescribedby}`;
348
+ }
338
349
  return attrs;
339
350
  });
340
351
  let prefloat = $state(true);
@@ -505,7 +516,7 @@ Bind `value` (and optionally `justValue`, `filterText`, `listOpen`, `focused`).
505
516
  );
506
517
  let ariaContext = $derived.by(() => {
507
518
  if (activeTagLabel !== undefined) {
508
- return `${activeTagLabel} is active. Press Backspace to remove, or left and right arrow keys to move between selected options.`;
519
+ return ariaActiveTag(activeTagLabel);
509
520
  }
510
521
  // Tracked triggers: the list opening/closing, the result count changing
511
522
  // (filtering), and the loading flag. hoverItemIndex is deliberately read
@@ -561,6 +572,10 @@ Bind `value` (and optionally `justValue`, `filterText`, `listOpen`, `focused`).
561
572
  // triggers scrolling.
562
573
  function handleKeyDown(e: KeyboardEvent): void {
563
574
  keyboardNav.handleKeyDown(e);
575
+ // Mirror keyboardNav's IME gate: during composition the arrows move
576
+ // through the IME candidate window, not the list — the keyboard cursor
577
+ // stays put, so the list must not scroll either.
578
+ if (e.isComposing || e.keyCode === 229) return;
564
579
  const isTypeAhead = !searchable && e.key.length === 1;
565
580
  if (
566
581
  e.key === 'ArrowDown' ||
@@ -740,8 +755,13 @@ Bind `value` (and optionally `justValue`, `filterText`, `listOpen`, `focused`).
740
755
  const currentItemId = itemId;
741
756
  untrack(() => {
742
757
  if (warnedAboutMissingItemId || !currentItems?.length) return;
743
- const missing = (currentItems as (Item | string)[]).some(
744
- (item) => typeof item !== 'string' && getItemProperty(item, currentItemId) === undefined,
758
+ // String items are converted to `{ value, label, index }` rows, so
759
+ // any other `itemId` never exists on them the same silent
760
+ // identity collapse the object-item check catches.
761
+ const missing = (currentItems as (Item | string)[]).some((item) =>
762
+ typeof item === 'string'
763
+ ? !['value', 'label', 'index'].includes(currentItemId)
764
+ : getItemProperty(item, currentItemId) === undefined,
745
765
  );
746
766
  if (!missing) return;
747
767
  warnedAboutMissingItemId = true;
@@ -754,6 +774,33 @@ Bind `value` (and optionally `justValue`, `filterText`, `listOpen`, `focused`).
754
774
  });
755
775
  });
756
776
 
777
+ // Dev-only: the same identity collapse can enter through `value` — partial
778
+ // objects that lack the itemId field (e.g. seeded from a form payload) all
779
+ // compare equal to each other and to nothing in `items`, so selection
780
+ // display and dedup misbehave while `items` itself passes the check above.
781
+ let warnedAboutValueMissingItemId = false;
782
+ $effect(() => {
783
+ if (!DEV) return;
784
+ const currentValue = value;
785
+ const currentItemId = itemId;
786
+ untrack(() => {
787
+ if (warnedAboutValueMissingItemId || currentValue == null) return;
788
+ const entries = Array.isArray(currentValue) ? currentValue : [currentValue];
789
+ // Raw strings are their own id, so only object entries can be missing it
790
+ const missing = (entries as (Item | string)[]).some(
791
+ (entry) => typeof entry !== 'string' && getItemProperty(entry, currentItemId) === undefined,
792
+ );
793
+ if (!missing) return;
794
+ warnedAboutValueMissingItemId = true;
795
+ console.warn(
796
+ `[svelte-select] Some \`value\` entries have no "${currentItemId}" field (the current ` +
797
+ '`itemId`). Value entries are matched against items by that field, so entries ' +
798
+ 'without it cannot be recognized as selected, deduplicated, or validated. Pass ' +
799
+ 'value entries that carry the `itemId` field.',
800
+ );
801
+ });
802
+ });
803
+
757
804
  // Dev-only: warn once the input is mounted if it has no robust accessible
758
805
  // name. The placeholder is only a last-resort fallback that some screen
759
806
  // readers ignore, so an unnamed combobox is a real gap worth surfacing.
@@ -767,7 +814,13 @@ Bind `value` (and optionally `justValue`, `filterText`, `listOpen`, `focused`).
767
814
  ariaLabel;
768
815
  untrack(() => {
769
816
  if (!input) return;
770
- const named = !!ariaLabel || !!input.getAttribute('aria-labelledby') || (input.labels?.length ?? 0) > 0;
817
+ // getAttribute over the props: an aria-label supplied through
818
+ // inputAttributes names the input just as well as the ariaLabel prop
819
+ const named =
820
+ !!ariaLabel ||
821
+ !!input.getAttribute('aria-label') ||
822
+ !!input.getAttribute('aria-labelledby') ||
823
+ (input.labels?.length ?? 0) > 0;
771
824
  if (!named) {
772
825
  console.warn(
773
826
  '[svelte-select] The Select input has no accessible name. Pass `ariaLabel`, ' +
@@ -801,6 +854,14 @@ Bind `value` (and optionally `justValue`, `filterText`, `listOpen`, `focused`).
801
854
  listboxLabelledby = inputLabelledby;
802
855
  return;
803
856
  }
857
+ // An aria-label supplied through inputAttributes names only the
858
+ // input; forward it so the listbox is named the same way the
859
+ // ariaLabel prop's is (accname precedence: labelledby above wins).
860
+ const inputAriaLabel = input.getAttribute('aria-label');
861
+ if (inputAriaLabel) {
862
+ listboxLabelText = inputAriaLabel;
863
+ return;
864
+ }
804
865
  const labelEl = input.labels?.[0];
805
866
  if (!labelEl) return;
806
867
  // An implicit wrapping <label> contains the component itself, so
@@ -867,13 +928,21 @@ Bind `value` (and optionally `justValue`, `filterText`, `listOpen`, `focused`).
867
928
  groupValues.push(groupValue);
868
929
  groups[groupValue] = [];
869
930
  if (groupValue) {
870
- groups[groupValue].push(
871
- Object.assign(createGroupHeaderItem(groupValue, item as Item), {
872
- id: groupValue,
873
- groupHeader: true,
874
- selectable: groupHeaderSelectable,
875
- }),
876
- );
931
+ const header = Object.assign(createGroupHeaderItem(groupValue, item as Item), {
932
+ id: groupValue,
933
+ groupHeader: true,
934
+ selectable: groupHeaderSelectable,
935
+ });
936
+ // Headers are compared by `itemId` like any other row
937
+ // (selection, hover, dedup): one lacking that field
938
+ // compares equal to every other bare header, so selecting
939
+ // one marked them all selected and made the rest
940
+ // unselectable. Stamp the group value when the builder
941
+ // didn't provide the field.
942
+ if (getItemProperty(header, itemId) === undefined) {
943
+ (header as SelectItem)[itemId] = groupValue;
944
+ }
945
+ groups[groupValue].push(header);
877
946
  }
878
947
  }
879
948
 
@@ -1123,7 +1192,7 @@ Bind `value` (and optionally `justValue`, `filterText`, `listOpen`, `focused`).
1123
1192
  The group's accessible name still resolves via aria-labelledby,
1124
1193
  which follows hidden targets. Selectable headers are real options. -->
1125
1194
  <div
1126
- onmouseover={() => hoverManager.handleHover(i)}
1195
+ onmousemove={() => hoverManager.handleHover(i)}
1127
1196
  onfocus={() => hoverManager.handleHover(i)}
1128
1197
  onclick={(ev) => {
1129
1198
  ev.stopPropagation();
@@ -1,5 +1,5 @@
1
- import type { ItemLike, SelectProps, SelectRow } from '../types';
2
- import type { SelectItem } from '../types';
1
+ import type { ItemLike, SelectProps, SelectRow } from '../types.js';
2
+ import type { SelectItem } from '../types.js';
3
3
  declare function $$render<Item extends ItemLike = SelectItem, Multiple extends boolean = false>(): {
4
4
  props: SelectProps<Item, Multiple>;
5
5
  exports: {
@@ -1,4 +1,4 @@
1
- import type { ItemLike, SelectItem, SelectState } from './types';
1
+ import type { ItemLike, SelectItem, SelectState } from './types.js';
2
2
  /**
3
3
  * The prop-backed part of the store: Select.svelte supplies live getter/setter
4
4
  * accessors over its `$props()` bindings and derived values, so reads stay
@@ -425,4 +425,23 @@ input:focus {
425
425
  outline: 2px dashed Highlight;
426
426
  outline-offset: -2px;
427
427
  }
428
+
429
+ /* The error state is conveyed only by an author border colour, which is
430
+ flattened to the standard border — border width + style survive the
431
+ forced palette, so a double border keeps the state visible. */
432
+ .svelte-select.error {
433
+ border: 3px double CanvasText;
434
+ }
435
+
436
+ /* Disabled is aria-disabled + readonly, not :disabled, so the UA never
437
+ applies its own GrayText treatment — do it explicitly. */
438
+ .disabled,
439
+ .disabled input,
440
+ .disabled input::placeholder {
441
+ color: GrayText;
442
+ }
443
+
444
+ .disabled {
445
+ border-color: GrayText;
446
+ }
428
447
  }
package/dist/tailwind.css CHANGED
@@ -226,6 +226,25 @@
226
226
  outline: 2px dashed Highlight;
227
227
  outline-offset: -2px;
228
228
  }
229
+
230
+ /* The error state is conveyed only by an author border colour, which is
231
+ flattened to the standard border — border width + style survive the
232
+ forced palette, so a double border keeps the state visible. */
233
+ .svelte-select.error {
234
+ border: 3px double CanvasText;
235
+ }
236
+
237
+ /* Disabled is aria-disabled + readonly, not :disabled, so the UA never
238
+ applies its own GrayText treatment — do it explicitly. */
239
+ .svelte-select.disabled,
240
+ .svelte-select.disabled input,
241
+ .svelte-select.disabled input::placeholder {
242
+ color: GrayText;
243
+ }
244
+
245
+ .svelte-select.disabled {
246
+ border-color: GrayText;
247
+ }
229
248
  }
230
249
 
231
250
  .list-item {
package/dist/types.d.ts CHANGED
@@ -345,7 +345,7 @@ export interface SelectProps<Item extends ItemLike = SelectItem, Multiple extend
345
345
  debounceWait?: number;
346
346
  /** Passed to floating-ui to position the list (placement, middleware, autoUpdate). */
347
347
  floatingConfig?: FloatingConfig;
348
- /** Bindable. Index of the option under the keyboard cursor. */
348
+ /** Bindable. Index of the option under the keyboard cursor. @default 0 */
349
349
  hoverItemIndex?: number;
350
350
  /**
351
351
  * Extra attributes for the text input. Merged over the component's own ARIA
@@ -353,6 +353,9 @@ export interface SelectProps<Item extends ItemLike = SelectItem, Multiple extend
353
353
  * `oninput`/`onblur`/`onfocus`/`onkeydown` here composes with (runs after)
354
354
  * the component's own handler instead of replacing it, so filtering, focus
355
355
  * handling, and keyboard navigation keep working alongside yours.
356
+ * `value`, `placeholder`, and `style` are ignored — the input's value IS the
357
+ * filter text; use `bind:filterText`, the `placeholder` prop, and
358
+ * `inputStyles` instead.
356
359
  */
357
360
  inputAttributes?: HTMLInputAttributes;
358
361
  /** Size the list to the container's width. @default true */
@@ -396,16 +399,26 @@ export interface SelectProps<Item extends ItemLike = SelectItem, Multiple extend
396
399
  /**
397
400
  * Fetches options asynchronously. Runs on mount, on typing (debounced by
398
401
  * `debounceWait`), and whenever `loadOptionsDeps` or `disabled` changes.
399
- * Opening or closing the list never fetches, with one exception: reopening
400
- * with retained filter text whose load was cancelled on close (e.g. with
401
- * `clearFilterTextOnBlur={false}`) refreshes the stale results immediately.
402
+ * Opening or closing the list never fetches by itself; beyond that, one rule
403
+ * holds: the open list never shows results stale for the visible text.
404
+ * Reopening refetches when the shown results don't match the retained filter
405
+ * text (`clearFilterTextOnBlur={false}`), when the text was wiped at close
406
+ * (Escape) over query-narrowed results, or after a failed load; emptying the
407
+ * text while the list stays open (deleting the query, or a selection with
408
+ * `closeListOnChange={false}`) refetches the baseline set in place.
402
409
  * Whatever it returns is what the list shows: results are not re-filtered by
403
410
  * `filterText`. Set `items` or this, not both.
404
411
  */
405
412
  loadOptions?: (filterText: string) => Promise<Item[] | string[]>;
413
+ /**
414
+ * Announced when the arrow-key tag cursor lands on a multi-select tag;
415
+ * receives that tag's label. The default explains the Backspace-to-remove
416
+ * and arrow-key mechanics — override it to localize them.
417
+ */
418
+ ariaActiveTag?: (label: string) => string;
406
419
  /** Announced when the selection is cleared. */
407
420
  ariaCleared?: () => string;
408
- /** `aria-label` for the clear-all button. */
421
+ /** `aria-label` for the clear-all button. @default 'Clear selection' */
409
422
  ariaClearSelectLabel?: string;
410
423
  /** Announced when an open list has no results. Also rendered as the list's visible empty-state copy (unless `emptySnippet` replaces it), so overriding it localizes both. */
411
424
  ariaEmpty?: () => string;
@@ -427,7 +440,7 @@ export interface SelectProps<Item extends ItemLike = SelectItem, Multiple extend
427
440
  ariaListOpen?: (label: string, count: number) => string;
428
441
  /** Announced while an open list is still fetching options. Also rendered as the list's visible loading copy (unless `emptySnippet` replaces it), so overriding it localizes both. */
429
442
  ariaLoading?: () => string;
430
- /** `aria-label` for each multi-select tag's remove button; receives the item's label. */
443
+ /** `aria-label` for each multi-select tag's remove button; receives the item's label. @default (label) => `Remove ${label}` */
431
444
  ariaRemoveItemLabel?: (label: string) => string;
432
445
  /** Announced when the selection changes; receives the selected labels, comma-joined. */
433
446
  ariaValues?: (values: string) => string;
@@ -447,10 +460,16 @@ export interface SelectProps<Item extends ItemLike = SelectItem, Multiple extend
447
460
  * Fires with the rendered list whenever it changes while open. Rows include any
448
461
  * synthesized group headers — narrow with `isGroupHeader`.
449
462
  */
450
- onfilter?: (items: SelectRow<Item>[]) => void;
463
+ onfilter?: (items: SelectRow<NoInfer<Item>>[]) => void;
451
464
  /** Literal DOM `focus` passthrough from the text input. Fires after the component's own focus handling. */
452
465
  onfocus?: (e: FocusEvent) => void;
453
- /** Fires with the index of the option under the keyboard/mouse cursor. */
466
+ /**
467
+ * Fires with the index of the option under the keyboard/mouse cursor.
468
+ * Naming note: by the camelCase-for-state-changes rule above this would be
469
+ * `onHoverItem` (it tracks the bindable `hoverItemIndex`), but it shipped
470
+ * lowercase in 2.0 and renaming is a breaking change — treat it as the one
471
+ * grandfathered exception, not a pattern to extend.
472
+ */
454
473
  onhoveritem?: (index: number) => void;
455
474
  /**
456
475
  * Fires with the options a `loadOptions` call resolved. When the loader
@@ -464,7 +483,7 @@ export interface SelectProps<Item extends ItemLike = SelectItem, Multiple extend
464
483
  * Fires alongside {@link SelectProps.onSelectionChange} when the user picks an
465
484
  * option, but receives just the item selected rather than the whole value.
466
485
  */
467
- onselect?: (selection: Item) => void;
486
+ onselect?: (selection: NoInfer<Item>) => void;
468
487
  /**
469
488
  * Fires only when the user picks an option from the list — never on a clear, a
470
489
  * programmatic `bind:value` write, or a `loadOptionsDeps` invalidation. Use
@@ -483,6 +502,12 @@ export interface SelectProps<Item extends ItemLike = SelectItem, Multiple extend
483
502
  * an `oninput` that never fired per keystroke read as the DOM event it wasn't.)
484
503
  */
485
504
  onValueChange?: (value: SelectValue<Item, Multiple>) => void;
505
+ /** @deprecated Removed in v2.0 — renamed to {@link SelectProps.onValueChange} (same payload and firing rules). */
506
+ oninput?: never;
507
+ /** @deprecated Removed in v2.0 — renamed to {@link SelectProps.onSelectionChange} (same payload and firing rules). */
508
+ onchange?: never;
509
+ /** @deprecated Removed in v2.0 — renamed to {@link SelectProps.listStyles}. */
510
+ listStyle?: never;
486
511
  /** Replaces the chevron icon; the boolean is `listOpen`, so the icon can flip while open. Rendered only with `showChevron`. */
487
512
  chevronIconSnippet?: Snippet<[boolean]>;
488
513
  /** Replaces the icon inside the clear-all button (the button itself, its `ariaClearSelectLabel`, and its behavior stay). */
@@ -495,10 +520,11 @@ export interface SelectProps<Item extends ItemLike = SelectItem, Multiple extend
495
520
  * `<input type="hidden" name={...}>` (the default submits JSON-stringified
496
521
  * items, or bare ids with `useJustValue`).
497
522
  */
498
- inputHiddenSnippet?: Snippet<[SelectValueProp<Item, Multiple> | undefined]>;
523
+ inputHiddenSnippet?: Snippet<[SelectValueProp<Item, Multiple>]>;
499
524
  /**
500
525
  * Renders one row of the list. Also renders synthesized group headers when
501
- * `groupBy` is set — narrow with `isGroupHeader`.
526
+ * `groupBy` is set — narrow with `isGroupHeader`. The number is the row's
527
+ * index in the rendered (filtered, grouped) list.
502
528
  */
503
529
  itemSnippet?: Snippet<[SelectRow<Item>, number]>;
504
530
  /** Rendered inside the list after the options — e.g. a "load more" row or footer. */
@@ -525,8 +551,11 @@ export interface SelectProps<Item extends ItemLike = SelectItem, Multiple extend
525
551
  * submission while `required` and empty; receives the current value. An
526
552
  * override owns the constraint-validation behavior.
527
553
  */
528
- requiredSnippet?: Snippet<[SelectValueProp<Item, Multiple> | undefined]>;
529
- /** Receives one item at a time: the value in single mode, each tag in multiple mode. */
554
+ requiredSnippet?: Snippet<[SelectValueProp<Item, Multiple>]>;
555
+ /**
556
+ * Receives one item at a time: the value in single mode, each tag in multiple
557
+ * mode. The number is the tag's index in multiple mode and absent in single mode.
558
+ */
530
559
  selectionSnippet?: Snippet<[NoInfer<Item>, number?]>;
531
560
  /** Bindable, read-only: the container element, once mounted. */
532
561
  container?: HTMLDivElement;
@@ -1,4 +1,4 @@
1
- import type { ItemLike, SelectItem, SelectState } from './types';
1
+ import type { ItemLike, SelectItem, SelectState } from './types.js';
2
2
  export declare function useHover<Item extends ItemLike = SelectItem>(state: SelectState<Item>): {
3
3
  setHoverIndex: (increment: number) => void;
4
4
  isItemActive: (item: SelectItem) => boolean;
@@ -1,5 +1,5 @@
1
1
  import { untrack } from 'svelte';
2
- import { areItemsEqual, getItemProperty, isItemSelectableCheck, normalizeItem } from './utils';
2
+ import { areItemsEqual, getItemProperty, isItemSelectableCheck, normalizeItem } from './utils.js';
3
3
  export function useHover(state) {
4
4
  function computeNextIndex(filteredItems, fromIndex, increment) {
5
5
  // Same predicate as click/Enter selection: arrow keys must be able to
@@ -82,10 +82,13 @@ export function useHover(state) {
82
82
  if (state.isScrolling)
83
83
  return;
84
84
  state.hoverItemIndex = i;
85
- // Deliberately NOT commit-intent for Tab: browsers synthesize mouseover
86
- // when the list renders under a stationary cursor, so hover alone can
87
- // happen with zero user action. Intent comes from real pointer movement
88
- // over the open list (the list's mousemove handler in Select.svelte).
85
+ // Driven by row mousemove (never mouseover): browsers synthesize
86
+ // mouseover when the list renders or scrolls rows under a
87
+ // stationary cursor, which is zero user action and used to yank both
88
+ // hover and aria-activedescendant off the keyboard cursor right after
89
+ // open. mousemove only fires for real pointer movement. Deliberately
90
+ // NOT commit-intent for Tab either; that is the list-level mousemove
91
+ // handler in Select.svelte.
89
92
  }
90
93
  let scrollEndFallback;
91
94
  function handleListScroll() {
@@ -133,6 +136,22 @@ export function useHover(state) {
133
136
  }
134
137
  });
135
138
  });
139
+ // A parent can write any number through bind:hoverItemIndex; internal
140
+ // writers are all range-safe. An out-of-range cursor silently kills
141
+ // Enter/Space/Tab (they read filteredItems[hoverItemIndex] → undefined)
142
+ // until the next arrow key, so correct it as soon as it lands. Only
143
+ // genuinely out-of-range values are touched: a pointer resting on a
144
+ // non-selectable row is a valid cursor position, which is why the
145
+ // keep-hover effect above reads the index untracked and must stay that way.
146
+ $effect(() => {
147
+ const idx = state.hoverItemIndex;
148
+ const inRange = idx >= 0 && idx < state.filteredItems.length;
149
+ untrack(() => {
150
+ if (!state.listOpen || state.filteredItems.length === 0 || inRange)
151
+ return;
152
+ checkHoverSelectable();
153
+ });
154
+ });
136
155
  // Reset hover to the first selectable item on filterText change
137
156
  $effect(() => {
138
157
  if (state.filterText) {
@@ -1,4 +1,4 @@
1
- import type { ItemLike, LoadOptionsActions, SelectItem, SelectState } from './types';
1
+ import type { ItemLike, LoadOptionsActions, SelectItem, SelectState } from './types.js';
2
2
  export interface HandleLoadOptionsOptions {
3
3
  /**
4
4
  * Validate the selection against the loaded items (dependency-driven reloads):
@@ -1,6 +1,6 @@
1
1
  import { untrack } from 'svelte';
2
2
  import { DEV } from 'esm-env';
3
- import { convertStringItemsToObjects, getItemProperty } from './utils';
3
+ import { convertStringItemsToObjects, getItemProperty } from './utils.js';
4
4
  export function useLoadOptions(state, actions) {
5
5
  // Monotonic token so responses that resolve after a newer request started are discarded
6
6
  let requestSequence = 0;
@@ -182,6 +182,13 @@ export function useLoadOptions(state, actions) {
182
182
  // A restored load has settled; the channel is spent
183
183
  if (token === restoredToken)
184
184
  restoredToken = 0;
185
+ // The newest load has settled: nothing filter-driven is
186
+ // pending anymore, so a later close must not "cancel" it —
187
+ // pre-fix the stale flag let cancelPendingFilterLoad hand
188
+ // currency back to a deps reload this load superseded,
189
+ // whose late response then overwrote these fresher items
190
+ if (token === requestSequence)
191
+ latestLoadIsFilterDriven = false;
185
192
  if (result && result.length > 0 && typeof result[0] === 'string') {
186
193
  state.items = convertStringItemsToObjects(result);
187
194
  }
@@ -207,6 +214,10 @@ export function useLoadOptions(state, actions) {
207
214
  return; // superseded; the newer request manages state
208
215
  if (token === restoredToken)
209
216
  restoredToken = 0;
217
+ // Settled (with an error) — same as the success path: the flag
218
+ // must not outlive the load it describes
219
+ if (token === requestSequence)
220
+ latestLoadIsFilterDriven = false;
210
221
  // Settled (with an error), so no longer live — a reopen may retry it
211
222
  liveLoadFilterText = undefined;
212
223
  console.error('loadOptions error:', err);
@@ -320,7 +331,25 @@ export function useLoadOptions(state, actions) {
320
331
  !disabled &&
321
332
  filterText !== loadedFilterText &&
322
333
  !loadIsLiveForText;
323
- if (isFirstRun || depsChanged || disabledChanged || (filterTextChanged && filterText.length > 0)) {
334
+ // Filter text emptied in place while the list stays open a selection
335
+ // wiped it (closeListOnChange={false}) or the user deleted the query —
336
+ // over results still narrowed by the old text. The same staleness rule
337
+ // as reopenedStale applies (results must reflect the current text; the
338
+ // '' re-fetch restores the baseline set), it just never crosses a
339
+ // close/open edge here. loadedFilterText distinguishes this from an
340
+ // armed-but-never-landed load, which the cancel branch below moots.
341
+ const emptiedWhileOpen = !isFirstRun &&
342
+ filterTextChanged &&
343
+ filterText.length === 0 &&
344
+ listOpen &&
345
+ prev.listOpen &&
346
+ !disabled &&
347
+ filterText !== loadedFilterText;
348
+ if (isFirstRun ||
349
+ depsChanged ||
350
+ disabledChanged ||
351
+ (filterTextChanged && filterText.length > 0) ||
352
+ emptiedWhileOpen) {
324
353
  untrack(() => handleLoadOptions(filterText, {
325
354
  validateValue: depsChanged,
326
355
  debounce: filterTextChanged && !depsChanged && !disabledChanged,
@@ -1,4 +1,4 @@
1
- import type { ItemLike, SelectItem, SelectState, ValueActions } from './types';
1
+ import type { ItemLike, SelectItem, SelectState, ValueActions } from './types.js';
2
2
  export declare function useValue<Item extends ItemLike = SelectItem>(state: SelectState<Item>, actions: ValueActions): {
3
3
  itemSelected: (selection: SelectItem) => void;
4
4
  handleMultiItemClear: (i: number) => Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import { untrack } from 'svelte';
2
- import { getItemProperty, hasValueChanged } from './utils';
2
+ import { getItemProperty, hasValueChanged } from './utils.js';
3
3
  export function useValue(state, actions) {
4
4
  // Tracked-read helper for the effects' trigger sections: reading length AND
5
5
  // every entry makes any in-place mutation of a bound array retrigger the
@@ -188,6 +188,12 @@ export function useValue(state, actions) {
188
188
  // Raw string entries key on the string itself: getItemProperty returns
189
189
  // undefined for non-objects, which would collapse all strings into one
190
190
  const id = typeof val === 'string' ? val : getItemProperty(val, itemId);
191
+ // An entry with no itemId field yields no evidence of duplication —
192
+ // collapsing every such entry into the first silently loses data on
193
+ // a config error (the dev warning in Select.svelte surfaces it).
194
+ // Only matching real ids may dedup.
195
+ if (id === undefined)
196
+ return true;
191
197
  if (seen.has(id))
192
198
  return false;
193
199
  seen.add(id);
@@ -307,6 +313,12 @@ export function useValue(state, actions) {
307
313
  actions.onSelectionChange(state.value);
308
314
  actions.onselect(selection);
309
315
  }
316
+ // Run once synchronously at creation: effects never run during SSR, so
317
+ // without this pass the server HTML ships raw string entries — empty chip
318
+ // text, aria-label="Remove undefined", and a hidden form input carrying
319
+ // JSON.stringify('NY'). The effect below re-runs the same idempotent
320
+ // resolution on the client.
321
+ untrack(() => normalizeValue());
310
322
  // Normalize string values on every value change (not just the hasValue
311
323
  // flip — replacing one string with another must re-resolve), and again when
312
324
  // async items arrive so fallback entries upgrade to the real item
package/dist/utils.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ItemLike, SelectGroupHeader, SelectItem } from './types';
1
+ import type { ItemLike, SelectGroupHeader, SelectItem } from './types.js';
2
2
  /**
3
3
  * Narrows a rendered list row to a synthesized group header (see the `groupBy` prop).
4
4
  * Rows that fail this guard are your own item type.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-5-select",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -63,7 +63,11 @@
63
63
  "select",
64
64
  "dropdown",
65
65
  "component",
66
- "select-component"
66
+ "select-component",
67
+ "combobox",
68
+ "autocomplete",
69
+ "typeahead",
70
+ "multiselect"
67
71
  ],
68
72
  "type": "module",
69
73
  "exports": {