@aurodesignsystem/auro-formkit 6.0.2 → 6.0.3

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 (57) hide show
  1. package/CHANGELOG.md +8 -10
  2. package/components/checkbox/demo/customize.min.js +1 -1
  3. package/components/checkbox/demo/getting-started.min.js +20 -1
  4. package/components/checkbox/demo/index.min.js +1 -1
  5. package/components/checkbox/dist/index.js +1 -1
  6. package/components/checkbox/dist/registered.js +1 -1
  7. package/components/combobox/demo/customize.md +40 -0
  8. package/components/combobox/demo/customize.min.js +390 -56
  9. package/components/combobox/demo/getting-started.min.js +390 -56
  10. package/components/combobox/demo/index.html +1 -1
  11. package/components/combobox/demo/index.min.js +390 -56
  12. package/components/combobox/dist/index.js +68 -29
  13. package/components/combobox/dist/registered.js +68 -29
  14. package/components/counter/demo/customize.min.js +38 -3
  15. package/components/counter/demo/index.min.js +12 -3
  16. package/components/counter/dist/index.js +12 -3
  17. package/components/counter/dist/registered.js +12 -3
  18. package/components/datepicker/demo/customize.min.js +49 -5
  19. package/components/datepicker/demo/index.min.js +14 -5
  20. package/components/datepicker/dist/index.js +14 -5
  21. package/components/datepicker/dist/registered.js +14 -5
  22. package/components/dropdown/demo/customize.md +4 -4
  23. package/components/dropdown/demo/customize.min.js +53 -5
  24. package/components/dropdown/demo/getting-started.html +3 -0
  25. package/components/dropdown/demo/getting-started.md +4 -4
  26. package/components/dropdown/demo/getting-started.min.js +13087 -77
  27. package/components/dropdown/demo/index.min.js +30 -11
  28. package/components/dropdown/dist/index.js +10 -1
  29. package/components/dropdown/dist/registered.js +10 -1
  30. package/components/form/demo/customize.min.js +430 -69
  31. package/components/form/demo/getting-started.min.js +430 -69
  32. package/components/form/demo/index.min.js +430 -69
  33. package/components/form/demo/registerDemoDeps.min.js +430 -69
  34. package/components/input/demo/customize.min.js +1 -1
  35. package/components/input/demo/getting-started.min.js +1 -1
  36. package/components/input/demo/index.min.js +1 -1
  37. package/components/input/dist/index.js +1 -1
  38. package/components/input/dist/registered.js +1 -1
  39. package/components/menu/demo/customize.md +50 -0
  40. package/components/menu/demo/index.min.js +322 -27
  41. package/components/menu/dist/auro-menu-utils.d.ts +30 -0
  42. package/components/menu/dist/auro-menu.d.ts +23 -0
  43. package/components/menu/dist/index.js +322 -27
  44. package/components/menu/dist/registered.js +322 -27
  45. package/components/radio/demo/customize.min.js +1 -1
  46. package/components/radio/demo/getting-started.min.js +1 -1
  47. package/components/radio/demo/index.min.js +1 -1
  48. package/components/radio/dist/index.js +1 -1
  49. package/components/radio/dist/registered.js +1 -1
  50. package/components/select/demo/customize.md +72 -0
  51. package/components/select/demo/customize.min.js +333 -29
  52. package/components/select/demo/getting-started.min.js +333 -29
  53. package/components/select/demo/index.min.js +333 -29
  54. package/components/select/dist/index.js +11 -2
  55. package/components/select/dist/registered.js +11 -2
  56. package/custom-elements.json +1613 -1510
  57. package/package.json +1 -1
@@ -172,6 +172,7 @@ export class AuroMenu extends AuroElement {
172
172
  * @public
173
173
  */
174
174
  public selectByValue(value: string | string[] | undefined | null): void;
175
+ _selectedKey: any;
175
176
  firstUpdated(): void;
176
177
  loadingSlots: NodeListOf<Element> | undefined;
177
178
  /**
@@ -181,6 +182,7 @@ export class AuroMenu extends AuroElement {
181
182
  */
182
183
  private setTagAttribute;
183
184
  updated(changedProperties: any): void;
185
+ _valueChangeFromSelection: boolean | undefined;
184
186
  _index: number | undefined;
185
187
  /**
186
188
  * Updates the UI state and appearance of menu items based on changed properties.
@@ -199,6 +201,19 @@ export class AuroMenu extends AuroElement {
199
201
  */
200
202
  private initItems;
201
203
  items: Element[] | undefined;
204
+ /**
205
+ * Assigns a private, auto-generated unique key (`_optionKey`) to each menu
206
+ * option that does not already have one. Keys are internal state on the
207
+ * element instance — never reflected as an attribute or exposed publicly —
208
+ * and let selection tracking distinguish options that share the same `value`.
209
+ *
210
+ * The `_optionKey === undefined` guard makes this idempotent: options keep the
211
+ * key they were first assigned across re-renders and slot changes, and if a
212
+ * nested menu's lifecycle runs a pass before the root, options simply wait for
213
+ * the root to key them (or keep whatever key they already hold).
214
+ * @private
215
+ */
216
+ private _assignOptionKeys;
202
217
  /**
203
218
  * Updates menu state when an option is selected.
204
219
  * @private
@@ -216,6 +231,14 @@ export class AuroMenu extends AuroElement {
216
231
  * @private
217
232
  */
218
233
  private clearSelection;
234
+ /**
235
+ * Re-sorts the multi-select selection into DOM order and rebuilds the derived
236
+ * `_selectedKey` and `value` from `optionSelected`. Selection is always stored
237
+ * and serialized in the order options appear in the menu, never in click
238
+ * order — so selecting C then A yields `[A, C]`.
239
+ * @private
240
+ */
241
+ private _sortSelectedByDomOrder;
219
242
  /**
220
243
  * Resets the menu to its initial state.
221
244
  * This is the only way to return value to undefined.
@@ -279,6 +279,116 @@ 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
+ // Mirror `resolved` as a Set for O(1) membership checks below, matching the
335
+ // indexMap optimization used for the sort rather than scanning `resolved`
336
+ // on every candidate.
337
+ const resolvedSet = new Set();
338
+
339
+ // Track how many of each value are still available to resolve. A value that
340
+ // appears N times in `valueArray` may be satisfied at most N times total across
341
+ // the key pass and the value fallback below — matching by count, not presence,
342
+ // on BOTH passes. This stops a duplicate value from being over-resolved: e.g.
343
+ // two keyed options that both carry `SEA` cannot both match a single requested
344
+ // `SEA` (which happens when `value` is set directly without clearing
345
+ // `_selectedKey`, so more keys survive than the value set now asks for).
346
+ const remaining = new Map();
347
+ valueArray.forEach((val) => remaining.set(val, (remaining.get(val) || 0) + 1));
348
+
349
+ // Resolve by key first: trust a key only when its option is still selectable
350
+ // and there is still an unmatched occurrence of its value in the request set.
351
+ if (Array.isArray(selectedKeys)) {
352
+ selectedKeys.forEach((key) => {
353
+ const keyed = items.find((item) => item._optionKey === key);
354
+ if (keyed && isSelectableByValue(keyed) && (remaining.get(keyed.value) || 0) > 0 && !resolvedSet.has(keyed)) {
355
+ resolved.push(keyed);
356
+ resolvedSet.add(keyed);
357
+ remaining.set(keyed.value, remaining.get(keyed.value) - 1);
358
+ }
359
+ });
360
+ }
361
+
362
+ // Fall back to value matching for the occurrences not resolved by key. Iterate
363
+ // the leftover per-value counts so a value that appears twice but was only
364
+ // resolved once by key still matches its remaining occurrence(s).
365
+ remaining.forEach((count, val) => {
366
+ for (let occurrence = 0; occurrence < count; occurrence += 1) {
367
+ const option = items.find((item) => isSelectableByValue(item) && item.value === val && !resolvedSet.has(item));
368
+ if (option) {
369
+ resolved.push(option);
370
+ resolvedSet.add(option);
371
+ }
372
+ }
373
+ });
374
+
375
+ // Always return in DOM order so display is consistent regardless of the order
376
+ // keys/values were selected. Every resolved option came from `items`, so an
377
+ // O(1) index lookup mirrors `_sortSelectedByDomOrder` and avoids the O(n)
378
+ // `items.indexOf` per comparison for large combobox option sets. Any element
379
+ // not in `items` (a stale snapshot from a future caller) sorts to the END via
380
+ // `?? items.length`, matching `_sortSelectedByDomOrder` and avoiding NaN
381
+ // comparisons.
382
+ const indexMap = new Map(items.map((item, index) => [
383
+ item,
384
+ index
385
+ ]));
386
+ resolved.sort((optionA, optionB) => (indexMap.get(optionA) ?? items.length) - (indexMap.get(optionB) ?? items.length));
387
+
388
+ return resolved;
389
+ }
390
+ /* eslint-enable no-underscore-dangle */
391
+
282
392
  /**
283
393
  * Helper method to dispatch custom events.
284
394
  * @param {HTMLElement} element - Element to dispatch event from.
@@ -304,6 +414,14 @@ function dispatchMenuEvent(element, eventName, detail = null) {
304
414
  // See LICENSE in the project root for license information.
305
415
 
306
416
 
417
+ /**
418
+ * Monotonically increasing counter used to give each menu instance a unique
419
+ * `_menuInstanceId` prefix. Auto-generating the id (rather than using a random
420
+ * string) keeps option keys deterministic and collision-free across menus.
421
+ * @private
422
+ */
423
+ let menuInstanceIdCounter = 0;
424
+
307
425
 
308
426
  /**
309
427
  * The `auro-menu` element provides users a way to select from a list of options.
@@ -375,9 +493,8 @@ class AuroMenu extends AuroElement {
375
493
 
376
494
  // Instance properties (non-reactive)
377
495
 
378
- /**
379
- * @private
380
- */
496
+ menuInstanceIdCounter += 1;
497
+
381
498
  Object.assign(this, {
382
499
  // Root-level menu (true) or a nested submenu (false)
383
500
  rootMenu: true,
@@ -387,6 +504,21 @@ class AuroMenu extends AuroElement {
387
504
  nestingSpacer: '<span class="nestingSpacer"></span>',
388
505
  // Loading indicator for slot elements
389
506
  loadingSlots: null,
507
+ // Unique id for this menu instance; prefixes every auto-generated option
508
+ // key so keys never collide across menus in the same document.
509
+ _menuInstanceId: `menu-${menuInstanceIdCounter}`,
510
+ // Monotonically increasing counter for option key generation. Never
511
+ // resets, so a key is never reused within this instance's lifetime.
512
+ _optionKeyCounter: 0,
513
+ // Key(s) of the option(s) the user has actively selected. A single string
514
+ // in single-select, an array in multi-select, undefined when nothing is
515
+ // user-selected. Used to disambiguate options that share a `value`.
516
+ _selectedKey: undefined,
517
+ // True only for the one updated() cycle following a user selection, so
518
+ // reconciliation trusts `_selectedKey`. A `value` change from a consumer's
519
+ // direct property assignment leaves this false, dropping the stale key so
520
+ // reconciliation falls back to first-by-value (see updated()).
521
+ _valueChangeFromSelection: false,
390
522
  });
391
523
  }
392
524
 
@@ -606,6 +738,13 @@ class AuroMenu extends AuroElement {
606
738
  return;
607
739
  }
608
740
 
741
+ // A programmatic value set carries no positional intent, so drop any
742
+ // `_selectedKey` left over from a prior user click. This makes reconciliation
743
+ // in updated() fall back to first-by-value (single) / value-in-DOM-order
744
+ // (multi), matching the documented contract for programmatic selection even
745
+ // when a stale key would still resolve to a duplicate-value option.
746
+ this._selectedKey = undefined;
747
+
609
748
  // `value` is a String property; stringify arrays so attribute reflection and `formattedValue` parsing stay correct.
610
749
  this.value = Array.isArray(value) ? JSON.stringify(value) : value;
611
750
  }
@@ -653,6 +792,17 @@ class AuroMenu extends AuroElement {
653
792
  updated(changedProperties) {
654
793
  super.updated(changedProperties);
655
794
 
795
+ // Consume the selection-driven flag for THIS cycle up front. Clearing it
796
+ // unconditionally — not only inside the `value` branch below — prevents it
797
+ // from lingering `true` when a selection produces a serialized `value`
798
+ // byte-identical to the current one, in which case Lit schedules no
799
+ // `value`-change cycle to consume it. A lingering flag would misclassify a
800
+ // later consumer's programmatic `value` set as selection-driven and keep a
801
+ // stale `_selectedKey`. The reconcile path below re-sets the instance flag
802
+ // after this point, so its intentional cross-cycle hand-off still works.
803
+ const valueChangeFromSelection = this._valueChangeFromSelection;
804
+ this._valueChangeFromSelection = false;
805
+
656
806
  // Single source of truth for 'auroMenu-selectedOption'. Selection handlers
657
807
  // mutate optionSelected and let Lit's update cycle dispatch here; the prior
658
808
  // .value comparison missed multi-select array changes and combined with the
@@ -676,6 +826,17 @@ class AuroMenu extends AuroElement {
676
826
  this.initItems();
677
827
  }
678
828
 
829
+ // Distinguish a selection-driven `value` change (a user click, which set
830
+ // the flag in handleSelectState / _sortSelectedByDomOrder) from a
831
+ // programmatic assignment by a consumer. A programmatic set carries no
832
+ // positional intent, so drop any leftover `_selectedKey` and let
833
+ // reconciliation fall back to first-by-value (single) / value-in-DOM-order
834
+ // (multi) — the same contract selectByValue() guarantees, even when a
835
+ // stale key would otherwise still resolve to a duplicate-value option.
836
+ if (!valueChangeFromSelection) {
837
+ this._selectedKey = undefined;
838
+ }
839
+
679
840
  // Set when reconciliation reassigns `value` below. That reassignment schedules a
680
841
  // second updated() cycle, so the `event`-attribute dispatch is deferred to that
681
842
  // cycle to avoid firing option custom events twice on the same selection.
@@ -693,19 +854,55 @@ class AuroMenu extends AuroElement {
693
854
  // Defensive default: `formattedValue` can be undefined for unexpected value types,
694
855
  // and calling `.includes` on undefined would throw during reconciliation.
695
856
  const valueArray = this.formattedValue || [];
696
- const matchingOptions = this.items ? this.items.filter((item) => isSelectableByValue(item) && valueArray.includes(item.value)) : [];
857
+ // Resolve by key first (the user's exact picks), then fall back to
858
+ // value matching for any values not resolved by key — so pre-selection
859
+ // and programmatic value sets keep working. Result is DOM-ordered.
860
+ const matchingOptions = resolveSelectedOptions(this.items, valueArray, this._selectedKey);
697
861
  newSelected = matchingOptions.length > 0 ? matchingOptions : undefined;
698
862
 
699
- // Reconcile `value` with the selectable set. Drop only entries whose option is
700
- // loaded but non-selectable (disabled/static) leaving them would desync `value`
701
- // from `optionSelected`, and the toggle handlers rebuild `value` from `formattedValue`,
702
- // so the rejected entry would resurface on the next select/deselect. Entries with no
703
- // matching item yet are preserved so async preselection still works once options render.
704
- const rejectedValues = this.items
705
- ? this.items.filter((item) => !isSelectableByValue(item) && valueArray.includes(item.value)).map((item) => item.value)
706
- : [];
707
- if (rejectedValues.length > 0) {
708
- const reconciled = valueArray.filter((val) => !rejectedValues.includes(val));
863
+ // Reconcile `value` with the selectable set. An occurrence is dropped
864
+ // only when it is loaded but no selectable option can satisfy it
865
+ // every loaded item sharing that value is non-selectable, or the value
866
+ // recurs more often than it has selectable options (a duplicate value
867
+ // whose extra siblings are disabled/static). This is count-based, not
868
+ // presence-based, so an enabled option is kept even when a disabled
869
+ // sibling shares its value mirroring how `resolveSelectedOptions`
870
+ // resolves the same set. Entries with no matching item yet are
871
+ // preserved so async preselection still works, and the toggle handlers
872
+ // rebuild `value` from `formattedValue`, so a rejected entry cannot
873
+ // resurface on the next select/deselect.
874
+ const selectableByValue = new Map();
875
+ const loadedValues = new Set();
876
+ if (this.items) {
877
+ this.items.forEach((item) => {
878
+ loadedValues.add(item.value);
879
+ if (isSelectableByValue(item)) {
880
+ selectableByValue.set(item.value, (selectableByValue.get(item.value) || 0) + 1);
881
+ }
882
+ });
883
+ }
884
+
885
+ const reconciled = valueArray.filter((val) => {
886
+ // Not loaded yet (async preselection) — keep for a later cycle.
887
+ if (!loadedValues.has(val)) {
888
+ return true;
889
+ }
890
+ // Consume one selectable option per occurrence; drop once exhausted.
891
+ const remaining = selectableByValue.get(val) || 0;
892
+ if (remaining > 0) {
893
+ selectableByValue.set(val, remaining - 1);
894
+ return true;
895
+ }
896
+ return false;
897
+ });
898
+
899
+ if (reconciled.length !== valueArray.length) {
900
+ // This is an internal correction, not a consumer's programmatic set,
901
+ // so preserve the selection-driven flag through the re-entrant
902
+ // updated() cycle it schedules. Otherwise that cycle would treat the
903
+ // reassignment as programmatic and drop `_selectedKey` mid-cascade,
904
+ // flipping resolution and looping.
905
+ this._valueChangeFromSelection = true;
709
906
  this.value = serializeMultiSelectValue(reconciled);
710
907
  valueReconciled = true;
711
908
  }
@@ -717,7 +914,11 @@ class AuroMenu extends AuroElement {
717
914
  // `hidden` is intentionally NOT excluded: the combobox toggles
718
915
  // `hidden` as its type-ahead filter, so a filtered-out option is
719
916
  // still a valid programmatic selection.
720
- const matchingOption = this.items ? this.items.find((item) => isSelectableByValue(item) && item.value === this.value) : undefined;
917
+ // Prefer the option the user actually selected (tracked by
918
+ // `_selectedKey`) so a click on the second of two options sharing a
919
+ // `value` resolves back to that exact element instead of the first
920
+ // value match. Falls back to first-by-value for programmatic sets.
921
+ const matchingOption = resolveSelectedOption(this.items, this.value, this._selectedKey);
721
922
 
722
923
  if (matchingOption) {
723
924
  newSelected = matchingOption;
@@ -945,6 +1146,14 @@ class AuroMenu extends AuroElement {
945
1146
  }
946
1147
  });
947
1148
 
1149
+ // Assign private keys once items are populated. Only the root menu assigns
1150
+ // keys: its `items` is a deep query that already includes nested submenu
1151
+ // options, so a single pass keys the entire tree. Nested menus skip this
1152
+ // and inherit keys from the root.
1153
+ if (this.rootMenu) {
1154
+ this._assignOptionKeys();
1155
+ }
1156
+
948
1157
  if (this.noCheckmark) {
949
1158
  this.updateItemsState(new Map([
950
1159
  [
@@ -961,6 +1170,31 @@ class AuroMenu extends AuroElement {
961
1170
  }));
962
1171
  }
963
1172
 
1173
+ /**
1174
+ * Assigns a private, auto-generated unique key (`_optionKey`) to each menu
1175
+ * option that does not already have one. Keys are internal state on the
1176
+ * element instance — never reflected as an attribute or exposed publicly —
1177
+ * and let selection tracking distinguish options that share the same `value`.
1178
+ *
1179
+ * The `_optionKey === undefined` guard makes this idempotent: options keep the
1180
+ * key they were first assigned across re-renders and slot changes, and if a
1181
+ * nested menu's lifecycle runs a pass before the root, options simply wait for
1182
+ * the root to key them (or keep whatever key they already hold).
1183
+ * @private
1184
+ */
1185
+ _assignOptionKeys() {
1186
+ if (!this.items) {
1187
+ return;
1188
+ }
1189
+
1190
+ this.items.forEach((option) => {
1191
+ if (option._optionKey === undefined) {
1192
+ this._optionKeyCounter += 1;
1193
+ option._optionKey = `${this._menuInstanceId}-${this._optionKeyCounter}`;
1194
+ }
1195
+ });
1196
+ }
1197
+
964
1198
  // Logic Methods
965
1199
 
966
1200
  /**
@@ -970,24 +1204,28 @@ class AuroMenu extends AuroElement {
970
1204
  */
971
1205
  handleSelectState(option) {
972
1206
  if (this.multiSelect) {
973
- const currentValue = this.formattedValue || [];
974
1207
  const currentSelected = this.optionSelected || [];
975
1208
 
976
- if (!currentValue.includes(option.value)) {
977
- this.value = serializeMultiSelectValue([
978
- ...currentValue,
979
- option.value
980
- ]);
981
- }
982
1209
  if (!currentSelected.includes(option)) {
983
1210
  this.optionSelected = [
984
1211
  ...currentSelected,
985
1212
  option
986
1213
  ];
987
1214
  }
1215
+
1216
+ // Re-sort by DOM order and rebuild `_selectedKey`/`value` from the
1217
+ // selected set so display order stays consistent with the menu, not with
1218
+ // click order.
1219
+ this._sortSelectedByDomOrder();
988
1220
  } else {
989
1221
  this.value = option.value;
990
1222
  this.optionSelected = option;
1223
+ // Track the specific option the user selected so the value→option
1224
+ // reconciliation in updated() resolves back to this exact element even
1225
+ // when another option shares the same `value`.
1226
+ this._selectedKey = option._optionKey;
1227
+ // Mark this `value` change as selection-driven so updated() trusts the key.
1228
+ this._valueChangeFromSelection = true;
991
1229
  }
992
1230
 
993
1231
  this._index = this.items.indexOf(option);
@@ -1000,18 +1238,22 @@ class AuroMenu extends AuroElement {
1000
1238
  */
1001
1239
  handleDeselectState(option) {
1002
1240
  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);
1241
+ // Remove this exact element from the selection (identity, not value two
1242
+ // options can share a `value`), then rebuild `value`/`_selectedKey` from
1243
+ // the remaining set in DOM order. An empty result collapses to undefined.
1244
+ this.optionSelected = this.optionSelected.filter((selected) => selected !== option);
1008
1245
  if (this.optionSelected.length === 0) {
1009
1246
  this.optionSelected = undefined;
1247
+ this._selectedKey = undefined;
1248
+ this.value = undefined;
1249
+ } else {
1250
+ this._sortSelectedByDomOrder();
1010
1251
  }
1011
1252
  } else {
1012
1253
  // For single-select: Back to undefined when deselected
1013
1254
  this.value = undefined;
1014
1255
  this.optionSelected = undefined;
1256
+ this._selectedKey = undefined;
1015
1257
  }
1016
1258
 
1017
1259
  // Update the index tracking
@@ -1035,9 +1277,46 @@ class AuroMenu extends AuroElement {
1035
1277
  clearSelection() {
1036
1278
  this.optionSelected = undefined;
1037
1279
  this.value = undefined;
1280
+ this._selectedKey = undefined;
1038
1281
  this._index = -1;
1039
1282
  }
1040
1283
 
1284
+ /**
1285
+ * Re-sorts the multi-select selection into DOM order and rebuilds the derived
1286
+ * `_selectedKey` and `value` from `optionSelected`. Selection is always stored
1287
+ * and serialized in the order options appear in the menu, never in click
1288
+ * order — so selecting C then A yields `[A, C]`.
1289
+ * @private
1290
+ */
1291
+ _sortSelectedByDomOrder() {
1292
+ if (!this.multiSelect || !Array.isArray(this.optionSelected) || !this.items) {
1293
+ return;
1294
+ }
1295
+
1296
+ const indexMap = new Map(this.items.map((item, index) => [
1297
+ item,
1298
+ index
1299
+ ]));
1300
+
1301
+ // Sorting in place mutates `optionSelected` without a new array reference,
1302
+ // which Lit's `===` change-detection cannot see on its own — but that is
1303
+ // intentional and safe: both callers (handleSelectState / handleDeselectState)
1304
+ // assign a fresh `optionSelected` array immediately before calling, so Lit
1305
+ // already has a changed reference to react to, and the `value` write below
1306
+ // schedules the updated() cycle that re-derives `optionSelected` in DOM order
1307
+ // via resolveSelectedOptions. Do not "fix" this into a new-array assignment.
1308
+ //
1309
+ // Sort any element no longer in `items` (a stale selection left over from a
1310
+ // dynamic rebuild that the consumer has not cleared) to the END rather than
1311
+ // the front, so it never displaces a live option to the head of the
1312
+ // serialized order. Value reconciliation drops it on the next updated() cycle.
1313
+ this.optionSelected.sort((optionA, optionB) => (indexMap.get(optionA) ?? this.items.length) - (indexMap.get(optionB) ?? this.items.length));
1314
+ this._selectedKey = this.optionSelected.map((option) => option._optionKey);
1315
+ this.value = serializeMultiSelectValue(this.optionSelected.map((option) => option.value));
1316
+ // Mark this `value` change as selection-driven so updated() trusts the keys.
1317
+ this._valueChangeFromSelection = true;
1318
+ }
1319
+
1041
1320
  /**
1042
1321
  * Resets the menu to its initial state.
1043
1322
  * This is the only way to return value to undefined.
@@ -1047,6 +1326,7 @@ class AuroMenu extends AuroElement {
1047
1326
  // Reset to undefined - initial state
1048
1327
  this.value = undefined;
1049
1328
  this.optionSelected = undefined;
1329
+ this._selectedKey = undefined;
1050
1330
  this._index = -1;
1051
1331
 
1052
1332
  // Clear active option state so a follow-up open/navigation starts fresh
@@ -1108,6 +1388,21 @@ class AuroMenu extends AuroElement {
1108
1388
  this.initItems();
1109
1389
  }
1110
1390
 
1391
+ // Recover `_index` from the highlighted option when it has been reset to -1.
1392
+ // The updated() reconciliation resets `_index = -1` whenever the value
1393
+ // collapses to undefined while `optionActive` still points at the highlighted
1394
+ // option — e.g. deselecting the last remaining option in multi-select, or a
1395
+ // programmatic clearSelection() in single-select while keyboard focus is on an
1396
+ // option. Without this, reading `items[-1]` returns undefined and the re-select
1397
+ // no-ops until the highlight is moved away and back. Mirrors auro-combobox's
1398
+ // reconcileMenuIndex.
1399
+ if (this._index < 0 && this.optionActive && this.items) {
1400
+ const activeIndex = this.items.indexOf(this.optionActive);
1401
+ if (activeIndex >= 0) {
1402
+ this._index = activeIndex;
1403
+ }
1404
+ }
1405
+
1111
1406
  // Get currently selected menu option based on index
1112
1407
  const option = this.items ? this.items[this._index] : undefined;
1113
1408