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.
- package/README.md +239 -109
- package/dist/ChevronIcon.svelte +8 -13
- package/dist/ClearIcon.svelte +3 -11
- package/dist/LoadingIcon.svelte +9 -2
- package/dist/Select.svelte +954 -436
- package/dist/Select.svelte.d.ts +37 -5
- package/dist/aria-handlers.svelte.d.ts +4 -1
- package/dist/aria-handlers.svelte.js +19 -8
- package/dist/filter.d.ts +10 -2
- package/dist/filter.js +14 -7
- package/dist/index.d.ts +2 -3
- package/dist/index.js +1 -2
- package/dist/keyboard-navigation.svelte.d.ts +7 -2
- package/dist/keyboard-navigation.svelte.js +285 -47
- package/dist/no-styles/ChevronIcon.svelte +11 -0
- package/dist/no-styles/ChevronIcon.svelte.d.ts +26 -0
- package/dist/no-styles/ClearIcon.svelte +8 -0
- package/dist/no-styles/ClearIcon.svelte.d.ts +26 -0
- package/dist/no-styles/LoadingIcon.svelte +13 -0
- package/dist/no-styles/LoadingIcon.svelte.d.ts +26 -0
- package/dist/no-styles/Select.svelte +1383 -0
- package/dist/no-styles/Select.svelte.d.ts +38 -0
- package/dist/select-state.svelte.d.ts +15 -0
- package/dist/select-state.svelte.js +161 -0
- package/dist/styles/default.css +112 -71
- package/dist/tailwind.css +120 -17
- package/dist/types.d.ts +459 -129
- package/dist/use-hover.svelte.d.ts +3 -7
- package/dist/use-hover.svelte.js +91 -36
- package/dist/use-load-options.svelte.d.ts +18 -3
- package/dist/use-load-options.svelte.js +333 -42
- package/dist/use-value.svelte.d.ts +2 -11
- package/dist/use-value.svelte.js +322 -111
- package/dist/utils.d.ts +19 -8
- package/dist/utils.js +52 -8
- package/package.json +117 -94
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare function useHover(
|
|
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
|
-
|
|
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;
|
package/dist/use-hover.svelte.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
-
|
|
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 =
|
|
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 } =
|
|
29
|
-
|
|
32
|
+
const { filteredItems, hoverItemIndex } = state;
|
|
33
|
+
state.hoverItemIndex = computeNextIndex(filteredItems, hoverItemIndex, increment);
|
|
30
34
|
}
|
|
31
35
|
function setValueIndexAsHoverIndex() {
|
|
32
|
-
const {
|
|
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
|
|
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,
|
|
43
|
-
const {
|
|
42
|
+
function checkHoverSelectable(startingIndex = 0, trustIndex) {
|
|
43
|
+
const { filteredItems } = state;
|
|
44
44
|
const idx = startingIndex < 0 ? 0 : startingIndex;
|
|
45
|
-
|
|
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
|
-
|
|
50
|
+
state.hoverItemIndex = computeNextIndex(filteredItems, idx, 1);
|
|
48
51
|
}
|
|
49
52
|
else {
|
|
50
|
-
|
|
53
|
+
state.hoverItemIndex = idx;
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
56
|
function getFirstSelectableIndex() {
|
|
54
|
-
const {
|
|
55
|
-
if (
|
|
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
|
|
64
|
-
const { multiple } =
|
|
65
|
-
if (multiple)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
75
|
-
if (isScrolling)
|
|
82
|
+
if (state.isScrolling)
|
|
76
83
|
return;
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
2
|
-
export
|
|
3
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
6
|
-
const
|
|
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
|
|
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
|
-
|
|
186
|
+
state.items = convertStringItemsToObjects(result);
|
|
12
187
|
}
|
|
13
188
|
else {
|
|
14
|
-
|
|
189
|
+
state.items = result ? result.slice() : null;
|
|
15
190
|
}
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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 (
|
|
45
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 {
|
|
2
|
-
export declare function useValue(
|
|
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
|
};
|