@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
@@ -4977,7 +4977,7 @@ let AuroHelpText$2 = class AuroHelpText extends i$3 {
4977
4977
  }
4978
4978
  };
4979
4979
 
4980
- var formkitVersion$2 = '202607291953';
4980
+ var formkitVersion$2 = '202608111803';
4981
4981
 
4982
4982
  let AuroElement$2 = class AuroElement extends i$3 {
4983
4983
  static get properties() {
@@ -5679,6 +5679,15 @@ class AuroDropdown extends AuroElement$2 {
5679
5679
 
5680
5680
  connectedCallback() {
5681
5681
  super.connectedCallback();
5682
+ // Re-register trigger event listeners removed by floater.disconnect() in
5683
+ // disconnectedCallback(). Fires when a parent element (e.g. auro-drawer)
5684
+ // moves this element to a new DOM location. firstUpdated() won't re-run,
5685
+ // so without this the trigger click/keydown handlers are permanently gone.
5686
+ // Guard on floater.element so this is a no-op on the initial connect
5687
+ // (before firstUpdated() has called configure()).
5688
+ if (this.floater?.element) {
5689
+ this.floater.configure(this, 'auroDropdown', !this.disableKeyboardHandling);
5690
+ }
5682
5691
  }
5683
5692
 
5684
5693
  disconnectedCallback() {
@@ -18666,7 +18675,7 @@ let AuroHelpText$1 = class AuroHelpText extends i$3 {
18666
18675
  }
18667
18676
  };
18668
18677
 
18669
- var formkitVersion$1 = '202607291953';
18678
+ var formkitVersion$1 = '202608111803';
18670
18679
 
18671
18680
  // Copyright (c) 2025 Alaska Airlines. All right reserved. Licensed under the Apache-2.0 license
18672
18681
  // See LICENSE in the project root for license information.
@@ -19822,7 +19831,7 @@ class AuroBibtemplate extends i$3 {
19822
19831
  }
19823
19832
  }
19824
19833
 
19825
- var formkitVersion = '202607291953';
19834
+ var formkitVersion = '202608111803';
19826
19835
 
19827
19836
  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}`;
19828
19837
 
@@ -21628,7 +21637,17 @@ class AuroCombobox extends AuroElement {
21628
21637
  return;
21629
21638
  }
21630
21639
 
21631
- this.value = this.input.value;
21640
+ // Skip this write during a display-value sync: that path sets the input to
21641
+ // the selected option's LABEL, which is not its machine value. When an
21642
+ // option's label differs from its value (and especially when several
21643
+ // options share a value but render distinct labels — AB#1602086), writing
21644
+ // the label back into this.value clobbers the machine value the selection
21645
+ // listener just set (this.value = optionSelected.value), collapsing the
21646
+ // selection. _syncingBibValue still tracks (that mirrors user-typed text
21647
+ // from the fullscreen bib, where value should follow the input).
21648
+ if (!this._syncingDisplayValue) {
21649
+ this.value = this.input.value;
21650
+ }
21632
21651
 
21633
21652
  // Ignore re-entrant input events caused by programmatic value sets.
21634
21653
  if (this._syncingBibValue || this._syncingDisplayValue) {
@@ -21892,33 +21911,53 @@ class AuroCombobox extends AuroElement {
21892
21911
  }
21893
21912
 
21894
21913
  if (this.input.value !== this.value) {
21895
- // Clear menu.value AND menu.optionSelected together. Clearing only
21896
- // menu.value leaves the previously-selected option element pinned
21897
- // as menu.optionSelected; a later auroMenu-selectedOption event
21898
- // would then write its stale .value back into combobox.value
21899
- // (e.g. Tab-after-Backspace re-selecting the prior option).
21900
- if (this.menu.value || this.menu.optionSelected) {
21901
- this.menu.clearSelection();
21902
- }
21914
+ // A fresh user selection leaves the input showing the option's LABEL
21915
+ // while this.value holds its machine value, so input.value !== this.value
21916
+ // is expected whenever label ≠ value. In that case the menu's current
21917
+ // selection is authoritative and when several options share a value it
21918
+ // is the ONLY thing that records WHICH same-value option the user picked
21919
+ // (the menu tracks it by element identity via `_selectedKey`). Clearing
21920
+ // it here drops that key; the ensuing value-only re-resolution then
21921
+ // collapses the selection onto the first same-value option (AB#1602086).
21922
+ // So only treat the divergence as a stale menu when the selected option
21923
+ // does NOT match the new value.
21924
+ const menuSelectionMatchesValue =
21925
+ this.menu.optionSelected &&
21926
+ // optionSelected is a single element here (scalar `.value`). A multiselect
21927
+ // menu exposes it as an ARRAY, which has no scalar `.value` to compare, so
21928
+ // that shape can't be a single-option match — fall through to the clear path.
21929
+ !Array.isArray(this.menu.optionSelected) &&
21930
+ this.menu.optionSelected.value === this.value;
21931
+
21932
+ if (!menuSelectionMatchesValue) {
21933
+ // Clear menu.value AND menu.optionSelected together. Clearing only
21934
+ // menu.value leaves the previously-selected option element pinned
21935
+ // as menu.optionSelected; a later auroMenu-selectedOption event
21936
+ // would then write its stale .value back into combobox.value
21937
+ // (e.g. Tab-after-Backspace re-selecting the prior option).
21938
+ if (this.menu.value || this.menu.optionSelected) {
21939
+ this.menu.clearSelection();
21940
+ }
21903
21941
 
21904
- if (!this.persistInput) {
21905
- this.syncInputValuesAcrossTriggerAndBib(this.value || '');
21906
- }
21942
+ if (!this.persistInput) {
21943
+ this.syncInputValuesAcrossTriggerAndBib(this.value || '');
21944
+ }
21907
21945
 
21908
- // Programmatic value with no matching option: updateFilter will close
21909
- // the bib silently (see line 648 — no noMatchOption + 0 results
21910
- // hides). Announce so screen-reader users hear the request was
21911
- // dropped. Gated on `input.value !== this.value` so this never fires
21912
- // for user typing — that path always reconciles input.value to
21913
- // this.value before updated() runs.
21914
- if (
21915
- this.value &&
21916
- this.menu &&
21917
- this.menu.options &&
21918
- this.menu.options.length > 0 &&
21919
- !this.menu.options.some((opt) => opt.value === this.value)
21920
- ) {
21921
- announceToScreenReader(this._getAnnouncementRoot(), `No matching option for ${this.value}`);
21946
+ // Programmatic value with no matching option: updateFilter will close
21947
+ // the bib silently (see line 648 — no noMatchOption + 0 results
21948
+ // hides). Announce so screen-reader users hear the request was
21949
+ // dropped. Gated on `input.value !== this.value` so this never fires
21950
+ // for user typing — that path always reconciles input.value to
21951
+ // this.value before updated() runs.
21952
+ if (
21953
+ this.value &&
21954
+ this.menu &&
21955
+ this.menu.options &&
21956
+ this.menu.options.length > 0 &&
21957
+ !this.menu.options.some((opt) => opt.value === this.value)
21958
+ ) {
21959
+ announceToScreenReader(this._getAnnouncementRoot(), `No matching option for ${this.value}`);
21960
+ }
21922
21961
  }
21923
21962
  }
21924
21963
 
@@ -22255,6 +22294,116 @@ function isSelectableByValue(option) {
22255
22294
  !option.hasAttribute('static');
22256
22295
  }
22257
22296
 
22297
+ /* eslint-disable no-underscore-dangle */
22298
+ /**
22299
+ * Resolves the single selected option for a given `value`, preferring the
22300
+ * option tracked by `selectedKey` (a user-initiated selection) over a
22301
+ * first-by-value match. When multiple options share the same `value`, matching
22302
+ * by `value` alone cannot distinguish which one the user picked; the key
22303
+ * disambiguates it.
22304
+ *
22305
+ * The key is trusted only when it still resolves to an option whose `value`
22306
+ * matches the requested `value`. If the key is stale (option removed) or the
22307
+ * value was changed programmatically, resolution falls back to value matching —
22308
+ * preserving backward-compatible behavior for preselection and `selectByValue`.
22309
+ * @private
22310
+ * @param {Array<HTMLElement>} items - The menu's flat option list.
22311
+ * @param {string} value - The value to resolve.
22312
+ * @param {string|undefined} selectedKey - The `_optionKey` of the user-selected option, if any.
22313
+ * @returns {HTMLElement|undefined} The resolved option, or undefined when none match.
22314
+ */
22315
+ function resolveSelectedOption(items, value, selectedKey) {
22316
+ if (!items) {
22317
+ return undefined;
22318
+ }
22319
+
22320
+ if (selectedKey !== undefined) {
22321
+ const keyed = items.find((item) => item._optionKey === selectedKey);
22322
+ if (keyed && isSelectableByValue(keyed) && keyed.value === value) {
22323
+ return keyed;
22324
+ }
22325
+ // Key exists but the option is gone or its value no longer matches — fall
22326
+ // through to value-based matching.
22327
+ }
22328
+
22329
+ return items.find((item) => isSelectableByValue(item) && item.value === value);
22330
+ }
22331
+
22332
+ /**
22333
+ * Resolves the selected options for a multi-select `value` array, preferring
22334
+ * options tracked by `selectedKeys` (user-initiated selections) and falling
22335
+ * back to value matching for any values not resolved by key. The result is
22336
+ * always sorted into DOM order regardless of selection sequence.
22337
+ * @private
22338
+ * @param {Array<HTMLElement>} items - The menu's flat option list.
22339
+ * @param {Array<string>} valueArray - The selected values.
22340
+ * @param {Array<string>|undefined} selectedKeys - The `_optionKey`s of the user-selected options, if any.
22341
+ * @returns {Array<HTMLElement>} The resolved options in DOM order.
22342
+ */
22343
+ function resolveSelectedOptions(items, valueArray, selectedKeys) {
22344
+ if (!items) {
22345
+ return [];
22346
+ }
22347
+
22348
+ const resolved = [];
22349
+ // Mirror `resolved` as a Set for O(1) membership checks below, matching the
22350
+ // indexMap optimization used for the sort rather than scanning `resolved`
22351
+ // on every candidate.
22352
+ const resolvedSet = new Set();
22353
+
22354
+ // Track how many of each value are still available to resolve. A value that
22355
+ // appears N times in `valueArray` may be satisfied at most N times total across
22356
+ // the key pass and the value fallback below — matching by count, not presence,
22357
+ // on BOTH passes. This stops a duplicate value from being over-resolved: e.g.
22358
+ // two keyed options that both carry `SEA` cannot both match a single requested
22359
+ // `SEA` (which happens when `value` is set directly without clearing
22360
+ // `_selectedKey`, so more keys survive than the value set now asks for).
22361
+ const remaining = new Map();
22362
+ valueArray.forEach((val) => remaining.set(val, (remaining.get(val) || 0) + 1));
22363
+
22364
+ // Resolve by key first: trust a key only when its option is still selectable
22365
+ // and there is still an unmatched occurrence of its value in the request set.
22366
+ if (Array.isArray(selectedKeys)) {
22367
+ selectedKeys.forEach((key) => {
22368
+ const keyed = items.find((item) => item._optionKey === key);
22369
+ if (keyed && isSelectableByValue(keyed) && (remaining.get(keyed.value) || 0) > 0 && !resolvedSet.has(keyed)) {
22370
+ resolved.push(keyed);
22371
+ resolvedSet.add(keyed);
22372
+ remaining.set(keyed.value, remaining.get(keyed.value) - 1);
22373
+ }
22374
+ });
22375
+ }
22376
+
22377
+ // Fall back to value matching for the occurrences not resolved by key. Iterate
22378
+ // the leftover per-value counts so a value that appears twice but was only
22379
+ // resolved once by key still matches its remaining occurrence(s).
22380
+ remaining.forEach((count, val) => {
22381
+ for (let occurrence = 0; occurrence < count; occurrence += 1) {
22382
+ const option = items.find((item) => isSelectableByValue(item) && item.value === val && !resolvedSet.has(item));
22383
+ if (option) {
22384
+ resolved.push(option);
22385
+ resolvedSet.add(option);
22386
+ }
22387
+ }
22388
+ });
22389
+
22390
+ // Always return in DOM order so display is consistent regardless of the order
22391
+ // keys/values were selected. Every resolved option came from `items`, so an
22392
+ // O(1) index lookup mirrors `_sortSelectedByDomOrder` and avoids the O(n)
22393
+ // `items.indexOf` per comparison for large combobox option sets. Any element
22394
+ // not in `items` (a stale snapshot from a future caller) sorts to the END via
22395
+ // `?? items.length`, matching `_sortSelectedByDomOrder` and avoiding NaN
22396
+ // comparisons.
22397
+ const indexMap = new Map(items.map((item, index) => [
22398
+ item,
22399
+ index
22400
+ ]));
22401
+ resolved.sort((optionA, optionB) => (indexMap.get(optionA) ?? items.length) - (indexMap.get(optionB) ?? items.length));
22402
+
22403
+ return resolved;
22404
+ }
22405
+ /* eslint-enable no-underscore-dangle */
22406
+
22258
22407
  /**
22259
22408
  * Helper method to dispatch custom events.
22260
22409
  * @param {HTMLElement} element - Element to dispatch event from.
@@ -22280,6 +22429,14 @@ function dispatchMenuEvent(element, eventName, detail = null) {
22280
22429
  // See LICENSE in the project root for license information.
22281
22430
 
22282
22431
 
22432
+ /**
22433
+ * Monotonically increasing counter used to give each menu instance a unique
22434
+ * `_menuInstanceId` prefix. Auto-generating the id (rather than using a random
22435
+ * string) keeps option keys deterministic and collision-free across menus.
22436
+ * @private
22437
+ */
22438
+ let menuInstanceIdCounter = 0;
22439
+
22283
22440
 
22284
22441
  /**
22285
22442
  * The `auro-menu` element provides users a way to select from a list of options.
@@ -22351,9 +22508,8 @@ class AuroMenu extends AuroElement {
22351
22508
 
22352
22509
  // Instance properties (non-reactive)
22353
22510
 
22354
- /**
22355
- * @private
22356
- */
22511
+ menuInstanceIdCounter += 1;
22512
+
22357
22513
  Object.assign(this, {
22358
22514
  // Root-level menu (true) or a nested submenu (false)
22359
22515
  rootMenu: true,
@@ -22363,6 +22519,21 @@ class AuroMenu extends AuroElement {
22363
22519
  nestingSpacer: '<span class="nestingSpacer"></span>',
22364
22520
  // Loading indicator for slot elements
22365
22521
  loadingSlots: null,
22522
+ // Unique id for this menu instance; prefixes every auto-generated option
22523
+ // key so keys never collide across menus in the same document.
22524
+ _menuInstanceId: `menu-${menuInstanceIdCounter}`,
22525
+ // Monotonically increasing counter for option key generation. Never
22526
+ // resets, so a key is never reused within this instance's lifetime.
22527
+ _optionKeyCounter: 0,
22528
+ // Key(s) of the option(s) the user has actively selected. A single string
22529
+ // in single-select, an array in multi-select, undefined when nothing is
22530
+ // user-selected. Used to disambiguate options that share a `value`.
22531
+ _selectedKey: undefined,
22532
+ // True only for the one updated() cycle following a user selection, so
22533
+ // reconciliation trusts `_selectedKey`. A `value` change from a consumer's
22534
+ // direct property assignment leaves this false, dropping the stale key so
22535
+ // reconciliation falls back to first-by-value (see updated()).
22536
+ _valueChangeFromSelection: false,
22366
22537
  });
22367
22538
  }
22368
22539
 
@@ -22582,6 +22753,13 @@ class AuroMenu extends AuroElement {
22582
22753
  return;
22583
22754
  }
22584
22755
 
22756
+ // A programmatic value set carries no positional intent, so drop any
22757
+ // `_selectedKey` left over from a prior user click. This makes reconciliation
22758
+ // in updated() fall back to first-by-value (single) / value-in-DOM-order
22759
+ // (multi), matching the documented contract for programmatic selection even
22760
+ // when a stale key would still resolve to a duplicate-value option.
22761
+ this._selectedKey = undefined;
22762
+
22585
22763
  // `value` is a String property; stringify arrays so attribute reflection and `formattedValue` parsing stay correct.
22586
22764
  this.value = Array.isArray(value) ? JSON.stringify(value) : value;
22587
22765
  }
@@ -22629,6 +22807,17 @@ class AuroMenu extends AuroElement {
22629
22807
  updated(changedProperties) {
22630
22808
  super.updated(changedProperties);
22631
22809
 
22810
+ // Consume the selection-driven flag for THIS cycle up front. Clearing it
22811
+ // unconditionally — not only inside the `value` branch below — prevents it
22812
+ // from lingering `true` when a selection produces a serialized `value`
22813
+ // byte-identical to the current one, in which case Lit schedules no
22814
+ // `value`-change cycle to consume it. A lingering flag would misclassify a
22815
+ // later consumer's programmatic `value` set as selection-driven and keep a
22816
+ // stale `_selectedKey`. The reconcile path below re-sets the instance flag
22817
+ // after this point, so its intentional cross-cycle hand-off still works.
22818
+ const valueChangeFromSelection = this._valueChangeFromSelection;
22819
+ this._valueChangeFromSelection = false;
22820
+
22632
22821
  // Single source of truth for 'auroMenu-selectedOption'. Selection handlers
22633
22822
  // mutate optionSelected and let Lit's update cycle dispatch here; the prior
22634
22823
  // .value comparison missed multi-select array changes and combined with the
@@ -22652,6 +22841,17 @@ class AuroMenu extends AuroElement {
22652
22841
  this.initItems();
22653
22842
  }
22654
22843
 
22844
+ // Distinguish a selection-driven `value` change (a user click, which set
22845
+ // the flag in handleSelectState / _sortSelectedByDomOrder) from a
22846
+ // programmatic assignment by a consumer. A programmatic set carries no
22847
+ // positional intent, so drop any leftover `_selectedKey` and let
22848
+ // reconciliation fall back to first-by-value (single) / value-in-DOM-order
22849
+ // (multi) — the same contract selectByValue() guarantees, even when a
22850
+ // stale key would otherwise still resolve to a duplicate-value option.
22851
+ if (!valueChangeFromSelection) {
22852
+ this._selectedKey = undefined;
22853
+ }
22854
+
22655
22855
  // Set when reconciliation reassigns `value` below. That reassignment schedules a
22656
22856
  // second updated() cycle, so the `event`-attribute dispatch is deferred to that
22657
22857
  // cycle to avoid firing option custom events twice on the same selection.
@@ -22669,19 +22869,55 @@ class AuroMenu extends AuroElement {
22669
22869
  // Defensive default: `formattedValue` can be undefined for unexpected value types,
22670
22870
  // and calling `.includes` on undefined would throw during reconciliation.
22671
22871
  const valueArray = this.formattedValue || [];
22672
- const matchingOptions = this.items ? this.items.filter((item) => isSelectableByValue(item) && valueArray.includes(item.value)) : [];
22872
+ // Resolve by key first (the user's exact picks), then fall back to
22873
+ // value matching for any values not resolved by key — so pre-selection
22874
+ // and programmatic value sets keep working. Result is DOM-ordered.
22875
+ const matchingOptions = resolveSelectedOptions(this.items, valueArray, this._selectedKey);
22673
22876
  newSelected = matchingOptions.length > 0 ? matchingOptions : undefined;
22674
22877
 
22675
- // Reconcile `value` with the selectable set. Drop only entries whose option is
22676
- // loaded but non-selectable (disabled/static) leaving them would desync `value`
22677
- // from `optionSelected`, and the toggle handlers rebuild `value` from `formattedValue`,
22678
- // so the rejected entry would resurface on the next select/deselect. Entries with no
22679
- // matching item yet are preserved so async preselection still works once options render.
22680
- const rejectedValues = this.items
22681
- ? this.items.filter((item) => !isSelectableByValue(item) && valueArray.includes(item.value)).map((item) => item.value)
22682
- : [];
22683
- if (rejectedValues.length > 0) {
22684
- const reconciled = valueArray.filter((val) => !rejectedValues.includes(val));
22878
+ // Reconcile `value` with the selectable set. An occurrence is dropped
22879
+ // only when it is loaded but no selectable option can satisfy it
22880
+ // every loaded item sharing that value is non-selectable, or the value
22881
+ // recurs more often than it has selectable options (a duplicate value
22882
+ // whose extra siblings are disabled/static). This is count-based, not
22883
+ // presence-based, so an enabled option is kept even when a disabled
22884
+ // sibling shares its value mirroring how `resolveSelectedOptions`
22885
+ // resolves the same set. Entries with no matching item yet are
22886
+ // preserved so async preselection still works, and the toggle handlers
22887
+ // rebuild `value` from `formattedValue`, so a rejected entry cannot
22888
+ // resurface on the next select/deselect.
22889
+ const selectableByValue = new Map();
22890
+ const loadedValues = new Set();
22891
+ if (this.items) {
22892
+ this.items.forEach((item) => {
22893
+ loadedValues.add(item.value);
22894
+ if (isSelectableByValue(item)) {
22895
+ selectableByValue.set(item.value, (selectableByValue.get(item.value) || 0) + 1);
22896
+ }
22897
+ });
22898
+ }
22899
+
22900
+ const reconciled = valueArray.filter((val) => {
22901
+ // Not loaded yet (async preselection) — keep for a later cycle.
22902
+ if (!loadedValues.has(val)) {
22903
+ return true;
22904
+ }
22905
+ // Consume one selectable option per occurrence; drop once exhausted.
22906
+ const remaining = selectableByValue.get(val) || 0;
22907
+ if (remaining > 0) {
22908
+ selectableByValue.set(val, remaining - 1);
22909
+ return true;
22910
+ }
22911
+ return false;
22912
+ });
22913
+
22914
+ if (reconciled.length !== valueArray.length) {
22915
+ // This is an internal correction, not a consumer's programmatic set,
22916
+ // so preserve the selection-driven flag through the re-entrant
22917
+ // updated() cycle it schedules. Otherwise that cycle would treat the
22918
+ // reassignment as programmatic and drop `_selectedKey` mid-cascade,
22919
+ // flipping resolution and looping.
22920
+ this._valueChangeFromSelection = true;
22685
22921
  this.value = serializeMultiSelectValue(reconciled);
22686
22922
  valueReconciled = true;
22687
22923
  }
@@ -22693,7 +22929,11 @@ class AuroMenu extends AuroElement {
22693
22929
  // `hidden` is intentionally NOT excluded: the combobox toggles
22694
22930
  // `hidden` as its type-ahead filter, so a filtered-out option is
22695
22931
  // still a valid programmatic selection.
22696
- const matchingOption = this.items ? this.items.find((item) => isSelectableByValue(item) && item.value === this.value) : undefined;
22932
+ // Prefer the option the user actually selected (tracked by
22933
+ // `_selectedKey`) so a click on the second of two options sharing a
22934
+ // `value` resolves back to that exact element instead of the first
22935
+ // value match. Falls back to first-by-value for programmatic sets.
22936
+ const matchingOption = resolveSelectedOption(this.items, this.value, this._selectedKey);
22697
22937
 
22698
22938
  if (matchingOption) {
22699
22939
  newSelected = matchingOption;
@@ -22921,6 +23161,14 @@ class AuroMenu extends AuroElement {
22921
23161
  }
22922
23162
  });
22923
23163
 
23164
+ // Assign private keys once items are populated. Only the root menu assigns
23165
+ // keys: its `items` is a deep query that already includes nested submenu
23166
+ // options, so a single pass keys the entire tree. Nested menus skip this
23167
+ // and inherit keys from the root.
23168
+ if (this.rootMenu) {
23169
+ this._assignOptionKeys();
23170
+ }
23171
+
22924
23172
  if (this.noCheckmark) {
22925
23173
  this.updateItemsState(new Map([
22926
23174
  [
@@ -22937,6 +23185,31 @@ class AuroMenu extends AuroElement {
22937
23185
  }));
22938
23186
  }
22939
23187
 
23188
+ /**
23189
+ * Assigns a private, auto-generated unique key (`_optionKey`) to each menu
23190
+ * option that does not already have one. Keys are internal state on the
23191
+ * element instance — never reflected as an attribute or exposed publicly —
23192
+ * and let selection tracking distinguish options that share the same `value`.
23193
+ *
23194
+ * The `_optionKey === undefined` guard makes this idempotent: options keep the
23195
+ * key they were first assigned across re-renders and slot changes, and if a
23196
+ * nested menu's lifecycle runs a pass before the root, options simply wait for
23197
+ * the root to key them (or keep whatever key they already hold).
23198
+ * @private
23199
+ */
23200
+ _assignOptionKeys() {
23201
+ if (!this.items) {
23202
+ return;
23203
+ }
23204
+
23205
+ this.items.forEach((option) => {
23206
+ if (option._optionKey === undefined) {
23207
+ this._optionKeyCounter += 1;
23208
+ option._optionKey = `${this._menuInstanceId}-${this._optionKeyCounter}`;
23209
+ }
23210
+ });
23211
+ }
23212
+
22940
23213
  // Logic Methods
22941
23214
 
22942
23215
  /**
@@ -22946,24 +23219,28 @@ class AuroMenu extends AuroElement {
22946
23219
  */
22947
23220
  handleSelectState(option) {
22948
23221
  if (this.multiSelect) {
22949
- const currentValue = this.formattedValue || [];
22950
23222
  const currentSelected = this.optionSelected || [];
22951
23223
 
22952
- if (!currentValue.includes(option.value)) {
22953
- this.value = serializeMultiSelectValue([
22954
- ...currentValue,
22955
- option.value
22956
- ]);
22957
- }
22958
23224
  if (!currentSelected.includes(option)) {
22959
23225
  this.optionSelected = [
22960
23226
  ...currentSelected,
22961
23227
  option
22962
23228
  ];
22963
23229
  }
23230
+
23231
+ // Re-sort by DOM order and rebuild `_selectedKey`/`value` from the
23232
+ // selected set so display order stays consistent with the menu, not with
23233
+ // click order.
23234
+ this._sortSelectedByDomOrder();
22964
23235
  } else {
22965
23236
  this.value = option.value;
22966
23237
  this.optionSelected = option;
23238
+ // Track the specific option the user selected so the value→option
23239
+ // reconciliation in updated() resolves back to this exact element even
23240
+ // when another option shares the same `value`.
23241
+ this._selectedKey = option._optionKey;
23242
+ // Mark this `value` change as selection-driven so updated() trusts the key.
23243
+ this._valueChangeFromSelection = true;
22967
23244
  }
22968
23245
 
22969
23246
  this._index = this.items.indexOf(option);
@@ -22976,18 +23253,22 @@ class AuroMenu extends AuroElement {
22976
23253
  */
22977
23254
  handleDeselectState(option) {
22978
23255
  if (this.multiSelect) {
22979
- // Remove this option from array; an empty result collapses `value` to undefined.
22980
- const newFormattedValue = (this.formattedValue || []).filter((val) => val !== option.value);
22981
- this.value = serializeMultiSelectValue(newFormattedValue);
22982
-
22983
- this.optionSelected = this.optionSelected.filter((val) => val !== option);
23256
+ // Remove this exact element from the selection (identity, not value two
23257
+ // options can share a `value`), then rebuild `value`/`_selectedKey` from
23258
+ // the remaining set in DOM order. An empty result collapses to undefined.
23259
+ this.optionSelected = this.optionSelected.filter((selected) => selected !== option);
22984
23260
  if (this.optionSelected.length === 0) {
22985
23261
  this.optionSelected = undefined;
23262
+ this._selectedKey = undefined;
23263
+ this.value = undefined;
23264
+ } else {
23265
+ this._sortSelectedByDomOrder();
22986
23266
  }
22987
23267
  } else {
22988
23268
  // For single-select: Back to undefined when deselected
22989
23269
  this.value = undefined;
22990
23270
  this.optionSelected = undefined;
23271
+ this._selectedKey = undefined;
22991
23272
  }
22992
23273
 
22993
23274
  // Update the index tracking
@@ -23011,9 +23292,46 @@ class AuroMenu extends AuroElement {
23011
23292
  clearSelection() {
23012
23293
  this.optionSelected = undefined;
23013
23294
  this.value = undefined;
23295
+ this._selectedKey = undefined;
23014
23296
  this._index = -1;
23015
23297
  }
23016
23298
 
23299
+ /**
23300
+ * Re-sorts the multi-select selection into DOM order and rebuilds the derived
23301
+ * `_selectedKey` and `value` from `optionSelected`. Selection is always stored
23302
+ * and serialized in the order options appear in the menu, never in click
23303
+ * order — so selecting C then A yields `[A, C]`.
23304
+ * @private
23305
+ */
23306
+ _sortSelectedByDomOrder() {
23307
+ if (!this.multiSelect || !Array.isArray(this.optionSelected) || !this.items) {
23308
+ return;
23309
+ }
23310
+
23311
+ const indexMap = new Map(this.items.map((item, index) => [
23312
+ item,
23313
+ index
23314
+ ]));
23315
+
23316
+ // Sorting in place mutates `optionSelected` without a new array reference,
23317
+ // which Lit's `===` change-detection cannot see on its own — but that is
23318
+ // intentional and safe: both callers (handleSelectState / handleDeselectState)
23319
+ // assign a fresh `optionSelected` array immediately before calling, so Lit
23320
+ // already has a changed reference to react to, and the `value` write below
23321
+ // schedules the updated() cycle that re-derives `optionSelected` in DOM order
23322
+ // via resolveSelectedOptions. Do not "fix" this into a new-array assignment.
23323
+ //
23324
+ // Sort any element no longer in `items` (a stale selection left over from a
23325
+ // dynamic rebuild that the consumer has not cleared) to the END rather than
23326
+ // the front, so it never displaces a live option to the head of the
23327
+ // serialized order. Value reconciliation drops it on the next updated() cycle.
23328
+ this.optionSelected.sort((optionA, optionB) => (indexMap.get(optionA) ?? this.items.length) - (indexMap.get(optionB) ?? this.items.length));
23329
+ this._selectedKey = this.optionSelected.map((option) => option._optionKey);
23330
+ this.value = serializeMultiSelectValue(this.optionSelected.map((option) => option.value));
23331
+ // Mark this `value` change as selection-driven so updated() trusts the keys.
23332
+ this._valueChangeFromSelection = true;
23333
+ }
23334
+
23017
23335
  /**
23018
23336
  * Resets the menu to its initial state.
23019
23337
  * This is the only way to return value to undefined.
@@ -23023,6 +23341,7 @@ class AuroMenu extends AuroElement {
23023
23341
  // Reset to undefined - initial state
23024
23342
  this.value = undefined;
23025
23343
  this.optionSelected = undefined;
23344
+ this._selectedKey = undefined;
23026
23345
  this._index = -1;
23027
23346
 
23028
23347
  // Clear active option state so a follow-up open/navigation starts fresh
@@ -23084,6 +23403,21 @@ class AuroMenu extends AuroElement {
23084
23403
  this.initItems();
23085
23404
  }
23086
23405
 
23406
+ // Recover `_index` from the highlighted option when it has been reset to -1.
23407
+ // The updated() reconciliation resets `_index = -1` whenever the value
23408
+ // collapses to undefined while `optionActive` still points at the highlighted
23409
+ // option — e.g. deselecting the last remaining option in multi-select, or a
23410
+ // programmatic clearSelection() in single-select while keyboard focus is on an
23411
+ // option. Without this, reading `items[-1]` returns undefined and the re-select
23412
+ // no-ops until the highlight is moved away and back. Mirrors auro-combobox's
23413
+ // reconcileMenuIndex.
23414
+ if (this._index < 0 && this.optionActive && this.items) {
23415
+ const activeIndex = this.items.indexOf(this.optionActive);
23416
+ if (activeIndex >= 0) {
23417
+ this._index = activeIndex;
23418
+ }
23419
+ }
23420
+
23087
23421
  // Get currently selected menu option based on index
23088
23422
  const option = this.items ? this.items[this._index] : undefined;
23089
23423