@aurodesignsystem-dev/auro-formkit 0.0.0-pr1574.0 → 0.0.0-pr1576.1

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 (51) hide show
  1. package/components/checkbox/demo/customize.min.js +1 -1
  2. package/components/checkbox/demo/getting-started.min.js +1 -1
  3. package/components/checkbox/demo/index.min.js +1 -1
  4. package/components/checkbox/dist/index.js +1 -1
  5. package/components/checkbox/dist/registered.js +1 -1
  6. package/components/combobox/demo/customize.md +40 -0
  7. package/components/combobox/demo/customize.min.js +256 -17
  8. package/components/combobox/demo/getting-started.min.js +256 -17
  9. package/components/combobox/demo/index.min.js +256 -17
  10. package/components/combobox/dist/index.js +3 -3
  11. package/components/combobox/dist/registered.js +3 -3
  12. package/components/counter/demo/customize.min.js +3 -3
  13. package/components/counter/demo/index.min.js +3 -3
  14. package/components/counter/dist/index.js +3 -3
  15. package/components/counter/dist/registered.js +3 -3
  16. package/components/datepicker/demo/customize.min.js +3 -3
  17. package/components/datepicker/demo/index.min.js +3 -3
  18. package/components/datepicker/dist/index.js +3 -3
  19. package/components/datepicker/dist/registered.js +3 -3
  20. package/components/dropdown/demo/customize.min.js +1 -1
  21. package/components/dropdown/demo/getting-started.min.js +1 -1
  22. package/components/dropdown/demo/index.min.js +1 -1
  23. package/components/dropdown/dist/index.js +1 -1
  24. package/components/dropdown/dist/registered.js +1 -1
  25. package/components/form/demo/customize.min.js +267 -28
  26. package/components/form/demo/getting-started.min.js +267 -28
  27. package/components/form/demo/index.min.js +267 -28
  28. package/components/form/demo/registerDemoDeps.min.js +267 -28
  29. package/components/input/demo/customize.min.js +1 -1
  30. package/components/input/demo/getting-started.min.js +1 -1
  31. package/components/input/demo/index.min.js +1 -1
  32. package/components/input/dist/index.js +1 -1
  33. package/components/input/dist/registered.js +1 -1
  34. package/components/menu/demo/index.min.js +253 -14
  35. package/components/menu/dist/auro-menu-utils.d.ts +30 -0
  36. package/components/menu/dist/auro-menu.d.ts +23 -0
  37. package/components/menu/dist/index.js +253 -14
  38. package/components/menu/dist/registered.js +253 -14
  39. package/components/radio/demo/customize.min.js +1 -1
  40. package/components/radio/demo/getting-started.min.js +1 -1
  41. package/components/radio/demo/index.min.js +1 -1
  42. package/components/radio/dist/index.js +1 -1
  43. package/components/radio/dist/registered.js +1 -1
  44. package/components/select/demo/customize.md +72 -0
  45. package/components/select/demo/customize.min.js +255 -16
  46. package/components/select/demo/getting-started.min.js +255 -16
  47. package/components/select/demo/index.min.js +255 -16
  48. package/components/select/dist/index.js +2 -2
  49. package/components/select/dist/registered.js +2 -2
  50. package/custom-elements.json +1617 -1513
  51. package/package.json +1 -1
@@ -279,6 +279,107 @@ function isSelectableByValue(option) {
279
279
  !option.hasAttribute('static');
280
280
  }
281
281
 
282
+ /* eslint-disable no-underscore-dangle */
283
+ /**
284
+ * Resolves the single selected option for a given `value`, preferring the
285
+ * option tracked by `selectedKey` (a user-initiated selection) over a
286
+ * first-by-value match. When multiple options share the same `value`, matching
287
+ * by `value` alone cannot distinguish which one the user picked; the key
288
+ * disambiguates it.
289
+ *
290
+ * The key is trusted only when it still resolves to an option whose `value`
291
+ * matches the requested `value`. If the key is stale (option removed) or the
292
+ * value was changed programmatically, resolution falls back to value matching —
293
+ * preserving backward-compatible behavior for preselection and `selectByValue`.
294
+ * @private
295
+ * @param {Array<HTMLElement>} items - The menu's flat option list.
296
+ * @param {string} value - The value to resolve.
297
+ * @param {string|undefined} selectedKey - The `_optionKey` of the user-selected option, if any.
298
+ * @returns {HTMLElement|undefined} The resolved option, or undefined when none match.
299
+ */
300
+ function resolveSelectedOption(items, value, selectedKey) {
301
+ if (!items) {
302
+ return undefined;
303
+ }
304
+
305
+ if (selectedKey !== undefined) {
306
+ const keyed = items.find((item) => item._optionKey === selectedKey);
307
+ if (keyed && isSelectableByValue(keyed) && keyed.value === value) {
308
+ return keyed;
309
+ }
310
+ // Key exists but the option is gone or its value no longer matches — fall
311
+ // through to value-based matching.
312
+ }
313
+
314
+ return items.find((item) => isSelectableByValue(item) && item.value === value);
315
+ }
316
+
317
+ /**
318
+ * Resolves the selected options for a multi-select `value` array, preferring
319
+ * options tracked by `selectedKeys` (user-initiated selections) and falling
320
+ * back to value matching for any values not resolved by key. The result is
321
+ * always sorted into DOM order regardless of selection sequence.
322
+ * @private
323
+ * @param {Array<HTMLElement>} items - The menu's flat option list.
324
+ * @param {Array<string>} valueArray - The selected values.
325
+ * @param {Array<string>|undefined} selectedKeys - The `_optionKey`s of the user-selected options, if any.
326
+ * @returns {Array<HTMLElement>} The resolved options in DOM order.
327
+ */
328
+ function resolveSelectedOptions(items, valueArray, selectedKeys) {
329
+ if (!items) {
330
+ return [];
331
+ }
332
+
333
+ const resolved = [];
334
+
335
+ // Track how many of each value are still available to resolve. A value that
336
+ // appears N times in `valueArray` may be satisfied at most N times total across
337
+ // the key pass and the value fallback below — matching by count, not presence,
338
+ // on BOTH passes. This stops a duplicate value from being over-resolved: e.g.
339
+ // two keyed options that both carry `SEA` cannot both match a single requested
340
+ // `SEA` (which happens when `value` is set directly without clearing
341
+ // `_selectedKey`, so more keys survive than the value set now asks for).
342
+ const remaining = new Map();
343
+ valueArray.forEach((val) => remaining.set(val, (remaining.get(val) || 0) + 1));
344
+
345
+ // Resolve by key first: trust a key only when its option is still selectable
346
+ // and there is still an unmatched occurrence of its value in the request set.
347
+ if (Array.isArray(selectedKeys)) {
348
+ selectedKeys.forEach((key) => {
349
+ const keyed = items.find((item) => item._optionKey === key);
350
+ if (keyed && isSelectableByValue(keyed) && (remaining.get(keyed.value) || 0) > 0 && !resolved.includes(keyed)) {
351
+ resolved.push(keyed);
352
+ remaining.set(keyed.value, remaining.get(keyed.value) - 1);
353
+ }
354
+ });
355
+ }
356
+
357
+ // Fall back to value matching for the occurrences not resolved by key. Iterate
358
+ // the leftover per-value counts so a value that appears twice but was only
359
+ // resolved once by key still matches its remaining occurrence(s).
360
+ remaining.forEach((count, val) => {
361
+ for (let occurrence = 0; occurrence < count; occurrence += 1) {
362
+ const option = items.find((item) => isSelectableByValue(item) && item.value === val && !resolved.includes(item));
363
+ if (option) {
364
+ resolved.push(option);
365
+ }
366
+ }
367
+ });
368
+
369
+ // Always return in DOM order so display is consistent regardless of the order
370
+ // keys/values were selected. Every resolved option came from `items`, so an
371
+ // O(1) index lookup mirrors `_sortSelectedByDomOrder` and avoids the O(n)
372
+ // `items.indexOf` per comparison for large combobox option sets.
373
+ const indexMap = new Map(items.map((item, index) => [
374
+ item,
375
+ index
376
+ ]));
377
+ resolved.sort((optionA, optionB) => indexMap.get(optionA) - indexMap.get(optionB));
378
+
379
+ return resolved;
380
+ }
381
+ /* eslint-enable no-underscore-dangle */
382
+
282
383
  /**
283
384
  * Helper method to dispatch custom events.
284
385
  * @param {HTMLElement} element - Element to dispatch event from.
@@ -304,6 +405,14 @@ function dispatchMenuEvent(element, eventName, detail = null) {
304
405
  // See LICENSE in the project root for license information.
305
406
 
306
407
 
408
+ /**
409
+ * Monotonically increasing counter used to give each menu instance a unique
410
+ * `_menuInstanceId` prefix. Auto-generating the id (rather than using a random
411
+ * string) keeps option keys deterministic and collision-free across menus.
412
+ * @private
413
+ */
414
+ let menuInstanceIdCounter = 0;
415
+
307
416
 
308
417
  /**
309
418
  * The `auro-menu` element provides users a way to select from a list of options.
@@ -378,6 +487,8 @@ class AuroMenu extends AuroElement {
378
487
  /**
379
488
  * @private
380
489
  */
490
+ menuInstanceIdCounter += 1;
491
+
381
492
  Object.assign(this, {
382
493
  // Root-level menu (true) or a nested submenu (false)
383
494
  rootMenu: true,
@@ -387,6 +498,21 @@ class AuroMenu extends AuroElement {
387
498
  nestingSpacer: '<span class="nestingSpacer"></span>',
388
499
  // Loading indicator for slot elements
389
500
  loadingSlots: null,
501
+ // Unique id for this menu instance; prefixes every auto-generated option
502
+ // key so keys never collide across menus in the same document.
503
+ _menuInstanceId: `menu-${menuInstanceIdCounter}`,
504
+ // Monotonically increasing counter for option key generation. Never
505
+ // resets, so a key is never reused within this instance's lifetime.
506
+ _optionKeyCounter: 0,
507
+ // Key(s) of the option(s) the user has actively selected. A single string
508
+ // in single-select, an array in multi-select, undefined when nothing is
509
+ // user-selected. Used to disambiguate options that share a `value`.
510
+ _selectedKey: undefined,
511
+ // True only for the one updated() cycle following a user selection, so
512
+ // reconciliation trusts `_selectedKey`. A `value` change from a consumer's
513
+ // direct property assignment leaves this false, dropping the stale key so
514
+ // reconciliation falls back to first-by-value (see updated()).
515
+ _valueChangeFromSelection: false,
390
516
  });
391
517
  }
392
518
 
@@ -606,6 +732,13 @@ class AuroMenu extends AuroElement {
606
732
  return;
607
733
  }
608
734
 
735
+ // A programmatic value set carries no positional intent, so drop any
736
+ // `_selectedKey` left over from a prior user click. This makes reconciliation
737
+ // in updated() fall back to first-by-value (single) / value-in-DOM-order
738
+ // (multi), matching the documented contract for programmatic selection even
739
+ // when a stale key would still resolve to a duplicate-value option.
740
+ this._selectedKey = undefined;
741
+
609
742
  // `value` is a String property; stringify arrays so attribute reflection and `formattedValue` parsing stay correct.
610
743
  this.value = Array.isArray(value) ? JSON.stringify(value) : value;
611
744
  }
@@ -653,6 +786,17 @@ class AuroMenu extends AuroElement {
653
786
  updated(changedProperties) {
654
787
  super.updated(changedProperties);
655
788
 
789
+ // Consume the selection-driven flag for THIS cycle up front. Clearing it
790
+ // unconditionally — not only inside the `value` branch below — prevents it
791
+ // from lingering `true` when a selection produces a serialized `value`
792
+ // byte-identical to the current one, in which case Lit schedules no
793
+ // `value`-change cycle to consume it. A lingering flag would misclassify a
794
+ // later consumer's programmatic `value` set as selection-driven and keep a
795
+ // stale `_selectedKey`. The reconcile path below re-sets the instance flag
796
+ // after this point, so its intentional cross-cycle hand-off still works.
797
+ const valueChangeFromSelection = this._valueChangeFromSelection;
798
+ this._valueChangeFromSelection = false;
799
+
656
800
  // Single source of truth for 'auroMenu-selectedOption'. Selection handlers
657
801
  // mutate optionSelected and let Lit's update cycle dispatch here; the prior
658
802
  // .value comparison missed multi-select array changes and combined with the
@@ -676,6 +820,17 @@ class AuroMenu extends AuroElement {
676
820
  this.initItems();
677
821
  }
678
822
 
823
+ // Distinguish a selection-driven `value` change (a user click, which set
824
+ // the flag in handleSelectState / _sortSelectedByDomOrder) from a
825
+ // programmatic assignment by a consumer. A programmatic set carries no
826
+ // positional intent, so drop any leftover `_selectedKey` and let
827
+ // reconciliation fall back to first-by-value (single) / value-in-DOM-order
828
+ // (multi) — the same contract selectByValue() guarantees, even when a
829
+ // stale key would otherwise still resolve to a duplicate-value option.
830
+ if (!valueChangeFromSelection) {
831
+ this._selectedKey = undefined;
832
+ }
833
+
679
834
  // Set when reconciliation reassigns `value` below. That reassignment schedules a
680
835
  // second updated() cycle, so the `event`-attribute dispatch is deferred to that
681
836
  // cycle to avoid firing option custom events twice on the same selection.
@@ -693,7 +848,10 @@ class AuroMenu extends AuroElement {
693
848
  // Defensive default: `formattedValue` can be undefined for unexpected value types,
694
849
  // and calling `.includes` on undefined would throw during reconciliation.
695
850
  const valueArray = this.formattedValue || [];
696
- const matchingOptions = this.items ? this.items.filter((item) => isSelectableByValue(item) && valueArray.includes(item.value)) : [];
851
+ // Resolve by key first (the user's exact picks), then fall back to
852
+ // value matching for any values not resolved by key — so pre-selection
853
+ // and programmatic value sets keep working. Result is DOM-ordered.
854
+ const matchingOptions = resolveSelectedOptions(this.items, valueArray, this._selectedKey);
697
855
  newSelected = matchingOptions.length > 0 ? matchingOptions : undefined;
698
856
 
699
857
  // Reconcile `value` with the selectable set. Drop only entries whose option is
@@ -706,6 +864,12 @@ class AuroMenu extends AuroElement {
706
864
  : [];
707
865
  if (rejectedValues.length > 0) {
708
866
  const reconciled = valueArray.filter((val) => !rejectedValues.includes(val));
867
+ // This is an internal correction, not a consumer's programmatic set,
868
+ // so preserve the selection-driven flag through the re-entrant
869
+ // updated() cycle it schedules. Otherwise that cycle would treat the
870
+ // reassignment as programmatic and drop `_selectedKey` mid-cascade,
871
+ // flipping resolution and looping.
872
+ this._valueChangeFromSelection = true;
709
873
  this.value = serializeMultiSelectValue(reconciled);
710
874
  valueReconciled = true;
711
875
  }
@@ -717,7 +881,11 @@ class AuroMenu extends AuroElement {
717
881
  // `hidden` is intentionally NOT excluded: the combobox toggles
718
882
  // `hidden` as its type-ahead filter, so a filtered-out option is
719
883
  // still a valid programmatic selection.
720
- const matchingOption = this.items ? this.items.find((item) => isSelectableByValue(item) && item.value === this.value) : undefined;
884
+ // Prefer the option the user actually selected (tracked by
885
+ // `_selectedKey`) so a click on the second of two options sharing a
886
+ // `value` resolves back to that exact element instead of the first
887
+ // value match. Falls back to first-by-value for programmatic sets.
888
+ const matchingOption = resolveSelectedOption(this.items, this.value, this._selectedKey);
721
889
 
722
890
  if (matchingOption) {
723
891
  newSelected = matchingOption;
@@ -945,6 +1113,14 @@ class AuroMenu extends AuroElement {
945
1113
  }
946
1114
  });
947
1115
 
1116
+ // Assign private keys once items are populated. Only the root menu assigns
1117
+ // keys: its `items` is a deep query that already includes nested submenu
1118
+ // options, so a single pass keys the entire tree. Nested menus skip this
1119
+ // and inherit keys from the root.
1120
+ if (this.rootMenu) {
1121
+ this._assignOptionKeys();
1122
+ }
1123
+
948
1124
  if (this.noCheckmark) {
949
1125
  this.updateItemsState(new Map([
950
1126
  [
@@ -961,6 +1137,31 @@ class AuroMenu extends AuroElement {
961
1137
  }));
962
1138
  }
963
1139
 
1140
+ /**
1141
+ * Assigns a private, auto-generated unique key (`_optionKey`) to each menu
1142
+ * option that does not already have one. Keys are internal state on the
1143
+ * element instance — never reflected as an attribute or exposed publicly —
1144
+ * and let selection tracking distinguish options that share the same `value`.
1145
+ *
1146
+ * The `_optionKey === undefined` guard makes this idempotent: options keep the
1147
+ * key they were first assigned across re-renders and slot changes, and if a
1148
+ * nested menu's lifecycle runs a pass before the root, options simply wait for
1149
+ * the root to key them (or keep whatever key they already hold).
1150
+ * @private
1151
+ */
1152
+ _assignOptionKeys() {
1153
+ if (!this.items) {
1154
+ return;
1155
+ }
1156
+
1157
+ this.items.forEach((option) => {
1158
+ if (option._optionKey === undefined) {
1159
+ this._optionKeyCounter += 1;
1160
+ option._optionKey = `${this._menuInstanceId}-${this._optionKeyCounter}`;
1161
+ }
1162
+ });
1163
+ }
1164
+
964
1165
  // Logic Methods
965
1166
 
966
1167
  /**
@@ -970,24 +1171,28 @@ class AuroMenu extends AuroElement {
970
1171
  */
971
1172
  handleSelectState(option) {
972
1173
  if (this.multiSelect) {
973
- const currentValue = this.formattedValue || [];
974
1174
  const currentSelected = this.optionSelected || [];
975
1175
 
976
- if (!currentValue.includes(option.value)) {
977
- this.value = serializeMultiSelectValue([
978
- ...currentValue,
979
- option.value
980
- ]);
981
- }
982
1176
  if (!currentSelected.includes(option)) {
983
1177
  this.optionSelected = [
984
1178
  ...currentSelected,
985
1179
  option
986
1180
  ];
987
1181
  }
1182
+
1183
+ // Re-sort by DOM order and rebuild `_selectedKey`/`value` from the
1184
+ // selected set so display order stays consistent with the menu, not with
1185
+ // click order.
1186
+ this._sortSelectedByDomOrder();
988
1187
  } else {
989
1188
  this.value = option.value;
990
1189
  this.optionSelected = option;
1190
+ // Track the specific option the user selected so the value→option
1191
+ // reconciliation in updated() resolves back to this exact element even
1192
+ // when another option shares the same `value`.
1193
+ this._selectedKey = option._optionKey;
1194
+ // Mark this `value` change as selection-driven so updated() trusts the key.
1195
+ this._valueChangeFromSelection = true;
991
1196
  }
992
1197
 
993
1198
  this._index = this.items.indexOf(option);
@@ -1000,18 +1205,22 @@ class AuroMenu extends AuroElement {
1000
1205
  */
1001
1206
  handleDeselectState(option) {
1002
1207
  if (this.multiSelect) {
1003
- // Remove this option from array; an empty result collapses `value` to undefined.
1004
- const newFormattedValue = (this.formattedValue || []).filter((val) => val !== option.value);
1005
- this.value = serializeMultiSelectValue(newFormattedValue);
1006
-
1007
- this.optionSelected = this.optionSelected.filter((val) => val !== option);
1208
+ // Remove this exact element from the selection (identity, not value — two
1209
+ // options can share a `value`), then rebuild `value`/`_selectedKey` from
1210
+ // the remaining set in DOM order. An empty result collapses to undefined.
1211
+ this.optionSelected = this.optionSelected.filter((selected) => selected !== option);
1008
1212
  if (this.optionSelected.length === 0) {
1009
1213
  this.optionSelected = undefined;
1214
+ this._selectedKey = undefined;
1215
+ this.value = undefined;
1216
+ } else {
1217
+ this._sortSelectedByDomOrder();
1010
1218
  }
1011
1219
  } else {
1012
1220
  // For single-select: Back to undefined when deselected
1013
1221
  this.value = undefined;
1014
1222
  this.optionSelected = undefined;
1223
+ this._selectedKey = undefined;
1015
1224
  }
1016
1225
 
1017
1226
  // Update the index tracking
@@ -1035,9 +1244,38 @@ class AuroMenu extends AuroElement {
1035
1244
  clearSelection() {
1036
1245
  this.optionSelected = undefined;
1037
1246
  this.value = undefined;
1247
+ this._selectedKey = undefined;
1038
1248
  this._index = -1;
1039
1249
  }
1040
1250
 
1251
+ /**
1252
+ * Re-sorts the multi-select selection into DOM order and rebuilds the derived
1253
+ * `_selectedKey` and `value` from `optionSelected`. Selection is always stored
1254
+ * and serialized in the order options appear in the menu, never in click
1255
+ * order — so selecting C then A yields `[A, C]`.
1256
+ * @private
1257
+ */
1258
+ _sortSelectedByDomOrder() {
1259
+ if (!this.multiSelect || !Array.isArray(this.optionSelected) || !this.items) {
1260
+ return;
1261
+ }
1262
+
1263
+ const indexMap = new Map(this.items.map((item, index) => [
1264
+ item,
1265
+ index
1266
+ ]));
1267
+
1268
+ // Sort any element no longer in `items` (a stale selection left over from a
1269
+ // dynamic rebuild that the consumer has not cleared) to the END rather than
1270
+ // the front, so it never displaces a live option to the head of the
1271
+ // serialized order. Value reconciliation drops it on the next updated() cycle.
1272
+ this.optionSelected.sort((optionA, optionB) => (indexMap.get(optionA) ?? this.items.length) - (indexMap.get(optionB) ?? this.items.length));
1273
+ this._selectedKey = this.optionSelected.map((option) => option._optionKey);
1274
+ this.value = serializeMultiSelectValue(this.optionSelected.map((option) => option.value));
1275
+ // Mark this `value` change as selection-driven so updated() trusts the keys.
1276
+ this._valueChangeFromSelection = true;
1277
+ }
1278
+
1041
1279
  /**
1042
1280
  * Resets the menu to its initial state.
1043
1281
  * This is the only way to return value to undefined.
@@ -1047,6 +1285,7 @@ class AuroMenu extends AuroElement {
1047
1285
  // Reset to undefined - initial state
1048
1286
  this.value = undefined;
1049
1287
  this.optionSelected = undefined;
1288
+ this._selectedKey = undefined;
1050
1289
  this._index = -1;
1051
1290
 
1052
1291
  // Clear active option state so a follow-up open/navigation starts fresh