@aurodesignsystem-dev/auro-formkit 0.0.0-pr1582.0 → 0.0.0-pr1582.2

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 (54) hide show
  1. package/components/checkbox/demo/customize.min.js +1 -1
  2. package/components/checkbox/demo/getting-started.min.js +1 -1
  3. package/components/checkbox/demo/index.min.js +1 -1
  4. package/components/checkbox/dist/index.js +1 -1
  5. package/components/checkbox/dist/registered.js +1 -1
  6. package/components/combobox/demo/customize.md +40 -0
  7. package/components/combobox/demo/customize.min.js +400 -62
  8. package/components/combobox/demo/getting-started.min.js +400 -62
  9. package/components/combobox/demo/index.html +1 -1
  10. package/components/combobox/demo/index.min.js +400 -62
  11. package/components/combobox/dist/index.js +78 -35
  12. package/components/combobox/dist/registered.js +78 -35
  13. package/components/counter/demo/customize.min.js +2 -2
  14. package/components/counter/demo/index.min.js +2 -2
  15. package/components/counter/dist/index.js +2 -2
  16. package/components/counter/dist/registered.js +2 -2
  17. package/components/datepicker/demo/customize.min.js +22 -9
  18. package/components/datepicker/demo/index.min.js +22 -9
  19. package/components/datepicker/dist/index.js +22 -9
  20. package/components/datepicker/dist/registered.js +22 -9
  21. package/components/dropdown/demo/customize.min.js +1 -1
  22. package/components/dropdown/demo/getting-started.min.js +21 -8
  23. package/components/dropdown/demo/index.min.js +1 -1
  24. package/components/dropdown/dist/index.js +1 -1
  25. package/components/dropdown/dist/registered.js +1 -1
  26. package/components/form/demo/customize.min.js +448 -84
  27. package/components/form/demo/getting-started.min.js +448 -84
  28. package/components/form/demo/index.min.js +448 -84
  29. package/components/form/demo/registerDemoDeps.min.js +448 -84
  30. package/components/input/demo/customize.min.js +20 -7
  31. package/components/input/demo/getting-started.min.js +20 -7
  32. package/components/input/demo/index.min.js +20 -7
  33. package/components/input/dist/auro-input.d.ts +8 -0
  34. package/components/input/dist/index.js +20 -7
  35. package/components/input/dist/registered.js +20 -7
  36. package/components/menu/demo/customize.md +50 -0
  37. package/components/menu/demo/index.min.js +322 -27
  38. package/components/menu/dist/auro-menu-utils.d.ts +30 -0
  39. package/components/menu/dist/auro-menu.d.ts +23 -0
  40. package/components/menu/dist/index.js +322 -27
  41. package/components/menu/dist/registered.js +322 -27
  42. package/components/radio/demo/customize.min.js +1 -1
  43. package/components/radio/demo/getting-started.min.js +1 -1
  44. package/components/radio/demo/index.min.js +1 -1
  45. package/components/radio/dist/index.js +1 -1
  46. package/components/radio/dist/registered.js +1 -1
  47. package/components/select/demo/customize.md +72 -0
  48. package/components/select/demo/customize.min.js +324 -29
  49. package/components/select/demo/getting-started.min.js +324 -29
  50. package/components/select/demo/index.min.js +324 -29
  51. package/components/select/dist/index.js +2 -2
  52. package/components/select/dist/registered.js +2 -2
  53. package/custom-elements.json +115 -0
  54. 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 = '202608102106';
4995
+ var formkitVersion$2 = '202608111845';
4996
4996
 
4997
4997
  let AuroElement$2 = class AuroElement extends i$3 {
4998
4998
  static get properties() {
@@ -18692,7 +18692,7 @@ let AuroHelpText$1 = class AuroHelpText extends i$3 {
18692
18692
  }
18693
18693
  };
18694
18694
 
18695
- var formkitVersion$1 = '202608102106';
18695
+ var formkitVersion$1 = '202608111845';
18696
18696
 
18697
18697
  // Copyright (c) 2025 Alaska Airlines. All right reserved. Licensed under the Apache-2.0 license
18698
18698
  // See LICENSE in the project root for license information.
@@ -18793,22 +18793,35 @@ class AuroInput extends BaseInput {
18793
18793
  * @private
18794
18794
  */
18795
18795
  get inputHidden() {
18796
- // When activeLabel is set, keep the input row visible so the label stays
18797
- // raised in the active position (classic/default layout) instead of
18798
- // collapsing to the centered placeholder position.
18799
- if (this.activeLabel) {
18800
- return false;
18801
- }
18796
+ // In the classic/default layout, activeLabel keeps the input row visible so
18797
+ // the label stays raised rather than collapsing to the centered placeholder
18798
+ // position. Emphasized/snowflake always render the label inside the field,
18799
+ // so activeLabel does not apply there. This only overrides the empty/
18800
+ // unfocused clause — the display-value clause (combobox/datepicker) is
18801
+ // preserved regardless.
18802
+ const activeLabelRaisesInput = this.activeLabel && this.isClassicLayout;
18802
18803
 
18803
18804
  return (
18804
18805
  this.hasDisplayValueContent && !this.hasFocus && this.hasValue) ||
18805
18806
  (
18807
+ !activeLabelRaisesInput &&
18806
18808
  (!this.value || this.value.length === 0) &&
18807
18809
  !this.hasFocus &&
18808
18810
  (!this.placeholderStr || this.placeholderStr === '')
18809
18811
  );
18810
18812
  }
18811
18813
 
18814
+ /**
18815
+ * Whether the component is rendering the classic/default layout, meaning not
18816
+ * emphasized and not snowflake. Kept as a single source of truth so
18817
+ * layout-scoped behavior such as activeLabel stays consistent across getters.
18818
+ * @returns {boolean} - True for the classic/default layout.
18819
+ * @private
18820
+ */
18821
+ get isClassicLayout() {
18822
+ return !this.layout.startsWith('emphasized') && this.layout !== 'snowflake';
18823
+ }
18824
+
18812
18825
  /**
18813
18826
  * Determines if the input should display in a state with no focus or value indication.
18814
18827
  * Returns true when the input has display content without focus and has a value,
@@ -19855,7 +19868,7 @@ class AuroBibtemplate extends i$3 {
19855
19868
  }
19856
19869
  }
19857
19870
 
19858
- var formkitVersion = '202608102106';
19871
+ var formkitVersion = '202608111845';
19859
19872
 
19860
19873
  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}`;
19861
19874
 
@@ -21661,7 +21674,17 @@ class AuroCombobox extends AuroElement {
21661
21674
  return;
21662
21675
  }
21663
21676
 
21664
- this.value = this.input.value;
21677
+ // Skip this write during a display-value sync: that path sets the input to
21678
+ // the selected option's LABEL, which is not its machine value. When an
21679
+ // option's label differs from its value (and especially when several
21680
+ // options share a value but render distinct labels — AB#1602086), writing
21681
+ // the label back into this.value clobbers the machine value the selection
21682
+ // listener just set (this.value = optionSelected.value), collapsing the
21683
+ // selection. _syncingBibValue still tracks (that mirrors user-typed text
21684
+ // from the fullscreen bib, where value should follow the input).
21685
+ if (!this._syncingDisplayValue) {
21686
+ this.value = this.input.value;
21687
+ }
21665
21688
 
21666
21689
  // Ignore re-entrant input events caused by programmatic value sets.
21667
21690
  if (this._syncingBibValue || this._syncingDisplayValue) {
@@ -21925,33 +21948,53 @@ class AuroCombobox extends AuroElement {
21925
21948
  }
21926
21949
 
21927
21950
  if (this.input.value !== this.value) {
21928
- // Clear menu.value AND menu.optionSelected together. Clearing only
21929
- // menu.value leaves the previously-selected option element pinned
21930
- // as menu.optionSelected; a later auroMenu-selectedOption event
21931
- // would then write its stale .value back into combobox.value
21932
- // (e.g. Tab-after-Backspace re-selecting the prior option).
21933
- if (this.menu.value || this.menu.optionSelected) {
21934
- this.menu.clearSelection();
21935
- }
21951
+ // A fresh user selection leaves the input showing the option's LABEL
21952
+ // while this.value holds its machine value, so input.value !== this.value
21953
+ // is expected whenever label ≠ value. In that case the menu's current
21954
+ // selection is authoritative — and when several options share a value it
21955
+ // is the ONLY thing that records WHICH same-value option the user picked
21956
+ // (the menu tracks it by element identity via `_selectedKey`). Clearing
21957
+ // it here drops that key; the ensuing value-only re-resolution then
21958
+ // collapses the selection onto the first same-value option (AB#1602086).
21959
+ // So only treat the divergence as a stale menu when the selected option
21960
+ // does NOT match the new value.
21961
+ const menuSelectionMatchesValue =
21962
+ this.menu.optionSelected &&
21963
+ // optionSelected is a single element here (scalar `.value`). A multiselect
21964
+ // menu exposes it as an ARRAY, which has no scalar `.value` to compare, so
21965
+ // that shape can't be a single-option match — fall through to the clear path.
21966
+ !Array.isArray(this.menu.optionSelected) &&
21967
+ this.menu.optionSelected.value === this.value;
21968
+
21969
+ if (!menuSelectionMatchesValue) {
21970
+ // Clear menu.value AND menu.optionSelected together. Clearing only
21971
+ // menu.value leaves the previously-selected option element pinned
21972
+ // as menu.optionSelected; a later auroMenu-selectedOption event
21973
+ // would then write its stale .value back into combobox.value
21974
+ // (e.g. Tab-after-Backspace re-selecting the prior option).
21975
+ if (this.menu.value || this.menu.optionSelected) {
21976
+ this.menu.clearSelection();
21977
+ }
21936
21978
 
21937
- if (!this.persistInput) {
21938
- this.syncInputValuesAcrossTriggerAndBib(this.value || '');
21939
- }
21979
+ if (!this.persistInput) {
21980
+ this.syncInputValuesAcrossTriggerAndBib(this.value || '');
21981
+ }
21940
21982
 
21941
- // Programmatic value with no matching option: updateFilter will close
21942
- // the bib silently (see line 648 — no noMatchOption + 0 results
21943
- // hides). Announce so screen-reader users hear the request was
21944
- // dropped. Gated on `input.value !== this.value` so this never fires
21945
- // for user typing — that path always reconciles input.value to
21946
- // this.value before updated() runs.
21947
- if (
21948
- this.value &&
21949
- this.menu &&
21950
- this.menu.options &&
21951
- this.menu.options.length > 0 &&
21952
- !this.menu.options.some((opt) => opt.value === this.value)
21953
- ) {
21954
- announceToScreenReader(this._getAnnouncementRoot(), `No matching option for ${this.value}`);
21983
+ // Programmatic value with no matching option: updateFilter will close
21984
+ // the bib silently (see line 648 — no noMatchOption + 0 results
21985
+ // hides). Announce so screen-reader users hear the request was
21986
+ // dropped. Gated on `input.value !== this.value` so this never fires
21987
+ // for user typing — that path always reconciles input.value to
21988
+ // this.value before updated() runs.
21989
+ if (
21990
+ this.value &&
21991
+ this.menu &&
21992
+ this.menu.options &&
21993
+ this.menu.options.length > 0 &&
21994
+ !this.menu.options.some((opt) => opt.value === this.value)
21995
+ ) {
21996
+ announceToScreenReader(this._getAnnouncementRoot(), `No matching option for ${this.value}`);
21997
+ }
21955
21998
  }
21956
21999
  }
21957
22000
 
@@ -22288,6 +22331,116 @@ function isSelectableByValue(option) {
22288
22331
  !option.hasAttribute('static');
22289
22332
  }
22290
22333
 
22334
+ /* eslint-disable no-underscore-dangle */
22335
+ /**
22336
+ * Resolves the single selected option for a given `value`, preferring the
22337
+ * option tracked by `selectedKey` (a user-initiated selection) over a
22338
+ * first-by-value match. When multiple options share the same `value`, matching
22339
+ * by `value` alone cannot distinguish which one the user picked; the key
22340
+ * disambiguates it.
22341
+ *
22342
+ * The key is trusted only when it still resolves to an option whose `value`
22343
+ * matches the requested `value`. If the key is stale (option removed) or the
22344
+ * value was changed programmatically, resolution falls back to value matching —
22345
+ * preserving backward-compatible behavior for preselection and `selectByValue`.
22346
+ * @private
22347
+ * @param {Array<HTMLElement>} items - The menu's flat option list.
22348
+ * @param {string} value - The value to resolve.
22349
+ * @param {string|undefined} selectedKey - The `_optionKey` of the user-selected option, if any.
22350
+ * @returns {HTMLElement|undefined} The resolved option, or undefined when none match.
22351
+ */
22352
+ function resolveSelectedOption(items, value, selectedKey) {
22353
+ if (!items) {
22354
+ return undefined;
22355
+ }
22356
+
22357
+ if (selectedKey !== undefined) {
22358
+ const keyed = items.find((item) => item._optionKey === selectedKey);
22359
+ if (keyed && isSelectableByValue(keyed) && keyed.value === value) {
22360
+ return keyed;
22361
+ }
22362
+ // Key exists but the option is gone or its value no longer matches — fall
22363
+ // through to value-based matching.
22364
+ }
22365
+
22366
+ return items.find((item) => isSelectableByValue(item) && item.value === value);
22367
+ }
22368
+
22369
+ /**
22370
+ * Resolves the selected options for a multi-select `value` array, preferring
22371
+ * options tracked by `selectedKeys` (user-initiated selections) and falling
22372
+ * back to value matching for any values not resolved by key. The result is
22373
+ * always sorted into DOM order regardless of selection sequence.
22374
+ * @private
22375
+ * @param {Array<HTMLElement>} items - The menu's flat option list.
22376
+ * @param {Array<string>} valueArray - The selected values.
22377
+ * @param {Array<string>|undefined} selectedKeys - The `_optionKey`s of the user-selected options, if any.
22378
+ * @returns {Array<HTMLElement>} The resolved options in DOM order.
22379
+ */
22380
+ function resolveSelectedOptions(items, valueArray, selectedKeys) {
22381
+ if (!items) {
22382
+ return [];
22383
+ }
22384
+
22385
+ const resolved = [];
22386
+ // Mirror `resolved` as a Set for O(1) membership checks below, matching the
22387
+ // indexMap optimization used for the sort rather than scanning `resolved`
22388
+ // on every candidate.
22389
+ const resolvedSet = new Set();
22390
+
22391
+ // Track how many of each value are still available to resolve. A value that
22392
+ // appears N times in `valueArray` may be satisfied at most N times total across
22393
+ // the key pass and the value fallback below — matching by count, not presence,
22394
+ // on BOTH passes. This stops a duplicate value from being over-resolved: e.g.
22395
+ // two keyed options that both carry `SEA` cannot both match a single requested
22396
+ // `SEA` (which happens when `value` is set directly without clearing
22397
+ // `_selectedKey`, so more keys survive than the value set now asks for).
22398
+ const remaining = new Map();
22399
+ valueArray.forEach((val) => remaining.set(val, (remaining.get(val) || 0) + 1));
22400
+
22401
+ // Resolve by key first: trust a key only when its option is still selectable
22402
+ // and there is still an unmatched occurrence of its value in the request set.
22403
+ if (Array.isArray(selectedKeys)) {
22404
+ selectedKeys.forEach((key) => {
22405
+ const keyed = items.find((item) => item._optionKey === key);
22406
+ if (keyed && isSelectableByValue(keyed) && (remaining.get(keyed.value) || 0) > 0 && !resolvedSet.has(keyed)) {
22407
+ resolved.push(keyed);
22408
+ resolvedSet.add(keyed);
22409
+ remaining.set(keyed.value, remaining.get(keyed.value) - 1);
22410
+ }
22411
+ });
22412
+ }
22413
+
22414
+ // Fall back to value matching for the occurrences not resolved by key. Iterate
22415
+ // the leftover per-value counts so a value that appears twice but was only
22416
+ // resolved once by key still matches its remaining occurrence(s).
22417
+ remaining.forEach((count, val) => {
22418
+ for (let occurrence = 0; occurrence < count; occurrence += 1) {
22419
+ const option = items.find((item) => isSelectableByValue(item) && item.value === val && !resolvedSet.has(item));
22420
+ if (option) {
22421
+ resolved.push(option);
22422
+ resolvedSet.add(option);
22423
+ }
22424
+ }
22425
+ });
22426
+
22427
+ // Always return in DOM order so display is consistent regardless of the order
22428
+ // keys/values were selected. Every resolved option came from `items`, so an
22429
+ // O(1) index lookup mirrors `_sortSelectedByDomOrder` and avoids the O(n)
22430
+ // `items.indexOf` per comparison for large combobox option sets. Any element
22431
+ // not in `items` (a stale snapshot from a future caller) sorts to the END via
22432
+ // `?? items.length`, matching `_sortSelectedByDomOrder` and avoiding NaN
22433
+ // comparisons.
22434
+ const indexMap = new Map(items.map((item, index) => [
22435
+ item,
22436
+ index
22437
+ ]));
22438
+ resolved.sort((optionA, optionB) => (indexMap.get(optionA) ?? items.length) - (indexMap.get(optionB) ?? items.length));
22439
+
22440
+ return resolved;
22441
+ }
22442
+ /* eslint-enable no-underscore-dangle */
22443
+
22291
22444
  /**
22292
22445
  * Helper method to dispatch custom events.
22293
22446
  * @param {HTMLElement} element - Element to dispatch event from.
@@ -22313,6 +22466,14 @@ function dispatchMenuEvent(element, eventName, detail = null) {
22313
22466
  // See LICENSE in the project root for license information.
22314
22467
 
22315
22468
 
22469
+ /**
22470
+ * Monotonically increasing counter used to give each menu instance a unique
22471
+ * `_menuInstanceId` prefix. Auto-generating the id (rather than using a random
22472
+ * string) keeps option keys deterministic and collision-free across menus.
22473
+ * @private
22474
+ */
22475
+ let menuInstanceIdCounter = 0;
22476
+
22316
22477
 
22317
22478
  /**
22318
22479
  * The `auro-menu` element provides users a way to select from a list of options.
@@ -22384,9 +22545,8 @@ class AuroMenu extends AuroElement {
22384
22545
 
22385
22546
  // Instance properties (non-reactive)
22386
22547
 
22387
- /**
22388
- * @private
22389
- */
22548
+ menuInstanceIdCounter += 1;
22549
+
22390
22550
  Object.assign(this, {
22391
22551
  // Root-level menu (true) or a nested submenu (false)
22392
22552
  rootMenu: true,
@@ -22396,6 +22556,21 @@ class AuroMenu extends AuroElement {
22396
22556
  nestingSpacer: '<span class="nestingSpacer"></span>',
22397
22557
  // Loading indicator for slot elements
22398
22558
  loadingSlots: null,
22559
+ // Unique id for this menu instance; prefixes every auto-generated option
22560
+ // key so keys never collide across menus in the same document.
22561
+ _menuInstanceId: `menu-${menuInstanceIdCounter}`,
22562
+ // Monotonically increasing counter for option key generation. Never
22563
+ // resets, so a key is never reused within this instance's lifetime.
22564
+ _optionKeyCounter: 0,
22565
+ // Key(s) of the option(s) the user has actively selected. A single string
22566
+ // in single-select, an array in multi-select, undefined when nothing is
22567
+ // user-selected. Used to disambiguate options that share a `value`.
22568
+ _selectedKey: undefined,
22569
+ // True only for the one updated() cycle following a user selection, so
22570
+ // reconciliation trusts `_selectedKey`. A `value` change from a consumer's
22571
+ // direct property assignment leaves this false, dropping the stale key so
22572
+ // reconciliation falls back to first-by-value (see updated()).
22573
+ _valueChangeFromSelection: false,
22399
22574
  });
22400
22575
  }
22401
22576
 
@@ -22615,6 +22790,13 @@ class AuroMenu extends AuroElement {
22615
22790
  return;
22616
22791
  }
22617
22792
 
22793
+ // A programmatic value set carries no positional intent, so drop any
22794
+ // `_selectedKey` left over from a prior user click. This makes reconciliation
22795
+ // in updated() fall back to first-by-value (single) / value-in-DOM-order
22796
+ // (multi), matching the documented contract for programmatic selection even
22797
+ // when a stale key would still resolve to a duplicate-value option.
22798
+ this._selectedKey = undefined;
22799
+
22618
22800
  // `value` is a String property; stringify arrays so attribute reflection and `formattedValue` parsing stay correct.
22619
22801
  this.value = Array.isArray(value) ? JSON.stringify(value) : value;
22620
22802
  }
@@ -22662,6 +22844,17 @@ class AuroMenu extends AuroElement {
22662
22844
  updated(changedProperties) {
22663
22845
  super.updated(changedProperties);
22664
22846
 
22847
+ // Consume the selection-driven flag for THIS cycle up front. Clearing it
22848
+ // unconditionally — not only inside the `value` branch below — prevents it
22849
+ // from lingering `true` when a selection produces a serialized `value`
22850
+ // byte-identical to the current one, in which case Lit schedules no
22851
+ // `value`-change cycle to consume it. A lingering flag would misclassify a
22852
+ // later consumer's programmatic `value` set as selection-driven and keep a
22853
+ // stale `_selectedKey`. The reconcile path below re-sets the instance flag
22854
+ // after this point, so its intentional cross-cycle hand-off still works.
22855
+ const valueChangeFromSelection = this._valueChangeFromSelection;
22856
+ this._valueChangeFromSelection = false;
22857
+
22665
22858
  // Single source of truth for 'auroMenu-selectedOption'. Selection handlers
22666
22859
  // mutate optionSelected and let Lit's update cycle dispatch here; the prior
22667
22860
  // .value comparison missed multi-select array changes and combined with the
@@ -22685,6 +22878,17 @@ class AuroMenu extends AuroElement {
22685
22878
  this.initItems();
22686
22879
  }
22687
22880
 
22881
+ // Distinguish a selection-driven `value` change (a user click, which set
22882
+ // the flag in handleSelectState / _sortSelectedByDomOrder) from a
22883
+ // programmatic assignment by a consumer. A programmatic set carries no
22884
+ // positional intent, so drop any leftover `_selectedKey` and let
22885
+ // reconciliation fall back to first-by-value (single) / value-in-DOM-order
22886
+ // (multi) — the same contract selectByValue() guarantees, even when a
22887
+ // stale key would otherwise still resolve to a duplicate-value option.
22888
+ if (!valueChangeFromSelection) {
22889
+ this._selectedKey = undefined;
22890
+ }
22891
+
22688
22892
  // Set when reconciliation reassigns `value` below. That reassignment schedules a
22689
22893
  // second updated() cycle, so the `event`-attribute dispatch is deferred to that
22690
22894
  // cycle to avoid firing option custom events twice on the same selection.
@@ -22702,19 +22906,55 @@ class AuroMenu extends AuroElement {
22702
22906
  // Defensive default: `formattedValue` can be undefined for unexpected value types,
22703
22907
  // and calling `.includes` on undefined would throw during reconciliation.
22704
22908
  const valueArray = this.formattedValue || [];
22705
- const matchingOptions = this.items ? this.items.filter((item) => isSelectableByValue(item) && valueArray.includes(item.value)) : [];
22909
+ // Resolve by key first (the user's exact picks), then fall back to
22910
+ // value matching for any values not resolved by key — so pre-selection
22911
+ // and programmatic value sets keep working. Result is DOM-ordered.
22912
+ const matchingOptions = resolveSelectedOptions(this.items, valueArray, this._selectedKey);
22706
22913
  newSelected = matchingOptions.length > 0 ? matchingOptions : undefined;
22707
22914
 
22708
- // Reconcile `value` with the selectable set. Drop only entries whose option is
22709
- // loaded but non-selectable (disabled/static) — leaving them would desync `value`
22710
- // from `optionSelected`, and the toggle handlers rebuild `value` from `formattedValue`,
22711
- // so the rejected entry would resurface on the next select/deselect. Entries with no
22712
- // matching item yet are preserved so async preselection still works once options render.
22713
- const rejectedValues = this.items
22714
- ? this.items.filter((item) => !isSelectableByValue(item) && valueArray.includes(item.value)).map((item) => item.value)
22715
- : [];
22716
- if (rejectedValues.length > 0) {
22717
- const reconciled = valueArray.filter((val) => !rejectedValues.includes(val));
22915
+ // Reconcile `value` with the selectable set. An occurrence is dropped
22916
+ // only when it is loaded but no selectable option can satisfy it —
22917
+ // every loaded item sharing that value is non-selectable, or the value
22918
+ // recurs more often than it has selectable options (a duplicate value
22919
+ // whose extra siblings are disabled/static). This is count-based, not
22920
+ // presence-based, so an enabled option is kept even when a disabled
22921
+ // sibling shares its value — mirroring how `resolveSelectedOptions`
22922
+ // resolves the same set. Entries with no matching item yet are
22923
+ // preserved so async preselection still works, and the toggle handlers
22924
+ // rebuild `value` from `formattedValue`, so a rejected entry cannot
22925
+ // resurface on the next select/deselect.
22926
+ const selectableByValue = new Map();
22927
+ const loadedValues = new Set();
22928
+ if (this.items) {
22929
+ this.items.forEach((item) => {
22930
+ loadedValues.add(item.value);
22931
+ if (isSelectableByValue(item)) {
22932
+ selectableByValue.set(item.value, (selectableByValue.get(item.value) || 0) + 1);
22933
+ }
22934
+ });
22935
+ }
22936
+
22937
+ const reconciled = valueArray.filter((val) => {
22938
+ // Not loaded yet (async preselection) — keep for a later cycle.
22939
+ if (!loadedValues.has(val)) {
22940
+ return true;
22941
+ }
22942
+ // Consume one selectable option per occurrence; drop once exhausted.
22943
+ const remaining = selectableByValue.get(val) || 0;
22944
+ if (remaining > 0) {
22945
+ selectableByValue.set(val, remaining - 1);
22946
+ return true;
22947
+ }
22948
+ return false;
22949
+ });
22950
+
22951
+ if (reconciled.length !== valueArray.length) {
22952
+ // This is an internal correction, not a consumer's programmatic set,
22953
+ // so preserve the selection-driven flag through the re-entrant
22954
+ // updated() cycle it schedules. Otherwise that cycle would treat the
22955
+ // reassignment as programmatic and drop `_selectedKey` mid-cascade,
22956
+ // flipping resolution and looping.
22957
+ this._valueChangeFromSelection = true;
22718
22958
  this.value = serializeMultiSelectValue(reconciled);
22719
22959
  valueReconciled = true;
22720
22960
  }
@@ -22726,7 +22966,11 @@ class AuroMenu extends AuroElement {
22726
22966
  // `hidden` is intentionally NOT excluded: the combobox toggles
22727
22967
  // `hidden` as its type-ahead filter, so a filtered-out option is
22728
22968
  // still a valid programmatic selection.
22729
- const matchingOption = this.items ? this.items.find((item) => isSelectableByValue(item) && item.value === this.value) : undefined;
22969
+ // Prefer the option the user actually selected (tracked by
22970
+ // `_selectedKey`) so a click on the second of two options sharing a
22971
+ // `value` resolves back to that exact element instead of the first
22972
+ // value match. Falls back to first-by-value for programmatic sets.
22973
+ const matchingOption = resolveSelectedOption(this.items, this.value, this._selectedKey);
22730
22974
 
22731
22975
  if (matchingOption) {
22732
22976
  newSelected = matchingOption;
@@ -22954,6 +23198,14 @@ class AuroMenu extends AuroElement {
22954
23198
  }
22955
23199
  });
22956
23200
 
23201
+ // Assign private keys once items are populated. Only the root menu assigns
23202
+ // keys: its `items` is a deep query that already includes nested submenu
23203
+ // options, so a single pass keys the entire tree. Nested menus skip this
23204
+ // and inherit keys from the root.
23205
+ if (this.rootMenu) {
23206
+ this._assignOptionKeys();
23207
+ }
23208
+
22957
23209
  if (this.noCheckmark) {
22958
23210
  this.updateItemsState(new Map([
22959
23211
  [
@@ -22970,6 +23222,31 @@ class AuroMenu extends AuroElement {
22970
23222
  }));
22971
23223
  }
22972
23224
 
23225
+ /**
23226
+ * Assigns a private, auto-generated unique key (`_optionKey`) to each menu
23227
+ * option that does not already have one. Keys are internal state on the
23228
+ * element instance — never reflected as an attribute or exposed publicly —
23229
+ * and let selection tracking distinguish options that share the same `value`.
23230
+ *
23231
+ * The `_optionKey === undefined` guard makes this idempotent: options keep the
23232
+ * key they were first assigned across re-renders and slot changes, and if a
23233
+ * nested menu's lifecycle runs a pass before the root, options simply wait for
23234
+ * the root to key them (or keep whatever key they already hold).
23235
+ * @private
23236
+ */
23237
+ _assignOptionKeys() {
23238
+ if (!this.items) {
23239
+ return;
23240
+ }
23241
+
23242
+ this.items.forEach((option) => {
23243
+ if (option._optionKey === undefined) {
23244
+ this._optionKeyCounter += 1;
23245
+ option._optionKey = `${this._menuInstanceId}-${this._optionKeyCounter}`;
23246
+ }
23247
+ });
23248
+ }
23249
+
22973
23250
  // Logic Methods
22974
23251
 
22975
23252
  /**
@@ -22979,24 +23256,28 @@ class AuroMenu extends AuroElement {
22979
23256
  */
22980
23257
  handleSelectState(option) {
22981
23258
  if (this.multiSelect) {
22982
- const currentValue = this.formattedValue || [];
22983
23259
  const currentSelected = this.optionSelected || [];
22984
23260
 
22985
- if (!currentValue.includes(option.value)) {
22986
- this.value = serializeMultiSelectValue([
22987
- ...currentValue,
22988
- option.value
22989
- ]);
22990
- }
22991
23261
  if (!currentSelected.includes(option)) {
22992
23262
  this.optionSelected = [
22993
23263
  ...currentSelected,
22994
23264
  option
22995
23265
  ];
22996
23266
  }
23267
+
23268
+ // Re-sort by DOM order and rebuild `_selectedKey`/`value` from the
23269
+ // selected set so display order stays consistent with the menu, not with
23270
+ // click order.
23271
+ this._sortSelectedByDomOrder();
22997
23272
  } else {
22998
23273
  this.value = option.value;
22999
23274
  this.optionSelected = option;
23275
+ // Track the specific option the user selected so the value→option
23276
+ // reconciliation in updated() resolves back to this exact element even
23277
+ // when another option shares the same `value`.
23278
+ this._selectedKey = option._optionKey;
23279
+ // Mark this `value` change as selection-driven so updated() trusts the key.
23280
+ this._valueChangeFromSelection = true;
23000
23281
  }
23001
23282
 
23002
23283
  this._index = this.items.indexOf(option);
@@ -23009,18 +23290,22 @@ class AuroMenu extends AuroElement {
23009
23290
  */
23010
23291
  handleDeselectState(option) {
23011
23292
  if (this.multiSelect) {
23012
- // Remove this option from array; an empty result collapses `value` to undefined.
23013
- const newFormattedValue = (this.formattedValue || []).filter((val) => val !== option.value);
23014
- this.value = serializeMultiSelectValue(newFormattedValue);
23015
-
23016
- this.optionSelected = this.optionSelected.filter((val) => val !== option);
23293
+ // Remove this exact element from the selection (identity, not value — two
23294
+ // options can share a `value`), then rebuild `value`/`_selectedKey` from
23295
+ // the remaining set in DOM order. An empty result collapses to undefined.
23296
+ this.optionSelected = this.optionSelected.filter((selected) => selected !== option);
23017
23297
  if (this.optionSelected.length === 0) {
23018
23298
  this.optionSelected = undefined;
23299
+ this._selectedKey = undefined;
23300
+ this.value = undefined;
23301
+ } else {
23302
+ this._sortSelectedByDomOrder();
23019
23303
  }
23020
23304
  } else {
23021
23305
  // For single-select: Back to undefined when deselected
23022
23306
  this.value = undefined;
23023
23307
  this.optionSelected = undefined;
23308
+ this._selectedKey = undefined;
23024
23309
  }
23025
23310
 
23026
23311
  // Update the index tracking
@@ -23044,9 +23329,46 @@ class AuroMenu extends AuroElement {
23044
23329
  clearSelection() {
23045
23330
  this.optionSelected = undefined;
23046
23331
  this.value = undefined;
23332
+ this._selectedKey = undefined;
23047
23333
  this._index = -1;
23048
23334
  }
23049
23335
 
23336
+ /**
23337
+ * Re-sorts the multi-select selection into DOM order and rebuilds the derived
23338
+ * `_selectedKey` and `value` from `optionSelected`. Selection is always stored
23339
+ * and serialized in the order options appear in the menu, never in click
23340
+ * order — so selecting C then A yields `[A, C]`.
23341
+ * @private
23342
+ */
23343
+ _sortSelectedByDomOrder() {
23344
+ if (!this.multiSelect || !Array.isArray(this.optionSelected) || !this.items) {
23345
+ return;
23346
+ }
23347
+
23348
+ const indexMap = new Map(this.items.map((item, index) => [
23349
+ item,
23350
+ index
23351
+ ]));
23352
+
23353
+ // Sorting in place mutates `optionSelected` without a new array reference,
23354
+ // which Lit's `===` change-detection cannot see on its own — but that is
23355
+ // intentional and safe: both callers (handleSelectState / handleDeselectState)
23356
+ // assign a fresh `optionSelected` array immediately before calling, so Lit
23357
+ // already has a changed reference to react to, and the `value` write below
23358
+ // schedules the updated() cycle that re-derives `optionSelected` in DOM order
23359
+ // via resolveSelectedOptions. Do not "fix" this into a new-array assignment.
23360
+ //
23361
+ // Sort any element no longer in `items` (a stale selection left over from a
23362
+ // dynamic rebuild that the consumer has not cleared) to the END rather than
23363
+ // the front, so it never displaces a live option to the head of the
23364
+ // serialized order. Value reconciliation drops it on the next updated() cycle.
23365
+ this.optionSelected.sort((optionA, optionB) => (indexMap.get(optionA) ?? this.items.length) - (indexMap.get(optionB) ?? this.items.length));
23366
+ this._selectedKey = this.optionSelected.map((option) => option._optionKey);
23367
+ this.value = serializeMultiSelectValue(this.optionSelected.map((option) => option.value));
23368
+ // Mark this `value` change as selection-driven so updated() trusts the keys.
23369
+ this._valueChangeFromSelection = true;
23370
+ }
23371
+
23050
23372
  /**
23051
23373
  * Resets the menu to its initial state.
23052
23374
  * This is the only way to return value to undefined.
@@ -23056,6 +23378,7 @@ class AuroMenu extends AuroElement {
23056
23378
  // Reset to undefined - initial state
23057
23379
  this.value = undefined;
23058
23380
  this.optionSelected = undefined;
23381
+ this._selectedKey = undefined;
23059
23382
  this._index = -1;
23060
23383
 
23061
23384
  // Clear active option state so a follow-up open/navigation starts fresh
@@ -23117,6 +23440,21 @@ class AuroMenu extends AuroElement {
23117
23440
  this.initItems();
23118
23441
  }
23119
23442
 
23443
+ // Recover `_index` from the highlighted option when it has been reset to -1.
23444
+ // The updated() reconciliation resets `_index = -1` whenever the value
23445
+ // collapses to undefined while `optionActive` still points at the highlighted
23446
+ // option — e.g. deselecting the last remaining option in multi-select, or a
23447
+ // programmatic clearSelection() in single-select while keyboard focus is on an
23448
+ // option. Without this, reading `items[-1]` returns undefined and the re-select
23449
+ // no-ops until the highlight is moved away and back. Mirrors auro-combobox's
23450
+ // reconcileMenuIndex.
23451
+ if (this._index < 0 && this.optionActive && this.items) {
23452
+ const activeIndex = this.items.indexOf(this.optionActive);
23453
+ if (activeIndex >= 0) {
23454
+ this._index = activeIndex;
23455
+ }
23456
+ }
23457
+
23120
23458
  // Get currently selected menu option based on index
23121
23459
  const option = this.items ? this.items[this._index] : undefined;
23122
23460