svelte-5-select 1.0.2 → 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 (39) hide show
  1. package/README.md +255 -115
  2. package/dist/ChevronIcon.svelte +14 -13
  3. package/dist/ChevronIcon.svelte.d.ts +6 -14
  4. package/dist/ClearIcon.svelte +9 -11
  5. package/dist/ClearIcon.svelte.d.ts +6 -14
  6. package/dist/LoadingIcon.svelte +15 -2
  7. package/dist/LoadingIcon.svelte.d.ts +6 -14
  8. package/dist/Select.svelte +1048 -442
  9. package/dist/Select.svelte.d.ts +37 -5
  10. package/dist/aria-handlers.svelte.d.ts +5 -2
  11. package/dist/aria-handlers.svelte.js +30 -8
  12. package/dist/filter.d.ts +10 -2
  13. package/dist/filter.js +15 -8
  14. package/dist/index.d.ts +3 -4
  15. package/dist/index.js +2 -3
  16. package/dist/keyboard-navigation.svelte.d.ts +7 -2
  17. package/dist/keyboard-navigation.svelte.js +293 -47
  18. package/dist/no-styles/ChevronIcon.svelte +17 -0
  19. package/dist/no-styles/ChevronIcon.svelte.d.ts +18 -0
  20. package/dist/no-styles/ClearIcon.svelte +14 -0
  21. package/dist/no-styles/ClearIcon.svelte.d.ts +18 -0
  22. package/dist/no-styles/LoadingIcon.svelte +19 -0
  23. package/dist/no-styles/LoadingIcon.svelte.d.ts +18 -0
  24. package/dist/no-styles/Select.svelte +1452 -0
  25. package/dist/no-styles/Select.svelte.d.ts +38 -0
  26. package/dist/select-state.svelte.d.ts +15 -0
  27. package/dist/select-state.svelte.js +161 -0
  28. package/dist/styles/default.css +131 -71
  29. package/dist/tailwind.css +139 -17
  30. package/dist/types.d.ts +488 -129
  31. package/dist/use-hover.svelte.d.ts +3 -7
  32. package/dist/use-hover.svelte.js +110 -36
  33. package/dist/use-load-options.svelte.d.ts +18 -3
  34. package/dist/use-load-options.svelte.js +362 -42
  35. package/dist/use-value.svelte.d.ts +2 -11
  36. package/dist/use-value.svelte.js +334 -111
  37. package/dist/utils.d.ts +19 -8
  38. package/dist/utils.js +52 -8
  39. package/package.json +121 -94
@@ -1,11 +1,7 @@
1
- import type { HoverContext, SelectItem } from './types';
2
- export declare function useHover(context: HoverContext): {
3
- computeNextIndex: (filteredItems: SelectItem[], fromIndex: number, increment: number) => number;
1
+ import type { ItemLike, SelectItem, SelectState } from './types.js';
2
+ export declare function useHover<Item extends ItemLike = SelectItem>(state: SelectState<Item>): {
4
3
  setHoverIndex: (increment: number) => void;
5
- setValueIndexAsHoverIndex: () => void;
6
- checkHoverSelectable: (startingIndex?: number, ignoreGroup?: boolean) => void;
7
- getFirstSelectableIndex: () => number;
8
- isItemActive: (item: SelectItem, val: SelectItem | SelectItem[] | null | undefined, itemId: string) => boolean | undefined;
4
+ isItemActive: (item: SelectItem) => boolean;
9
5
  isItemSelectable: (item: SelectItem) => boolean;
10
6
  handleHover: (i: number) => void;
11
7
  handleListScroll: () => void;
@@ -1,7 +1,10 @@
1
- import { areItemsEqual, isItemSelectableCheck } from './utils';
2
- export function useHover(context) {
1
+ import { untrack } from 'svelte';
2
+ import { areItemsEqual, getItemProperty, isItemSelectableCheck, normalizeItem } from './utils.js';
3
+ export function useHover(state) {
3
4
  function computeNextIndex(filteredItems, fromIndex, increment) {
4
- let selectableFilteredItems = filteredItems.filter((item) => !Object.hasOwn(item, 'selectable') || item.selectable === true);
5
+ // Same predicate as click/Enter selection: arrow keys must be able to
6
+ // reach every item the user can otherwise select
7
+ const selectableFilteredItems = filteredItems.filter((item) => isItemSelectableCheck(item));
5
8
  if (selectableFilteredItems.length === 0) {
6
9
  return 0;
7
10
  }
@@ -9,7 +12,7 @@ export function useHover(context) {
9
12
  const isCurrentSelectable = isItemSelectableCheck(currentItem);
10
13
  let currentSelectableIndex;
11
14
  if (isCurrentSelectable) {
12
- currentSelectableIndex = selectableFilteredItems.findIndex(item => item === currentItem);
15
+ currentSelectableIndex = selectableFilteredItems.findIndex((item) => item === currentItem);
13
16
  }
14
17
  else {
15
18
  currentSelectableIndex = increment > 0 ? -1 : selectableFilteredItems.length;
@@ -19,40 +22,40 @@ export function useHover(context) {
19
22
  newSelectableIndex = (currentSelectableIndex + 1) % selectableFilteredItems.length;
20
23
  }
21
24
  else {
22
- newSelectableIndex = (currentSelectableIndex - 1 + selectableFilteredItems.length) % selectableFilteredItems.length;
25
+ newSelectableIndex =
26
+ (currentSelectableIndex - 1 + selectableFilteredItems.length) % selectableFilteredItems.length;
23
27
  }
24
28
  const newItem = selectableFilteredItems[newSelectableIndex];
25
- return filteredItems.findIndex(item => item === newItem);
29
+ return filteredItems.findIndex((item) => item === newItem);
26
30
  }
27
31
  function setHoverIndex(increment) {
28
- const { filteredItems, hoverItemIndex } = context.getState();
29
- context.setHoverItemIndex(computeNextIndex(filteredItems, hoverItemIndex, increment));
32
+ const { filteredItems, hoverItemIndex } = state;
33
+ state.hoverItemIndex = computeNextIndex(filteredItems, hoverItemIndex, increment);
30
34
  }
31
35
  function setValueIndexAsHoverIndex() {
32
- const { value, filteredItems, itemId } = context.getState();
33
- const normalizedValue = !value ? null : typeof value === 'string' ? { value, label: value } : value;
36
+ const { normalizedValue, filteredItems, itemId } = state;
34
37
  if (!normalizedValue || Array.isArray(normalizedValue))
35
38
  return;
36
- const singleValue = normalizedValue;
37
- const valueIndex = filteredItems.findIndex((i) => {
38
- return i[itemId] === singleValue[itemId];
39
- });
39
+ const valueIndex = filteredItems.findIndex((item) => getItemProperty(item, itemId) === getItemProperty(normalizedValue, itemId));
40
40
  checkHoverSelectable(valueIndex, true);
41
41
  }
42
- function checkHoverSelectable(startingIndex = 0, ignoreGroup) {
43
- const { groupBy, filteredItems } = context.getState();
42
+ function checkHoverSelectable(startingIndex = 0, trustIndex) {
43
+ const { filteredItems } = state;
44
44
  const idx = startingIndex < 0 ? 0 : startingIndex;
45
- if (!ignoreGroup && groupBy && filteredItems[idx] && !filteredItems[idx].selectable) {
45
+ // Same predicate as click/Enter selection (a missing `selectable` key
46
+ // means selectable), and not gated on groupBy: a plain list can start
47
+ // with a non-selectable item too
48
+ if (!trustIndex && filteredItems[idx] && !isItemSelectableCheck(filteredItems[idx])) {
46
49
  // Compute the final index without intermediate state writes
47
- context.setHoverItemIndex(computeNextIndex(filteredItems, idx, 1));
50
+ state.hoverItemIndex = computeNextIndex(filteredItems, idx, 1);
48
51
  }
49
52
  else {
50
- context.setHoverItemIndex(idx);
53
+ state.hoverItemIndex = idx;
51
54
  }
52
55
  }
53
56
  function getFirstSelectableIndex() {
54
- const { groupBy, filteredItems } = context.getState();
55
- if (!groupBy || filteredItems.length === 0)
57
+ const { filteredItems } = state;
58
+ if (filteredItems.length === 0)
56
59
  return 0;
57
60
  if (!isItemSelectableCheck(filteredItems[0])) {
58
61
  const firstSelectable = filteredItems.findIndex(isItemSelectableCheck);
@@ -60,34 +63,105 @@ export function useHover(context) {
60
63
  }
61
64
  return 0;
62
65
  }
63
- function isItemActive(item, val, itemId) {
64
- const { multiple } = context.getState();
65
- if (multiple)
66
- return;
67
- const normalized = !val ? null : typeof val === 'string' ? { value: val, label: val } : val;
68
- return areItemsEqual(normalized, item, itemId);
66
+ function isItemActive(item) {
67
+ const { multiple, normalizedValue, itemId } = state;
68
+ if (multiple) {
69
+ if (!Array.isArray(normalizedValue))
70
+ return false;
71
+ // Array entries may still be raw strings; normalize each before comparing
72
+ return normalizedValue.some((v) => areItemsEqual(normalizeItem(v), item, itemId));
73
+ }
74
+ if (Array.isArray(normalizedValue))
75
+ return false;
76
+ return areItemsEqual(normalizedValue, item, itemId);
69
77
  }
70
78
  function isItemSelectable(item) {
71
79
  return (item.groupHeader && item.selectable) || isItemSelectableCheck(item);
72
80
  }
73
81
  function handleHover(i) {
74
- const { isScrolling } = context.getState();
75
- if (isScrolling)
82
+ if (state.isScrolling)
76
83
  return;
77
- context.setHoverItemIndex(i);
84
+ state.hoverItemIndex = i;
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.
78
92
  }
93
+ let scrollEndFallback;
79
94
  function handleListScroll() {
80
- context.setIsScrolling(true);
95
+ state.isScrolling = true;
96
+ // scrollend never fires on some browsers (e.g. Safari < 18.2); without a
97
+ // fallback a single scroll would wedge isScrolling and kill hover + blur
98
+ clearTimeout(scrollEndFallback);
99
+ scrollEndFallback = setTimeout(() => {
100
+ state.isScrolling = false;
101
+ }, 150);
81
102
  }
82
103
  function handleListScrollEnd() {
83
- context.setIsScrolling(false);
104
+ clearTimeout(scrollEndFallback);
105
+ state.isScrolling = false;
84
106
  }
107
+ // Teardown-only effect (not onDestroy: composables are also created inside
108
+ // $effect.root in tests, where onDestroy has no component context)
109
+ $effect(() => {
110
+ return () => clearTimeout(scrollEndFallback);
111
+ });
112
+ // Set value index as hover when list opens
113
+ $effect(() => {
114
+ const shouldSnap = !state.multiple && state.listOpen && !!state.value && !!state.filteredItems;
115
+ untrack(() => {
116
+ // One-shot: a type-ahead keypress that opened the list has already
117
+ // parked hover on its match — snapping to the value would clobber
118
+ // it. Consume the flag on every run so it can never go stale.
119
+ const suppressed = state.suppressValueHoverSnap;
120
+ state.suppressValueHoverSnap = false;
121
+ if (shouldSnap && !suppressed)
122
+ setValueIndexAsHoverIndex();
123
+ });
124
+ });
125
+ // Keep hover on a selectable item
126
+ $effect(() => {
127
+ state.filteredItems;
128
+ state.value;
129
+ state.multiple;
130
+ state.listOpen;
131
+ untrack(() => {
132
+ if (state.listOpen && state.filteredItems.length > 0) {
133
+ if (!isItemSelectableCheck(state.filteredItems[state.hoverItemIndex])) {
134
+ checkHoverSelectable();
135
+ }
136
+ }
137
+ });
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
+ });
155
+ // Reset hover to the first selectable item on filterText change
156
+ $effect(() => {
157
+ if (state.filterText) {
158
+ untrack(() => {
159
+ state.hoverItemIndex = getFirstSelectableIndex();
160
+ });
161
+ }
162
+ });
85
163
  return {
86
- computeNextIndex,
87
164
  setHoverIndex,
88
- setValueIndexAsHoverIndex,
89
- checkHoverSelectable,
90
- getFirstSelectableIndex,
91
165
  isItemActive,
92
166
  isItemSelectable,
93
167
  handleHover,
@@ -1,4 +1,19 @@
1
- import type { LoadOptionsContext } from './types';
2
- export declare function useLoadOptions(context: LoadOptionsContext): {
3
- handleLoadOptions: (currentFilterText: string, currentDeps: any[]) => void;
1
+ import type { ItemLike, LoadOptionsActions, SelectItem, SelectState } from './types.js';
2
+ export interface HandleLoadOptionsOptions {
3
+ /**
4
+ * Validate the selection against the loaded items (dependency-driven reloads):
5
+ * entries missing from a non-empty result are dropped; an empty result is no
6
+ * evidence and never clears.
7
+ */
8
+ validateValue?: boolean;
9
+ /** Defer the fetch through the debounce action (typing); defaults to the legacy prevFilterText comparison. */
10
+ debounce?: boolean;
11
+ /** Clear value/items in the disabled branch; the load effect only passes true on an actual disabled transition. */
12
+ clearValueOnDisabled?: boolean;
13
+ }
14
+ export declare function useLoadOptions<Item extends ItemLike = SelectItem>(state: SelectState<Item>, actions: LoadOptionsActions): {
15
+ handleLoadOptions: (currentFilterText: string, options?: HandleLoadOptionsOptions) => void;
16
+ cancelPendingFilterLoad: () => void;
17
+ retireValidationForFreshSelection: () => void;
18
+ invalidateLoads: () => void;
4
19
  };