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,87 +1,199 @@
1
- import { hasValueChanged } from './utils';
2
- export function useValue(context) {
1
+ import { untrack } from 'svelte';
2
+ import { getItemProperty, hasValueChanged } from './utils.js';
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 } = context.getState();
5
- return items?.find(item => item[itemId] === id);
6
- }
7
- function findItem(selection) {
8
- const { normalizedValue, items, itemId } = context.getState();
9
- let matchTo = selection ? selection[itemId] : normalizedValue[itemId];
10
- return items?.find(item => item[itemId] === matchTo);
11
- }
12
- function setValue() {
13
- const { value, multiple, itemId } = context.getState();
14
- context.setPrevValue(value);
15
- if (typeof value === 'string') {
16
- let item = findItemByValue(value);
17
- context.setValue(item || {
18
- [itemId]: value,
19
- label: value,
20
- });
21
- }
22
- else if (multiple && Array.isArray(value) && value.length > 0) {
23
- context.setValue(value.map((val) => {
24
- if (typeof val === 'string') {
25
- let item = findItemByValue(val);
26
- return item || { value: val, label: val };
27
- }
28
- return val;
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
- function updateValueDisplay(items) {
33
- const { value, itemId } = context.getState();
34
- if (!items || items.length === 0 || items.some((item) => typeof item !== 'object'))
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
- if (!value)
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
- else if (typeof value === 'object') {
44
- if (!value[itemId])
67
+ if (multiple && Array.isArray(value)) {
68
+ if (value.length === 0)
45
69
  return;
46
- context.setValue(findItem() || value);
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
- function computeJustValue() {
50
- const { multiple, value, itemId, useJustValue, justValue, clearState } = context.getState();
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
- ? (Array.isArray(justValue) && justValue.length > 0)
53
- : (justValue !== '' && justValue != null);
54
- if (useJustValue && !value && !clearState && hasJustValue) {
55
- const { items } = context.getState();
56
- const typedItems = items || [];
57
- if (multiple && Array.isArray(justValue)) {
58
- const justValueArr = justValue;
59
- context.setValue(typedItems.filter((item) => justValueArr.includes(item[itemId])));
60
- }
61
- else {
62
- context.setValue(typedItems.filter((item) => item[itemId] === justValue)[0]);
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
- const wasClearing = clearState;
66
- context.setClearState(false);
67
- if (useJustValue && !value && !wasClearing) {
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[itemId]);
133
+ return value.map((item) => getItemProperty(item, itemId));
72
134
  }
73
- if (!value || typeof value === 'string' || Array.isArray(value)) {
135
+ if (typeof value === 'string' || Array.isArray(value)) {
74
136
  return value;
75
137
  }
76
- return value[itemId];
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 } = context.getState();
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
- const id = val[itemId];
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);
191
+ // An entry with no itemId field yields no evidence of duplication —
192
+ // collapsing every such entry into the first silently loses data on
193
+ // a config error (the dev warning in Select.svelte surfaces it).
194
+ // Only matching real ids may dedup.
195
+ if (id === undefined)
196
+ return true;
85
197
  if (seen.has(id))
86
198
  return false;
87
199
  seen.add(id);
@@ -89,87 +201,198 @@ export function useValue(context) {
89
201
  });
90
202
  const noDuplicates = uniqueValues.length === value.length;
91
203
  if (!noDuplicates)
92
- context.setValue(uniqueValues);
204
+ state.value = uniqueValues;
93
205
  return noDuplicates;
94
206
  }
95
207
  function dispatchSelectedItem() {
96
- const { multiple, value, prevValue, itemId } = context.getState();
208
+ const { multiple, value, prevValue, itemId } = state;
209
+ // The two empty representations (undefined/null and []) are the same
210
+ // no-selection state: moving between them is not a change and must not
211
+ // dispatch. A real clear reports null in single mode — an empty array
212
+ // is truthy, so `if (payload)` in consumers would read "cleared" as
213
+ // "has value" — and [] in multiple mode.
214
+ const hasNoValue = !value || (Array.isArray(value) && value.length === 0);
215
+ if (hasNoValue) {
216
+ const hadNoValue = !prevValue || (Array.isArray(prevValue) && prevValue.length === 0);
217
+ if (!hadNoValue)
218
+ actions.onValueChange(multiple ? [] : null);
219
+ state.prevValue = Array.isArray(value) ? value.slice() : value;
220
+ return;
221
+ }
97
222
  if (multiple) {
98
- if (hasValueChanged(value, prevValue)) {
99
- if (checkValueForDuplicates()) {
100
- context.oninput(value || []);
101
- }
223
+ if (hasValueChanged(value, prevValue, itemId) && checkValueForDuplicates()) {
224
+ actions.onValueChange(value);
102
225
  }
103
- return;
104
226
  }
105
- if (!prevValue || hasValueChanged(value[itemId], prevValue[itemId])) {
106
- context.oninput(value);
227
+ else if (!prevValue || hasValueChanged(value, prevValue, itemId)) {
228
+ actions.onValueChange(value);
107
229
  }
230
+ // Snapshot a copy of the settled (post-dedup) value: storing the live
231
+ // array would alias prevValue to `value`, so an in-place push could
232
+ // never register as a change
233
+ const settled = state.value;
234
+ state.prevValue = Array.isArray(settled) ? settled.slice() : settled;
108
235
  }
109
236
  function setupMulti() {
110
- const { value } = context.getState();
237
+ const { value, prevValue } = state;
111
238
  if (value) {
112
239
  if (Array.isArray(value)) {
113
- context.setValue([...value]);
240
+ state.value = [...value];
114
241
  }
115
242
  else {
116
- context.setValue([value]);
243
+ // A raw string value stays a string here; normalizeValue resolves it to an item
244
+ state.value = [value];
245
+ }
246
+ // Wrapping is shape normalization, not a selection change: mirror it
247
+ // on the dispatch baseline, so a mount seeded with a bare item (or a
248
+ // single->multi flip) does not register as a change and fire onValueChange
249
+ if (prevValue && !Array.isArray(prevValue)) {
250
+ state.prevValue = [prevValue];
117
251
  }
118
252
  }
119
253
  }
120
254
  async function handleMultiItemClear(i) {
121
- const { value } = context.getState();
255
+ const { value } = state;
122
256
  if (!Array.isArray(value))
123
257
  return;
258
+ // A stale index (the arrow-key cursor can outlive a mouse removal's
259
+ // reindexing) must not fire onclear(undefined) — or, via the length-1
260
+ // branch below, clear a tag it never pointed at
261
+ if (i < 0 || i >= value.length)
262
+ return;
124
263
  const itemToRemove = value[i];
125
- context.setClearState(true);
264
+ state.clearState = true;
126
265
  if (value.length === 1) {
127
- context.setValue(undefined);
266
+ state.value = undefined;
128
267
  }
129
268
  else {
130
- context.setValue(value.filter((item) => {
131
- return item !== itemToRemove;
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
- });
269
+ state.value = value.filter((item) => item !== itemToRemove);
270
+ }
271
+ actions.onclear(itemToRemove);
144
272
  }
145
273
  function itemSelected(selection) {
146
- const { multiple, value, closeListOnChange } = context.getState();
147
- if (selection) {
148
- context.setFilterText('');
149
- const item = Object.assign({}, selection);
150
- if (item.groupHeader && !item.selectable)
274
+ if (!selection)
275
+ return;
276
+ const { multiple, value, closeListOnChange, itemId } = state;
277
+ const item = { ...selection };
278
+ if (item.groupHeader && !item.selectable)
279
+ return;
280
+ // Re-selecting an already-selected item in multiple mode is a no-op: the
281
+ // value is a distinct set. Without this the item was concatenated and
282
+ // onSelectionChange fired synchronously with a duplicate array ([a, a]) before a
283
+ // later effect deduped state.value — leaving onSelectionChange disagreeing with the
284
+ // (correctly suppressed) onValueChange. Single mode already no-ops the same way
285
+ // in handleItemClick / handleEnterKey. The no-op runs before the
286
+ // filterText wipe below: with filterSelectedItems={false} a re-click
287
+ // selects nothing and must not clear what the user typed.
288
+ if (multiple && Array.isArray(value)) {
289
+ const selectedId = getItemProperty(item, itemId);
290
+ const alreadySelected = value.some((entry) => (typeof entry === 'string' ? entry : getItemProperty(entry, itemId)) === selectedId);
291
+ if (alreadySelected) {
292
+ if (closeListOnChange)
293
+ actions.closeList();
294
+ state.activeValue = undefined;
151
295
  return;
152
- setValue();
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);
296
+ }
160
297
  }
298
+ // A committed selection retires a pending deps-reload verdict when the
299
+ // displayed results it was picked from are fresher than that reload —
300
+ // otherwise the reload's late response wipes this selection
301
+ actions.retireStaleValidation?.();
302
+ state.filterText = '';
303
+ state.value = multiple ? (value ? value.concat([item]) : [item]) : item;
304
+ // A completed selection consumes Tab's commit-intent: with
305
+ // closeListOnChange={false} the list stays open and (with
306
+ // filterSelectedItems) the cursor re-parks on a neighbouring option,
307
+ // which a later Tab must not commit. closeList() also resets the flag,
308
+ // but only when the selection actually closes the list.
309
+ state.userNavigatedSinceOpen = false;
310
+ if (closeListOnChange)
311
+ actions.closeList();
312
+ state.activeValue = undefined;
313
+ actions.onSelectionChange(state.value);
314
+ actions.onselect(selection);
161
315
  }
316
+ // Run once synchronously at creation: effects never run during SSR, so
317
+ // without this pass the server HTML ships raw string entries — empty chip
318
+ // text, aria-label="Remove undefined", and a hidden form input carrying
319
+ // JSON.stringify('NY'). The effect below re-runs the same idempotent
320
+ // resolution on the client.
321
+ untrack(() => normalizeValue());
322
+ // Normalize string values on every value change (not just the hasValue
323
+ // flip — replacing one string with another must re-resolve), and again when
324
+ // async items arrive so fallback entries upgrade to the real item
325
+ $effect(() => {
326
+ const value = state.value;
327
+ trackArrayEntries(value); // in-place growth and entry replacement both retrigger
328
+ state.items;
329
+ untrack(() => normalizeValue());
330
+ });
331
+ // Multiple-mode transitions (single<->multi) and duplicate guard
332
+ $effect(() => {
333
+ state.multiple;
334
+ const value = state.value;
335
+ trackArrayEntries(value); // in-place growth and entry replacement both retrigger
336
+ untrack(() => {
337
+ const wasMultiple = state.prevMultiple;
338
+ state.prevMultiple = state.multiple;
339
+ if (state.multiple !== wasMultiple) {
340
+ if (state.multiple) {
341
+ setupMulti();
342
+ }
343
+ else if (wasMultiple && Array.isArray(state.value)) {
344
+ // Only the old (array-shaped) value is wiped: a replacement
345
+ // single value supplied together with the multiple flip is a
346
+ // deliberate new selection and must survive.
347
+ // `undefined`, not `null`: an emptied value is `undefined`
348
+ // everywhere else (clear button, last tag removed, a deps
349
+ // reload invalidating the selection), and one empty
350
+ // representation is the whole contract
351
+ state.value = undefined;
352
+ return;
353
+ }
354
+ }
355
+ if (state.multiple && Array.isArray(state.value) && state.value.length > 1) {
356
+ checkValueForDuplicates();
357
+ }
358
+ });
359
+ });
360
+ // Dispatch onValueChange when value changes (selection, update, or clear)
361
+ $effect(() => {
362
+ const value = state.value;
363
+ trackArrayEntries(value); // in-place growth and entry replacement both retrigger
364
+ untrack(() => dispatchSelectedItem());
365
+ });
366
+ // Hydrate value from justValue, then keep justValue in sync.
367
+ // items is a tracked trigger so hydration retries when async items arrive.
368
+ // justValue is tracked too, so a post-mount write applies deterministically
369
+ // right away — it hydrates when no selection exists, and is corrected back
370
+ // to the selection-derived values when one does (value wins). Previously a
371
+ // late write sat dormant and then hydrated on the next unrelated trigger.
372
+ // The equality-guarded write breaks the write→track cycle (a derived array
373
+ // is a new reference every run) and keeps justValue's identity stable when
374
+ // its entries are unchanged.
375
+ // clearState is a tracked trigger too, so an explicit clear always reaches
376
+ // syncJustValue (which consumes and resets the flag) even when it is not
377
+ // accompanied by a value change — otherwise the flag could stick `true` and
378
+ // block the next hydration. syncJustValue resets it to `false`, which settles
379
+ // in one extra run (the false→false write no longer notifies).
380
+ $effect(() => {
381
+ state.multiple;
382
+ state.itemId;
383
+ const value = state.value;
384
+ trackArrayEntries(value); // in-place growth and entry replacement both retrigger
385
+ state.items;
386
+ state.clearState;
387
+ state.justValue;
388
+ untrack(() => {
389
+ const derived = syncJustValue();
390
+ if (!justValuesEqual(derived, state.justValue))
391
+ state.justValue = derived;
392
+ });
393
+ });
162
394
  return {
163
- setValue,
164
- updateValueDisplay,
165
- computeJustValue,
166
- checkValueForDuplicates,
167
- findItem,
168
- findItemByValue,
169
- dispatchSelectedItem,
170
395
  itemSelected,
171
- setupMulti,
172
396
  handleMultiItemClear,
173
- convertStringItemsToObjects,
174
397
  };
175
398
  }
package/dist/utils.d.ts CHANGED
@@ -1,10 +1,21 @@
1
- import type { SelectItem } from './types';
1
+ import type { ItemLike, SelectGroupHeader, SelectItem } from './types.js';
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
- export declare function areItemsEqual(a: SelectItem | null | undefined, b: SelectItem | null | undefined, itemId: string): boolean;
4
- export declare function isCancelled(res: any): res is {
5
- cancelled: boolean;
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
- export declare function hasValueChanged(newValue: unknown, oldValue: unknown): boolean;
10
- export declare function createGroupHeaderItem(groupValue: string, item: SelectItem, labelKey?: string): SelectItem;
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 !item.hasOwnProperty('selectable') || item.selectable !== false;
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 hasValueChanged(newValue, oldValue) {
21
- return JSON.stringify(newValue) !== JSON.stringify(oldValue);
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, item, labelKey = 'label') {
67
+ export function createGroupHeaderItem(groupValue, labelKey = 'label') {
24
68
  return {
25
69
  value: groupValue,
26
70
  [labelKey]: groupValue,