@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
@@ -4992,7 +4992,7 @@ let AuroHelpText$2 = class AuroHelpText extends i$3 {
4992
4992
  }
4993
4993
  };
4994
4994
 
4995
- var formkitVersion$2 = '202607291953';
4995
+ var formkitVersion$2 = '202608111803';
4996
4996
 
4997
4997
  let AuroElement$2 = class AuroElement extends i$3 {
4998
4998
  static get properties() {
@@ -5694,6 +5694,15 @@ class AuroDropdown extends AuroElement$2 {
5694
5694
 
5695
5695
  connectedCallback() {
5696
5696
  super.connectedCallback();
5697
+ // Re-register trigger event listeners removed by floater.disconnect() in
5698
+ // disconnectedCallback(). Fires when a parent element (e.g. auro-drawer)
5699
+ // moves this element to a new DOM location. firstUpdated() won't re-run,
5700
+ // so without this the trigger click/keydown handlers are permanently gone.
5701
+ // Guard on floater.element so this is a no-op on the initial connect
5702
+ // (before firstUpdated() has called configure()).
5703
+ if (this.floater?.element) {
5704
+ this.floater.configure(this, 'auroDropdown', !this.disableKeyboardHandling);
5705
+ }
5697
5706
  }
5698
5707
 
5699
5708
  disconnectedCallback() {
@@ -18681,7 +18690,7 @@ let AuroHelpText$1 = class AuroHelpText extends i$3 {
18681
18690
  }
18682
18691
  };
18683
18692
 
18684
- var formkitVersion$1 = '202607291953';
18693
+ var formkitVersion$1 = '202608111803';
18685
18694
 
18686
18695
  // Copyright (c) 2025 Alaska Airlines. All right reserved. Licensed under the Apache-2.0 license
18687
18696
  // See LICENSE in the project root for license information.
@@ -19837,7 +19846,7 @@ class AuroBibtemplate extends i$3 {
19837
19846
  }
19838
19847
  }
19839
19848
 
19840
- var formkitVersion = '202607291953';
19849
+ var formkitVersion = '202608111803';
19841
19850
 
19842
19851
  var styleCss$3 = i$6`.util_displayInline{display:inline}.util_displayInlineBlock{display:inline-block}.util_displayBlock{display:block}.util_displayFlex{display:flex}.util_displayHidden{display:none}.util_displayHiddenVisually{position:absolute;overflow:hidden;clip:rect(1px, 1px, 1px, 1px);width:1px;height:1px;padding:0;border:0}:host{display:block;text-align:left}:host [auro-dropdown]{--ds-auro-dropdown-trigger-background-color: transparent}:host #inputInBib::part(wrapper){box-shadow:none}:host #inputInBib::part(accent-left){display:none}:host([layout*=classic]) [auro-input]{width:100%}:host([layout*=classic]) [auro-input]::part(helpText){display:none}:host([layout*=classic]) #slotHolder{display:none}`;
19843
19852
 
@@ -21643,7 +21652,17 @@ class AuroCombobox extends AuroElement {
21643
21652
  return;
21644
21653
  }
21645
21654
 
21646
- this.value = this.input.value;
21655
+ // Skip this write during a display-value sync: that path sets the input to
21656
+ // the selected option's LABEL, which is not its machine value. When an
21657
+ // option's label differs from its value (and especially when several
21658
+ // options share a value but render distinct labels — AB#1602086), writing
21659
+ // the label back into this.value clobbers the machine value the selection
21660
+ // listener just set (this.value = optionSelected.value), collapsing the
21661
+ // selection. _syncingBibValue still tracks (that mirrors user-typed text
21662
+ // from the fullscreen bib, where value should follow the input).
21663
+ if (!this._syncingDisplayValue) {
21664
+ this.value = this.input.value;
21665
+ }
21647
21666
 
21648
21667
  // Ignore re-entrant input events caused by programmatic value sets.
21649
21668
  if (this._syncingBibValue || this._syncingDisplayValue) {
@@ -21907,33 +21926,53 @@ class AuroCombobox extends AuroElement {
21907
21926
  }
21908
21927
 
21909
21928
  if (this.input.value !== this.value) {
21910
- // Clear menu.value AND menu.optionSelected together. Clearing only
21911
- // menu.value leaves the previously-selected option element pinned
21912
- // as menu.optionSelected; a later auroMenu-selectedOption event
21913
- // would then write its stale .value back into combobox.value
21914
- // (e.g. Tab-after-Backspace re-selecting the prior option).
21915
- if (this.menu.value || this.menu.optionSelected) {
21916
- this.menu.clearSelection();
21917
- }
21929
+ // A fresh user selection leaves the input showing the option's LABEL
21930
+ // while this.value holds its machine value, so input.value !== this.value
21931
+ // is expected whenever label ≠ value. In that case the menu's current
21932
+ // selection is authoritative and when several options share a value it
21933
+ // is the ONLY thing that records WHICH same-value option the user picked
21934
+ // (the menu tracks it by element identity via `_selectedKey`). Clearing
21935
+ // it here drops that key; the ensuing value-only re-resolution then
21936
+ // collapses the selection onto the first same-value option (AB#1602086).
21937
+ // So only treat the divergence as a stale menu when the selected option
21938
+ // does NOT match the new value.
21939
+ const menuSelectionMatchesValue =
21940
+ this.menu.optionSelected &&
21941
+ // optionSelected is a single element here (scalar `.value`). A multiselect
21942
+ // menu exposes it as an ARRAY, which has no scalar `.value` to compare, so
21943
+ // that shape can't be a single-option match — fall through to the clear path.
21944
+ !Array.isArray(this.menu.optionSelected) &&
21945
+ this.menu.optionSelected.value === this.value;
21946
+
21947
+ if (!menuSelectionMatchesValue) {
21948
+ // Clear menu.value AND menu.optionSelected together. Clearing only
21949
+ // menu.value leaves the previously-selected option element pinned
21950
+ // as menu.optionSelected; a later auroMenu-selectedOption event
21951
+ // would then write its stale .value back into combobox.value
21952
+ // (e.g. Tab-after-Backspace re-selecting the prior option).
21953
+ if (this.menu.value || this.menu.optionSelected) {
21954
+ this.menu.clearSelection();
21955
+ }
21918
21956
 
21919
- if (!this.persistInput) {
21920
- this.syncInputValuesAcrossTriggerAndBib(this.value || '');
21921
- }
21957
+ if (!this.persistInput) {
21958
+ this.syncInputValuesAcrossTriggerAndBib(this.value || '');
21959
+ }
21922
21960
 
21923
- // Programmatic value with no matching option: updateFilter will close
21924
- // the bib silently (see line 648 — no noMatchOption + 0 results
21925
- // hides). Announce so screen-reader users hear the request was
21926
- // dropped. Gated on `input.value !== this.value` so this never fires
21927
- // for user typing — that path always reconciles input.value to
21928
- // this.value before updated() runs.
21929
- if (
21930
- this.value &&
21931
- this.menu &&
21932
- this.menu.options &&
21933
- this.menu.options.length > 0 &&
21934
- !this.menu.options.some((opt) => opt.value === this.value)
21935
- ) {
21936
- announceToScreenReader(this._getAnnouncementRoot(), `No matching option for ${this.value}`);
21961
+ // Programmatic value with no matching option: updateFilter will close
21962
+ // the bib silently (see line 648 — no noMatchOption + 0 results
21963
+ // hides). Announce so screen-reader users hear the request was
21964
+ // dropped. Gated on `input.value !== this.value` so this never fires
21965
+ // for user typing — that path always reconciles input.value to
21966
+ // this.value before updated() runs.
21967
+ if (
21968
+ this.value &&
21969
+ this.menu &&
21970
+ this.menu.options &&
21971
+ this.menu.options.length > 0 &&
21972
+ !this.menu.options.some((opt) => opt.value === this.value)
21973
+ ) {
21974
+ announceToScreenReader(this._getAnnouncementRoot(), `No matching option for ${this.value}`);
21975
+ }
21937
21976
  }
21938
21977
  }
21939
21978
 
@@ -22270,6 +22309,116 @@ function isSelectableByValue(option) {
22270
22309
  !option.hasAttribute('static');
22271
22310
  }
22272
22311
 
22312
+ /* eslint-disable no-underscore-dangle */
22313
+ /**
22314
+ * Resolves the single selected option for a given `value`, preferring the
22315
+ * option tracked by `selectedKey` (a user-initiated selection) over a
22316
+ * first-by-value match. When multiple options share the same `value`, matching
22317
+ * by `value` alone cannot distinguish which one the user picked; the key
22318
+ * disambiguates it.
22319
+ *
22320
+ * The key is trusted only when it still resolves to an option whose `value`
22321
+ * matches the requested `value`. If the key is stale (option removed) or the
22322
+ * value was changed programmatically, resolution falls back to value matching —
22323
+ * preserving backward-compatible behavior for preselection and `selectByValue`.
22324
+ * @private
22325
+ * @param {Array<HTMLElement>} items - The menu's flat option list.
22326
+ * @param {string} value - The value to resolve.
22327
+ * @param {string|undefined} selectedKey - The `_optionKey` of the user-selected option, if any.
22328
+ * @returns {HTMLElement|undefined} The resolved option, or undefined when none match.
22329
+ */
22330
+ function resolveSelectedOption(items, value, selectedKey) {
22331
+ if (!items) {
22332
+ return undefined;
22333
+ }
22334
+
22335
+ if (selectedKey !== undefined) {
22336
+ const keyed = items.find((item) => item._optionKey === selectedKey);
22337
+ if (keyed && isSelectableByValue(keyed) && keyed.value === value) {
22338
+ return keyed;
22339
+ }
22340
+ // Key exists but the option is gone or its value no longer matches — fall
22341
+ // through to value-based matching.
22342
+ }
22343
+
22344
+ return items.find((item) => isSelectableByValue(item) && item.value === value);
22345
+ }
22346
+
22347
+ /**
22348
+ * Resolves the selected options for a multi-select `value` array, preferring
22349
+ * options tracked by `selectedKeys` (user-initiated selections) and falling
22350
+ * back to value matching for any values not resolved by key. The result is
22351
+ * always sorted into DOM order regardless of selection sequence.
22352
+ * @private
22353
+ * @param {Array<HTMLElement>} items - The menu's flat option list.
22354
+ * @param {Array<string>} valueArray - The selected values.
22355
+ * @param {Array<string>|undefined} selectedKeys - The `_optionKey`s of the user-selected options, if any.
22356
+ * @returns {Array<HTMLElement>} The resolved options in DOM order.
22357
+ */
22358
+ function resolveSelectedOptions(items, valueArray, selectedKeys) {
22359
+ if (!items) {
22360
+ return [];
22361
+ }
22362
+
22363
+ const resolved = [];
22364
+ // Mirror `resolved` as a Set for O(1) membership checks below, matching the
22365
+ // indexMap optimization used for the sort rather than scanning `resolved`
22366
+ // on every candidate.
22367
+ const resolvedSet = new Set();
22368
+
22369
+ // Track how many of each value are still available to resolve. A value that
22370
+ // appears N times in `valueArray` may be satisfied at most N times total across
22371
+ // the key pass and the value fallback below — matching by count, not presence,
22372
+ // on BOTH passes. This stops a duplicate value from being over-resolved: e.g.
22373
+ // two keyed options that both carry `SEA` cannot both match a single requested
22374
+ // `SEA` (which happens when `value` is set directly without clearing
22375
+ // `_selectedKey`, so more keys survive than the value set now asks for).
22376
+ const remaining = new Map();
22377
+ valueArray.forEach((val) => remaining.set(val, (remaining.get(val) || 0) + 1));
22378
+
22379
+ // Resolve by key first: trust a key only when its option is still selectable
22380
+ // and there is still an unmatched occurrence of its value in the request set.
22381
+ if (Array.isArray(selectedKeys)) {
22382
+ selectedKeys.forEach((key) => {
22383
+ const keyed = items.find((item) => item._optionKey === key);
22384
+ if (keyed && isSelectableByValue(keyed) && (remaining.get(keyed.value) || 0) > 0 && !resolvedSet.has(keyed)) {
22385
+ resolved.push(keyed);
22386
+ resolvedSet.add(keyed);
22387
+ remaining.set(keyed.value, remaining.get(keyed.value) - 1);
22388
+ }
22389
+ });
22390
+ }
22391
+
22392
+ // Fall back to value matching for the occurrences not resolved by key. Iterate
22393
+ // the leftover per-value counts so a value that appears twice but was only
22394
+ // resolved once by key still matches its remaining occurrence(s).
22395
+ remaining.forEach((count, val) => {
22396
+ for (let occurrence = 0; occurrence < count; occurrence += 1) {
22397
+ const option = items.find((item) => isSelectableByValue(item) && item.value === val && !resolvedSet.has(item));
22398
+ if (option) {
22399
+ resolved.push(option);
22400
+ resolvedSet.add(option);
22401
+ }
22402
+ }
22403
+ });
22404
+
22405
+ // Always return in DOM order so display is consistent regardless of the order
22406
+ // keys/values were selected. Every resolved option came from `items`, so an
22407
+ // O(1) index lookup mirrors `_sortSelectedByDomOrder` and avoids the O(n)
22408
+ // `items.indexOf` per comparison for large combobox option sets. Any element
22409
+ // not in `items` (a stale snapshot from a future caller) sorts to the END via
22410
+ // `?? items.length`, matching `_sortSelectedByDomOrder` and avoiding NaN
22411
+ // comparisons.
22412
+ const indexMap = new Map(items.map((item, index) => [
22413
+ item,
22414
+ index
22415
+ ]));
22416
+ resolved.sort((optionA, optionB) => (indexMap.get(optionA) ?? items.length) - (indexMap.get(optionB) ?? items.length));
22417
+
22418
+ return resolved;
22419
+ }
22420
+ /* eslint-enable no-underscore-dangle */
22421
+
22273
22422
  /**
22274
22423
  * Helper method to dispatch custom events.
22275
22424
  * @param {HTMLElement} element - Element to dispatch event from.
@@ -22295,6 +22444,14 @@ function dispatchMenuEvent(element, eventName, detail = null) {
22295
22444
  // See LICENSE in the project root for license information.
22296
22445
 
22297
22446
 
22447
+ /**
22448
+ * Monotonically increasing counter used to give each menu instance a unique
22449
+ * `_menuInstanceId` prefix. Auto-generating the id (rather than using a random
22450
+ * string) keeps option keys deterministic and collision-free across menus.
22451
+ * @private
22452
+ */
22453
+ let menuInstanceIdCounter = 0;
22454
+
22298
22455
 
22299
22456
  /**
22300
22457
  * The `auro-menu` element provides users a way to select from a list of options.
@@ -22366,9 +22523,8 @@ class AuroMenu extends AuroElement {
22366
22523
 
22367
22524
  // Instance properties (non-reactive)
22368
22525
 
22369
- /**
22370
- * @private
22371
- */
22526
+ menuInstanceIdCounter += 1;
22527
+
22372
22528
  Object.assign(this, {
22373
22529
  // Root-level menu (true) or a nested submenu (false)
22374
22530
  rootMenu: true,
@@ -22378,6 +22534,21 @@ class AuroMenu extends AuroElement {
22378
22534
  nestingSpacer: '<span class="nestingSpacer"></span>',
22379
22535
  // Loading indicator for slot elements
22380
22536
  loadingSlots: null,
22537
+ // Unique id for this menu instance; prefixes every auto-generated option
22538
+ // key so keys never collide across menus in the same document.
22539
+ _menuInstanceId: `menu-${menuInstanceIdCounter}`,
22540
+ // Monotonically increasing counter for option key generation. Never
22541
+ // resets, so a key is never reused within this instance's lifetime.
22542
+ _optionKeyCounter: 0,
22543
+ // Key(s) of the option(s) the user has actively selected. A single string
22544
+ // in single-select, an array in multi-select, undefined when nothing is
22545
+ // user-selected. Used to disambiguate options that share a `value`.
22546
+ _selectedKey: undefined,
22547
+ // True only for the one updated() cycle following a user selection, so
22548
+ // reconciliation trusts `_selectedKey`. A `value` change from a consumer's
22549
+ // direct property assignment leaves this false, dropping the stale key so
22550
+ // reconciliation falls back to first-by-value (see updated()).
22551
+ _valueChangeFromSelection: false,
22381
22552
  });
22382
22553
  }
22383
22554
 
@@ -22597,6 +22768,13 @@ class AuroMenu extends AuroElement {
22597
22768
  return;
22598
22769
  }
22599
22770
 
22771
+ // A programmatic value set carries no positional intent, so drop any
22772
+ // `_selectedKey` left over from a prior user click. This makes reconciliation
22773
+ // in updated() fall back to first-by-value (single) / value-in-DOM-order
22774
+ // (multi), matching the documented contract for programmatic selection even
22775
+ // when a stale key would still resolve to a duplicate-value option.
22776
+ this._selectedKey = undefined;
22777
+
22600
22778
  // `value` is a String property; stringify arrays so attribute reflection and `formattedValue` parsing stay correct.
22601
22779
  this.value = Array.isArray(value) ? JSON.stringify(value) : value;
22602
22780
  }
@@ -22644,6 +22822,17 @@ class AuroMenu extends AuroElement {
22644
22822
  updated(changedProperties) {
22645
22823
  super.updated(changedProperties);
22646
22824
 
22825
+ // Consume the selection-driven flag for THIS cycle up front. Clearing it
22826
+ // unconditionally — not only inside the `value` branch below — prevents it
22827
+ // from lingering `true` when a selection produces a serialized `value`
22828
+ // byte-identical to the current one, in which case Lit schedules no
22829
+ // `value`-change cycle to consume it. A lingering flag would misclassify a
22830
+ // later consumer's programmatic `value` set as selection-driven and keep a
22831
+ // stale `_selectedKey`. The reconcile path below re-sets the instance flag
22832
+ // after this point, so its intentional cross-cycle hand-off still works.
22833
+ const valueChangeFromSelection = this._valueChangeFromSelection;
22834
+ this._valueChangeFromSelection = false;
22835
+
22647
22836
  // Single source of truth for 'auroMenu-selectedOption'. Selection handlers
22648
22837
  // mutate optionSelected and let Lit's update cycle dispatch here; the prior
22649
22838
  // .value comparison missed multi-select array changes and combined with the
@@ -22667,6 +22856,17 @@ class AuroMenu extends AuroElement {
22667
22856
  this.initItems();
22668
22857
  }
22669
22858
 
22859
+ // Distinguish a selection-driven `value` change (a user click, which set
22860
+ // the flag in handleSelectState / _sortSelectedByDomOrder) from a
22861
+ // programmatic assignment by a consumer. A programmatic set carries no
22862
+ // positional intent, so drop any leftover `_selectedKey` and let
22863
+ // reconciliation fall back to first-by-value (single) / value-in-DOM-order
22864
+ // (multi) — the same contract selectByValue() guarantees, even when a
22865
+ // stale key would otherwise still resolve to a duplicate-value option.
22866
+ if (!valueChangeFromSelection) {
22867
+ this._selectedKey = undefined;
22868
+ }
22869
+
22670
22870
  // Set when reconciliation reassigns `value` below. That reassignment schedules a
22671
22871
  // second updated() cycle, so the `event`-attribute dispatch is deferred to that
22672
22872
  // cycle to avoid firing option custom events twice on the same selection.
@@ -22684,19 +22884,55 @@ class AuroMenu extends AuroElement {
22684
22884
  // Defensive default: `formattedValue` can be undefined for unexpected value types,
22685
22885
  // and calling `.includes` on undefined would throw during reconciliation.
22686
22886
  const valueArray = this.formattedValue || [];
22687
- const matchingOptions = this.items ? this.items.filter((item) => isSelectableByValue(item) && valueArray.includes(item.value)) : [];
22887
+ // Resolve by key first (the user's exact picks), then fall back to
22888
+ // value matching for any values not resolved by key — so pre-selection
22889
+ // and programmatic value sets keep working. Result is DOM-ordered.
22890
+ const matchingOptions = resolveSelectedOptions(this.items, valueArray, this._selectedKey);
22688
22891
  newSelected = matchingOptions.length > 0 ? matchingOptions : undefined;
22689
22892
 
22690
- // Reconcile `value` with the selectable set. Drop only entries whose option is
22691
- // loaded but non-selectable (disabled/static) leaving them would desync `value`
22692
- // from `optionSelected`, and the toggle handlers rebuild `value` from `formattedValue`,
22693
- // so the rejected entry would resurface on the next select/deselect. Entries with no
22694
- // matching item yet are preserved so async preselection still works once options render.
22695
- const rejectedValues = this.items
22696
- ? this.items.filter((item) => !isSelectableByValue(item) && valueArray.includes(item.value)).map((item) => item.value)
22697
- : [];
22698
- if (rejectedValues.length > 0) {
22699
- const reconciled = valueArray.filter((val) => !rejectedValues.includes(val));
22893
+ // Reconcile `value` with the selectable set. An occurrence is dropped
22894
+ // only when it is loaded but no selectable option can satisfy it
22895
+ // every loaded item sharing that value is non-selectable, or the value
22896
+ // recurs more often than it has selectable options (a duplicate value
22897
+ // whose extra siblings are disabled/static). This is count-based, not
22898
+ // presence-based, so an enabled option is kept even when a disabled
22899
+ // sibling shares its value mirroring how `resolveSelectedOptions`
22900
+ // resolves the same set. Entries with no matching item yet are
22901
+ // preserved so async preselection still works, and the toggle handlers
22902
+ // rebuild `value` from `formattedValue`, so a rejected entry cannot
22903
+ // resurface on the next select/deselect.
22904
+ const selectableByValue = new Map();
22905
+ const loadedValues = new Set();
22906
+ if (this.items) {
22907
+ this.items.forEach((item) => {
22908
+ loadedValues.add(item.value);
22909
+ if (isSelectableByValue(item)) {
22910
+ selectableByValue.set(item.value, (selectableByValue.get(item.value) || 0) + 1);
22911
+ }
22912
+ });
22913
+ }
22914
+
22915
+ const reconciled = valueArray.filter((val) => {
22916
+ // Not loaded yet (async preselection) — keep for a later cycle.
22917
+ if (!loadedValues.has(val)) {
22918
+ return true;
22919
+ }
22920
+ // Consume one selectable option per occurrence; drop once exhausted.
22921
+ const remaining = selectableByValue.get(val) || 0;
22922
+ if (remaining > 0) {
22923
+ selectableByValue.set(val, remaining - 1);
22924
+ return true;
22925
+ }
22926
+ return false;
22927
+ });
22928
+
22929
+ if (reconciled.length !== valueArray.length) {
22930
+ // This is an internal correction, not a consumer's programmatic set,
22931
+ // so preserve the selection-driven flag through the re-entrant
22932
+ // updated() cycle it schedules. Otherwise that cycle would treat the
22933
+ // reassignment as programmatic and drop `_selectedKey` mid-cascade,
22934
+ // flipping resolution and looping.
22935
+ this._valueChangeFromSelection = true;
22700
22936
  this.value = serializeMultiSelectValue(reconciled);
22701
22937
  valueReconciled = true;
22702
22938
  }
@@ -22708,7 +22944,11 @@ class AuroMenu extends AuroElement {
22708
22944
  // `hidden` is intentionally NOT excluded: the combobox toggles
22709
22945
  // `hidden` as its type-ahead filter, so a filtered-out option is
22710
22946
  // still a valid programmatic selection.
22711
- const matchingOption = this.items ? this.items.find((item) => isSelectableByValue(item) && item.value === this.value) : undefined;
22947
+ // Prefer the option the user actually selected (tracked by
22948
+ // `_selectedKey`) so a click on the second of two options sharing a
22949
+ // `value` resolves back to that exact element instead of the first
22950
+ // value match. Falls back to first-by-value for programmatic sets.
22951
+ const matchingOption = resolveSelectedOption(this.items, this.value, this._selectedKey);
22712
22952
 
22713
22953
  if (matchingOption) {
22714
22954
  newSelected = matchingOption;
@@ -22936,6 +23176,14 @@ class AuroMenu extends AuroElement {
22936
23176
  }
22937
23177
  });
22938
23178
 
23179
+ // Assign private keys once items are populated. Only the root menu assigns
23180
+ // keys: its `items` is a deep query that already includes nested submenu
23181
+ // options, so a single pass keys the entire tree. Nested menus skip this
23182
+ // and inherit keys from the root.
23183
+ if (this.rootMenu) {
23184
+ this._assignOptionKeys();
23185
+ }
23186
+
22939
23187
  if (this.noCheckmark) {
22940
23188
  this.updateItemsState(new Map([
22941
23189
  [
@@ -22952,6 +23200,31 @@ class AuroMenu extends AuroElement {
22952
23200
  }));
22953
23201
  }
22954
23202
 
23203
+ /**
23204
+ * Assigns a private, auto-generated unique key (`_optionKey`) to each menu
23205
+ * option that does not already have one. Keys are internal state on the
23206
+ * element instance — never reflected as an attribute or exposed publicly —
23207
+ * and let selection tracking distinguish options that share the same `value`.
23208
+ *
23209
+ * The `_optionKey === undefined` guard makes this idempotent: options keep the
23210
+ * key they were first assigned across re-renders and slot changes, and if a
23211
+ * nested menu's lifecycle runs a pass before the root, options simply wait for
23212
+ * the root to key them (or keep whatever key they already hold).
23213
+ * @private
23214
+ */
23215
+ _assignOptionKeys() {
23216
+ if (!this.items) {
23217
+ return;
23218
+ }
23219
+
23220
+ this.items.forEach((option) => {
23221
+ if (option._optionKey === undefined) {
23222
+ this._optionKeyCounter += 1;
23223
+ option._optionKey = `${this._menuInstanceId}-${this._optionKeyCounter}`;
23224
+ }
23225
+ });
23226
+ }
23227
+
22955
23228
  // Logic Methods
22956
23229
 
22957
23230
  /**
@@ -22961,24 +23234,28 @@ class AuroMenu extends AuroElement {
22961
23234
  */
22962
23235
  handleSelectState(option) {
22963
23236
  if (this.multiSelect) {
22964
- const currentValue = this.formattedValue || [];
22965
23237
  const currentSelected = this.optionSelected || [];
22966
23238
 
22967
- if (!currentValue.includes(option.value)) {
22968
- this.value = serializeMultiSelectValue([
22969
- ...currentValue,
22970
- option.value
22971
- ]);
22972
- }
22973
23239
  if (!currentSelected.includes(option)) {
22974
23240
  this.optionSelected = [
22975
23241
  ...currentSelected,
22976
23242
  option
22977
23243
  ];
22978
23244
  }
23245
+
23246
+ // Re-sort by DOM order and rebuild `_selectedKey`/`value` from the
23247
+ // selected set so display order stays consistent with the menu, not with
23248
+ // click order.
23249
+ this._sortSelectedByDomOrder();
22979
23250
  } else {
22980
23251
  this.value = option.value;
22981
23252
  this.optionSelected = option;
23253
+ // Track the specific option the user selected so the value→option
23254
+ // reconciliation in updated() resolves back to this exact element even
23255
+ // when another option shares the same `value`.
23256
+ this._selectedKey = option._optionKey;
23257
+ // Mark this `value` change as selection-driven so updated() trusts the key.
23258
+ this._valueChangeFromSelection = true;
22982
23259
  }
22983
23260
 
22984
23261
  this._index = this.items.indexOf(option);
@@ -22991,18 +23268,22 @@ class AuroMenu extends AuroElement {
22991
23268
  */
22992
23269
  handleDeselectState(option) {
22993
23270
  if (this.multiSelect) {
22994
- // Remove this option from array; an empty result collapses `value` to undefined.
22995
- const newFormattedValue = (this.formattedValue || []).filter((val) => val !== option.value);
22996
- this.value = serializeMultiSelectValue(newFormattedValue);
22997
-
22998
- this.optionSelected = this.optionSelected.filter((val) => val !== option);
23271
+ // Remove this exact element from the selection (identity, not value two
23272
+ // options can share a `value`), then rebuild `value`/`_selectedKey` from
23273
+ // the remaining set in DOM order. An empty result collapses to undefined.
23274
+ this.optionSelected = this.optionSelected.filter((selected) => selected !== option);
22999
23275
  if (this.optionSelected.length === 0) {
23000
23276
  this.optionSelected = undefined;
23277
+ this._selectedKey = undefined;
23278
+ this.value = undefined;
23279
+ } else {
23280
+ this._sortSelectedByDomOrder();
23001
23281
  }
23002
23282
  } else {
23003
23283
  // For single-select: Back to undefined when deselected
23004
23284
  this.value = undefined;
23005
23285
  this.optionSelected = undefined;
23286
+ this._selectedKey = undefined;
23006
23287
  }
23007
23288
 
23008
23289
  // Update the index tracking
@@ -23026,9 +23307,46 @@ class AuroMenu extends AuroElement {
23026
23307
  clearSelection() {
23027
23308
  this.optionSelected = undefined;
23028
23309
  this.value = undefined;
23310
+ this._selectedKey = undefined;
23029
23311
  this._index = -1;
23030
23312
  }
23031
23313
 
23314
+ /**
23315
+ * Re-sorts the multi-select selection into DOM order and rebuilds the derived
23316
+ * `_selectedKey` and `value` from `optionSelected`. Selection is always stored
23317
+ * and serialized in the order options appear in the menu, never in click
23318
+ * order — so selecting C then A yields `[A, C]`.
23319
+ * @private
23320
+ */
23321
+ _sortSelectedByDomOrder() {
23322
+ if (!this.multiSelect || !Array.isArray(this.optionSelected) || !this.items) {
23323
+ return;
23324
+ }
23325
+
23326
+ const indexMap = new Map(this.items.map((item, index) => [
23327
+ item,
23328
+ index
23329
+ ]));
23330
+
23331
+ // Sorting in place mutates `optionSelected` without a new array reference,
23332
+ // which Lit's `===` change-detection cannot see on its own — but that is
23333
+ // intentional and safe: both callers (handleSelectState / handleDeselectState)
23334
+ // assign a fresh `optionSelected` array immediately before calling, so Lit
23335
+ // already has a changed reference to react to, and the `value` write below
23336
+ // schedules the updated() cycle that re-derives `optionSelected` in DOM order
23337
+ // via resolveSelectedOptions. Do not "fix" this into a new-array assignment.
23338
+ //
23339
+ // Sort any element no longer in `items` (a stale selection left over from a
23340
+ // dynamic rebuild that the consumer has not cleared) to the END rather than
23341
+ // the front, so it never displaces a live option to the head of the
23342
+ // serialized order. Value reconciliation drops it on the next updated() cycle.
23343
+ this.optionSelected.sort((optionA, optionB) => (indexMap.get(optionA) ?? this.items.length) - (indexMap.get(optionB) ?? this.items.length));
23344
+ this._selectedKey = this.optionSelected.map((option) => option._optionKey);
23345
+ this.value = serializeMultiSelectValue(this.optionSelected.map((option) => option.value));
23346
+ // Mark this `value` change as selection-driven so updated() trusts the keys.
23347
+ this._valueChangeFromSelection = true;
23348
+ }
23349
+
23032
23350
  /**
23033
23351
  * Resets the menu to its initial state.
23034
23352
  * This is the only way to return value to undefined.
@@ -23038,6 +23356,7 @@ class AuroMenu extends AuroElement {
23038
23356
  // Reset to undefined - initial state
23039
23357
  this.value = undefined;
23040
23358
  this.optionSelected = undefined;
23359
+ this._selectedKey = undefined;
23041
23360
  this._index = -1;
23042
23361
 
23043
23362
  // Clear active option state so a follow-up open/navigation starts fresh
@@ -23099,6 +23418,21 @@ class AuroMenu extends AuroElement {
23099
23418
  this.initItems();
23100
23419
  }
23101
23420
 
23421
+ // Recover `_index` from the highlighted option when it has been reset to -1.
23422
+ // The updated() reconciliation resets `_index = -1` whenever the value
23423
+ // collapses to undefined while `optionActive` still points at the highlighted
23424
+ // option — e.g. deselecting the last remaining option in multi-select, or a
23425
+ // programmatic clearSelection() in single-select while keyboard focus is on an
23426
+ // option. Without this, reading `items[-1]` returns undefined and the re-select
23427
+ // no-ops until the highlight is moved away and back. Mirrors auro-combobox's
23428
+ // reconcileMenuIndex.
23429
+ if (this._index < 0 && this.optionActive && this.items) {
23430
+ const activeIndex = this.items.indexOf(this.optionActive);
23431
+ if (activeIndex >= 0) {
23432
+ this._index = activeIndex;
23433
+ }
23434
+ }
23435
+
23102
23436
  // Get currently selected menu option based on index
23103
23437
  const option = this.items ? this.items[this._index] : undefined;
23104
23438