svelte-5-select 1.0.2 → 2.0.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 (36) hide show
  1. package/README.md +239 -109
  2. package/dist/ChevronIcon.svelte +8 -13
  3. package/dist/ClearIcon.svelte +3 -11
  4. package/dist/LoadingIcon.svelte +9 -2
  5. package/dist/Select.svelte +954 -436
  6. package/dist/Select.svelte.d.ts +37 -5
  7. package/dist/aria-handlers.svelte.d.ts +4 -1
  8. package/dist/aria-handlers.svelte.js +19 -8
  9. package/dist/filter.d.ts +10 -2
  10. package/dist/filter.js +14 -7
  11. package/dist/index.d.ts +2 -3
  12. package/dist/index.js +1 -2
  13. package/dist/keyboard-navigation.svelte.d.ts +7 -2
  14. package/dist/keyboard-navigation.svelte.js +285 -47
  15. package/dist/no-styles/ChevronIcon.svelte +11 -0
  16. package/dist/no-styles/ChevronIcon.svelte.d.ts +26 -0
  17. package/dist/no-styles/ClearIcon.svelte +8 -0
  18. package/dist/no-styles/ClearIcon.svelte.d.ts +26 -0
  19. package/dist/no-styles/LoadingIcon.svelte +13 -0
  20. package/dist/no-styles/LoadingIcon.svelte.d.ts +26 -0
  21. package/dist/no-styles/Select.svelte +1383 -0
  22. package/dist/no-styles/Select.svelte.d.ts +38 -0
  23. package/dist/select-state.svelte.d.ts +15 -0
  24. package/dist/select-state.svelte.js +161 -0
  25. package/dist/styles/default.css +112 -71
  26. package/dist/tailwind.css +120 -17
  27. package/dist/types.d.ts +459 -129
  28. package/dist/use-hover.svelte.d.ts +3 -7
  29. package/dist/use-hover.svelte.js +91 -36
  30. package/dist/use-load-options.svelte.d.ts +18 -3
  31. package/dist/use-load-options.svelte.js +333 -42
  32. package/dist/use-value.svelte.d.ts +2 -11
  33. package/dist/use-value.svelte.js +322 -111
  34. package/dist/utils.d.ts +19 -8
  35. package/dist/utils.js +52 -8
  36. package/package.json +117 -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';
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';
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,86 @@ 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
+ // 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).
78
89
  }
90
+ let scrollEndFallback;
79
91
  function handleListScroll() {
80
- context.setIsScrolling(true);
92
+ state.isScrolling = true;
93
+ // scrollend never fires on some browsers (e.g. Safari < 18.2); without a
94
+ // fallback a single scroll would wedge isScrolling and kill hover + blur
95
+ clearTimeout(scrollEndFallback);
96
+ scrollEndFallback = setTimeout(() => {
97
+ state.isScrolling = false;
98
+ }, 150);
81
99
  }
82
100
  function handleListScrollEnd() {
83
- context.setIsScrolling(false);
101
+ clearTimeout(scrollEndFallback);
102
+ state.isScrolling = false;
84
103
  }
104
+ // Teardown-only effect (not onDestroy: composables are also created inside
105
+ // $effect.root in tests, where onDestroy has no component context)
106
+ $effect(() => {
107
+ return () => clearTimeout(scrollEndFallback);
108
+ });
109
+ // Set value index as hover when list opens
110
+ $effect(() => {
111
+ const shouldSnap = !state.multiple && state.listOpen && !!state.value && !!state.filteredItems;
112
+ untrack(() => {
113
+ // One-shot: a type-ahead keypress that opened the list has already
114
+ // parked hover on its match — snapping to the value would clobber
115
+ // it. Consume the flag on every run so it can never go stale.
116
+ const suppressed = state.suppressValueHoverSnap;
117
+ state.suppressValueHoverSnap = false;
118
+ if (shouldSnap && !suppressed)
119
+ setValueIndexAsHoverIndex();
120
+ });
121
+ });
122
+ // Keep hover on a selectable item
123
+ $effect(() => {
124
+ state.filteredItems;
125
+ state.value;
126
+ state.multiple;
127
+ state.listOpen;
128
+ untrack(() => {
129
+ if (state.listOpen && state.filteredItems.length > 0) {
130
+ if (!isItemSelectableCheck(state.filteredItems[state.hoverItemIndex])) {
131
+ checkHoverSelectable();
132
+ }
133
+ }
134
+ });
135
+ });
136
+ // Reset hover to the first selectable item on filterText change
137
+ $effect(() => {
138
+ if (state.filterText) {
139
+ untrack(() => {
140
+ state.hoverItemIndex = getFirstSelectableIndex();
141
+ });
142
+ }
143
+ });
85
144
  return {
86
- computeNextIndex,
87
145
  setHoverIndex,
88
- setValueIndexAsHoverIndex,
89
- checkHoverSelectable,
90
- getFirstSelectableIndex,
91
146
  isItemActive,
92
147
  isItemSelectable,
93
148
  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';
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
  };
@@ -1,68 +1,359 @@
1
- export function useLoadOptions(context) {
2
- function handleLoadOptions(currentFilterText, currentDeps) {
3
- const { loadOptions, disabled, multiple, value, itemId, useJustValue, justValue, prevFilterText, debounceWait, listOpen } = context.getState();
1
+ import { untrack } from 'svelte';
2
+ import { DEV } from 'esm-env';
3
+ import { convertStringItemsToObjects, getItemProperty } from './utils';
4
+ export function useLoadOptions(state, actions) {
5
+ // Monotonic token so responses that resolve after a newer request started are discarded
6
+ let requestSequence = 0;
7
+ // Whether the newest armed/in-flight load came from typing; only those may be
8
+ // cancelled by selection or list close — dependency reloads must still land
9
+ let latestLoadIsFilterDriven = false;
10
+ // Token of the newest ARMED load and whether it validates the value. A
11
+ // dependency reload superseded by a plain filter load must still deliver its
12
+ // validation verdict (its response is authoritative for the deps change),
13
+ // while a newer validating load owns the verdict itself, and plain
14
+ // invalidation (disable, unmount, cancel) must deliver nothing.
15
+ let armedToken = 0;
16
+ let armedLoadValidates = false;
17
+ // Filter text of the newest armed/in-flight load while it is live; cleared
18
+ // once it settles. Lets the effect tell "shown results are stale for this
19
+ // text" from "a load for this text is already on its way" — mounting with
20
+ // an initial filterText opens the list after the mount fetch is armed, and
21
+ // that open must not fire a duplicate fetch.
22
+ let liveLoadFilterText = undefined;
23
+ // Token of the newest validating (dependency-driven) load while it is
24
+ // unsettled; 0 otherwise. If a filter load that superseded it is cancelled,
25
+ // the deps reload is the most relevant request again and must get its full
26
+ // authority back — see restoredToken.
27
+ let liveValidatingToken = 0;
28
+ // A load allowed to land in full despite no longer being the newest request:
29
+ // cancelPendingFilterLoad hands currency back to the in-flight deps reload
30
+ // the cancelled load superseded. Without this, closing the list during a
31
+ // deps reload discarded both the reload's items and its validation verdict,
32
+ // leaving stale options and an unvalidated stale selection (the reopen-stale
33
+ // heuristic could re-fetch the items later, but never the verdict).
34
+ let restoredToken = 0;
35
+ // Token of the newest load whose response fully landed (items written).
36
+ // Lets a user selection tell whether the options it was picked from are
37
+ // fresher than a still-pending validating reload — see
38
+ // retireValidationForFreshSelection.
39
+ let lastLandedToken = 0;
40
+ // The filter text the currently-displayed items reflect (set when a load
41
+ // settles). Lets a reopen tell "results are stale for the current text" from
42
+ // "results already match", so only the former re-fetches.
43
+ let loadedFilterText = undefined;
44
+ // Invalidate every pending and in-flight load: a pending one never fetches,
45
+ // an in-flight response is discarded. Does not touch reactive state, so it
46
+ // is safe to call during component teardown.
47
+ function invalidateLoads() {
48
+ requestSequence++;
49
+ latestLoadIsFilterDriven = false;
50
+ restoredToken = 0;
51
+ liveValidatingToken = 0;
52
+ }
53
+ // A filter-driven load becomes moot when the user selects an item, empties
54
+ // the filter text, or closes the list before the debounce fires.
55
+ function cancelPendingFilterLoad() {
56
+ if (!latestLoadIsFilterDriven)
57
+ return;
58
+ const validating = liveValidatingToken;
59
+ invalidateLoads();
60
+ if (validating !== 0) {
61
+ // The cancelled filter load had superseded an in-flight dependency
62
+ // reload. That reload is authoritative for the deps change — items
63
+ // and validation verdict — so restore its currency and keep the
64
+ // loading flag up until it settles. It stays restore-eligible so
65
+ // further type→cancel cycles before it settles hand back to it too;
66
+ // only a settle, a disable, or unmount (invalidateLoads callers)
67
+ // ends its authority.
68
+ restoredToken = validating;
69
+ liveValidatingToken = validating;
70
+ }
71
+ else if (state.loading) {
72
+ state.loading = false;
73
+ }
74
+ }
75
+ // A user selection picked from results FRESHER than a still-pending
76
+ // validating (dependency-driven) reload retires that reload's authority:
77
+ // its late verdict would judge the new selection against results fetched
78
+ // for the pre-selection filter text and wipe a choice the user made from
79
+ // post-deps-change options. A selection made from results OLDER than the
80
+ // reload (a stale pick while it is still in flight) keeps the verdict —
81
+ // validating exactly that pick is the reload's job.
82
+ function retireValidationForFreshSelection() {
83
+ if (liveValidatingToken === 0 || lastLandedToken < liveValidatingToken)
84
+ return;
85
+ // The retired reload may also own the restore channel (a cancel handed
86
+ // currency back to it); spend that too so its items cannot land either
87
+ if (restoredToken === liveValidatingToken)
88
+ restoredToken = 0;
89
+ liveValidatingToken = 0;
90
+ }
91
+ function handleLoadOptions(currentFilterText, options = {}) {
92
+ const { loadOptions, disabled, prevFilterText, debounceWait } = state;
93
+ const { validateValue = false, debounce: shouldDebounce = currentFilterText !== prevFilterText, clearValueOnDisabled = true, } = options;
4
94
  if (loadOptions && !disabled) {
5
- context.setLoading(true);
6
- const isFilterTextChange = currentFilterText !== prevFilterText;
95
+ state.loading = true;
96
+ const token = ++requestSequence;
97
+ armedToken = token;
98
+ armedLoadValidates = validateValue;
99
+ latestLoadIsFilterDriven = shouldDebounce;
100
+ liveLoadFilterText = currentFilterText;
101
+ // A new request supersedes any restored one; a newer validating
102
+ // load also takes over the restore channel from an older one.
103
+ restoredToken = 0;
104
+ if (validateValue)
105
+ liveValidatingToken = token;
106
+ // Only a dependency-driven reload invalidates the selection: when a
107
+ // parent select changes, a stale child value must clear. A
108
+ // filter-driven load merely narrows the results and must not wipe
109
+ // a selection that happens to fall outside them.
110
+ const validateValueAgainstLoaded = (loaded) => {
111
+ // Re-read state after the async boundary
112
+ const { value, multiple, itemId, useJustValue } = state;
113
+ if (!value)
114
+ return;
115
+ // An empty (or null) result is no evidence about validity: the
116
+ // reload queries with the retained filter text (usually '' after
117
+ // a selection), and a search endpoint returns nothing for an
118
+ // empty query regardless of the selection. Only a non-empty
119
+ // result can prove an entry stale, so only it may clear.
120
+ if (!loaded || loaded.length === 0)
121
+ return;
122
+ const idOf = (entry) => typeof entry === 'string' ? entry : getItemProperty(entry, itemId);
123
+ const existsInLoaded = (v) => loaded.some((item) => idOf(item) === idOf(v));
124
+ // One empty representation, matching every other clear path (the
125
+ // clear button, the last tag removed, a multi->single transition):
126
+ // `undefined` in both modes, never `null` or `[]`.
127
+ const clearInvalidatedValue = () => {
128
+ state.value = undefined;
129
+ if (useJustValue)
130
+ state.justValue = undefined;
131
+ };
132
+ if (multiple && Array.isArray(value)) {
133
+ // Drop only provably stale entries; entries the reload still
134
+ // offers survive (justValue re-derives from the value write)
135
+ const survivors = value.filter(existsInLoaded);
136
+ if (survivors.length === 0) {
137
+ clearInvalidatedValue();
138
+ }
139
+ else if (survivors.length !== value.length) {
140
+ state.value = survivors;
141
+ }
142
+ }
143
+ else if (!existsInLoaded(value)) {
144
+ clearInvalidatedValue();
145
+ }
146
+ };
7
147
  const executeLoad = async () => {
148
+ // Cancelled or superseded while waiting in the debounce queue: never fetch
149
+ if (token !== requestSequence)
150
+ return;
151
+ // Re-read the loader at fire time: the prop can be swapped (or
152
+ // removed) during the debounce wait, and the closure captured at
153
+ // arm time would run the old fetcher and attribute its response
154
+ // to the new prop
155
+ const load = state.loadOptions;
156
+ if (!load) {
157
+ liveLoadFilterText = undefined;
158
+ state.loading = false;
159
+ return;
160
+ }
8
161
  try {
9
- const result = await loadOptions(currentFilterText);
162
+ const result = await load(currentFilterText);
163
+ // Captured before clearing: a reload retired by a fresh user
164
+ // selection (retireValidationForFreshSelection) is no longer
165
+ // live-validating and must not deliver its verdict below
166
+ const wasLiveValidating = token === liveValidatingToken;
167
+ if (wasLiveValidating)
168
+ liveValidatingToken = 0;
169
+ if (token !== requestSequence && token !== restoredToken) {
170
+ // Superseded by a newer request: the response must not land, but a
171
+ // dependency reload's validation verdict still applies to the deps
172
+ // change — unless a newer armed load validates on its own, or a
173
+ // fresh user selection retired the verdict.
174
+ if (validateValue &&
175
+ wasLiveValidating &&
176
+ armedToken === requestSequence &&
177
+ !armedLoadValidates) {
178
+ validateValueAgainstLoaded(result ?? null);
179
+ }
180
+ return;
181
+ }
182
+ // A restored load has settled; the channel is spent
183
+ if (token === restoredToken)
184
+ restoredToken = 0;
10
185
  if (result && result.length > 0 && typeof result[0] === 'string') {
11
- context.setItems(context.convertStringItemsToObjects(result));
186
+ state.items = convertStringItemsToObjects(result);
12
187
  }
13
188
  else {
14
- context.setItems(result ? result.slice() : null);
189
+ state.items = result ? result.slice() : null;
15
190
  }
16
- // Re-read state after async operation
17
- const state = context.getState();
18
- if (state.value && state.items && state.items.length > 0) {
19
- const items = state.items;
20
- const valueExists = state.multiple
21
- ? Array.isArray(state.value) && state.value.every((v) => items.some((item) => (typeof item === 'string' ? item : item[state.itemId]) === (typeof v === 'string' ? v : v[state.itemId])))
22
- : items.some((item) => (typeof item === 'string' ? item : item[state.itemId]) === (typeof state.value === 'string' ? state.value : state.value[state.itemId]));
23
- if (!valueExists) {
24
- context.setValue(state.multiple ? [] : undefined);
25
- if (state.useJustValue) {
26
- context.setJustValue(state.multiple ? [] : '');
27
- }
28
- }
29
- }
30
- else if (state.value && (!state.items || state.items.length === 0)) {
31
- context.setValue(state.multiple ? [] : undefined);
32
- }
33
- context.setLoading(false);
34
- const finalState = context.getState();
35
- context.onloaded((finalState.items || []));
191
+ // Displayed items now reflect this filter text — a reopen with
192
+ // the same text won't refetch
193
+ loadedFilterText = currentFilterText;
194
+ liveLoadFilterText = undefined;
195
+ lastLandedToken = token;
196
+ if (validateValue)
197
+ validateValueAgainstLoaded(state.items);
198
+ state.loading = false;
199
+ actions.onloaded(state.items || []);
36
200
  }
37
201
  catch (err) {
202
+ // An errored load must lose restore eligibility, or a later
203
+ // cancel would restore a request that can never settle
204
+ if (token === liveValidatingToken)
205
+ liveValidatingToken = 0;
206
+ if (token !== requestSequence && token !== restoredToken)
207
+ return; // superseded; the newer request manages state
208
+ if (token === restoredToken)
209
+ restoredToken = 0;
210
+ // Settled (with an error), so no longer live — a reopen may retry it
211
+ liveLoadFilterText = undefined;
38
212
  console.error('loadOptions error:', err);
39
- context.onerror({ type: 'loadOptions', details: err });
40
- context.setItems(null);
41
- context.setLoading(false);
213
+ actions.onerror({ type: 'loadOptions', details: err });
214
+ state.items = null;
215
+ // Deliberately leave loadedFilterText unchanged: an errored load
216
+ // did not produce results for this text, so a reopen should
217
+ // retry it (a transient failure then recovers). No loop risk —
218
+ // a reopen is a manual gesture, so it retries at most once each.
219
+ state.loading = false;
42
220
  }
43
221
  };
44
- if (isFilterTextChange) {
45
- context.debounce(executeLoad, debounceWait);
222
+ if (shouldDebounce) {
223
+ actions.debounce(executeLoad, debounceWait);
46
224
  }
47
225
  else {
48
226
  executeLoad();
49
227
  }
50
- if (currentFilterText.length > 0 && !listOpen) {
51
- context.setListOpen(true);
52
- }
53
228
  }
54
229
  else if (loadOptions && disabled) {
55
- const state = context.getState();
56
- if (state.value || (state.useJustValue && state.justValue)) {
57
- context.setValue(state.multiple ? [] : undefined);
58
- if (state.useJustValue) {
59
- context.setJustValue(state.multiple ? [] : '');
230
+ // A response arriving after the disable must not repopulate the control
231
+ invalidateLoads();
232
+ if (state.loading)
233
+ state.loading = false;
234
+ if (clearValueOnDisabled) {
235
+ if (state.value || (state.useJustValue && state.justValue)) {
236
+ // `undefined` in both modes — see clearInvalidatedValue above
237
+ state.value = undefined;
238
+ if (state.useJustValue) {
239
+ state.justValue = undefined;
240
+ }
60
241
  }
242
+ state.items = null;
61
243
  }
62
- context.setItems(null);
63
244
  }
64
245
  }
246
+ // Snapshot of the previous effect run so it can tell WHICH input changed
247
+ let prevRun;
248
+ // Dev-only, once per instance: deps elements are compared by identity, so an
249
+ // inline object/array literal recreated per parent render re-fires the reload
250
+ // (and its value validation) on every render.
251
+ let warnedAboutDepsIdentity = false;
252
+ function warnIfDepsChangedByIdentityOnly(deps, prevDeps) {
253
+ if (warnedAboutDepsIdentity)
254
+ return;
255
+ try {
256
+ if (JSON.stringify(deps) !== JSON.stringify(prevDeps))
257
+ return;
258
+ }
259
+ catch {
260
+ return; // non-serializable deps: skip the heuristic
261
+ }
262
+ warnedAboutDepsIdentity = true;
263
+ console.warn('[svelte-select] loadOptionsDeps changed by identity but not by content. Deps elements ' +
264
+ 'are compared with ===, so an object or array literal created inline re-triggers the ' +
265
+ 'reload — and its selection validation — on every parent render. Pass primitives ' +
266
+ '(e.g. the id itself) or stable references instead.');
267
+ }
268
+ // Run loadOptions when its inputs change: typing non-empty filter text
269
+ // re-queries (debounced), a loadOptionsDeps change re-queries and re-validates
270
+ // the value, and toggling disabled clears or reloads the loaded state; mount,
271
+ // deps, and disabled loads fire immediately. listOpen only matters for the
272
+ // reopen-stale case below — opening or closing the list otherwise never fetches,
273
+ // and clearing the filter text on close must not fire a loadOptions('').
274
+ $effect(() => {
275
+ const filterText = state.filterText;
276
+ const deps = [...state.loadOptionsDeps];
277
+ const disabled = state.disabled;
278
+ const listOpen = state.listOpen;
279
+ if (!state.loadOptions) {
280
+ // Only a genuine removal (a loader ran before) does anything here:
281
+ // a Select that never had a loader owns its `loading` prop and must
282
+ // not have it stomped. A removed loader resets the run history —
283
+ // restoring it later must behave like mount (initial fetch), not
284
+ // like "nothing changed" — and invalidates: an in-flight response
285
+ // must not land items (and fire onloaded) on a Select that no
286
+ // longer has a loader. With no landing left to clear it, the
287
+ // loading flag drops here too.
288
+ if (prevRun !== undefined) {
289
+ prevRun = undefined;
290
+ untrack(() => {
291
+ invalidateLoads();
292
+ if (state.loading)
293
+ state.loading = false;
294
+ });
295
+ }
296
+ return;
297
+ }
298
+ const prev = prevRun;
299
+ prevRun = { filterText, deps, disabled, listOpen };
300
+ const isFirstRun = prev === undefined;
301
+ const depsChanged = !isFirstRun && (deps.length !== prev.deps.length || deps.some((dep, i) => dep !== prev.deps[i]));
302
+ if (DEV && depsChanged)
303
+ warnIfDepsChangedByIdentityOnly(deps, prev.deps);
304
+ const filterTextChanged = !isFirstRun && filterText !== prev.filterText;
305
+ const disabledChanged = !isFirstRun && disabled !== prev.disabled;
306
+ // A genuine closed->open transition (not a typing-open, where filterText
307
+ // also changed) that reveals results stale for the retained filter text.
308
+ // An armed or in-flight load for the same text (mount with an initial
309
+ // filterText opens the list right after the mount fetch) is not stale —
310
+ // its response is already on the way.
311
+ const loadIsLiveForText = armedToken === requestSequence && liveLoadFilterText === filterText;
312
+ // No non-empty requirement on filterText: results narrowed by a typed
313
+ // query whose text was wiped at close (Escape) are just as stale under
314
+ // an empty input on reopen — the '' re-fetch restores the baseline set.
315
+ // A mount load still in flight is covered by loadIsLiveForText.
316
+ const reopenedStale = !isFirstRun &&
317
+ listOpen &&
318
+ !prev.listOpen &&
319
+ !filterTextChanged &&
320
+ !disabled &&
321
+ filterText !== loadedFilterText &&
322
+ !loadIsLiveForText;
323
+ if (isFirstRun || depsChanged || disabledChanged || (filterTextChanged && filterText.length > 0)) {
324
+ untrack(() => handleLoadOptions(filterText, {
325
+ validateValue: depsChanged,
326
+ debounce: filterTextChanged && !depsChanged && !disabledChanged,
327
+ clearValueOnDisabled: disabledChanged,
328
+ }));
329
+ }
330
+ else if (reopenedStale) {
331
+ // Reopened onto results that do not reflect the current filter text —
332
+ // retained text whose load was cancelled on close
333
+ // (clearFilterTextOnBlur=false), or an empty input over query-narrowed
334
+ // results (Escape wiped the text): refresh immediately. A typing-open
335
+ // is handled by the branch above (filterText changed → debounced); a
336
+ // pure reopen whose results already match and the initial mount never
337
+ // reach here.
338
+ untrack(() => handleLoadOptions(filterText, { debounce: false }));
339
+ }
340
+ else if (filterTextChanged) {
341
+ // Filter text was emptied: a load armed for the old text is moot now
342
+ untrack(() => cancelPendingFilterLoad());
343
+ }
344
+ else if (!listOpen && prev.listOpen) {
345
+ // The list closed without a text change — a programmatic
346
+ // bind:listOpen=false write, which never goes through closeList()'s
347
+ // synchronous cancel: a load armed by typing must not fire (spinner
348
+ // + stale items) on a closed list. No-op when nothing filter-driven
349
+ // is armed.
350
+ untrack(() => cancelPendingFilterLoad());
351
+ }
352
+ });
65
353
  return {
66
354
  handleLoadOptions,
355
+ cancelPendingFilterLoad,
356
+ retireValidationForFreshSelection,
357
+ invalidateLoads,
67
358
  };
68
359
  }
@@ -1,14 +1,5 @@
1
- import type { ValueContext, SelectItem, JustValue } from './types';
2
- export declare function useValue(context: ValueContext): {
3
- setValue: () => void;
4
- updateValueDisplay: (items?: SelectItem[] | string[] | null) => void;
5
- computeJustValue: () => JustValue | undefined;
6
- checkValueForDuplicates: () => boolean;
7
- findItem: (selection?: SelectItem) => SelectItem | undefined;
8
- findItemByValue: (id: string | number) => SelectItem | undefined;
9
- dispatchSelectedItem: () => void;
1
+ import type { ItemLike, SelectItem, SelectState, ValueActions } from './types';
2
+ export declare function useValue<Item extends ItemLike = SelectItem>(state: SelectState<Item>, actions: ValueActions): {
10
3
  itemSelected: (selection: SelectItem) => void;
11
- setupMulti: () => void;
12
4
  handleMultiItemClear: (i: number) => Promise<void>;
13
- convertStringItemsToObjects: (_items: string[]) => SelectItem[];
14
5
  };