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,68 +1,388 @@
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.js';
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;
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;
10
192
  if (result && result.length > 0 && typeof result[0] === 'string') {
11
- context.setItems(context.convertStringItemsToObjects(result));
193
+ state.items = convertStringItemsToObjects(result);
12
194
  }
13
195
  else {
14
- context.setItems(result ? result.slice() : null);
196
+ state.items = result ? result.slice() : null;
15
197
  }
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 || []));
198
+ // Displayed items now reflect this filter text — a reopen with
199
+ // the same text won't refetch
200
+ loadedFilterText = currentFilterText;
201
+ liveLoadFilterText = undefined;
202
+ lastLandedToken = token;
203
+ if (validateValue)
204
+ validateValueAgainstLoaded(state.items);
205
+ state.loading = false;
206
+ actions.onloaded(state.items || []);
36
207
  }
37
208
  catch (err) {
209
+ // An errored load must lose restore eligibility, or a later
210
+ // cancel would restore a request that can never settle
211
+ if (token === liveValidatingToken)
212
+ liveValidatingToken = 0;
213
+ if (token !== requestSequence && token !== restoredToken)
214
+ return; // superseded; the newer request manages state
215
+ if (token === restoredToken)
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;
221
+ // Settled (with an error), so no longer live — a reopen may retry it
222
+ liveLoadFilterText = undefined;
38
223
  console.error('loadOptions error:', err);
39
- context.onerror({ type: 'loadOptions', details: err });
40
- context.setItems(null);
41
- context.setLoading(false);
224
+ actions.onerror({ type: 'loadOptions', details: err });
225
+ state.items = null;
226
+ // Deliberately leave loadedFilterText unchanged: an errored load
227
+ // did not produce results for this text, so a reopen should
228
+ // retry it (a transient failure then recovers). No loop risk —
229
+ // a reopen is a manual gesture, so it retries at most once each.
230
+ state.loading = false;
42
231
  }
43
232
  };
44
- if (isFilterTextChange) {
45
- context.debounce(executeLoad, debounceWait);
233
+ if (shouldDebounce) {
234
+ actions.debounce(executeLoad, debounceWait);
46
235
  }
47
236
  else {
48
237
  executeLoad();
49
238
  }
50
- if (currentFilterText.length > 0 && !listOpen) {
51
- context.setListOpen(true);
52
- }
53
239
  }
54
240
  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 ? [] : '');
241
+ // A response arriving after the disable must not repopulate the control
242
+ invalidateLoads();
243
+ if (state.loading)
244
+ state.loading = false;
245
+ if (clearValueOnDisabled) {
246
+ if (state.value || (state.useJustValue && state.justValue)) {
247
+ // `undefined` in both modes — see clearInvalidatedValue above
248
+ state.value = undefined;
249
+ if (state.useJustValue) {
250
+ state.justValue = undefined;
251
+ }
60
252
  }
253
+ state.items = null;
61
254
  }
62
- context.setItems(null);
63
255
  }
64
256
  }
257
+ // Snapshot of the previous effect run so it can tell WHICH input changed
258
+ let prevRun;
259
+ // Dev-only, once per instance: deps elements are compared by identity, so an
260
+ // inline object/array literal recreated per parent render re-fires the reload
261
+ // (and its value validation) on every render.
262
+ let warnedAboutDepsIdentity = false;
263
+ function warnIfDepsChangedByIdentityOnly(deps, prevDeps) {
264
+ if (warnedAboutDepsIdentity)
265
+ return;
266
+ try {
267
+ if (JSON.stringify(deps) !== JSON.stringify(prevDeps))
268
+ return;
269
+ }
270
+ catch {
271
+ return; // non-serializable deps: skip the heuristic
272
+ }
273
+ warnedAboutDepsIdentity = true;
274
+ console.warn('[svelte-select] loadOptionsDeps changed by identity but not by content. Deps elements ' +
275
+ 'are compared with ===, so an object or array literal created inline re-triggers the ' +
276
+ 'reload — and its selection validation — on every parent render. Pass primitives ' +
277
+ '(e.g. the id itself) or stable references instead.');
278
+ }
279
+ // Run loadOptions when its inputs change: typing non-empty filter text
280
+ // re-queries (debounced), a loadOptionsDeps change re-queries and re-validates
281
+ // the value, and toggling disabled clears or reloads the loaded state; mount,
282
+ // deps, and disabled loads fire immediately. listOpen only matters for the
283
+ // reopen-stale case below — opening or closing the list otherwise never fetches,
284
+ // and clearing the filter text on close must not fire a loadOptions('').
285
+ $effect(() => {
286
+ const filterText = state.filterText;
287
+ const deps = [...state.loadOptionsDeps];
288
+ const disabled = state.disabled;
289
+ const listOpen = state.listOpen;
290
+ if (!state.loadOptions) {
291
+ // Only a genuine removal (a loader ran before) does anything here:
292
+ // a Select that never had a loader owns its `loading` prop and must
293
+ // not have it stomped. A removed loader resets the run history —
294
+ // restoring it later must behave like mount (initial fetch), not
295
+ // like "nothing changed" — and invalidates: an in-flight response
296
+ // must not land items (and fire onloaded) on a Select that no
297
+ // longer has a loader. With no landing left to clear it, the
298
+ // loading flag drops here too.
299
+ if (prevRun !== undefined) {
300
+ prevRun = undefined;
301
+ untrack(() => {
302
+ invalidateLoads();
303
+ if (state.loading)
304
+ state.loading = false;
305
+ });
306
+ }
307
+ return;
308
+ }
309
+ const prev = prevRun;
310
+ prevRun = { filterText, deps, disabled, listOpen };
311
+ const isFirstRun = prev === undefined;
312
+ const depsChanged = !isFirstRun && (deps.length !== prev.deps.length || deps.some((dep, i) => dep !== prev.deps[i]));
313
+ if (DEV && depsChanged)
314
+ warnIfDepsChangedByIdentityOnly(deps, prev.deps);
315
+ const filterTextChanged = !isFirstRun && filterText !== prev.filterText;
316
+ const disabledChanged = !isFirstRun && disabled !== prev.disabled;
317
+ // A genuine closed->open transition (not a typing-open, where filterText
318
+ // also changed) that reveals results stale for the retained filter text.
319
+ // An armed or in-flight load for the same text (mount with an initial
320
+ // filterText opens the list right after the mount fetch) is not stale —
321
+ // its response is already on the way.
322
+ const loadIsLiveForText = armedToken === requestSequence && liveLoadFilterText === filterText;
323
+ // No non-empty requirement on filterText: results narrowed by a typed
324
+ // query whose text was wiped at close (Escape) are just as stale under
325
+ // an empty input on reopen — the '' re-fetch restores the baseline set.
326
+ // A mount load still in flight is covered by loadIsLiveForText.
327
+ const reopenedStale = !isFirstRun &&
328
+ listOpen &&
329
+ !prev.listOpen &&
330
+ !filterTextChanged &&
331
+ !disabled &&
332
+ filterText !== loadedFilterText &&
333
+ !loadIsLiveForText;
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) {
353
+ untrack(() => handleLoadOptions(filterText, {
354
+ validateValue: depsChanged,
355
+ debounce: filterTextChanged && !depsChanged && !disabledChanged,
356
+ clearValueOnDisabled: disabledChanged,
357
+ }));
358
+ }
359
+ else if (reopenedStale) {
360
+ // Reopened onto results that do not reflect the current filter text —
361
+ // retained text whose load was cancelled on close
362
+ // (clearFilterTextOnBlur=false), or an empty input over query-narrowed
363
+ // results (Escape wiped the text): refresh immediately. A typing-open
364
+ // is handled by the branch above (filterText changed → debounced); a
365
+ // pure reopen whose results already match and the initial mount never
366
+ // reach here.
367
+ untrack(() => handleLoadOptions(filterText, { debounce: false }));
368
+ }
369
+ else if (filterTextChanged) {
370
+ // Filter text was emptied: a load armed for the old text is moot now
371
+ untrack(() => cancelPendingFilterLoad());
372
+ }
373
+ else if (!listOpen && prev.listOpen) {
374
+ // The list closed without a text change — a programmatic
375
+ // bind:listOpen=false write, which never goes through closeList()'s
376
+ // synchronous cancel: a load armed by typing must not fire (spinner
377
+ // + stale items) on a closed list. No-op when nothing filter-driven
378
+ // is armed.
379
+ untrack(() => cancelPendingFilterLoad());
380
+ }
381
+ });
65
382
  return {
66
383
  handleLoadOptions,
384
+ cancelPendingFilterLoad,
385
+ retireValidationForFreshSelection,
386
+ invalidateLoads,
67
387
  };
68
388
  }
@@ -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.js';
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
  };