svelte-5-select 1.0.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +239 -109
  2. package/dist/ChevronIcon.svelte +8 -13
  3. package/dist/ClearIcon.svelte +3 -11
  4. package/dist/LoadingIcon.svelte +9 -2
  5. package/dist/Select.svelte +954 -436
  6. package/dist/Select.svelte.d.ts +37 -5
  7. package/dist/aria-handlers.svelte.d.ts +4 -1
  8. package/dist/aria-handlers.svelte.js +19 -8
  9. package/dist/filter.d.ts +10 -2
  10. package/dist/filter.js +14 -7
  11. package/dist/index.d.ts +2 -3
  12. package/dist/index.js +1 -2
  13. package/dist/keyboard-navigation.svelte.d.ts +7 -2
  14. package/dist/keyboard-navigation.svelte.js +285 -47
  15. package/dist/no-styles/ChevronIcon.svelte +11 -0
  16. package/dist/no-styles/ChevronIcon.svelte.d.ts +26 -0
  17. package/dist/no-styles/ClearIcon.svelte +8 -0
  18. package/dist/no-styles/ClearIcon.svelte.d.ts +26 -0
  19. package/dist/no-styles/LoadingIcon.svelte +13 -0
  20. package/dist/no-styles/LoadingIcon.svelte.d.ts +26 -0
  21. package/dist/no-styles/Select.svelte +1383 -0
  22. package/dist/no-styles/Select.svelte.d.ts +38 -0
  23. package/dist/select-state.svelte.d.ts +15 -0
  24. package/dist/select-state.svelte.js +161 -0
  25. package/dist/styles/default.css +112 -71
  26. package/dist/tailwind.css +120 -17
  27. package/dist/types.d.ts +459 -129
  28. package/dist/use-hover.svelte.d.ts +3 -7
  29. package/dist/use-hover.svelte.js +91 -36
  30. package/dist/use-load-options.svelte.d.ts +18 -3
  31. package/dist/use-load-options.svelte.js +333 -42
  32. package/dist/use-value.svelte.d.ts +2 -11
  33. package/dist/use-value.svelte.js +322 -111
  34. package/dist/utils.d.ts +19 -8
  35. package/dist/utils.js +52 -8
  36. package/package.json +117 -94
@@ -1,87 +1,193 @@
1
- import { hasValueChanged } from './utils';
2
- export function useValue(context) {
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 } = 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);
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
- context.setValue(uniqueValues);
198
+ state.value = uniqueValues;
93
199
  return noDuplicates;
94
200
  }
95
201
  function dispatchSelectedItem() {
96
- const { multiple, value, prevValue, itemId } = context.getState();
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
- if (checkValueForDuplicates()) {
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[itemId], prevValue[itemId])) {
106
- context.oninput(value);
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 } = context.getState();
231
+ const { value, prevValue } = state;
111
232
  if (value) {
112
233
  if (Array.isArray(value)) {
113
- context.setValue([...value]);
234
+ state.value = [...value];
114
235
  }
115
236
  else {
116
- context.setValue([value]);
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 } = context.getState();
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
- context.setClearState(true);
258
+ state.clearState = true;
126
259
  if (value.length === 1) {
127
- context.setValue(undefined);
260
+ state.value = undefined;
128
261
  }
129
262
  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
- });
263
+ state.value = value.filter((item) => item !== itemToRemove);
264
+ }
265
+ actions.onclear(itemToRemove);
144
266
  }
145
267
  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)
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
- 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);
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
- 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,