@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
@@ -4977,7 +4977,7 @@ let AuroHelpText$2 = class AuroHelpText extends i$3 {
4977
4977
  }
4978
4978
  };
4979
4979
 
4980
- var formkitVersion$2 = '202608102106';
4980
+ var formkitVersion$2 = '202608111845';
4981
4981
 
4982
4982
  let AuroElement$2 = class AuroElement extends i$3 {
4983
4983
  static get properties() {
@@ -18677,7 +18677,7 @@ let AuroHelpText$1 = class AuroHelpText extends i$3 {
18677
18677
  }
18678
18678
  };
18679
18679
 
18680
- var formkitVersion$1 = '202608102106';
18680
+ var formkitVersion$1 = '202608111845';
18681
18681
 
18682
18682
  // Copyright (c) 2025 Alaska Airlines. All right reserved. Licensed under the Apache-2.0 license
18683
18683
  // See LICENSE in the project root for license information.
@@ -18778,22 +18778,35 @@ class AuroInput extends BaseInput {
18778
18778
  * @private
18779
18779
  */
18780
18780
  get inputHidden() {
18781
- // When activeLabel is set, keep the input row visible so the label stays
18782
- // raised in the active position (classic/default layout) instead of
18783
- // collapsing to the centered placeholder position.
18784
- if (this.activeLabel) {
18785
- return false;
18786
- }
18781
+ // In the classic/default layout, activeLabel keeps the input row visible so
18782
+ // the label stays raised rather than collapsing to the centered placeholder
18783
+ // position. Emphasized/snowflake always render the label inside the field,
18784
+ // so activeLabel does not apply there. This only overrides the empty/
18785
+ // unfocused clause — the display-value clause (combobox/datepicker) is
18786
+ // preserved regardless.
18787
+ const activeLabelRaisesInput = this.activeLabel && this.isClassicLayout;
18787
18788
 
18788
18789
  return (
18789
18790
  this.hasDisplayValueContent && !this.hasFocus && this.hasValue) ||
18790
18791
  (
18792
+ !activeLabelRaisesInput &&
18791
18793
  (!this.value || this.value.length === 0) &&
18792
18794
  !this.hasFocus &&
18793
18795
  (!this.placeholderStr || this.placeholderStr === '')
18794
18796
  );
18795
18797
  }
18796
18798
 
18799
+ /**
18800
+ * Whether the component is rendering the classic/default layout, meaning not
18801
+ * emphasized and not snowflake. Kept as a single source of truth so
18802
+ * layout-scoped behavior such as activeLabel stays consistent across getters.
18803
+ * @returns {boolean} - True for the classic/default layout.
18804
+ * @private
18805
+ */
18806
+ get isClassicLayout() {
18807
+ return !this.layout.startsWith('emphasized') && this.layout !== 'snowflake';
18808
+ }
18809
+
18797
18810
  /**
18798
18811
  * Determines if the input should display in a state with no focus or value indication.
18799
18812
  * Returns true when the input has display content without focus and has a value,
@@ -19840,7 +19853,7 @@ class AuroBibtemplate extends i$3 {
19840
19853
  }
19841
19854
  }
19842
19855
 
19843
- var formkitVersion = '202608102106';
19856
+ var formkitVersion = '202608111845';
19844
19857
 
19845
19858
  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}`;
19846
19859
 
@@ -21646,7 +21659,17 @@ class AuroCombobox extends AuroElement {
21646
21659
  return;
21647
21660
  }
21648
21661
 
21649
- this.value = this.input.value;
21662
+ // Skip this write during a display-value sync: that path sets the input to
21663
+ // the selected option's LABEL, which is not its machine value. When an
21664
+ // option's label differs from its value (and especially when several
21665
+ // options share a value but render distinct labels — AB#1602086), writing
21666
+ // the label back into this.value clobbers the machine value the selection
21667
+ // listener just set (this.value = optionSelected.value), collapsing the
21668
+ // selection. _syncingBibValue still tracks (that mirrors user-typed text
21669
+ // from the fullscreen bib, where value should follow the input).
21670
+ if (!this._syncingDisplayValue) {
21671
+ this.value = this.input.value;
21672
+ }
21650
21673
 
21651
21674
  // Ignore re-entrant input events caused by programmatic value sets.
21652
21675
  if (this._syncingBibValue || this._syncingDisplayValue) {
@@ -21910,33 +21933,53 @@ class AuroCombobox extends AuroElement {
21910
21933
  }
21911
21934
 
21912
21935
  if (this.input.value !== this.value) {
21913
- // Clear menu.value AND menu.optionSelected together. Clearing only
21914
- // menu.value leaves the previously-selected option element pinned
21915
- // as menu.optionSelected; a later auroMenu-selectedOption event
21916
- // would then write its stale .value back into combobox.value
21917
- // (e.g. Tab-after-Backspace re-selecting the prior option).
21918
- if (this.menu.value || this.menu.optionSelected) {
21919
- this.menu.clearSelection();
21920
- }
21936
+ // A fresh user selection leaves the input showing the option's LABEL
21937
+ // while this.value holds its machine value, so input.value !== this.value
21938
+ // is expected whenever label ≠ value. In that case the menu's current
21939
+ // selection is authoritative — and when several options share a value it
21940
+ // is the ONLY thing that records WHICH same-value option the user picked
21941
+ // (the menu tracks it by element identity via `_selectedKey`). Clearing
21942
+ // it here drops that key; the ensuing value-only re-resolution then
21943
+ // collapses the selection onto the first same-value option (AB#1602086).
21944
+ // So only treat the divergence as a stale menu when the selected option
21945
+ // does NOT match the new value.
21946
+ const menuSelectionMatchesValue =
21947
+ this.menu.optionSelected &&
21948
+ // optionSelected is a single element here (scalar `.value`). A multiselect
21949
+ // menu exposes it as an ARRAY, which has no scalar `.value` to compare, so
21950
+ // that shape can't be a single-option match — fall through to the clear path.
21951
+ !Array.isArray(this.menu.optionSelected) &&
21952
+ this.menu.optionSelected.value === this.value;
21953
+
21954
+ if (!menuSelectionMatchesValue) {
21955
+ // Clear menu.value AND menu.optionSelected together. Clearing only
21956
+ // menu.value leaves the previously-selected option element pinned
21957
+ // as menu.optionSelected; a later auroMenu-selectedOption event
21958
+ // would then write its stale .value back into combobox.value
21959
+ // (e.g. Tab-after-Backspace re-selecting the prior option).
21960
+ if (this.menu.value || this.menu.optionSelected) {
21961
+ this.menu.clearSelection();
21962
+ }
21921
21963
 
21922
- if (!this.persistInput) {
21923
- this.syncInputValuesAcrossTriggerAndBib(this.value || '');
21924
- }
21964
+ if (!this.persistInput) {
21965
+ this.syncInputValuesAcrossTriggerAndBib(this.value || '');
21966
+ }
21925
21967
 
21926
- // Programmatic value with no matching option: updateFilter will close
21927
- // the bib silently (see line 648 — no noMatchOption + 0 results
21928
- // hides). Announce so screen-reader users hear the request was
21929
- // dropped. Gated on `input.value !== this.value` so this never fires
21930
- // for user typing — that path always reconciles input.value to
21931
- // this.value before updated() runs.
21932
- if (
21933
- this.value &&
21934
- this.menu &&
21935
- this.menu.options &&
21936
- this.menu.options.length > 0 &&
21937
- !this.menu.options.some((opt) => opt.value === this.value)
21938
- ) {
21939
- announceToScreenReader(this._getAnnouncementRoot(), `No matching option for ${this.value}`);
21968
+ // Programmatic value with no matching option: updateFilter will close
21969
+ // the bib silently (see line 648 — no noMatchOption + 0 results
21970
+ // hides). Announce so screen-reader users hear the request was
21971
+ // dropped. Gated on `input.value !== this.value` so this never fires
21972
+ // for user typing — that path always reconciles input.value to
21973
+ // this.value before updated() runs.
21974
+ if (
21975
+ this.value &&
21976
+ this.menu &&
21977
+ this.menu.options &&
21978
+ this.menu.options.length > 0 &&
21979
+ !this.menu.options.some((opt) => opt.value === this.value)
21980
+ ) {
21981
+ announceToScreenReader(this._getAnnouncementRoot(), `No matching option for ${this.value}`);
21982
+ }
21940
21983
  }
21941
21984
  }
21942
21985
 
@@ -22273,6 +22316,116 @@ function isSelectableByValue(option) {
22273
22316
  !option.hasAttribute('static');
22274
22317
  }
22275
22318
 
22319
+ /* eslint-disable no-underscore-dangle */
22320
+ /**
22321
+ * Resolves the single selected option for a given `value`, preferring the
22322
+ * option tracked by `selectedKey` (a user-initiated selection) over a
22323
+ * first-by-value match. When multiple options share the same `value`, matching
22324
+ * by `value` alone cannot distinguish which one the user picked; the key
22325
+ * disambiguates it.
22326
+ *
22327
+ * The key is trusted only when it still resolves to an option whose `value`
22328
+ * matches the requested `value`. If the key is stale (option removed) or the
22329
+ * value was changed programmatically, resolution falls back to value matching —
22330
+ * preserving backward-compatible behavior for preselection and `selectByValue`.
22331
+ * @private
22332
+ * @param {Array<HTMLElement>} items - The menu's flat option list.
22333
+ * @param {string} value - The value to resolve.
22334
+ * @param {string|undefined} selectedKey - The `_optionKey` of the user-selected option, if any.
22335
+ * @returns {HTMLElement|undefined} The resolved option, or undefined when none match.
22336
+ */
22337
+ function resolveSelectedOption(items, value, selectedKey) {
22338
+ if (!items) {
22339
+ return undefined;
22340
+ }
22341
+
22342
+ if (selectedKey !== undefined) {
22343
+ const keyed = items.find((item) => item._optionKey === selectedKey);
22344
+ if (keyed && isSelectableByValue(keyed) && keyed.value === value) {
22345
+ return keyed;
22346
+ }
22347
+ // Key exists but the option is gone or its value no longer matches — fall
22348
+ // through to value-based matching.
22349
+ }
22350
+
22351
+ return items.find((item) => isSelectableByValue(item) && item.value === value);
22352
+ }
22353
+
22354
+ /**
22355
+ * Resolves the selected options for a multi-select `value` array, preferring
22356
+ * options tracked by `selectedKeys` (user-initiated selections) and falling
22357
+ * back to value matching for any values not resolved by key. The result is
22358
+ * always sorted into DOM order regardless of selection sequence.
22359
+ * @private
22360
+ * @param {Array<HTMLElement>} items - The menu's flat option list.
22361
+ * @param {Array<string>} valueArray - The selected values.
22362
+ * @param {Array<string>|undefined} selectedKeys - The `_optionKey`s of the user-selected options, if any.
22363
+ * @returns {Array<HTMLElement>} The resolved options in DOM order.
22364
+ */
22365
+ function resolveSelectedOptions(items, valueArray, selectedKeys) {
22366
+ if (!items) {
22367
+ return [];
22368
+ }
22369
+
22370
+ const resolved = [];
22371
+ // Mirror `resolved` as a Set for O(1) membership checks below, matching the
22372
+ // indexMap optimization used for the sort rather than scanning `resolved`
22373
+ // on every candidate.
22374
+ const resolvedSet = new Set();
22375
+
22376
+ // Track how many of each value are still available to resolve. A value that
22377
+ // appears N times in `valueArray` may be satisfied at most N times total across
22378
+ // the key pass and the value fallback below — matching by count, not presence,
22379
+ // on BOTH passes. This stops a duplicate value from being over-resolved: e.g.
22380
+ // two keyed options that both carry `SEA` cannot both match a single requested
22381
+ // `SEA` (which happens when `value` is set directly without clearing
22382
+ // `_selectedKey`, so more keys survive than the value set now asks for).
22383
+ const remaining = new Map();
22384
+ valueArray.forEach((val) => remaining.set(val, (remaining.get(val) || 0) + 1));
22385
+
22386
+ // Resolve by key first: trust a key only when its option is still selectable
22387
+ // and there is still an unmatched occurrence of its value in the request set.
22388
+ if (Array.isArray(selectedKeys)) {
22389
+ selectedKeys.forEach((key) => {
22390
+ const keyed = items.find((item) => item._optionKey === key);
22391
+ if (keyed && isSelectableByValue(keyed) && (remaining.get(keyed.value) || 0) > 0 && !resolvedSet.has(keyed)) {
22392
+ resolved.push(keyed);
22393
+ resolvedSet.add(keyed);
22394
+ remaining.set(keyed.value, remaining.get(keyed.value) - 1);
22395
+ }
22396
+ });
22397
+ }
22398
+
22399
+ // Fall back to value matching for the occurrences not resolved by key. Iterate
22400
+ // the leftover per-value counts so a value that appears twice but was only
22401
+ // resolved once by key still matches its remaining occurrence(s).
22402
+ remaining.forEach((count, val) => {
22403
+ for (let occurrence = 0; occurrence < count; occurrence += 1) {
22404
+ const option = items.find((item) => isSelectableByValue(item) && item.value === val && !resolvedSet.has(item));
22405
+ if (option) {
22406
+ resolved.push(option);
22407
+ resolvedSet.add(option);
22408
+ }
22409
+ }
22410
+ });
22411
+
22412
+ // Always return in DOM order so display is consistent regardless of the order
22413
+ // keys/values were selected. Every resolved option came from `items`, so an
22414
+ // O(1) index lookup mirrors `_sortSelectedByDomOrder` and avoids the O(n)
22415
+ // `items.indexOf` per comparison for large combobox option sets. Any element
22416
+ // not in `items` (a stale snapshot from a future caller) sorts to the END via
22417
+ // `?? items.length`, matching `_sortSelectedByDomOrder` and avoiding NaN
22418
+ // comparisons.
22419
+ const indexMap = new Map(items.map((item, index) => [
22420
+ item,
22421
+ index
22422
+ ]));
22423
+ resolved.sort((optionA, optionB) => (indexMap.get(optionA) ?? items.length) - (indexMap.get(optionB) ?? items.length));
22424
+
22425
+ return resolved;
22426
+ }
22427
+ /* eslint-enable no-underscore-dangle */
22428
+
22276
22429
  /**
22277
22430
  * Helper method to dispatch custom events.
22278
22431
  * @param {HTMLElement} element - Element to dispatch event from.
@@ -22298,6 +22451,14 @@ function dispatchMenuEvent(element, eventName, detail = null) {
22298
22451
  // See LICENSE in the project root for license information.
22299
22452
 
22300
22453
 
22454
+ /**
22455
+ * Monotonically increasing counter used to give each menu instance a unique
22456
+ * `_menuInstanceId` prefix. Auto-generating the id (rather than using a random
22457
+ * string) keeps option keys deterministic and collision-free across menus.
22458
+ * @private
22459
+ */
22460
+ let menuInstanceIdCounter = 0;
22461
+
22301
22462
 
22302
22463
  /**
22303
22464
  * The `auro-menu` element provides users a way to select from a list of options.
@@ -22369,9 +22530,8 @@ class AuroMenu extends AuroElement {
22369
22530
 
22370
22531
  // Instance properties (non-reactive)
22371
22532
 
22372
- /**
22373
- * @private
22374
- */
22533
+ menuInstanceIdCounter += 1;
22534
+
22375
22535
  Object.assign(this, {
22376
22536
  // Root-level menu (true) or a nested submenu (false)
22377
22537
  rootMenu: true,
@@ -22381,6 +22541,21 @@ class AuroMenu extends AuroElement {
22381
22541
  nestingSpacer: '<span class="nestingSpacer"></span>',
22382
22542
  // Loading indicator for slot elements
22383
22543
  loadingSlots: null,
22544
+ // Unique id for this menu instance; prefixes every auto-generated option
22545
+ // key so keys never collide across menus in the same document.
22546
+ _menuInstanceId: `menu-${menuInstanceIdCounter}`,
22547
+ // Monotonically increasing counter for option key generation. Never
22548
+ // resets, so a key is never reused within this instance's lifetime.
22549
+ _optionKeyCounter: 0,
22550
+ // Key(s) of the option(s) the user has actively selected. A single string
22551
+ // in single-select, an array in multi-select, undefined when nothing is
22552
+ // user-selected. Used to disambiguate options that share a `value`.
22553
+ _selectedKey: undefined,
22554
+ // True only for the one updated() cycle following a user selection, so
22555
+ // reconciliation trusts `_selectedKey`. A `value` change from a consumer's
22556
+ // direct property assignment leaves this false, dropping the stale key so
22557
+ // reconciliation falls back to first-by-value (see updated()).
22558
+ _valueChangeFromSelection: false,
22384
22559
  });
22385
22560
  }
22386
22561
 
@@ -22600,6 +22775,13 @@ class AuroMenu extends AuroElement {
22600
22775
  return;
22601
22776
  }
22602
22777
 
22778
+ // A programmatic value set carries no positional intent, so drop any
22779
+ // `_selectedKey` left over from a prior user click. This makes reconciliation
22780
+ // in updated() fall back to first-by-value (single) / value-in-DOM-order
22781
+ // (multi), matching the documented contract for programmatic selection even
22782
+ // when a stale key would still resolve to a duplicate-value option.
22783
+ this._selectedKey = undefined;
22784
+
22603
22785
  // `value` is a String property; stringify arrays so attribute reflection and `formattedValue` parsing stay correct.
22604
22786
  this.value = Array.isArray(value) ? JSON.stringify(value) : value;
22605
22787
  }
@@ -22647,6 +22829,17 @@ class AuroMenu extends AuroElement {
22647
22829
  updated(changedProperties) {
22648
22830
  super.updated(changedProperties);
22649
22831
 
22832
+ // Consume the selection-driven flag for THIS cycle up front. Clearing it
22833
+ // unconditionally — not only inside the `value` branch below — prevents it
22834
+ // from lingering `true` when a selection produces a serialized `value`
22835
+ // byte-identical to the current one, in which case Lit schedules no
22836
+ // `value`-change cycle to consume it. A lingering flag would misclassify a
22837
+ // later consumer's programmatic `value` set as selection-driven and keep a
22838
+ // stale `_selectedKey`. The reconcile path below re-sets the instance flag
22839
+ // after this point, so its intentional cross-cycle hand-off still works.
22840
+ const valueChangeFromSelection = this._valueChangeFromSelection;
22841
+ this._valueChangeFromSelection = false;
22842
+
22650
22843
  // Single source of truth for 'auroMenu-selectedOption'. Selection handlers
22651
22844
  // mutate optionSelected and let Lit's update cycle dispatch here; the prior
22652
22845
  // .value comparison missed multi-select array changes and combined with the
@@ -22670,6 +22863,17 @@ class AuroMenu extends AuroElement {
22670
22863
  this.initItems();
22671
22864
  }
22672
22865
 
22866
+ // Distinguish a selection-driven `value` change (a user click, which set
22867
+ // the flag in handleSelectState / _sortSelectedByDomOrder) from a
22868
+ // programmatic assignment by a consumer. A programmatic set carries no
22869
+ // positional intent, so drop any leftover `_selectedKey` and let
22870
+ // reconciliation fall back to first-by-value (single) / value-in-DOM-order
22871
+ // (multi) — the same contract selectByValue() guarantees, even when a
22872
+ // stale key would otherwise still resolve to a duplicate-value option.
22873
+ if (!valueChangeFromSelection) {
22874
+ this._selectedKey = undefined;
22875
+ }
22876
+
22673
22877
  // Set when reconciliation reassigns `value` below. That reassignment schedules a
22674
22878
  // second updated() cycle, so the `event`-attribute dispatch is deferred to that
22675
22879
  // cycle to avoid firing option custom events twice on the same selection.
@@ -22687,19 +22891,55 @@ class AuroMenu extends AuroElement {
22687
22891
  // Defensive default: `formattedValue` can be undefined for unexpected value types,
22688
22892
  // and calling `.includes` on undefined would throw during reconciliation.
22689
22893
  const valueArray = this.formattedValue || [];
22690
- const matchingOptions = this.items ? this.items.filter((item) => isSelectableByValue(item) && valueArray.includes(item.value)) : [];
22894
+ // Resolve by key first (the user's exact picks), then fall back to
22895
+ // value matching for any values not resolved by key — so pre-selection
22896
+ // and programmatic value sets keep working. Result is DOM-ordered.
22897
+ const matchingOptions = resolveSelectedOptions(this.items, valueArray, this._selectedKey);
22691
22898
  newSelected = matchingOptions.length > 0 ? matchingOptions : undefined;
22692
22899
 
22693
- // Reconcile `value` with the selectable set. Drop only entries whose option is
22694
- // loaded but non-selectable (disabled/static) — leaving them would desync `value`
22695
- // from `optionSelected`, and the toggle handlers rebuild `value` from `formattedValue`,
22696
- // so the rejected entry would resurface on the next select/deselect. Entries with no
22697
- // matching item yet are preserved so async preselection still works once options render.
22698
- const rejectedValues = this.items
22699
- ? this.items.filter((item) => !isSelectableByValue(item) && valueArray.includes(item.value)).map((item) => item.value)
22700
- : [];
22701
- if (rejectedValues.length > 0) {
22702
- const reconciled = valueArray.filter((val) => !rejectedValues.includes(val));
22900
+ // Reconcile `value` with the selectable set. An occurrence is dropped
22901
+ // only when it is loaded but no selectable option can satisfy it —
22902
+ // every loaded item sharing that value is non-selectable, or the value
22903
+ // recurs more often than it has selectable options (a duplicate value
22904
+ // whose extra siblings are disabled/static). This is count-based, not
22905
+ // presence-based, so an enabled option is kept even when a disabled
22906
+ // sibling shares its value — mirroring how `resolveSelectedOptions`
22907
+ // resolves the same set. Entries with no matching item yet are
22908
+ // preserved so async preselection still works, and the toggle handlers
22909
+ // rebuild `value` from `formattedValue`, so a rejected entry cannot
22910
+ // resurface on the next select/deselect.
22911
+ const selectableByValue = new Map();
22912
+ const loadedValues = new Set();
22913
+ if (this.items) {
22914
+ this.items.forEach((item) => {
22915
+ loadedValues.add(item.value);
22916
+ if (isSelectableByValue(item)) {
22917
+ selectableByValue.set(item.value, (selectableByValue.get(item.value) || 0) + 1);
22918
+ }
22919
+ });
22920
+ }
22921
+
22922
+ const reconciled = valueArray.filter((val) => {
22923
+ // Not loaded yet (async preselection) — keep for a later cycle.
22924
+ if (!loadedValues.has(val)) {
22925
+ return true;
22926
+ }
22927
+ // Consume one selectable option per occurrence; drop once exhausted.
22928
+ const remaining = selectableByValue.get(val) || 0;
22929
+ if (remaining > 0) {
22930
+ selectableByValue.set(val, remaining - 1);
22931
+ return true;
22932
+ }
22933
+ return false;
22934
+ });
22935
+
22936
+ if (reconciled.length !== valueArray.length) {
22937
+ // This is an internal correction, not a consumer's programmatic set,
22938
+ // so preserve the selection-driven flag through the re-entrant
22939
+ // updated() cycle it schedules. Otherwise that cycle would treat the
22940
+ // reassignment as programmatic and drop `_selectedKey` mid-cascade,
22941
+ // flipping resolution and looping.
22942
+ this._valueChangeFromSelection = true;
22703
22943
  this.value = serializeMultiSelectValue(reconciled);
22704
22944
  valueReconciled = true;
22705
22945
  }
@@ -22711,7 +22951,11 @@ class AuroMenu extends AuroElement {
22711
22951
  // `hidden` is intentionally NOT excluded: the combobox toggles
22712
22952
  // `hidden` as its type-ahead filter, so a filtered-out option is
22713
22953
  // still a valid programmatic selection.
22714
- const matchingOption = this.items ? this.items.find((item) => isSelectableByValue(item) && item.value === this.value) : undefined;
22954
+ // Prefer the option the user actually selected (tracked by
22955
+ // `_selectedKey`) so a click on the second of two options sharing a
22956
+ // `value` resolves back to that exact element instead of the first
22957
+ // value match. Falls back to first-by-value for programmatic sets.
22958
+ const matchingOption = resolveSelectedOption(this.items, this.value, this._selectedKey);
22715
22959
 
22716
22960
  if (matchingOption) {
22717
22961
  newSelected = matchingOption;
@@ -22939,6 +23183,14 @@ class AuroMenu extends AuroElement {
22939
23183
  }
22940
23184
  });
22941
23185
 
23186
+ // Assign private keys once items are populated. Only the root menu assigns
23187
+ // keys: its `items` is a deep query that already includes nested submenu
23188
+ // options, so a single pass keys the entire tree. Nested menus skip this
23189
+ // and inherit keys from the root.
23190
+ if (this.rootMenu) {
23191
+ this._assignOptionKeys();
23192
+ }
23193
+
22942
23194
  if (this.noCheckmark) {
22943
23195
  this.updateItemsState(new Map([
22944
23196
  [
@@ -22955,6 +23207,31 @@ class AuroMenu extends AuroElement {
22955
23207
  }));
22956
23208
  }
22957
23209
 
23210
+ /**
23211
+ * Assigns a private, auto-generated unique key (`_optionKey`) to each menu
23212
+ * option that does not already have one. Keys are internal state on the
23213
+ * element instance — never reflected as an attribute or exposed publicly —
23214
+ * and let selection tracking distinguish options that share the same `value`.
23215
+ *
23216
+ * The `_optionKey === undefined` guard makes this idempotent: options keep the
23217
+ * key they were first assigned across re-renders and slot changes, and if a
23218
+ * nested menu's lifecycle runs a pass before the root, options simply wait for
23219
+ * the root to key them (or keep whatever key they already hold).
23220
+ * @private
23221
+ */
23222
+ _assignOptionKeys() {
23223
+ if (!this.items) {
23224
+ return;
23225
+ }
23226
+
23227
+ this.items.forEach((option) => {
23228
+ if (option._optionKey === undefined) {
23229
+ this._optionKeyCounter += 1;
23230
+ option._optionKey = `${this._menuInstanceId}-${this._optionKeyCounter}`;
23231
+ }
23232
+ });
23233
+ }
23234
+
22958
23235
  // Logic Methods
22959
23236
 
22960
23237
  /**
@@ -22964,24 +23241,28 @@ class AuroMenu extends AuroElement {
22964
23241
  */
22965
23242
  handleSelectState(option) {
22966
23243
  if (this.multiSelect) {
22967
- const currentValue = this.formattedValue || [];
22968
23244
  const currentSelected = this.optionSelected || [];
22969
23245
 
22970
- if (!currentValue.includes(option.value)) {
22971
- this.value = serializeMultiSelectValue([
22972
- ...currentValue,
22973
- option.value
22974
- ]);
22975
- }
22976
23246
  if (!currentSelected.includes(option)) {
22977
23247
  this.optionSelected = [
22978
23248
  ...currentSelected,
22979
23249
  option
22980
23250
  ];
22981
23251
  }
23252
+
23253
+ // Re-sort by DOM order and rebuild `_selectedKey`/`value` from the
23254
+ // selected set so display order stays consistent with the menu, not with
23255
+ // click order.
23256
+ this._sortSelectedByDomOrder();
22982
23257
  } else {
22983
23258
  this.value = option.value;
22984
23259
  this.optionSelected = option;
23260
+ // Track the specific option the user selected so the value→option
23261
+ // reconciliation in updated() resolves back to this exact element even
23262
+ // when another option shares the same `value`.
23263
+ this._selectedKey = option._optionKey;
23264
+ // Mark this `value` change as selection-driven so updated() trusts the key.
23265
+ this._valueChangeFromSelection = true;
22985
23266
  }
22986
23267
 
22987
23268
  this._index = this.items.indexOf(option);
@@ -22994,18 +23275,22 @@ class AuroMenu extends AuroElement {
22994
23275
  */
22995
23276
  handleDeselectState(option) {
22996
23277
  if (this.multiSelect) {
22997
- // Remove this option from array; an empty result collapses `value` to undefined.
22998
- const newFormattedValue = (this.formattedValue || []).filter((val) => val !== option.value);
22999
- this.value = serializeMultiSelectValue(newFormattedValue);
23000
-
23001
- this.optionSelected = this.optionSelected.filter((val) => val !== option);
23278
+ // Remove this exact element from the selection (identity, not value — two
23279
+ // options can share a `value`), then rebuild `value`/`_selectedKey` from
23280
+ // the remaining set in DOM order. An empty result collapses to undefined.
23281
+ this.optionSelected = this.optionSelected.filter((selected) => selected !== option);
23002
23282
  if (this.optionSelected.length === 0) {
23003
23283
  this.optionSelected = undefined;
23284
+ this._selectedKey = undefined;
23285
+ this.value = undefined;
23286
+ } else {
23287
+ this._sortSelectedByDomOrder();
23004
23288
  }
23005
23289
  } else {
23006
23290
  // For single-select: Back to undefined when deselected
23007
23291
  this.value = undefined;
23008
23292
  this.optionSelected = undefined;
23293
+ this._selectedKey = undefined;
23009
23294
  }
23010
23295
 
23011
23296
  // Update the index tracking
@@ -23029,9 +23314,46 @@ class AuroMenu extends AuroElement {
23029
23314
  clearSelection() {
23030
23315
  this.optionSelected = undefined;
23031
23316
  this.value = undefined;
23317
+ this._selectedKey = undefined;
23032
23318
  this._index = -1;
23033
23319
  }
23034
23320
 
23321
+ /**
23322
+ * Re-sorts the multi-select selection into DOM order and rebuilds the derived
23323
+ * `_selectedKey` and `value` from `optionSelected`. Selection is always stored
23324
+ * and serialized in the order options appear in the menu, never in click
23325
+ * order — so selecting C then A yields `[A, C]`.
23326
+ * @private
23327
+ */
23328
+ _sortSelectedByDomOrder() {
23329
+ if (!this.multiSelect || !Array.isArray(this.optionSelected) || !this.items) {
23330
+ return;
23331
+ }
23332
+
23333
+ const indexMap = new Map(this.items.map((item, index) => [
23334
+ item,
23335
+ index
23336
+ ]));
23337
+
23338
+ // Sorting in place mutates `optionSelected` without a new array reference,
23339
+ // which Lit's `===` change-detection cannot see on its own — but that is
23340
+ // intentional and safe: both callers (handleSelectState / handleDeselectState)
23341
+ // assign a fresh `optionSelected` array immediately before calling, so Lit
23342
+ // already has a changed reference to react to, and the `value` write below
23343
+ // schedules the updated() cycle that re-derives `optionSelected` in DOM order
23344
+ // via resolveSelectedOptions. Do not "fix" this into a new-array assignment.
23345
+ //
23346
+ // Sort any element no longer in `items` (a stale selection left over from a
23347
+ // dynamic rebuild that the consumer has not cleared) to the END rather than
23348
+ // the front, so it never displaces a live option to the head of the
23349
+ // serialized order. Value reconciliation drops it on the next updated() cycle.
23350
+ this.optionSelected.sort((optionA, optionB) => (indexMap.get(optionA) ?? this.items.length) - (indexMap.get(optionB) ?? this.items.length));
23351
+ this._selectedKey = this.optionSelected.map((option) => option._optionKey);
23352
+ this.value = serializeMultiSelectValue(this.optionSelected.map((option) => option.value));
23353
+ // Mark this `value` change as selection-driven so updated() trusts the keys.
23354
+ this._valueChangeFromSelection = true;
23355
+ }
23356
+
23035
23357
  /**
23036
23358
  * Resets the menu to its initial state.
23037
23359
  * This is the only way to return value to undefined.
@@ -23041,6 +23363,7 @@ class AuroMenu extends AuroElement {
23041
23363
  // Reset to undefined - initial state
23042
23364
  this.value = undefined;
23043
23365
  this.optionSelected = undefined;
23366
+ this._selectedKey = undefined;
23044
23367
  this._index = -1;
23045
23368
 
23046
23369
  // Clear active option state so a follow-up open/navigation starts fresh
@@ -23102,6 +23425,21 @@ class AuroMenu extends AuroElement {
23102
23425
  this.initItems();
23103
23426
  }
23104
23427
 
23428
+ // Recover `_index` from the highlighted option when it has been reset to -1.
23429
+ // The updated() reconciliation resets `_index = -1` whenever the value
23430
+ // collapses to undefined while `optionActive` still points at the highlighted
23431
+ // option — e.g. deselecting the last remaining option in multi-select, or a
23432
+ // programmatic clearSelection() in single-select while keyboard focus is on an
23433
+ // option. Without this, reading `items[-1]` returns undefined and the re-select
23434
+ // no-ops until the highlight is moved away and back. Mirrors auro-combobox's
23435
+ // reconcileMenuIndex.
23436
+ if (this._index < 0 && this.optionActive && this.items) {
23437
+ const activeIndex = this.items.indexOf(this.optionActive);
23438
+ if (activeIndex >= 0) {
23439
+ this._index = activeIndex;
23440
+ }
23441
+ }
23442
+
23105
23443
  // Get currently selected menu option based on index
23106
23444
  const option = this.items ? this.items[this._index] : undefined;
23107
23445