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
package/dist/use-value.svelte.js
CHANGED
|
@@ -1,87 +1,193 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { untrack } from 'svelte';
|
|
2
|
+
import { getItemProperty, hasValueChanged } from './utils';
|
|
3
|
+
export function useValue(state, actions) {
|
|
4
|
+
// Tracked-read helper for the effects' trigger sections: reading length AND
|
|
5
|
+
// every entry makes any in-place mutation of a bound array retrigger the
|
|
6
|
+
// effect — push (length change) and index assignment (value[0] = x) alike.
|
|
7
|
+
function trackArrayEntries(value) {
|
|
8
|
+
if (!Array.isArray(value))
|
|
9
|
+
return;
|
|
10
|
+
value.length;
|
|
11
|
+
for (let i = 0; i < value.length; i += 1)
|
|
12
|
+
value[i];
|
|
13
|
+
}
|
|
3
14
|
function findItemByValue(id) {
|
|
4
|
-
const { items, itemId } =
|
|
5
|
-
return items?.find(item => item
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
15
|
+
const { items, itemId } = state;
|
|
16
|
+
return items?.find((item) => getItemProperty(item, itemId) === id);
|
|
17
|
+
}
|
|
18
|
+
// Exactly the shape synthesized below: an id-as-label item with no other keys
|
|
19
|
+
function isSynthesizedFallback(entry) {
|
|
20
|
+
const id = getItemProperty(entry, state.itemId);
|
|
21
|
+
if (id == null || id !== getItemProperty(entry, state.label))
|
|
22
|
+
return false;
|
|
23
|
+
return Object.keys(entry).every((key) => key === state.itemId || key === state.label);
|
|
24
|
+
}
|
|
25
|
+
// Comparisons here must be structural: Svelte's state proxies mean an item
|
|
26
|
+
// written into `value` is never identical (===) to the same item re-read
|
|
27
|
+
// from `items`, so an identity check would re-write (and loop) forever
|
|
28
|
+
function shallowEqualItems(a, b) {
|
|
29
|
+
const aKeys = Object.keys(a);
|
|
30
|
+
const bKeys = Object.keys(b);
|
|
31
|
+
return aKeys.length === bKeys.length && aKeys.every((key) => a[key] === b[key]);
|
|
32
|
+
}
|
|
33
|
+
// Resolves one value entry: a raw string becomes the matching item, or a
|
|
34
|
+
// synthesized fallback (label = id) so the control can render before items
|
|
35
|
+
// load; an earlier fallback upgrades to the real item once it exists.
|
|
36
|
+
function resolveEntry(entry) {
|
|
37
|
+
const { itemId, label } = state;
|
|
38
|
+
if (typeof entry === 'string') {
|
|
39
|
+
// The synthesized fallback is deliberately not a real Item — it only
|
|
40
|
+
// carries enough shape to render until the matching item exists. It
|
|
41
|
+
// must be keyed with the configured `label`, not a literal 'label':
|
|
42
|
+
// the selection display reads `normalizedValue[label]`
|
|
43
|
+
return findItemByValue(entry) || { [itemId]: entry, [label]: entry };
|
|
44
|
+
}
|
|
45
|
+
if (isSynthesizedFallback(entry)) {
|
|
46
|
+
const found = findItemByValue(getItemProperty(entry, itemId));
|
|
47
|
+
if (found && !shallowEqualItems(found, entry))
|
|
48
|
+
return found;
|
|
30
49
|
}
|
|
50
|
+
return entry;
|
|
31
51
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
52
|
+
// Normalizes raw string values into items, resolving against `items` when
|
|
53
|
+
// possible. Only writes when an entry actually changed — the effect below
|
|
54
|
+
// tracks `value`, so an unconditional write would loop.
|
|
55
|
+
function normalizeValue() {
|
|
56
|
+
const { value, multiple } = state;
|
|
57
|
+
if (value == null)
|
|
35
58
|
return;
|
|
36
|
-
|
|
59
|
+
// A bare (non-array) value written while multiple is on gets the same
|
|
60
|
+
// wrap setupMulti gives it on the mode transition — mount supported the
|
|
61
|
+
// shape, so post-mount writes must too. The write re-runs this effect,
|
|
62
|
+
// which then resolves the entries below.
|
|
63
|
+
if (multiple && !Array.isArray(value)) {
|
|
64
|
+
state.value = [value];
|
|
37
65
|
return;
|
|
38
|
-
if (Array.isArray(value)) {
|
|
39
|
-
if (value.some((selection) => !selection || !selection[itemId]))
|
|
40
|
-
return;
|
|
41
|
-
context.setValue(value.map((selection) => findItem(selection) || selection));
|
|
42
66
|
}
|
|
43
|
-
|
|
44
|
-
if (
|
|
67
|
+
if (multiple && Array.isArray(value)) {
|
|
68
|
+
if (value.length === 0)
|
|
45
69
|
return;
|
|
46
|
-
|
|
70
|
+
const resolved = value.map(resolveEntry);
|
|
71
|
+
if (resolved.some((item, i) => item !== value[i])) {
|
|
72
|
+
state.value = resolved;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else if (!Array.isArray(value)) {
|
|
76
|
+
const resolved = resolveEntry(value);
|
|
77
|
+
if (resolved !== value)
|
|
78
|
+
state.value = resolved;
|
|
47
79
|
}
|
|
48
80
|
}
|
|
49
|
-
|
|
50
|
-
|
|
81
|
+
// Command: when an initial justValue is supplied without a value, resolve it against items and set value
|
|
82
|
+
function hydrateValueFromJustValue() {
|
|
83
|
+
const { multiple, value, itemId, useJustValue, justValue, clearState } = state;
|
|
51
84
|
const hasJustValue = multiple
|
|
52
|
-
?
|
|
53
|
-
:
|
|
54
|
-
if (useJustValue
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
85
|
+
? Array.isArray(justValue) && justValue.length > 0
|
|
86
|
+
: justValue !== '' && justValue != null;
|
|
87
|
+
if (!useJustValue || hasRealValue(multiple, value) || clearState || !hasJustValue)
|
|
88
|
+
return;
|
|
89
|
+
// Hydration is all-or-nothing and retries when items change: writing a
|
|
90
|
+
// partial (or empty) match would silently narrow justValue and block
|
|
91
|
+
// later hydration against fuller items
|
|
92
|
+
const typedItems = state.items || [];
|
|
93
|
+
// A raw string item is its own id, so string[] items hydrate too; the
|
|
94
|
+
// normalize effect upgrades a matched raw string to its synthesized item
|
|
95
|
+
const idOf = (item) => (typeof item === 'string' ? item : getItemProperty(item, itemId));
|
|
96
|
+
if (multiple && Array.isArray(justValue)) {
|
|
97
|
+
const justValueArr = justValue;
|
|
98
|
+
// Match in justValue order: hydration must not reorder the parent's
|
|
99
|
+
// entries to items order (the reordered array would then be written
|
|
100
|
+
// back over the parent's bound justValue as a spurious correction)
|
|
101
|
+
const matches = [];
|
|
102
|
+
for (const jv of justValueArr) {
|
|
103
|
+
const match = typedItems.find((item) => idOf(item) === jv);
|
|
104
|
+
if (match === undefined)
|
|
105
|
+
return;
|
|
106
|
+
matches.push(match);
|
|
63
107
|
}
|
|
108
|
+
state.value = matches;
|
|
64
109
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
110
|
+
else {
|
|
111
|
+
const match = typedItems.find((item) => idOf(item) === justValue);
|
|
112
|
+
if (match !== undefined)
|
|
113
|
+
state.value = match;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Query: pure derivation of justValue from a state snapshot
|
|
117
|
+
function deriveJustValue(snapshot) {
|
|
118
|
+
const { multiple, value, itemId, useJustValue, justValue, clearState } = snapshot;
|
|
119
|
+
// hasRealValue, not `!value`: an empty array is "nothing hydrated yet",
|
|
120
|
+
// and deriving [] from it would clobber a seeded justValue before
|
|
121
|
+
// hydration can resolve it (permanently, when items arrive late —
|
|
122
|
+
// hydration needs the justValue that was just overwritten)
|
|
123
|
+
if (useJustValue && !hasRealValue(multiple, value) && !clearState) {
|
|
68
124
|
return justValue;
|
|
69
125
|
}
|
|
126
|
+
// Every remaining empty representation (undefined, null, []) derives as
|
|
127
|
+
// undefined: null and [] are accepted on the way in but never written
|
|
128
|
+
// back (the JustValue contract) — returning `value` verbatim here would
|
|
129
|
+
// leak a parent's `bind:value = null` clear into justValue.
|
|
130
|
+
if (!hasRealValue(multiple, value))
|
|
131
|
+
return undefined;
|
|
70
132
|
if (multiple && Array.isArray(value)) {
|
|
71
|
-
return value.map((item) => item
|
|
133
|
+
return value.map((item) => getItemProperty(item, itemId));
|
|
72
134
|
}
|
|
73
|
-
if (
|
|
135
|
+
if (typeof value === 'string' || Array.isArray(value)) {
|
|
74
136
|
return value;
|
|
75
137
|
}
|
|
76
|
-
return value
|
|
138
|
+
return getItemProperty(value, itemId);
|
|
139
|
+
}
|
|
140
|
+
// Sync-loop scratch (deliberately non-reactive): lastSyncedHadValue tells an
|
|
141
|
+
// external `bind:value` clear apart from "not hydrated yet", and
|
|
142
|
+
// lastWrittenJustValue tells a justValue we derived ourselves (a stale echo
|
|
143
|
+
// after such a clear) apart from fresh input that must hydrate.
|
|
144
|
+
let lastSyncedHadValue = false;
|
|
145
|
+
let lastWrittenJustValue;
|
|
146
|
+
function justValuesEqual(a, b) {
|
|
147
|
+
if (a === b)
|
|
148
|
+
return true;
|
|
149
|
+
// Proxied round trips break array identity; compare entries instead
|
|
150
|
+
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((entry, i) => entry === b[i]);
|
|
151
|
+
}
|
|
152
|
+
function hasRealValue(multiple, value) {
|
|
153
|
+
// An empty array means "nothing hydrated yet", not a real selection
|
|
154
|
+
return multiple ? Array.isArray(value) && value.length > 0 : !!value;
|
|
155
|
+
}
|
|
156
|
+
function syncJustValue() {
|
|
157
|
+
// Snapshot before hydration so the derivation sees the pre-hydration state
|
|
158
|
+
const snapshot = {
|
|
159
|
+
multiple: state.multiple,
|
|
160
|
+
value: state.value,
|
|
161
|
+
itemId: state.itemId,
|
|
162
|
+
useJustValue: state.useJustValue,
|
|
163
|
+
justValue: state.justValue,
|
|
164
|
+
clearState: state.clearState,
|
|
165
|
+
};
|
|
166
|
+
// A parent clearing `bind:value` directly must behave like an internal
|
|
167
|
+
// clear: without this, hydration would resurrect the cleared selection
|
|
168
|
+
// from the stale justValue echo written on a previous sync.
|
|
169
|
+
const externallyCleared = !snapshot.clearState &&
|
|
170
|
+
lastSyncedHadValue &&
|
|
171
|
+
!hasRealValue(snapshot.multiple, snapshot.value) &&
|
|
172
|
+
justValuesEqual(snapshot.justValue, lastWrittenJustValue);
|
|
173
|
+
if (!externallyCleared)
|
|
174
|
+
hydrateValueFromJustValue();
|
|
175
|
+
state.clearState = false;
|
|
176
|
+
lastSyncedHadValue = hasRealValue(state.multiple, state.value);
|
|
177
|
+
const derived = deriveJustValue(externallyCleared ? { ...snapshot, clearState: true } : snapshot);
|
|
178
|
+
lastWrittenJustValue = derived;
|
|
179
|
+
return derived;
|
|
77
180
|
}
|
|
78
181
|
function checkValueForDuplicates() {
|
|
79
|
-
const { value, itemId } =
|
|
182
|
+
const { value, itemId } = state;
|
|
80
183
|
if (!Array.isArray(value) || value.length === 0)
|
|
81
184
|
return true;
|
|
185
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local dedup scratch, not reactive state
|
|
82
186
|
const seen = new Set();
|
|
83
187
|
const uniqueValues = value.filter((val) => {
|
|
84
|
-
|
|
188
|
+
// Raw string entries key on the string itself: getItemProperty returns
|
|
189
|
+
// undefined for non-objects, which would collapse all strings into one
|
|
190
|
+
const id = typeof val === 'string' ? val : getItemProperty(val, itemId);
|
|
85
191
|
if (seen.has(id))
|
|
86
192
|
return false;
|
|
87
193
|
seen.add(id);
|
|
@@ -89,87 +195,192 @@ export function useValue(context) {
|
|
|
89
195
|
});
|
|
90
196
|
const noDuplicates = uniqueValues.length === value.length;
|
|
91
197
|
if (!noDuplicates)
|
|
92
|
-
|
|
198
|
+
state.value = uniqueValues;
|
|
93
199
|
return noDuplicates;
|
|
94
200
|
}
|
|
95
201
|
function dispatchSelectedItem() {
|
|
96
|
-
const { multiple, value, prevValue, itemId } =
|
|
202
|
+
const { multiple, value, prevValue, itemId } = state;
|
|
203
|
+
// The two empty representations (undefined/null and []) are the same
|
|
204
|
+
// no-selection state: moving between them is not a change and must not
|
|
205
|
+
// dispatch. A real clear reports null in single mode — an empty array
|
|
206
|
+
// is truthy, so `if (payload)` in consumers would read "cleared" as
|
|
207
|
+
// "has value" — and [] in multiple mode.
|
|
208
|
+
const hasNoValue = !value || (Array.isArray(value) && value.length === 0);
|
|
209
|
+
if (hasNoValue) {
|
|
210
|
+
const hadNoValue = !prevValue || (Array.isArray(prevValue) && prevValue.length === 0);
|
|
211
|
+
if (!hadNoValue)
|
|
212
|
+
actions.onValueChange(multiple ? [] : null);
|
|
213
|
+
state.prevValue = Array.isArray(value) ? value.slice() : value;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
97
216
|
if (multiple) {
|
|
98
|
-
if (hasValueChanged(value, prevValue)) {
|
|
99
|
-
|
|
100
|
-
context.oninput(value || []);
|
|
101
|
-
}
|
|
217
|
+
if (hasValueChanged(value, prevValue, itemId) && checkValueForDuplicates()) {
|
|
218
|
+
actions.onValueChange(value);
|
|
102
219
|
}
|
|
103
|
-
return;
|
|
104
220
|
}
|
|
105
|
-
if (!prevValue || hasValueChanged(value
|
|
106
|
-
|
|
221
|
+
else if (!prevValue || hasValueChanged(value, prevValue, itemId)) {
|
|
222
|
+
actions.onValueChange(value);
|
|
107
223
|
}
|
|
224
|
+
// Snapshot a copy of the settled (post-dedup) value: storing the live
|
|
225
|
+
// array would alias prevValue to `value`, so an in-place push could
|
|
226
|
+
// never register as a change
|
|
227
|
+
const settled = state.value;
|
|
228
|
+
state.prevValue = Array.isArray(settled) ? settled.slice() : settled;
|
|
108
229
|
}
|
|
109
230
|
function setupMulti() {
|
|
110
|
-
const { value } =
|
|
231
|
+
const { value, prevValue } = state;
|
|
111
232
|
if (value) {
|
|
112
233
|
if (Array.isArray(value)) {
|
|
113
|
-
|
|
234
|
+
state.value = [...value];
|
|
114
235
|
}
|
|
115
236
|
else {
|
|
116
|
-
|
|
237
|
+
// A raw string value stays a string here; normalizeValue resolves it to an item
|
|
238
|
+
state.value = [value];
|
|
239
|
+
}
|
|
240
|
+
// Wrapping is shape normalization, not a selection change: mirror it
|
|
241
|
+
// on the dispatch baseline, so a mount seeded with a bare item (or a
|
|
242
|
+
// single->multi flip) does not register as a change and fire onValueChange
|
|
243
|
+
if (prevValue && !Array.isArray(prevValue)) {
|
|
244
|
+
state.prevValue = [prevValue];
|
|
117
245
|
}
|
|
118
246
|
}
|
|
119
247
|
}
|
|
120
248
|
async function handleMultiItemClear(i) {
|
|
121
|
-
const { value } =
|
|
249
|
+
const { value } = state;
|
|
122
250
|
if (!Array.isArray(value))
|
|
123
251
|
return;
|
|
252
|
+
// A stale index (the arrow-key cursor can outlive a mouse removal's
|
|
253
|
+
// reindexing) must not fire onclear(undefined) — or, via the length-1
|
|
254
|
+
// branch below, clear a tag it never pointed at
|
|
255
|
+
if (i < 0 || i >= value.length)
|
|
256
|
+
return;
|
|
124
257
|
const itemToRemove = value[i];
|
|
125
|
-
|
|
258
|
+
state.clearState = true;
|
|
126
259
|
if (value.length === 1) {
|
|
127
|
-
|
|
260
|
+
state.value = undefined;
|
|
128
261
|
}
|
|
129
262
|
else {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
context.onclear(itemToRemove);
|
|
135
|
-
}
|
|
136
|
-
function convertStringItemsToObjects(_items) {
|
|
137
|
-
return _items.map((item, index) => {
|
|
138
|
-
return {
|
|
139
|
-
index,
|
|
140
|
-
value: item,
|
|
141
|
-
label: `${item}`,
|
|
142
|
-
};
|
|
143
|
-
});
|
|
263
|
+
state.value = value.filter((item) => item !== itemToRemove);
|
|
264
|
+
}
|
|
265
|
+
actions.onclear(itemToRemove);
|
|
144
266
|
}
|
|
145
267
|
function itemSelected(selection) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
268
|
+
if (!selection)
|
|
269
|
+
return;
|
|
270
|
+
const { multiple, value, closeListOnChange, itemId } = state;
|
|
271
|
+
const item = { ...selection };
|
|
272
|
+
if (item.groupHeader && !item.selectable)
|
|
273
|
+
return;
|
|
274
|
+
// Re-selecting an already-selected item in multiple mode is a no-op: the
|
|
275
|
+
// value is a distinct set. Without this the item was concatenated and
|
|
276
|
+
// onSelectionChange fired synchronously with a duplicate array ([a, a]) before a
|
|
277
|
+
// later effect deduped state.value — leaving onSelectionChange disagreeing with the
|
|
278
|
+
// (correctly suppressed) onValueChange. Single mode already no-ops the same way
|
|
279
|
+
// in handleItemClick / handleEnterKey. The no-op runs before the
|
|
280
|
+
// filterText wipe below: with filterSelectedItems={false} a re-click
|
|
281
|
+
// selects nothing and must not clear what the user typed.
|
|
282
|
+
if (multiple && Array.isArray(value)) {
|
|
283
|
+
const selectedId = getItemProperty(item, itemId);
|
|
284
|
+
const alreadySelected = value.some((entry) => (typeof entry === 'string' ? entry : getItemProperty(entry, itemId)) === selectedId);
|
|
285
|
+
if (alreadySelected) {
|
|
286
|
+
if (closeListOnChange)
|
|
287
|
+
actions.closeList();
|
|
288
|
+
state.activeValue = undefined;
|
|
151
289
|
return;
|
|
152
|
-
|
|
153
|
-
updateValueDisplay(context.getState().items);
|
|
154
|
-
context.setValue(multiple ? (value ? value.concat([item]) : [item]) : item);
|
|
155
|
-
if (closeListOnChange)
|
|
156
|
-
context.closeList();
|
|
157
|
-
context.setActiveValue(undefined);
|
|
158
|
-
context.onchange(context.getState().value);
|
|
159
|
-
context.onselect(selection);
|
|
290
|
+
}
|
|
160
291
|
}
|
|
292
|
+
// A committed selection retires a pending deps-reload verdict when the
|
|
293
|
+
// displayed results it was picked from are fresher than that reload —
|
|
294
|
+
// otherwise the reload's late response wipes this selection
|
|
295
|
+
actions.retireStaleValidation?.();
|
|
296
|
+
state.filterText = '';
|
|
297
|
+
state.value = multiple ? (value ? value.concat([item]) : [item]) : item;
|
|
298
|
+
// A completed selection consumes Tab's commit-intent: with
|
|
299
|
+
// closeListOnChange={false} the list stays open and (with
|
|
300
|
+
// filterSelectedItems) the cursor re-parks on a neighbouring option,
|
|
301
|
+
// which a later Tab must not commit. closeList() also resets the flag,
|
|
302
|
+
// but only when the selection actually closes the list.
|
|
303
|
+
state.userNavigatedSinceOpen = false;
|
|
304
|
+
if (closeListOnChange)
|
|
305
|
+
actions.closeList();
|
|
306
|
+
state.activeValue = undefined;
|
|
307
|
+
actions.onSelectionChange(state.value);
|
|
308
|
+
actions.onselect(selection);
|
|
161
309
|
}
|
|
310
|
+
// Normalize string values on every value change (not just the hasValue
|
|
311
|
+
// flip — replacing one string with another must re-resolve), and again when
|
|
312
|
+
// async items arrive so fallback entries upgrade to the real item
|
|
313
|
+
$effect(() => {
|
|
314
|
+
const value = state.value;
|
|
315
|
+
trackArrayEntries(value); // in-place growth and entry replacement both retrigger
|
|
316
|
+
state.items;
|
|
317
|
+
untrack(() => normalizeValue());
|
|
318
|
+
});
|
|
319
|
+
// Multiple-mode transitions (single<->multi) and duplicate guard
|
|
320
|
+
$effect(() => {
|
|
321
|
+
state.multiple;
|
|
322
|
+
const value = state.value;
|
|
323
|
+
trackArrayEntries(value); // in-place growth and entry replacement both retrigger
|
|
324
|
+
untrack(() => {
|
|
325
|
+
const wasMultiple = state.prevMultiple;
|
|
326
|
+
state.prevMultiple = state.multiple;
|
|
327
|
+
if (state.multiple !== wasMultiple) {
|
|
328
|
+
if (state.multiple) {
|
|
329
|
+
setupMulti();
|
|
330
|
+
}
|
|
331
|
+
else if (wasMultiple && Array.isArray(state.value)) {
|
|
332
|
+
// Only the old (array-shaped) value is wiped: a replacement
|
|
333
|
+
// single value supplied together with the multiple flip is a
|
|
334
|
+
// deliberate new selection and must survive.
|
|
335
|
+
// `undefined`, not `null`: an emptied value is `undefined`
|
|
336
|
+
// everywhere else (clear button, last tag removed, a deps
|
|
337
|
+
// reload invalidating the selection), and one empty
|
|
338
|
+
// representation is the whole contract
|
|
339
|
+
state.value = undefined;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (state.multiple && Array.isArray(state.value) && state.value.length > 1) {
|
|
344
|
+
checkValueForDuplicates();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
// Dispatch onValueChange when value changes (selection, update, or clear)
|
|
349
|
+
$effect(() => {
|
|
350
|
+
const value = state.value;
|
|
351
|
+
trackArrayEntries(value); // in-place growth and entry replacement both retrigger
|
|
352
|
+
untrack(() => dispatchSelectedItem());
|
|
353
|
+
});
|
|
354
|
+
// Hydrate value from justValue, then keep justValue in sync.
|
|
355
|
+
// items is a tracked trigger so hydration retries when async items arrive.
|
|
356
|
+
// justValue is tracked too, so a post-mount write applies deterministically
|
|
357
|
+
// right away — it hydrates when no selection exists, and is corrected back
|
|
358
|
+
// to the selection-derived values when one does (value wins). Previously a
|
|
359
|
+
// late write sat dormant and then hydrated on the next unrelated trigger.
|
|
360
|
+
// The equality-guarded write breaks the write→track cycle (a derived array
|
|
361
|
+
// is a new reference every run) and keeps justValue's identity stable when
|
|
362
|
+
// its entries are unchanged.
|
|
363
|
+
// clearState is a tracked trigger too, so an explicit clear always reaches
|
|
364
|
+
// syncJustValue (which consumes and resets the flag) even when it is not
|
|
365
|
+
// accompanied by a value change — otherwise the flag could stick `true` and
|
|
366
|
+
// block the next hydration. syncJustValue resets it to `false`, which settles
|
|
367
|
+
// in one extra run (the false→false write no longer notifies).
|
|
368
|
+
$effect(() => {
|
|
369
|
+
state.multiple;
|
|
370
|
+
state.itemId;
|
|
371
|
+
const value = state.value;
|
|
372
|
+
trackArrayEntries(value); // in-place growth and entry replacement both retrigger
|
|
373
|
+
state.items;
|
|
374
|
+
state.clearState;
|
|
375
|
+
state.justValue;
|
|
376
|
+
untrack(() => {
|
|
377
|
+
const derived = syncJustValue();
|
|
378
|
+
if (!justValuesEqual(derived, state.justValue))
|
|
379
|
+
state.justValue = derived;
|
|
380
|
+
});
|
|
381
|
+
});
|
|
162
382
|
return {
|
|
163
|
-
setValue,
|
|
164
|
-
updateValueDisplay,
|
|
165
|
-
computeJustValue,
|
|
166
|
-
checkValueForDuplicates,
|
|
167
|
-
findItem,
|
|
168
|
-
findItemByValue,
|
|
169
|
-
dispatchSelectedItem,
|
|
170
383
|
itemSelected,
|
|
171
|
-
setupMulti,
|
|
172
384
|
handleMultiItemClear,
|
|
173
|
-
convertStringItemsToObjects,
|
|
174
385
|
};
|
|
175
386
|
}
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
import type { SelectItem } from './types';
|
|
1
|
+
import type { ItemLike, SelectGroupHeader, SelectItem } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Narrows a rendered list row to a synthesized group header (see the `groupBy` prop).
|
|
4
|
+
* Rows that fail this guard are your own item type.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isGroupHeader(row: ItemLike | null | undefined): row is SelectGroupHeader;
|
|
2
7
|
export declare function getItemProperty<T>(item: T, key: keyof T | string): T[keyof T] | undefined;
|
|
3
|
-
|
|
4
|
-
export declare function
|
|
5
|
-
|
|
6
|
-
};
|
|
7
|
-
export declare function isStringArray(arr: any[]): arr is string[];
|
|
8
|
+
/** Compares two value entries by their `itemId` field, never by reference. Accepts any {@link ItemLike} item type. */
|
|
9
|
+
export declare function areItemsEqual(a: ItemLike | null | undefined, b: ItemLike | null | undefined, itemId: string): boolean;
|
|
10
|
+
export declare function isStringArray(arr: unknown[]): arr is string[];
|
|
8
11
|
export declare function isItemSelectableCheck(item: SelectItem | undefined): boolean;
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Normalizes a raw string value into a synthesized `{ value, label }` item; items
|
|
14
|
+
* (and arrays of them) pass through unchanged. Accepts any {@link ItemLike} item
|
|
15
|
+
* type — a synthesized entry is a `SelectItem`, not your item type.
|
|
16
|
+
*/
|
|
17
|
+
export declare function normalizeItem<Item extends ItemLike = SelectItem>(value: string | Item | null | undefined): Item | SelectItem | null;
|
|
18
|
+
export declare function normalizeItem<Item extends ItemLike = SelectItem>(value: string | Item | (Item | string)[] | null | undefined): Item | SelectItem | (Item | SelectItem)[] | null;
|
|
19
|
+
export declare function hasValueChanged(newValue: SelectItem | string | (SelectItem | string)[] | null | undefined, oldValue: SelectItem | string | (SelectItem | string)[] | null | undefined, itemId: string): boolean;
|
|
20
|
+
export declare function convertStringItemsToObjects(_items: string[]): SelectItem[];
|
|
21
|
+
export declare function createGroupHeaderItem(groupValue: string, labelKey?: string): SelectItem;
|
package/dist/utils.js
CHANGED
|
@@ -1,26 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrows a rendered list row to a synthesized group header (see the `groupBy` prop).
|
|
3
|
+
* Rows that fail this guard are your own item type.
|
|
4
|
+
*/
|
|
5
|
+
export function isGroupHeader(row) {
|
|
6
|
+
return !!row && row.groupHeader === true;
|
|
7
|
+
}
|
|
1
8
|
export function getItemProperty(item, key) {
|
|
2
9
|
return item && typeof item === 'object' ? item[key] : undefined;
|
|
3
10
|
}
|
|
11
|
+
/** Compares two value entries by their `itemId` field, never by reference. Accepts any {@link ItemLike} item type. */
|
|
4
12
|
export function areItemsEqual(a, b, itemId) {
|
|
5
13
|
if (!a || !b)
|
|
6
14
|
return false;
|
|
7
15
|
return getItemProperty(a, itemId) === getItemProperty(b, itemId);
|
|
8
16
|
}
|
|
9
|
-
export function isCancelled(res) {
|
|
10
|
-
return res && typeof res === 'object' && 'cancelled' in res && res.cancelled === true;
|
|
11
|
-
}
|
|
12
17
|
export function isStringArray(arr) {
|
|
13
|
-
return arr.length > 0 && arr.every(item => typeof item === 'string');
|
|
18
|
+
return arr.length > 0 && arr.every((item) => typeof item === 'string');
|
|
14
19
|
}
|
|
15
20
|
export function isItemSelectableCheck(item) {
|
|
16
21
|
if (!item)
|
|
17
22
|
return false;
|
|
18
|
-
return !
|
|
23
|
+
return !Object.prototype.hasOwnProperty.call(item, 'selectable') || item.selectable !== false;
|
|
24
|
+
}
|
|
25
|
+
export function normalizeItem(value) {
|
|
26
|
+
if (!value)
|
|
27
|
+
return null;
|
|
28
|
+
// Entries of an array value may still be raw strings; callers normalize per entry
|
|
29
|
+
return typeof value === 'string' ? { value, label: value } : value;
|
|
30
|
+
}
|
|
31
|
+
// The comparable id of a value entry: a raw string is its own id, an item uses
|
|
32
|
+
// its itemId field.
|
|
33
|
+
function entryId(v, itemId) {
|
|
34
|
+
return typeof v === 'string' ? v : getItemProperty(v, itemId);
|
|
35
|
+
}
|
|
36
|
+
// Compare by id, not by reference type: a raw string and the item it resolves to
|
|
37
|
+
// share an id, so mount-time string-to-item normalization is the same selection —
|
|
38
|
+
// not a change — and must not dispatch onValueChange. Two items compare by itemId.
|
|
39
|
+
function haveEntriesChanged(a, b, itemId) {
|
|
40
|
+
if (typeof a === 'string' || typeof b === 'string') {
|
|
41
|
+
return entryId(a, itemId) !== entryId(b, itemId);
|
|
42
|
+
}
|
|
43
|
+
if (!a || !b)
|
|
44
|
+
return a !== b;
|
|
45
|
+
return !areItemsEqual(a, b, itemId);
|
|
46
|
+
}
|
|
47
|
+
export function hasValueChanged(newValue, oldValue, itemId) {
|
|
48
|
+
if (Array.isArray(newValue) !== Array.isArray(oldValue))
|
|
49
|
+
return true;
|
|
50
|
+
if (Array.isArray(newValue) && Array.isArray(oldValue)) {
|
|
51
|
+
if (newValue.length !== oldValue.length)
|
|
52
|
+
return true;
|
|
53
|
+
return newValue.some((item, i) => haveEntriesChanged(item, oldValue[i], itemId));
|
|
54
|
+
}
|
|
55
|
+
// Neither side is an array here
|
|
56
|
+
return haveEntriesChanged(newValue, oldValue, itemId);
|
|
19
57
|
}
|
|
20
|
-
export function
|
|
21
|
-
return
|
|
58
|
+
export function convertStringItemsToObjects(_items) {
|
|
59
|
+
return _items.map((item, index) => {
|
|
60
|
+
return {
|
|
61
|
+
index,
|
|
62
|
+
value: item,
|
|
63
|
+
label: `${item}`,
|
|
64
|
+
};
|
|
65
|
+
});
|
|
22
66
|
}
|
|
23
|
-
export function createGroupHeaderItem(groupValue,
|
|
67
|
+
export function createGroupHeaderItem(groupValue, labelKey = 'label') {
|
|
24
68
|
return {
|
|
25
69
|
value: groupValue,
|
|
26
70
|
[labelKey]: groupValue,
|