@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
@@ -12268,7 +12268,7 @@ class AuroHelpText extends i$3 {
12268
12268
  }
12269
12269
  }
12270
12270
 
12271
- var formkitVersion = '202607291953';
12271
+ var formkitVersion = '202608111803';
12272
12272
 
12273
12273
  /**
12274
12274
  * @license
@@ -12268,7 +12268,7 @@ class AuroHelpText extends i$3 {
12268
12268
  }
12269
12269
  }
12270
12270
 
12271
- var formkitVersion = '202607291953';
12271
+ var formkitVersion = '202608111803';
12272
12272
 
12273
12273
  /**
12274
12274
  * @license
@@ -12268,7 +12268,7 @@ class AuroHelpText extends i$3 {
12268
12268
  }
12269
12269
  }
12270
12270
 
12271
- var formkitVersion = '202607291953';
12271
+ var formkitVersion = '202608111803';
12272
12272
 
12273
12273
  /**
12274
12274
  * @license
@@ -12210,7 +12210,7 @@ class AuroHelpText extends LitElement {
12210
12210
  }
12211
12211
  }
12212
12212
 
12213
- var formkitVersion = '202607291953';
12213
+ var formkitVersion = '202608111803';
12214
12214
 
12215
12215
  // Copyright (c) 2025 Alaska Airlines. All right reserved. Licensed under the Apache-2.0 license
12216
12216
  // See LICENSE in the project root for license information.
@@ -12210,7 +12210,7 @@ class AuroHelpText extends LitElement {
12210
12210
  }
12211
12211
  }
12212
12212
 
12213
- var formkitVersion = '202607291953';
12213
+ var formkitVersion = '202608111803';
12214
12214
 
12215
12215
  // Copyright (c) 2025 Alaska Airlines. All right reserved. Licensed under the Apache-2.0 license
12216
12216
  // See LICENSE in the project root for license information.
@@ -23,6 +23,7 @@
23
23
  <auro-anchorlink fluid href="#multiselect" class="level2 body-xs">Multi-Select</auro-anchorlink>
24
24
  <auro-anchorlink fluid href="#presetValue" class="level2 body-xs">Preset Value</auro-anchorlink>
25
25
  <auro-anchorlink fluid href="#presetValueMultiselect" class="level2 body-xs">Preset Value (Multi)</auro-anchorlink>
26
+ <auro-anchorlink fluid href="#nonUniqueValues" class="level2 body-xs">Non-Unique Option Values</auro-anchorlink>
26
27
  </auro-nav>
27
28
  </nav>
28
29
  <div class="mainContent">
@@ -863,6 +864,55 @@
863
864
  &lt;/auro-menu&gt;</code></pre>
864
865
  <!-- AURO-GENERATED-CONTENT:END -->
865
866
  </auro-accordion>
867
+ <auro-header level="3" id="nonUniqueValues">Non-Unique Option Values</auro-header>
868
+ <p>Two or more <code>auro-menuoption</code> elements may share the same <code>value</code>. This is common when the <code>value</code> represents a coarser grouping than the option label &mdash; for example, several airports that all serve the same city. The menu tracks the specific option a user selects rather than resolving by <code>value</code> alone, so selecting one of several options that share a <code>value</code> highlights exactly the option that was chosen.</p>
869
+ <div class="exampleWrapper">
870
+ <!-- AURO-GENERATED-CONTENT:START (FILE:src=./../apiExamples/duplicate-values.html) -->
871
+ <!-- The below content is automatically added from ./../apiExamples/duplicate-values.html -->
872
+ <auro-menu>
873
+ <auro-menuoption value="seattle">Seattle&ndash;Tacoma International (SEA)</auro-menuoption>
874
+ <auro-menuoption value="seattle">Seattle Paine Field (PAE)</auro-menuoption>
875
+ <auro-menuoption value="portland">Portland International (PDX)</auro-menuoption>
876
+ <auro-menuoption value="spokane">Spokane International (GEG)</auro-menuoption>
877
+ </auro-menu>
878
+ <!-- AURO-GENERATED-CONTENT:END -->
879
+ </div>
880
+ <auro-accordion alignRight>
881
+ <span slot="trigger">See code</span>
882
+ <!-- AURO-GENERATED-CONTENT:START (CODE:src=./../apiExamples/duplicate-values.html) -->
883
+ <!-- The below code snippet is automatically added from ./../apiExamples/duplicate-values.html -->
884
+ <pre class="language-html"><code class="language-html">&lt;auro-menu&gt;
885
+ &lt;auro-menuoption value="seattle"&gt;Seattle&amp;ndash;Tacoma International (SEA)&lt;/auro-menuoption&gt;
886
+ &lt;auro-menuoption value="seattle"&gt;Seattle Paine Field (PAE)&lt;/auro-menuoption&gt;
887
+ &lt;auro-menuoption value="portland"&gt;Portland International (PDX)&lt;/auro-menuoption&gt;
888
+ &lt;auro-menuoption value="spokane"&gt;Spokane International (GEG)&lt;/auro-menuoption&gt;
889
+ &lt;/auro-menu&gt;</code></pre>
890
+ <!-- AURO-GENERATED-CONTENT:END -->
891
+ </auro-accordion>
892
+ <p>In <code>multiselect</code> mode, options that share a <code>value</code> are tracked independently, so each can be selected and removed on its own. Selections are stored in DOM order regardless of the order in which they were chosen.</p>
893
+ <div class="exampleWrapper">
894
+ <!-- AURO-GENERATED-CONTENT:START (FILE:src=./../apiExamples/duplicate-values-multiselect.html) -->
895
+ <!-- The below content is automatically added from ./../apiExamples/duplicate-values-multiselect.html -->
896
+ <auro-menu multiselect>
897
+ <auro-menuoption value="seattle">Seattle&ndash;Tacoma International (SEA)</auro-menuoption>
898
+ <auro-menuoption value="seattle">Seattle Paine Field (PAE)</auro-menuoption>
899
+ <auro-menuoption value="portland">Portland International (PDX)</auro-menuoption>
900
+ <auro-menuoption value="spokane">Spokane International (GEG)</auro-menuoption>
901
+ </auro-menu>
902
+ <!-- AURO-GENERATED-CONTENT:END -->
903
+ </div>
904
+ <auro-accordion alignRight>
905
+ <span slot="trigger">See code</span>
906
+ <!-- AURO-GENERATED-CONTENT:START (CODE:src=./../apiExamples/duplicate-values-multiselect.html) -->
907
+ <!-- The below code snippet is automatically added from ./../apiExamples/duplicate-values-multiselect.html -->
908
+ <pre class="language-html"><code class="language-html">&lt;auro-menu multiselect&gt;
909
+ &lt;auro-menuoption value="seattle"&gt;Seattle&amp;ndash;Tacoma International (SEA)&lt;/auro-menuoption&gt;
910
+ &lt;auro-menuoption value="seattle"&gt;Seattle Paine Field (PAE)&lt;/auro-menuoption&gt;
911
+ &lt;auro-menuoption value="portland"&gt;Portland International (PDX)&lt;/auro-menuoption&gt;
912
+ &lt;auro-menuoption value="spokane"&gt;Spokane International (GEG)&lt;/auro-menuoption&gt;
913
+ &lt;/auro-menu&gt;</code></pre>
914
+ <!-- AURO-GENERATED-CONTENT:END -->
915
+ </auro-accordion>
866
916
  </section>
867
917
  </div>
868
918
  </div>
@@ -259,6 +259,116 @@ function isSelectableByValue(option) {
259
259
  !option.hasAttribute('static');
260
260
  }
261
261
 
262
+ /* eslint-disable no-underscore-dangle */
263
+ /**
264
+ * Resolves the single selected option for a given `value`, preferring the
265
+ * option tracked by `selectedKey` (a user-initiated selection) over a
266
+ * first-by-value match. When multiple options share the same `value`, matching
267
+ * by `value` alone cannot distinguish which one the user picked; the key
268
+ * disambiguates it.
269
+ *
270
+ * The key is trusted only when it still resolves to an option whose `value`
271
+ * matches the requested `value`. If the key is stale (option removed) or the
272
+ * value was changed programmatically, resolution falls back to value matching —
273
+ * preserving backward-compatible behavior for preselection and `selectByValue`.
274
+ * @private
275
+ * @param {Array<HTMLElement>} items - The menu's flat option list.
276
+ * @param {string} value - The value to resolve.
277
+ * @param {string|undefined} selectedKey - The `_optionKey` of the user-selected option, if any.
278
+ * @returns {HTMLElement|undefined} The resolved option, or undefined when none match.
279
+ */
280
+ function resolveSelectedOption(items, value, selectedKey) {
281
+ if (!items) {
282
+ return undefined;
283
+ }
284
+
285
+ if (selectedKey !== undefined) {
286
+ const keyed = items.find((item) => item._optionKey === selectedKey);
287
+ if (keyed && isSelectableByValue(keyed) && keyed.value === value) {
288
+ return keyed;
289
+ }
290
+ // Key exists but the option is gone or its value no longer matches — fall
291
+ // through to value-based matching.
292
+ }
293
+
294
+ return items.find((item) => isSelectableByValue(item) && item.value === value);
295
+ }
296
+
297
+ /**
298
+ * Resolves the selected options for a multi-select `value` array, preferring
299
+ * options tracked by `selectedKeys` (user-initiated selections) and falling
300
+ * back to value matching for any values not resolved by key. The result is
301
+ * always sorted into DOM order regardless of selection sequence.
302
+ * @private
303
+ * @param {Array<HTMLElement>} items - The menu's flat option list.
304
+ * @param {Array<string>} valueArray - The selected values.
305
+ * @param {Array<string>|undefined} selectedKeys - The `_optionKey`s of the user-selected options, if any.
306
+ * @returns {Array<HTMLElement>} The resolved options in DOM order.
307
+ */
308
+ function resolveSelectedOptions(items, valueArray, selectedKeys) {
309
+ if (!items) {
310
+ return [];
311
+ }
312
+
313
+ const resolved = [];
314
+ // Mirror `resolved` as a Set for O(1) membership checks below, matching the
315
+ // indexMap optimization used for the sort rather than scanning `resolved`
316
+ // on every candidate.
317
+ const resolvedSet = new Set();
318
+
319
+ // Track how many of each value are still available to resolve. A value that
320
+ // appears N times in `valueArray` may be satisfied at most N times total across
321
+ // the key pass and the value fallback below — matching by count, not presence,
322
+ // on BOTH passes. This stops a duplicate value from being over-resolved: e.g.
323
+ // two keyed options that both carry `SEA` cannot both match a single requested
324
+ // `SEA` (which happens when `value` is set directly without clearing
325
+ // `_selectedKey`, so more keys survive than the value set now asks for).
326
+ const remaining = new Map();
327
+ valueArray.forEach((val) => remaining.set(val, (remaining.get(val) || 0) + 1));
328
+
329
+ // Resolve by key first: trust a key only when its option is still selectable
330
+ // and there is still an unmatched occurrence of its value in the request set.
331
+ if (Array.isArray(selectedKeys)) {
332
+ selectedKeys.forEach((key) => {
333
+ const keyed = items.find((item) => item._optionKey === key);
334
+ if (keyed && isSelectableByValue(keyed) && (remaining.get(keyed.value) || 0) > 0 && !resolvedSet.has(keyed)) {
335
+ resolved.push(keyed);
336
+ resolvedSet.add(keyed);
337
+ remaining.set(keyed.value, remaining.get(keyed.value) - 1);
338
+ }
339
+ });
340
+ }
341
+
342
+ // Fall back to value matching for the occurrences not resolved by key. Iterate
343
+ // the leftover per-value counts so a value that appears twice but was only
344
+ // resolved once by key still matches its remaining occurrence(s).
345
+ remaining.forEach((count, val) => {
346
+ for (let occurrence = 0; occurrence < count; occurrence += 1) {
347
+ const option = items.find((item) => isSelectableByValue(item) && item.value === val && !resolvedSet.has(item));
348
+ if (option) {
349
+ resolved.push(option);
350
+ resolvedSet.add(option);
351
+ }
352
+ }
353
+ });
354
+
355
+ // Always return in DOM order so display is consistent regardless of the order
356
+ // keys/values were selected. Every resolved option came from `items`, so an
357
+ // O(1) index lookup mirrors `_sortSelectedByDomOrder` and avoids the O(n)
358
+ // `items.indexOf` per comparison for large combobox option sets. Any element
359
+ // not in `items` (a stale snapshot from a future caller) sorts to the END via
360
+ // `?? items.length`, matching `_sortSelectedByDomOrder` and avoiding NaN
361
+ // comparisons.
362
+ const indexMap = new Map(items.map((item, index) => [
363
+ item,
364
+ index
365
+ ]));
366
+ resolved.sort((optionA, optionB) => (indexMap.get(optionA) ?? items.length) - (indexMap.get(optionB) ?? items.length));
367
+
368
+ return resolved;
369
+ }
370
+ /* eslint-enable no-underscore-dangle */
371
+
262
372
  /**
263
373
  * Helper method to dispatch custom events.
264
374
  * @param {HTMLElement} element - Element to dispatch event from.
@@ -297,6 +407,14 @@ const t={ATTRIBUTE:1},e$1=t=>(...e)=>({_$litDirective$:t,values:e});let i$1 = cl
297
407
  // See LICENSE in the project root for license information.
298
408
 
299
409
 
410
+ /**
411
+ * Monotonically increasing counter used to give each menu instance a unique
412
+ * `_menuInstanceId` prefix. Auto-generating the id (rather than using a random
413
+ * string) keeps option keys deterministic and collision-free across menus.
414
+ * @private
415
+ */
416
+ let menuInstanceIdCounter = 0;
417
+
300
418
 
301
419
  /**
302
420
  * The `auro-menu` element provides users a way to select from a list of options.
@@ -368,9 +486,8 @@ class AuroMenu extends AuroElement {
368
486
 
369
487
  // Instance properties (non-reactive)
370
488
 
371
- /**
372
- * @private
373
- */
489
+ menuInstanceIdCounter += 1;
490
+
374
491
  Object.assign(this, {
375
492
  // Root-level menu (true) or a nested submenu (false)
376
493
  rootMenu: true,
@@ -380,6 +497,21 @@ class AuroMenu extends AuroElement {
380
497
  nestingSpacer: '<span class="nestingSpacer"></span>',
381
498
  // Loading indicator for slot elements
382
499
  loadingSlots: null,
500
+ // Unique id for this menu instance; prefixes every auto-generated option
501
+ // key so keys never collide across menus in the same document.
502
+ _menuInstanceId: `menu-${menuInstanceIdCounter}`,
503
+ // Monotonically increasing counter for option key generation. Never
504
+ // resets, so a key is never reused within this instance's lifetime.
505
+ _optionKeyCounter: 0,
506
+ // Key(s) of the option(s) the user has actively selected. A single string
507
+ // in single-select, an array in multi-select, undefined when nothing is
508
+ // user-selected. Used to disambiguate options that share a `value`.
509
+ _selectedKey: undefined,
510
+ // True only for the one updated() cycle following a user selection, so
511
+ // reconciliation trusts `_selectedKey`. A `value` change from a consumer's
512
+ // direct property assignment leaves this false, dropping the stale key so
513
+ // reconciliation falls back to first-by-value (see updated()).
514
+ _valueChangeFromSelection: false,
383
515
  });
384
516
  }
385
517
 
@@ -599,6 +731,13 @@ class AuroMenu extends AuroElement {
599
731
  return;
600
732
  }
601
733
 
734
+ // A programmatic value set carries no positional intent, so drop any
735
+ // `_selectedKey` left over from a prior user click. This makes reconciliation
736
+ // in updated() fall back to first-by-value (single) / value-in-DOM-order
737
+ // (multi), matching the documented contract for programmatic selection even
738
+ // when a stale key would still resolve to a duplicate-value option.
739
+ this._selectedKey = undefined;
740
+
602
741
  // `value` is a String property; stringify arrays so attribute reflection and `formattedValue` parsing stay correct.
603
742
  this.value = Array.isArray(value) ? JSON.stringify(value) : value;
604
743
  }
@@ -646,6 +785,17 @@ class AuroMenu extends AuroElement {
646
785
  updated(changedProperties) {
647
786
  super.updated(changedProperties);
648
787
 
788
+ // Consume the selection-driven flag for THIS cycle up front. Clearing it
789
+ // unconditionally — not only inside the `value` branch below — prevents it
790
+ // from lingering `true` when a selection produces a serialized `value`
791
+ // byte-identical to the current one, in which case Lit schedules no
792
+ // `value`-change cycle to consume it. A lingering flag would misclassify a
793
+ // later consumer's programmatic `value` set as selection-driven and keep a
794
+ // stale `_selectedKey`. The reconcile path below re-sets the instance flag
795
+ // after this point, so its intentional cross-cycle hand-off still works.
796
+ const valueChangeFromSelection = this._valueChangeFromSelection;
797
+ this._valueChangeFromSelection = false;
798
+
649
799
  // Single source of truth for 'auroMenu-selectedOption'. Selection handlers
650
800
  // mutate optionSelected and let Lit's update cycle dispatch here; the prior
651
801
  // .value comparison missed multi-select array changes and combined with the
@@ -669,6 +819,17 @@ class AuroMenu extends AuroElement {
669
819
  this.initItems();
670
820
  }
671
821
 
822
+ // Distinguish a selection-driven `value` change (a user click, which set
823
+ // the flag in handleSelectState / _sortSelectedByDomOrder) from a
824
+ // programmatic assignment by a consumer. A programmatic set carries no
825
+ // positional intent, so drop any leftover `_selectedKey` and let
826
+ // reconciliation fall back to first-by-value (single) / value-in-DOM-order
827
+ // (multi) — the same contract selectByValue() guarantees, even when a
828
+ // stale key would otherwise still resolve to a duplicate-value option.
829
+ if (!valueChangeFromSelection) {
830
+ this._selectedKey = undefined;
831
+ }
832
+
672
833
  // Set when reconciliation reassigns `value` below. That reassignment schedules a
673
834
  // second updated() cycle, so the `event`-attribute dispatch is deferred to that
674
835
  // cycle to avoid firing option custom events twice on the same selection.
@@ -686,19 +847,55 @@ class AuroMenu extends AuroElement {
686
847
  // Defensive default: `formattedValue` can be undefined for unexpected value types,
687
848
  // and calling `.includes` on undefined would throw during reconciliation.
688
849
  const valueArray = this.formattedValue || [];
689
- const matchingOptions = this.items ? this.items.filter((item) => isSelectableByValue(item) && valueArray.includes(item.value)) : [];
850
+ // Resolve by key first (the user's exact picks), then fall back to
851
+ // value matching for any values not resolved by key — so pre-selection
852
+ // and programmatic value sets keep working. Result is DOM-ordered.
853
+ const matchingOptions = resolveSelectedOptions(this.items, valueArray, this._selectedKey);
690
854
  newSelected = matchingOptions.length > 0 ? matchingOptions : undefined;
691
855
 
692
- // Reconcile `value` with the selectable set. Drop only entries whose option is
693
- // loaded but non-selectable (disabled/static) leaving them would desync `value`
694
- // from `optionSelected`, and the toggle handlers rebuild `value` from `formattedValue`,
695
- // so the rejected entry would resurface on the next select/deselect. Entries with no
696
- // matching item yet are preserved so async preselection still works once options render.
697
- const rejectedValues = this.items
698
- ? this.items.filter((item) => !isSelectableByValue(item) && valueArray.includes(item.value)).map((item) => item.value)
699
- : [];
700
- if (rejectedValues.length > 0) {
701
- const reconciled = valueArray.filter((val) => !rejectedValues.includes(val));
856
+ // Reconcile `value` with the selectable set. An occurrence is dropped
857
+ // only when it is loaded but no selectable option can satisfy it
858
+ // every loaded item sharing that value is non-selectable, or the value
859
+ // recurs more often than it has selectable options (a duplicate value
860
+ // whose extra siblings are disabled/static). This is count-based, not
861
+ // presence-based, so an enabled option is kept even when a disabled
862
+ // sibling shares its value mirroring how `resolveSelectedOptions`
863
+ // resolves the same set. Entries with no matching item yet are
864
+ // preserved so async preselection still works, and the toggle handlers
865
+ // rebuild `value` from `formattedValue`, so a rejected entry cannot
866
+ // resurface on the next select/deselect.
867
+ const selectableByValue = new Map();
868
+ const loadedValues = new Set();
869
+ if (this.items) {
870
+ this.items.forEach((item) => {
871
+ loadedValues.add(item.value);
872
+ if (isSelectableByValue(item)) {
873
+ selectableByValue.set(item.value, (selectableByValue.get(item.value) || 0) + 1);
874
+ }
875
+ });
876
+ }
877
+
878
+ const reconciled = valueArray.filter((val) => {
879
+ // Not loaded yet (async preselection) — keep for a later cycle.
880
+ if (!loadedValues.has(val)) {
881
+ return true;
882
+ }
883
+ // Consume one selectable option per occurrence; drop once exhausted.
884
+ const remaining = selectableByValue.get(val) || 0;
885
+ if (remaining > 0) {
886
+ selectableByValue.set(val, remaining - 1);
887
+ return true;
888
+ }
889
+ return false;
890
+ });
891
+
892
+ if (reconciled.length !== valueArray.length) {
893
+ // This is an internal correction, not a consumer's programmatic set,
894
+ // so preserve the selection-driven flag through the re-entrant
895
+ // updated() cycle it schedules. Otherwise that cycle would treat the
896
+ // reassignment as programmatic and drop `_selectedKey` mid-cascade,
897
+ // flipping resolution and looping.
898
+ this._valueChangeFromSelection = true;
702
899
  this.value = serializeMultiSelectValue(reconciled);
703
900
  valueReconciled = true;
704
901
  }
@@ -710,7 +907,11 @@ class AuroMenu extends AuroElement {
710
907
  // `hidden` is intentionally NOT excluded: the combobox toggles
711
908
  // `hidden` as its type-ahead filter, so a filtered-out option is
712
909
  // still a valid programmatic selection.
713
- const matchingOption = this.items ? this.items.find((item) => isSelectableByValue(item) && item.value === this.value) : undefined;
910
+ // Prefer the option the user actually selected (tracked by
911
+ // `_selectedKey`) so a click on the second of two options sharing a
912
+ // `value` resolves back to that exact element instead of the first
913
+ // value match. Falls back to first-by-value for programmatic sets.
914
+ const matchingOption = resolveSelectedOption(this.items, this.value, this._selectedKey);
714
915
 
715
916
  if (matchingOption) {
716
917
  newSelected = matchingOption;
@@ -938,6 +1139,14 @@ class AuroMenu extends AuroElement {
938
1139
  }
939
1140
  });
940
1141
 
1142
+ // Assign private keys once items are populated. Only the root menu assigns
1143
+ // keys: its `items` is a deep query that already includes nested submenu
1144
+ // options, so a single pass keys the entire tree. Nested menus skip this
1145
+ // and inherit keys from the root.
1146
+ if (this.rootMenu) {
1147
+ this._assignOptionKeys();
1148
+ }
1149
+
941
1150
  if (this.noCheckmark) {
942
1151
  this.updateItemsState(new Map([
943
1152
  [
@@ -954,6 +1163,31 @@ class AuroMenu extends AuroElement {
954
1163
  }));
955
1164
  }
956
1165
 
1166
+ /**
1167
+ * Assigns a private, auto-generated unique key (`_optionKey`) to each menu
1168
+ * option that does not already have one. Keys are internal state on the
1169
+ * element instance — never reflected as an attribute or exposed publicly —
1170
+ * and let selection tracking distinguish options that share the same `value`.
1171
+ *
1172
+ * The `_optionKey === undefined` guard makes this idempotent: options keep the
1173
+ * key they were first assigned across re-renders and slot changes, and if a
1174
+ * nested menu's lifecycle runs a pass before the root, options simply wait for
1175
+ * the root to key them (or keep whatever key they already hold).
1176
+ * @private
1177
+ */
1178
+ _assignOptionKeys() {
1179
+ if (!this.items) {
1180
+ return;
1181
+ }
1182
+
1183
+ this.items.forEach((option) => {
1184
+ if (option._optionKey === undefined) {
1185
+ this._optionKeyCounter += 1;
1186
+ option._optionKey = `${this._menuInstanceId}-${this._optionKeyCounter}`;
1187
+ }
1188
+ });
1189
+ }
1190
+
957
1191
  // Logic Methods
958
1192
 
959
1193
  /**
@@ -963,24 +1197,28 @@ class AuroMenu extends AuroElement {
963
1197
  */
964
1198
  handleSelectState(option) {
965
1199
  if (this.multiSelect) {
966
- const currentValue = this.formattedValue || [];
967
1200
  const currentSelected = this.optionSelected || [];
968
1201
 
969
- if (!currentValue.includes(option.value)) {
970
- this.value = serializeMultiSelectValue([
971
- ...currentValue,
972
- option.value
973
- ]);
974
- }
975
1202
  if (!currentSelected.includes(option)) {
976
1203
  this.optionSelected = [
977
1204
  ...currentSelected,
978
1205
  option
979
1206
  ];
980
1207
  }
1208
+
1209
+ // Re-sort by DOM order and rebuild `_selectedKey`/`value` from the
1210
+ // selected set so display order stays consistent with the menu, not with
1211
+ // click order.
1212
+ this._sortSelectedByDomOrder();
981
1213
  } else {
982
1214
  this.value = option.value;
983
1215
  this.optionSelected = option;
1216
+ // Track the specific option the user selected so the value→option
1217
+ // reconciliation in updated() resolves back to this exact element even
1218
+ // when another option shares the same `value`.
1219
+ this._selectedKey = option._optionKey;
1220
+ // Mark this `value` change as selection-driven so updated() trusts the key.
1221
+ this._valueChangeFromSelection = true;
984
1222
  }
985
1223
 
986
1224
  this._index = this.items.indexOf(option);
@@ -993,18 +1231,22 @@ class AuroMenu extends AuroElement {
993
1231
  */
994
1232
  handleDeselectState(option) {
995
1233
  if (this.multiSelect) {
996
- // Remove this option from array; an empty result collapses `value` to undefined.
997
- const newFormattedValue = (this.formattedValue || []).filter((val) => val !== option.value);
998
- this.value = serializeMultiSelectValue(newFormattedValue);
999
-
1000
- this.optionSelected = this.optionSelected.filter((val) => val !== option);
1234
+ // Remove this exact element from the selection (identity, not value two
1235
+ // options can share a `value`), then rebuild `value`/`_selectedKey` from
1236
+ // the remaining set in DOM order. An empty result collapses to undefined.
1237
+ this.optionSelected = this.optionSelected.filter((selected) => selected !== option);
1001
1238
  if (this.optionSelected.length === 0) {
1002
1239
  this.optionSelected = undefined;
1240
+ this._selectedKey = undefined;
1241
+ this.value = undefined;
1242
+ } else {
1243
+ this._sortSelectedByDomOrder();
1003
1244
  }
1004
1245
  } else {
1005
1246
  // For single-select: Back to undefined when deselected
1006
1247
  this.value = undefined;
1007
1248
  this.optionSelected = undefined;
1249
+ this._selectedKey = undefined;
1008
1250
  }
1009
1251
 
1010
1252
  // Update the index tracking
@@ -1028,9 +1270,46 @@ class AuroMenu extends AuroElement {
1028
1270
  clearSelection() {
1029
1271
  this.optionSelected = undefined;
1030
1272
  this.value = undefined;
1273
+ this._selectedKey = undefined;
1031
1274
  this._index = -1;
1032
1275
  }
1033
1276
 
1277
+ /**
1278
+ * Re-sorts the multi-select selection into DOM order and rebuilds the derived
1279
+ * `_selectedKey` and `value` from `optionSelected`. Selection is always stored
1280
+ * and serialized in the order options appear in the menu, never in click
1281
+ * order — so selecting C then A yields `[A, C]`.
1282
+ * @private
1283
+ */
1284
+ _sortSelectedByDomOrder() {
1285
+ if (!this.multiSelect || !Array.isArray(this.optionSelected) || !this.items) {
1286
+ return;
1287
+ }
1288
+
1289
+ const indexMap = new Map(this.items.map((item, index) => [
1290
+ item,
1291
+ index
1292
+ ]));
1293
+
1294
+ // Sorting in place mutates `optionSelected` without a new array reference,
1295
+ // which Lit's `===` change-detection cannot see on its own — but that is
1296
+ // intentional and safe: both callers (handleSelectState / handleDeselectState)
1297
+ // assign a fresh `optionSelected` array immediately before calling, so Lit
1298
+ // already has a changed reference to react to, and the `value` write below
1299
+ // schedules the updated() cycle that re-derives `optionSelected` in DOM order
1300
+ // via resolveSelectedOptions. Do not "fix" this into a new-array assignment.
1301
+ //
1302
+ // Sort any element no longer in `items` (a stale selection left over from a
1303
+ // dynamic rebuild that the consumer has not cleared) to the END rather than
1304
+ // the front, so it never displaces a live option to the head of the
1305
+ // serialized order. Value reconciliation drops it on the next updated() cycle.
1306
+ this.optionSelected.sort((optionA, optionB) => (indexMap.get(optionA) ?? this.items.length) - (indexMap.get(optionB) ?? this.items.length));
1307
+ this._selectedKey = this.optionSelected.map((option) => option._optionKey);
1308
+ this.value = serializeMultiSelectValue(this.optionSelected.map((option) => option.value));
1309
+ // Mark this `value` change as selection-driven so updated() trusts the keys.
1310
+ this._valueChangeFromSelection = true;
1311
+ }
1312
+
1034
1313
  /**
1035
1314
  * Resets the menu to its initial state.
1036
1315
  * This is the only way to return value to undefined.
@@ -1040,6 +1319,7 @@ class AuroMenu extends AuroElement {
1040
1319
  // Reset to undefined - initial state
1041
1320
  this.value = undefined;
1042
1321
  this.optionSelected = undefined;
1322
+ this._selectedKey = undefined;
1043
1323
  this._index = -1;
1044
1324
 
1045
1325
  // Clear active option state so a follow-up open/navigation starts fresh
@@ -1101,6 +1381,21 @@ class AuroMenu extends AuroElement {
1101
1381
  this.initItems();
1102
1382
  }
1103
1383
 
1384
+ // Recover `_index` from the highlighted option when it has been reset to -1.
1385
+ // The updated() reconciliation resets `_index = -1` whenever the value
1386
+ // collapses to undefined while `optionActive` still points at the highlighted
1387
+ // option — e.g. deselecting the last remaining option in multi-select, or a
1388
+ // programmatic clearSelection() in single-select while keyboard focus is on an
1389
+ // option. Without this, reading `items[-1]` returns undefined and the re-select
1390
+ // no-ops until the highlight is moved away and back. Mirrors auro-combobox's
1391
+ // reconcileMenuIndex.
1392
+ if (this._index < 0 && this.optionActive && this.items) {
1393
+ const activeIndex = this.items.indexOf(this.optionActive);
1394
+ if (activeIndex >= 0) {
1395
+ this._index = activeIndex;
1396
+ }
1397
+ }
1398
+
1104
1399
  // Get currently selected menu option based on index
1105
1400
  const option = this.items ? this.items[this._index] : undefined;
1106
1401
 
@@ -44,6 +44,36 @@ export function isOptionInteractive(option: HTMLElement): boolean;
44
44
  * @returns {boolean} True if option can be selected by value.
45
45
  */
46
46
  export function isSelectableByValue(option: HTMLElement): boolean;
47
+ /**
48
+ * Resolves the single selected option for a given `value`, preferring the
49
+ * option tracked by `selectedKey` (a user-initiated selection) over a
50
+ * first-by-value match. When multiple options share the same `value`, matching
51
+ * by `value` alone cannot distinguish which one the user picked; the key
52
+ * disambiguates it.
53
+ *
54
+ * The key is trusted only when it still resolves to an option whose `value`
55
+ * matches the requested `value`. If the key is stale (option removed) or the
56
+ * value was changed programmatically, resolution falls back to value matching —
57
+ * preserving backward-compatible behavior for preselection and `selectByValue`.
58
+ * @private
59
+ * @param {Array<HTMLElement>} items - The menu's flat option list.
60
+ * @param {string} value - The value to resolve.
61
+ * @param {string|undefined} selectedKey - The `_optionKey` of the user-selected option, if any.
62
+ * @returns {HTMLElement|undefined} The resolved option, or undefined when none match.
63
+ */
64
+ export function resolveSelectedOption(items: Array<HTMLElement>, value: string, selectedKey: string | undefined): HTMLElement | undefined;
65
+ /**
66
+ * Resolves the selected options for a multi-select `value` array, preferring
67
+ * options tracked by `selectedKeys` (user-initiated selections) and falling
68
+ * back to value matching for any values not resolved by key. The result is
69
+ * always sorted into DOM order regardless of selection sequence.
70
+ * @private
71
+ * @param {Array<HTMLElement>} items - The menu's flat option list.
72
+ * @param {Array<string>} valueArray - The selected values.
73
+ * @param {Array<string>|undefined} selectedKeys - The `_optionKey`s of the user-selected options, if any.
74
+ * @returns {Array<HTMLElement>} The resolved options in DOM order.
75
+ */
76
+ export function resolveSelectedOptions(items: Array<HTMLElement>, valueArray: Array<string>, selectedKeys: Array<string> | undefined): Array<HTMLElement>;
47
77
  /**
48
78
  * Helper method to dispatch custom events.
49
79
  * @param {HTMLElement} element - Element to dispatch event from.