@aurodesignsystem-dev/auro-formkit 0.0.0-pr1498.0 → 0.0.0-pr1499.0

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 (62) hide show
  1. package/components/checkbox/demo/customize.min.js +2 -2
  2. package/components/checkbox/demo/getting-started.min.js +2 -2
  3. package/components/checkbox/demo/index.min.js +2 -2
  4. package/components/checkbox/dist/index.js +2 -2
  5. package/components/checkbox/dist/registered.js +2 -2
  6. package/components/combobox/demo/customize.md +3 -7
  7. package/components/combobox/demo/customize.min.js +1705 -1459
  8. package/components/combobox/demo/getting-started.min.js +1705 -1459
  9. package/components/combobox/demo/index.min.js +1705 -1459
  10. package/components/combobox/dist/auro-combobox.d.ts +0 -5
  11. package/components/combobox/dist/index.js +162 -491
  12. package/components/combobox/dist/registered.js +162 -491
  13. package/components/counter/demo/customize.min.js +19 -18
  14. package/components/counter/demo/index.min.js +19 -18
  15. package/components/counter/dist/index.js +187 -5442
  16. package/components/counter/dist/registered.js +187 -5442
  17. package/components/datepicker/demo/customize.md +477 -384
  18. package/components/datepicker/demo/customize.min.js +96 -161
  19. package/components/datepicker/demo/index.md +0 -1
  20. package/components/datepicker/demo/index.min.js +96 -161
  21. package/components/datepicker/dist/index.js +96 -161
  22. package/components/datepicker/dist/registered.js +96 -161
  23. package/components/datepicker/dist/src/auro-datepicker.d.ts +2 -2
  24. package/components/dropdown/demo/customize.min.js +17 -16
  25. package/components/dropdown/demo/getting-started.min.js +17 -16
  26. package/components/dropdown/demo/index.min.js +17 -16
  27. package/components/dropdown/dist/auro-dropdown.d.ts +2 -1
  28. package/components/dropdown/dist/index.js +17 -16
  29. package/components/dropdown/dist/registered.js +17 -16
  30. package/components/form/demo/customize.min.js +7965 -7884
  31. package/components/form/demo/getting-started.min.js +7965 -7884
  32. package/components/form/demo/index.min.js +7965 -7884
  33. package/components/form/demo/registerDemoDeps.min.js +7934 -7853
  34. package/components/input/demo/customize.md +539 -472
  35. package/components/input/demo/customize.min.js +61 -132
  36. package/components/input/demo/getting-started.min.js +61 -132
  37. package/components/input/demo/index.min.js +61 -132
  38. package/components/input/dist/base-input.d.ts +0 -16
  39. package/components/input/dist/index.js +61 -132
  40. package/components/input/dist/registered.js +61 -132
  41. package/components/menu/demo/api.md +45 -41
  42. package/components/menu/demo/customize.md +28 -0
  43. package/components/menu/demo/index.min.js +1358 -783
  44. package/components/menu/dist/auro-menu.context.d.ts +238 -0
  45. package/components/menu/dist/auro-menu.d.ts +110 -95
  46. package/components/menu/dist/auro-menuoption.d.ts +138 -32
  47. package/components/menu/dist/index.js +1312 -757
  48. package/components/menu/dist/registered.js +1312 -769
  49. package/components/radio/demo/customize.min.js +2 -2
  50. package/components/radio/demo/getting-started.min.js +2 -2
  51. package/components/radio/demo/index.min.js +2 -2
  52. package/components/radio/dist/index.js +2 -2
  53. package/components/radio/dist/registered.js +2 -2
  54. package/components/select/demo/customize.md +3 -7
  55. package/components/select/demo/customize.min.js +1634 -1089
  56. package/components/select/demo/getting-started.min.js +1634 -1089
  57. package/components/select/demo/index.min.js +1634 -1089
  58. package/components/select/dist/index.js +31 -61
  59. package/components/select/dist/registered.js +31 -61
  60. package/custom-elements.json +2085 -1439
  61. package/package.json +1 -1
  62. package/components/select/dist/selectUtils.d.ts +0 -12
@@ -1,4 +1,5 @@
1
1
  import { css, LitElement, html } from 'lit';
2
+ import { createContext, ContextProvider, ContextConsumer } from '@lit/context';
2
3
  import { classMap } from 'lit/directives/class-map.js';
3
4
  import { unsafeStatic, literal, html as html$1 } from 'lit/static-html.js';
4
5
  import { ifDefined } from 'lit/directives/if-defined.js';
@@ -113,6 +114,720 @@ class AuroElement extends LitElement {
113
114
  }
114
115
  }
115
116
 
117
+ /* eslint-disable */
118
+
119
+ class MenuService {
120
+
121
+ /**
122
+ * PROPERTIES AND GETTERS
123
+ */
124
+
125
+ /**
126
+ * Gets the list of registered menu options.
127
+ * @returns {AuroMenuOption[]}
128
+ */
129
+ get menuOptions() {
130
+ return this._menuOptions;
131
+ }
132
+
133
+ /**
134
+ * Gets the currently highlighted option.
135
+ * @returns {AuroMenuOption|null}
136
+ */
137
+ get highlightedOption() {
138
+ return this._menuOptions[this.highlightedIndex] || null;
139
+ }
140
+
141
+ /**
142
+ * Gets the current value(s) of the selected option(s).
143
+ * @returns {string|string[]|undefined}
144
+ */
145
+ get currentValue() {
146
+ const values = (this.selectedOptions || []).map(option => option.value);
147
+ return this.multiSelect ? values : values[0];
148
+ }
149
+
150
+ /**
151
+ * Gets the label(s) of the currently selected option(s).
152
+ * @returns {string}
153
+ */
154
+ get currentLabel() {
155
+ const labels = (this.selectedOptions || []).map(option => option.textContent);
156
+ return this.multiSelect ? labels.join(", ") : labels[0] || '';
157
+ }
158
+
159
+ /**
160
+ * Gets the string representation of the current value(s).
161
+ * For multi-select, this is a JSON stringified array.
162
+ * @returns {string|undefined}
163
+ */
164
+ get stringValue() {
165
+ const { currentValue } = this;
166
+
167
+ if (Array.isArray(currentValue)) {
168
+ if (currentValue.length > 0) {
169
+ return JSON.stringify(currentValue);
170
+ }
171
+ return undefined;
172
+ }
173
+
174
+ if (typeof currentValue === 'string') {
175
+ if (currentValue.length > 0) {
176
+ return currentValue;
177
+ }
178
+ return undefined;
179
+ }
180
+
181
+ // Future: handle other types here (e.g., number, object, etc.)
182
+ return undefined;
183
+ }
184
+
185
+ /**
186
+ * Gets the key(s) of the currently selected option(s).
187
+ * @returns {string|string[]|undefined}
188
+ */
189
+ get currentKeys() {
190
+ const keys = (this.selectedOptions || []).map(option => option.key);
191
+ return this.multiSelect ? keys : keys[0];
192
+ }
193
+
194
+ /**
195
+ * CONSTRUCTOR
196
+ */
197
+
198
+ /**
199
+ * Creates a new MenuService instance.
200
+ * @param {Object} options - The options object.
201
+ * @param {AuroMenu} options.host - The host element that this service will control. Required.
202
+ * @throws {Error} If the host is not provided.
203
+ */
204
+ constructor({ host } = {}) {
205
+
206
+ // Ensure a host was passed
207
+ if (!host) {
208
+ throw new Error("MenuService requires a host element.");
209
+ }
210
+
211
+ // Attach the service to the host
212
+ this.host = host;
213
+ this.host.addController(this);
214
+
215
+ // Set default properties
216
+ this.size = undefined;
217
+ this.shape = undefined;
218
+ this.noCheckmark = undefined;
219
+ this.disabled = undefined;
220
+ this.matchWord = undefined;
221
+ this.multiSelect = undefined;
222
+ this.allowDeselect = undefined;
223
+ this.selectAllMatchingOptions = undefined;
224
+
225
+ this.highlightedIndex = -1;
226
+
227
+ this._menuOptions = [];
228
+ this._subscribers = [];
229
+ this.internalUpdateInProgress = false;
230
+ this.selectedOptions = [];
231
+ this._pendingValue = null;
232
+ this._pendingRetryScheduled = false;
233
+ this._pendingRetryCount = 0;
234
+ }
235
+
236
+ /**
237
+ * PROPERTY SYNCING
238
+ */
239
+
240
+ /**
241
+ * Handles host updates.
242
+ * This is a lit reactive lifecycle method.
243
+ * This comes from the Lit controller interface provided by adding this service as a controller to the host.
244
+ * See constructor for `this.host.addController(this)`
245
+ * You can read more about Lit reactive controllers here: https://lit.dev/docs/composition/controllers/
246
+ */
247
+ hostUpdated() {
248
+
249
+ // Reset selection if multiSelect mode changes
250
+ if (this.host.multiSelect !== this.multiSelect) {
251
+ this.selectedOptions = [];
252
+ }
253
+
254
+ // Update properties on host update
255
+ this.setProperties({
256
+ size: this.host.size,
257
+ shape: this.host.shape,
258
+ noCheckmark: this.host.noCheckmark,
259
+ disabled: this.host.disabled,
260
+ matchWord: this.host.matchWord,
261
+ multiSelect: this.host.multiSelect,
262
+ allowDeselect: this.host.allowDeselect,
263
+ selectAllMatchingOptions: this.host.selectAllMatchingOptions
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Handles host disconnection and memory cleanup.
269
+ */
270
+ hostDisconnected() {
271
+ this._subscribers = [];
272
+ this._menuOptions = [];
273
+ this._pendingValue = null;
274
+ this._pendingRetryScheduled = false;
275
+ this._pendingRetryCount = 0;
276
+ }
277
+
278
+ /**
279
+ * Sets a property value if it exists on the instance and the value has changed.
280
+ * @param {string} property
281
+ * @param {any} value
282
+ */
283
+ setProperty(property, value) {
284
+
285
+ // Only update if we are tracking the property in this service
286
+ if (this.hasOwnProperty(property)) {
287
+
288
+ // Check if the value has changed
289
+ const valueChanged = this[property] !== value;
290
+
291
+ // Update and notify if changed
292
+ if (valueChanged) {
293
+ this[property] = value;
294
+ this.notify({ property, value });
295
+ }
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Sets multiple properties on the instance.
301
+ * @param {Object} properties - Key-value pairs of properties to set.
302
+ */
303
+ setProperties(properties) {
304
+ for (const [key, value] of Object.entries(properties)) {
305
+ this.setProperty(key, value);
306
+ }
307
+ }
308
+
309
+ /**
310
+ * MENU OPTION HIGHLIGHTING
311
+ */
312
+
313
+ /**
314
+ * Highlights the next active option in the menu.
315
+ */
316
+ highlightNext() {
317
+ this.moveHighlightedOption("next");
318
+ }
319
+
320
+ /**
321
+ * Highlights the previous active option in the menu.
322
+ */
323
+ highlightPrevious() {
324
+ this.moveHighlightedOption("previous");
325
+ }
326
+
327
+ /**
328
+ * Moves the highlighted option in the specified direction.
329
+ * @param {string} direction - The direction to move the highlight ("next" or "previous").
330
+ */
331
+ moveHighlightedOption(direction) {
332
+
333
+ // Get the active options
334
+ const activeOptions = this._menuOptions.filter(option => option.isActive);
335
+
336
+ // Get the currently active option
337
+ const currentActiveOption = activeOptions[activeOptions.indexOf(this.highlightedOption)];
338
+
339
+ // Determine the new index based on the currently active option and direction
340
+ let newIndex = currentActiveOption
341
+ ? direction === "previous"
342
+ ? activeOptions.indexOf(currentActiveOption) - 1
343
+ : activeOptions.indexOf(currentActiveOption) + 1
344
+ : direction === "previous"
345
+ ? activeOptions.length - 1
346
+ : 0;
347
+
348
+ // Wrap around the index if needed
349
+ newIndex = newIndex < 0 ? activeOptions.length - 1 : newIndex >= activeOptions.length ? 0 : newIndex;
350
+
351
+ // Get the new active option and set it as highlighted
352
+ const newActiveOption = activeOptions[newIndex];
353
+ this.setHighlightedOption(newActiveOption);
354
+ }
355
+
356
+ /**
357
+ * Sets the highlighted index to the specified option.
358
+ * @param {AuroMenuOption} option - The option to highlight.
359
+ */
360
+ setHighlightedOption(option) {
361
+
362
+ if (!option) return;
363
+
364
+ // Get the index of the option to highlight
365
+ const index = this._menuOptions.indexOf(option);
366
+
367
+ // Update highlighted index
368
+ this.highlightedIndex = index;
369
+
370
+ // Notify subscribers of highlight change
371
+ this.notify({ type: 'highlightChange', option, index: this.highlightedIndex });
372
+
373
+ // Dispatch the change event
374
+ this.dispatchChangeEvent('auroMenu-activatedOption', option);
375
+ }
376
+
377
+ /**
378
+ * Sets the highlighted option to the option at the specified index if it exists.
379
+ * @param {number} index
380
+ */
381
+ setHighlightedIndex(index) {
382
+ const option = this._menuOptions[index] || null;
383
+ this.setHighlightedOption(option);
384
+ }
385
+
386
+ /**
387
+ * Selects the currently highlighted option.
388
+ */
389
+ selectHighlightedOption() {
390
+ if (this.highlightedOption) {
391
+ this.toggleOption(this.highlightedOption);
392
+ }
393
+ }
394
+
395
+ /**
396
+ * SELECTION AND DESELECTION METHODS
397
+ */
398
+
399
+ /**
400
+ * Selects one or more options in a batch operation
401
+ * @param {AuroMenuOption|AuroMenuOption[]} options - Single option or array of options to select
402
+ */
403
+ selectOptions(options) {
404
+ let optionsToSelect = Array.isArray(options) ? options : [options];
405
+
406
+ // Filter out options that are inactive
407
+ optionsToSelect = optionsToSelect.filter(option => option.isActive);
408
+
409
+ if (!optionsToSelect.length) return;
410
+
411
+ if (this.multiSelect) {
412
+ this.selectedOptions = [...(this.selectedOptions || []), ...optionsToSelect];
413
+ } else {
414
+ // In single select mode, only take the last option
415
+ this.selectedOptions = [optionsToSelect[optionsToSelect.length - 1]];
416
+ }
417
+
418
+ this.stageUpdate();
419
+ }
420
+
421
+ /**
422
+ * Deselects one or more options in a batch operation
423
+ * @param {AuroMenuOption|AuroMenuOption[]} options - Single option or array of options to deselect
424
+ */
425
+ deselectOptions(options) {
426
+ const optionsToDeselect = Array.isArray(options) ? options : [options];
427
+
428
+ if (!optionsToDeselect.length) return;
429
+
430
+ // Check if deselection should be prevented
431
+ const shouldPreventDeselect = !this.allowDeselect && !this.multiSelect;
432
+ const isOnlySelectedOption = this.selectedOptions.length === 1 && optionsToDeselect.includes(this.selectedOptions[0]);
433
+
434
+ // Prevent deselecting the only selected option if not allowed
435
+ if (shouldPreventDeselect && isOnlySelectedOption) {
436
+ optionsToDeselect.forEach(option => {
437
+ option.selected = true;
438
+ });
439
+ this.dispatchChangeEvent('auroMenu-deselectPrevented', {
440
+ values: optionsToDeselect
441
+ });
442
+ return;
443
+ }
444
+
445
+ const optionsSet = new Set(optionsToDeselect);
446
+ const previousCount = this.selectedOptions.length;
447
+ this.selectedOptions = (this.selectedOptions || [])
448
+ .filter(opt => !optionsSet.has(opt));
449
+
450
+ if (this.selectedOptions.length < previousCount) {
451
+ this.stageUpdate();
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Selects a single option.
457
+ * @param {AuroMenuOption} option
458
+ */
459
+ selectOption(option) {
460
+ this.selectOptions(option);
461
+ }
462
+
463
+ /**
464
+ * Deselects a single option.
465
+ * @param {AuroMenuOption} option
466
+ */
467
+ deselectOption(option) {
468
+ this.deselectOptions(option);
469
+ }
470
+
471
+ /**
472
+ * Toggles the selection state of a single option.
473
+ * @param {AuroMenuOption} option
474
+ */
475
+ toggleOption(option) {
476
+ if (option.selected) {
477
+ this.deselectOption(option);
478
+ } else {
479
+ this.selectOption(option);
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Selects options based on their value(s) when compared to a passed value or values.
485
+ * Value or values are normalized to an array of strings that can be matched to option keys.
486
+ * @param {string|number|Array<string|number>} value - The value(s) to select.
487
+ */
488
+ selectByValue(value) {
489
+ const isEmptyValue = value === undefined ||
490
+ value === null ||
491
+ (Array.isArray(value) && value.length === 0) ||
492
+ (typeof value === 'string' && value.trim() === '');
493
+
494
+ // Early exit for invalid/empty values
495
+ if (isEmptyValue) {
496
+ this.selectedOptions.forEach(opt => opt.selected = false);
497
+ this.selectedOptions = [];
498
+ return;
499
+ }
500
+
501
+ // If an internal update cycle is still in progress, defer value application
502
+ // rather than dropping it.
503
+ if (this.internalUpdateInProgress || this.host.internalUpdateInProgress) {
504
+ this.queuePendingValue(value);
505
+ return;
506
+ }
507
+
508
+ // Normalize values to array of strings
509
+ const normalizedValues = this._getNormalizedValues(value);
510
+
511
+ // Validate for single-select mode
512
+ let validatedValues = normalizedValues;
513
+ if (normalizedValues.length > 1 && !this.multiSelect) {
514
+ console.warn("MenuService - Multiple values provided for single-select menu. Only the first value will be selected.");
515
+ validatedValues = [normalizedValues[0]];
516
+ }
517
+
518
+ if (this._menuOptions.length === 0) {
519
+ this.queuePendingValue(value);
520
+ return;
521
+ }
522
+
523
+ // Find matching options by comparing available options to validated values
524
+ const trackedKeys = new Set();
525
+ const optionsToSelect = this._menuOptions.filter(option => {
526
+ const passesFilter = validatedValues.includes(option.key);
527
+ const alreadyTracked = trackedKeys.has(option.key);
528
+ const isActive = option.isActive;
529
+
530
+ trackedKeys.add(option.key);
531
+
532
+ // Include the option in the options to be selected if it passes the filter check and
533
+ // either hasn't been tracked yet or selectAllMatchingOptions is true
534
+ return isActive && passesFilter && (!alreadyTracked || (alreadyTracked && this.selectAllMatchingOptions));
535
+ });
536
+
537
+ // Handle no matches: clear existing selection, but do not dispatch an intermediate
538
+ // undefined value that can overwrite the host value in parent components.
539
+ if (!optionsToSelect.length) {
540
+ const hasUnresolvedKeys = this._menuOptions.some((option) => option.isActive && option.key == null);
541
+
542
+ if (hasUnresolvedKeys) {
543
+ this.queuePendingValue(value);
544
+ return;
545
+ }
546
+
547
+ this.clearPendingValue();
548
+
549
+ if (this.selectedOptions.length > 0) {
550
+ this.selectedOptions = [];
551
+ }
552
+
553
+ // Always notify so the host resets any stale invalid value, even when
554
+ // selectedOptions was already empty (e.g. double-clicking set-invalid).
555
+ this.stageUpdate({ reason: 'no-match' });
556
+
557
+ // Dispatch failure event if no matches found
558
+ if (validatedValues.length) {
559
+ this.dispatchChangeEvent('auroMenu-selectValueFailure', {
560
+ message: 'No matching options found for the provided value(s).',
561
+ values: validatedValues
562
+ });
563
+ }
564
+
565
+ return;
566
+ }
567
+
568
+ this.clearPendingValue();
569
+
570
+ if (this.optionsArraysMatch(optionsToSelect, this.selectedOptions)) {
571
+ return;
572
+ }
573
+
574
+ // Apply programmatic selection as a single transaction and emit one final state.
575
+ this.selectedOptions = optionsToSelect;
576
+ this.stageUpdate();
577
+ }
578
+
579
+ /**
580
+ * Queues a pending value and schedules a bounded retry.
581
+ * @param {string|number|Array<string|number>} value - The value to retry.
582
+ */
583
+ queuePendingValue(value) {
584
+ this._pendingValue = value;
585
+
586
+ if (this._pendingRetryScheduled || this._pendingRetryCount >= 5) {
587
+ return;
588
+ }
589
+
590
+ this._pendingRetryScheduled = true;
591
+ this._pendingRetryCount += 1;
592
+
593
+ setTimeout(() => {
594
+ this._pendingRetryScheduled = false;
595
+
596
+ if (this._pendingValue == null) {
597
+ return;
598
+ }
599
+
600
+ const pendingValue = this._pendingValue;
601
+ this.selectByValue(pendingValue);
602
+ }, 0);
603
+ }
604
+
605
+ /**
606
+ * Clears pending retry state.
607
+ */
608
+ clearPendingValue() {
609
+ this._pendingValue = null;
610
+ this._pendingRetryScheduled = false;
611
+ this._pendingRetryCount = 0;
612
+ }
613
+
614
+ /**
615
+ * Resets the selected options to an empty array.
616
+ */
617
+ reset() {
618
+ const previousOptions = [...this.selectedOptions];
619
+ previousOptions.forEach(opt => opt.selected = false);
620
+ this.selectedOptions = [];
621
+
622
+ // Single update after clearing all
623
+ if (previousOptions.length) {
624
+ this.stageUpdate();
625
+ }
626
+ }
627
+
628
+ /**
629
+ * SUBSCRIPTION, NOTIFICATION AND EVENT DISPATCH METHODS
630
+ */
631
+
632
+ /**
633
+ * Subscribes a callback to menu service events.
634
+ * @param {Function} callback - The callback to invoke on events.
635
+ */
636
+ subscribe(callback) {
637
+ this._subscribers.push(callback);
638
+ }
639
+
640
+ /**
641
+ * Remove a previously subscribed callback from menu service events.
642
+ * @param {Function} callback
643
+ */
644
+ unsubscribe(callback) {
645
+ this._subscribers = this._subscribers.filter(cb => cb !== callback);
646
+ }
647
+
648
+ /**
649
+ * Stages an update to notify subscribers of state and value changes.
650
+ */
651
+ stageUpdate(meta = {}) {
652
+ this.notifyStateChange(meta);
653
+ this.notifyValueChange(meta);
654
+ }
655
+
656
+ /**
657
+ * Notifies subscribers of a menu service event.
658
+ * All notifications are sent to all subscribers.
659
+ * @param {string} event - The event to send to subscribers.
660
+ */
661
+ notify(event) {
662
+ this._subscribers.forEach(callback => callback(event));
663
+ }
664
+
665
+ /**
666
+ * Notifies subscribers of a state change (selected options has changed).
667
+ */
668
+ notifyStateChange(meta = {}) {
669
+ this.notify({
670
+ type: 'stateChange',
671
+ selectedOptions: this.selectedOptions,
672
+ ...meta
673
+ });
674
+ }
675
+
676
+ /**
677
+ * Notifies subscribers of a value change (current value has changed).
678
+ */
679
+ notifyValueChange(meta = {}) {
680
+
681
+ // Prepare details for the event
682
+ const details = {
683
+ value: this.currentValue,
684
+ stringValue: this.stringValue,
685
+ keys: this.currentKeys,
686
+ options: this.selectedOptions,
687
+ label: this.currentLabel
688
+ };
689
+
690
+ // If only one option is selected, include its index
691
+ if (this.selectedOptions.length === 1) details.index = this._menuOptions.indexOf(this.selectedOptions[0]);
692
+
693
+ this.notify({
694
+ type: 'valueChange',
695
+ ...meta,
696
+ ...details
697
+ });
698
+ }
699
+
700
+ /**
701
+ * Dispatches a custom event from the host element.
702
+ * @param {string} eventName
703
+ * @param {any} detail
704
+ */
705
+ dispatchChangeEvent(eventName, detail) {
706
+ this.host.dispatchEvent(new CustomEvent(eventName, {
707
+ bubbles: true,
708
+ cancelable: false,
709
+ composed: true,
710
+ detail
711
+ }));
712
+ }
713
+
714
+ /**
715
+ * MENU OPTION MANAGEMENT METHODS
716
+ */
717
+
718
+ /**
719
+ * Adds a menu option to the service's list.
720
+ * @param {AuroMenuOption} option - the option to track
721
+ */
722
+ addMenuOption(option) {
723
+ this._menuOptions.push(option);
724
+ this.notify({ type: 'optionsChange', options: this._menuOptions });
725
+
726
+ if (this._pendingValue != null) {
727
+ this.queuePendingValue(this._pendingValue);
728
+ }
729
+ }
730
+
731
+ /**
732
+ * Removes a menu option from the service's list.
733
+ * @param {AuroMenuOption} option - the option to remove
734
+ */
735
+ removeMenuOption(option) {
736
+ this._menuOptions = this._menuOptions.filter(opt => opt !== option);
737
+ this.notify({ type: 'optionsChange', options: this._menuOptions });
738
+
739
+ if (this._menuOptions.length === 0) {
740
+ this.clearPendingValue();
741
+ }
742
+ }
743
+
744
+ /**
745
+ * UTILITIES
746
+ */
747
+
748
+ /**
749
+ * Normalizes a value or array of values into an array of strings for option selection.
750
+ * This function ensures that input values are consistently formatted for matching menu options.
751
+ *
752
+ * @param {string|number|Array<string|number>} value - The value(s) to normalize.
753
+ * @returns {Array<string>} An array of string values suitable for option matching.
754
+ * @throws {Error} If any value is not a string or number.
755
+ */
756
+ _getNormalizedValues(value) {
757
+ let values = value;
758
+
759
+ // Handle JSON string and single value string input
760
+ if (!Array.isArray(values) && typeof values === 'string') {
761
+
762
+ // Attempt to parse as JSON array
763
+ try {
764
+
765
+ // Normalize single quotes to double quotes for JSON parsing
766
+ // This will not handle complex cases but will cover basic usage
767
+ const parseValue = values.replace(/'([^']*?)'/g, '"$1"');
768
+
769
+ // Attempt parse
770
+ const parsed = JSON.parse(parseValue);
771
+
772
+ // Ensure parsed value is an array
773
+ if (!Array.isArray(parsed)) throw new Error('Not an array');
774
+
775
+ // Set values to parsed array
776
+ values = parsed;
777
+ } catch (err) {
778
+
779
+ // If parsing fails, treat as single value
780
+ values = [value];
781
+ }
782
+ }
783
+
784
+ // Handle a single number being passed
785
+ if (typeof values === 'number') {
786
+ values = [String(values)];
787
+ }
788
+
789
+ // Coerce each value to string and validate types
790
+ values.forEach((val, index) => {
791
+
792
+ // Throw an error for invalid value types
793
+ if (typeof val !== 'string' && typeof val !== 'number') {
794
+ throw new Error('Value contains invalid value type. Supported types are string and number.');
795
+ }
796
+
797
+ // Convert numbers to strings for consistency
798
+ if (typeof val === 'number') {
799
+ values[index] = String(val);
800
+ }
801
+ });
802
+
803
+ // Return the resulting array of string values
804
+ return values;
805
+ }
806
+
807
+ /**
808
+ * Returns whether two arrays of options contain the same elements.
809
+ * @param {AuroMenuOption[]} arr1 - First array of options.
810
+ * @param {AuroMenuOption[]} arr2 - Second array of options.
811
+ * @returns {boolean} True if arrays match, false otherwise.
812
+ */
813
+ optionsArraysMatch(arr1, arr2) {
814
+ if (arr1.length !== arr2.length) return false;
815
+
816
+ const set1 = new Set(arr1);
817
+ const set2 = new Set(arr2);
818
+
819
+ for (let item of set1) {
820
+ if (!set2.has(item)) {
821
+ return false;
822
+ }
823
+ }
824
+
825
+ return true;
826
+ }
827
+ }
828
+
829
+ const MenuContext = createContext('menu-context');
830
+
116
831
  // Copyright (c) Alaska Air. All right reserved. Licensed under the Apache-2.0 license
117
832
  // See LICENSE in the project root for license information.
118
833
 
@@ -273,7 +988,7 @@ function dispatchMenuEvent(element, eventName, detail = null) {
273
988
  element.dispatchEvent(new CustomEvent(eventName, eventConfig));
274
989
  }
275
990
 
276
- /* eslint-disable no-underscore-dangle, no-magic-numbers, max-lines, no-extra-parens, max-depth */
991
+ /* eslint-disable no-underscore-dangle */
277
992
  // Copyright (c) 2025 Alaska Airlines. All right reserved. Licensed under the Apache-2.0 license
278
993
  // See LICENSE in the project root for license information.
279
994
 
@@ -287,12 +1002,16 @@ function dispatchMenuEvent(element, eventName, detail = null) {
287
1002
  * @event {CustomEvent<any>} auroMenu-customEventFired - Notifies that a custom event has been fired.
288
1003
  * @event {CustomEvent<{ loading: boolean; hasLoadingPlaceholder: boolean; }>} auroMenu-loadingChange - Notifies when the loading attribute is changed.
289
1004
  * @event {CustomEvent<any>} auroMenu-selectValueFailure - Notifies that an attempt to select a menuoption by matching a value has failed.
1005
+ * @event {CustomEvent<{ values: HTMLElement[] }>} auroMenu-deselectPrevented - Notifies that deselection was prevented and includes the affected options in `detail.values`.
290
1006
  * @event {CustomEvent<any>} auroMenu-selectValueReset - Notifies that the component value has been reset.
291
1007
  * @event {CustomEvent<any>} auroMenu-selectedOption - Notifies that a new menuoption selection has been made.
292
1008
  * @slot loadingText - Text to show while loading attribute is set
293
1009
  * @slot loadingIcon - Icon to show while loading attribute is set
294
1010
  * @slot - Slot for insertion of menu options.
295
1011
  */
1012
+
1013
+ /* eslint-disable max-lines */
1014
+
296
1015
  class AuroMenu extends AuroElement {
297
1016
 
298
1017
  constructor() {
@@ -310,8 +1029,6 @@ class AuroMenu extends AuroElement {
310
1029
  */
311
1030
  this.size = "sm";
312
1031
 
313
- // Value of the selected options
314
- this.value = undefined;
315
1032
  // Currently selected option
316
1033
  this.optionSelected = undefined;
317
1034
  // String used for highlighting/filtering
@@ -324,24 +1041,13 @@ class AuroMenu extends AuroElement {
324
1041
  this.loading = false;
325
1042
  // Multi-select mode
326
1043
  this.multiSelect = false;
1044
+ // Allow deselecting of menu options
1045
+ this.allowDeselect = false;
1046
+ // Select all matching options when setting value in multi-select mode
1047
+ this.selectAllMatchingOptions = false;
327
1048
 
328
1049
  // Event Bindings
329
1050
 
330
- /**
331
- * @private
332
- */
333
- this.handleKeyDown = this.handleKeyDown.bind(this);
334
-
335
- /**
336
- * @private
337
- */
338
- this.handleMouseSelect = this.handleMouseSelect.bind(this);
339
-
340
- /**
341
- * @private
342
- */
343
- this.handleOptionHover = this.handleOptionHover.bind(this);
344
-
345
1051
  /**
346
1052
  * @private
347
1053
  */
@@ -368,6 +1074,14 @@ class AuroMenu extends AuroElement {
368
1074
  return {
369
1075
  ...super.properties,
370
1076
 
1077
+ /**
1078
+ * Allows deselecting an already selected option when clicked again in single-select mode.
1079
+ */
1080
+ allowDeselect: {
1081
+ type: Boolean,
1082
+ reflect: true,
1083
+ },
1084
+
371
1085
  /**
372
1086
  * When true, the entire menu and all options are disabled.
373
1087
  */
@@ -376,6 +1090,20 @@ class AuroMenu extends AuroElement {
376
1090
  reflect: true
377
1091
  },
378
1092
 
1093
+ /**
1094
+ * Indicates whether the menu has a loadingIcon or loadingText to render when in a loading state.
1095
+ */
1096
+ hasLoadingPlaceholder: {
1097
+ type: Boolean
1098
+ },
1099
+
1100
+ /**
1101
+ * @private
1102
+ */
1103
+ layout: {
1104
+ type: String
1105
+ },
1106
+
379
1107
  /**
380
1108
  * Indent level for submenus.
381
1109
  * @private
@@ -436,6 +1164,44 @@ class AuroMenu extends AuroElement {
436
1164
  type: Object
437
1165
  },
438
1166
 
1167
+ /**
1168
+ * Available menu options.
1169
+ * @readonly
1170
+ */
1171
+ options: {
1172
+ type: Array,
1173
+ reflect: false,
1174
+ attribute: false
1175
+ },
1176
+
1177
+ /**
1178
+ * Sets the size of the menu.
1179
+ * @type {'sm' | 'md'}
1180
+ * @default 'sm'
1181
+ */
1182
+ size: {
1183
+ type: String,
1184
+ reflect: true
1185
+ },
1186
+
1187
+ /**
1188
+ * When true, selects all options that match the provided value/key when setting value and multiselect is enabled.
1189
+ */
1190
+ selectAllMatchingOptions: {
1191
+ type: Boolean,
1192
+ reflect: true,
1193
+ },
1194
+
1195
+ /**
1196
+ * Sets the shape of the menu.
1197
+ * @type {'box' | 'round'}
1198
+ * @default 'box'
1199
+ */
1200
+ shape: {
1201
+ type: String,
1202
+ reflect: true
1203
+ },
1204
+
439
1205
  /**
440
1206
  * The value of the selected option. In multi-select mode, this is a JSON stringified array of selected option values.
441
1207
  */
@@ -456,23 +1222,20 @@ class AuroMenu extends AuroElement {
456
1222
  }
457
1223
 
458
1224
  /**
459
- * This will register this element with the browser.
460
- * @param {string} [name="auro-menu"] - The name of element that you want to register to.
461
- *
462
- * @example
463
- * AuroMenu.register("custom-menu") // this will register this element to <custom-menu/>
464
- *
1225
+ * @readonly
1226
+ * @returns {string} - Returns the label of the currently selected option(s).
465
1227
  */
466
- static register(name = "auro-menu") {
467
- AuroLibraryRuntimeUtils.prototype.registerComponent(name, AuroMenu);
468
- }
1228
+ get currentLabel() {
1229
+ return this.menuService.currentLabel;
1230
+ };
469
1231
 
470
1232
  /**
471
1233
  * @readonly
472
1234
  * @returns {Array<HTMLElement>} - Returns the array of available menu options.
1235
+ * @deprecated Use `options` property instead.
473
1236
  */
474
- get options() {
475
- return this.items;
1237
+ get items() {
1238
+ return this.options;
476
1239
  }
477
1240
 
478
1241
  /**
@@ -483,101 +1246,145 @@ class AuroMenu extends AuroElement {
483
1246
  }
484
1247
 
485
1248
  /**
486
- * @param {number} value - Sets the index of the currently active option.
1249
+ * @param {number} value - Sets the index of the currently active option.
1250
+ */
1251
+ set index(value) {
1252
+ this.menuService.setHighlightedIndex(value);
1253
+ }
1254
+
1255
+ /**
1256
+ * This will register this element with the browser.
1257
+ * @param {string} [name="auro-menu"] - The name of the element that you want to register.
1258
+ *
1259
+ * @example
1260
+ * AuroMenu.register("custom-menu") // this will register this element to <custom-menu/>
1261
+ *
1262
+ */
1263
+ static register(name = "auro-menu") {
1264
+ AuroLibraryRuntimeUtils.prototype.registerComponent(name, AuroMenu);
1265
+ }
1266
+
1267
+ /**
1268
+ * Formatted value based on `multiSelect` state.
1269
+ * Default type is `String`, changing to `Array<String>` when `multiSelect` is true.
1270
+ * @private
1271
+ * @returns {String|Array<String>}
1272
+ */
1273
+ get formattedValue() {
1274
+ return this.menuService.currentValue;
1275
+ }
1276
+
1277
+ /**
1278
+ * Gets the current property values for the menu service.
1279
+ * @private
1280
+ * @returns {Object}
487
1281
  */
488
- set index(value) {
489
- this.updateActiveOption(value);
1282
+ get propertyValues() {
1283
+ return {
1284
+ size: this.size,
1285
+ shape: this.shape,
1286
+ noCheckmark: this.nocheckmark,
1287
+ disabled: this.disabled
1288
+ };
490
1289
  }
491
1290
 
492
1291
  /**
493
- * Gets the currently selected options as an array.
494
- * @returns {Array<HTMLElement>}
1292
+ * Provides the menu context to child components.
1293
+ * Initializes the MenuService and subscribes to menu changes.
1294
+ * @protected
495
1295
  */
496
- get selectedOptions() {
497
- if (!this.optionSelected) {
498
- return [];
499
- }
500
- if (Array.isArray(this.optionSelected)) {
501
- return this.optionSelected;
1296
+ provideContext() {
1297
+ if (this.parentElement && this.parentElement.closest('auro-menu, [auro-menu]')) {
1298
+ this.rootMenu = false;
1299
+ this.menuService = this.parentElement.menuService;
1300
+ this._contextProvider = this.parentElement._contextProvider;
1301
+ return;
502
1302
  }
503
- return [this.optionSelected];
1303
+
1304
+ this.menuService = new MenuService({host: this});
1305
+ this.menuService.setProperties(this.propertyValues);
1306
+ this.menuService.subscribe(this.handleMenuChange.bind(this));
1307
+ this._contextProvider = new ContextProvider(this, {
1308
+ context: MenuContext,
1309
+ initialValue: this.menuService
1310
+ });
504
1311
  }
505
1312
 
506
1313
  /**
507
- * Gets the first selected option, or null if none.
508
- * @returns {HTMLElement|null}
1314
+ * Updates the currently active option in the menu.
1315
+ * @param {HTMLElement} option - The option to set as active.
509
1316
  */
510
- get selectedOption() {
511
- const opts = this.selectedOptions;
512
- return opts.length > 0 ? opts[0] : null;
1317
+ updateActiveOption(option) {
1318
+ this.menuService.setHighlightedOption(option);
513
1319
  }
514
1320
 
515
1321
  /**
516
- * @readonly
517
- * @returns {string} - Returns the label of the currently selected option(s).
1322
+ * Sets the internal value and manages update state.
1323
+ * @param {String|Array<String>} value - The value to set.
1324
+ * @protected
518
1325
  */
519
- get currentLabel() {
520
- if (!this.optionSelected) {
521
- return '';
522
- }
523
- if (Array.isArray(this.optionSelected)) {
524
- return this.optionSelected.map((opt) => opt.textContent).join(', ');
1326
+ setInternalValue(value) {
1327
+ if (this.value !== value) {
1328
+ this.internalUpdateInProgress = true;
1329
+ this.value = value;
1330
+
1331
+ setTimeout(() => {
1332
+ this.internalUpdateInProgress = false;
1333
+ });
525
1334
  }
526
- return this.optionSelected.textContent || '';
527
1335
  }
528
1336
 
529
1337
  /**
530
- * Formatted value based on `multiSelect` state.
531
- * Default type is `String`, changing to `Array<String>` when `multiSelect` is true.
532
- * @private
533
- * @returns {String|Array<String>}
1338
+ * Handles changes from the menu service and updates component state.
1339
+ * @param {Object} event - The event object from the menu service.
1340
+ * @protected
534
1341
  */
535
- get formattedValue() {
536
- if (this.multiSelect) {
537
- if (!this.value) {
538
- return undefined;
539
- }
540
- // Defensive: `value` is declared as String, but consumers may assign arrays or other
541
- // types programmatically. Normalize without throwing so render/update never hard-crashes.
542
- if (Array.isArray(this.value)) {
543
- return this.value;
544
- }
545
- if (typeof this.value !== 'string') {
546
- return [String(this.value)];
1342
+ handleMenuChange(event) {
1343
+ if (event.type === 'valueChange') {
1344
+
1345
+ // New option is array value or first option with fallback to undefined for empty array in all cases
1346
+ const newOption = this.multiSelect && event.options.length ? event.options : event.options[0] || undefined;
1347
+ const newValue = event.stringValue;
1348
+
1349
+ // Check if the option or value has actually changed
1350
+ if (this.optionSelected !== newOption || this.stringValue !== newValue) {
1351
+ this.optionSelected = newOption;
1352
+ this.setInternalValue(newValue);
547
1353
  }
548
- if (this.value.startsWith("[")) {
549
- // Malformed JSON (e.g. a literal string that happens to start with "[") falls back
550
- // to a single-item array rather than throwing during render.
551
- try {
552
- const parsed = JSON.parse(this.value);
553
- return Array.isArray(parsed) ? parsed : [this.value];
554
- } catch {
555
- return [this.value];
1354
+
1355
+ // Notify components of selection change
1356
+ this.notifySelectionChange(event);
1357
+ }
1358
+
1359
+ if (event.type === 'highlightChange') {
1360
+ this.optionActive = event.option;
1361
+ this._index = event.index;
1362
+ }
1363
+
1364
+ if (event.type === 'optionsChange') {
1365
+ this.options = event.options;
1366
+ this.dispatchEvent(new CustomEvent('auroMenu-optionsChange', {
1367
+ detail: {
1368
+ options: event.options
556
1369
  }
557
- }
558
- return [this.value];
1370
+ }));
559
1371
  }
560
- return this.value;
561
1372
  }
562
1373
 
563
1374
  /**
564
- * Selects options by value.
565
- * @param {string|string[]|undefined|null} value - The value(s) to select.
566
- * @public
1375
+ * Gets the currently selected options.
1376
+ * @returns {Array<HTMLElement>}
567
1377
  */
568
- selectByValue(value) {
569
- const isEmpty = value === undefined ||
570
- value === null ||
571
- (Array.isArray(value) && value.length === 0) ||
572
- (typeof value === 'string' && value.trim() === '');
573
-
574
- if (isEmpty) {
575
- this.clearSelection();
576
- return;
577
- }
1378
+ get selectedOptions() {
1379
+ return this.menuService ? this.menuService.selectedOptions : [];
1380
+ }
578
1381
 
579
- // `value` is a String property; stringify arrays so attribute reflection and `formattedValue` parsing stay correct.
580
- this.value = Array.isArray(value) ? JSON.stringify(value) : value;
1382
+ /**
1383
+ * Gets the first selected option, or null if none.
1384
+ * @returns {HTMLElement|null}
1385
+ */
1386
+ get selectedOption() {
1387
+ return this.menuService ? this.menuService.selectedOptions[0] : null;
581
1388
  }
582
1389
 
583
1390
  // Lifecycle Methods
@@ -585,7 +1392,9 @@ class AuroMenu extends AuroElement {
585
1392
  connectedCallback() {
586
1393
  super.connectedCallback();
587
1394
 
588
- this.addEventListener('keydown', this.handleKeyDown);
1395
+ this.provideContext();
1396
+
1397
+ // this.addEventListener('keydown', this.handleKeyDown);
589
1398
  this.addEventListener('auroMenuOption-click', this.handleMouseSelect);
590
1399
  this.addEventListener('auroMenuOption-mouseover', this.handleOptionHover);
591
1400
  this.addEventListener('slotchange', this.handleSlotChange);
@@ -593,7 +1402,7 @@ class AuroMenu extends AuroElement {
593
1402
  }
594
1403
 
595
1404
  disconnectedCallback() {
596
- this.removeEventListener('keydown', this.handleKeyDown);
1405
+ // this.removeEventListener('keydown', this.handleKeyDown);
597
1406
  this.removeEventListener('auroMenuOption-click', this.handleMouseSelect);
598
1407
  this.removeEventListener('auroMenuOption-mouseover', this.handleOptionHover);
599
1408
  this.removeEventListener('slotchange', this.handleSlotChange);
@@ -608,114 +1417,25 @@ class AuroMenu extends AuroElement {
608
1417
  this.initializeMenu();
609
1418
  }
610
1419
 
611
- /**
612
- * Sets an attribute that matches the default tag name if the tag name is not the default.
613
- * @param {string} tagName - The tag name to set as an attribute.
614
- * @private
615
- */
616
- setTagAttribute(tagName) {
617
- if (this.tagName.toLowerCase() !== tagName) {
618
- this.setAttribute(tagName, true);
619
- }
620
- }
621
1420
 
622
- // eslint-disable-next-line complexity
623
1421
  updated(changedProperties) {
624
1422
  super.updated(changedProperties);
625
1423
 
626
- // Single source of truth for 'auroMenu-selectedOption'. Selection handlers
627
- // mutate optionSelected and let Lit's update cycle dispatch here; the prior
628
- // .value comparison missed multi-select array changes and combined with the
629
- // explicit calls in handleDeselectState/makeSelection produced 2-3 duplicate
630
- // events per selection.
631
- if (changedProperties.has('optionSelected')) {
632
- this.notifySelectionChange();
633
- }
634
-
635
- // Reset selection if multiSelect mode changes
636
- if (changedProperties.has('multiSelect') && !changedProperties.has("value")) {
637
- this.clearSelection();
1424
+ // Apply value selection synchronously so that static-HTML fixtures
1425
+ // resolve within a single update cycle. The refactored selectByValue
1426
+ // no longer calls reset() first, so the destructive intermediate-event
1427
+ // cascade that originally required deferral is eliminated. If option
1428
+ // keys are not yet resolved (framework mount-order race), selectByValue
1429
+ // queues a bounded retry automatically via queuePendingValue.
1430
+ if (changedProperties.has('value') && !this.internalUpdateInProgress) {
1431
+ this.menuService.selectByValue(this.value);
638
1432
  }
639
1433
 
640
- if (changedProperties.has("value")) {
641
- // Ensure items are populated before matching. `firstUpdated` normally initializes them,
642
- // but a `value` change can arrive before slotted options are appended (e.g. parent sets
643
- // value before children render). Without this guard, matching against an empty `items`
644
- // would falsely dispatch `auroMenu-selectValueFailure` for valid initial values.
645
- if (!this.items) {
646
- this.initItems();
647
- }
648
-
649
- // Handle null/undefined/empty case — empty/whitespace strings clear selection
650
- // consistently with selectByValue(''), and avoid downstream `.includes('')` matches.
651
- if (this.value === undefined || this.value === null || (typeof this.value === 'string' && this.value.trim() === '')) {
652
- this.clearSelection();
653
- } else {
654
- let newSelected = null;
655
-
656
- if (this.multiSelect) {
657
- // In multiselect mode, this.value should be an array of strings.
658
- // Defensive default: `formattedValue` can be undefined for unexpected value types,
659
- // and calling `.includes` on undefined would throw during reconciliation.
660
- const valueArray = this.formattedValue || [];
661
- const matchingOptions = this.items ? this.items.filter((item) => valueArray.includes(item.value)) : [];
662
- newSelected = matchingOptions.length > 0 ? matchingOptions : undefined;
663
- } else {
664
- // In single-select mode, this.value should be a string
665
- const matchingOption = this.items ? this.items.find((item) => item.value === this.value) : undefined;
666
-
667
- if (matchingOption) {
668
- newSelected = matchingOption;
669
- this._index = this.items.indexOf(matchingOption);
670
- } else {
671
- // If no matching option found, reset selection
672
- newSelected = undefined;
673
- this._index = -1;
674
- }
675
- }
676
-
677
- // If no matching options were found in either mode
678
- if (!newSelected || (Array.isArray(newSelected) && newSelected.length === 0)) {
679
- // Defer failure when no options are loaded yet (async pattern: parent sets
680
- // value before slotted options render). handleSlotChange re-runs matching
681
- // once items arrive. Without this guard, a valid preselected value gets
682
- // cleared by the failure listener before options ever exist to match against.
683
- const hasItemsToMatch = this.items && this.items.length > 0;
684
- if (hasItemsToMatch) {
685
- // Clear state BEFORE dispatching so synchronous listeners (e.g. auro-select's
686
- // updateDisplayedValue) read fresh `optionSelected` rather than the stale prior
687
- // selection and re-render the old label.
688
- if (this.optionSelected !== undefined) {
689
- this.optionSelected = undefined;
690
- }
691
- this._index = -1;
692
- dispatchMenuEvent(this, 'auroMenu-selectValueFailure');
693
- }
694
- } else if (!this.selectionEquals(this.optionSelected, newSelected)) {
695
- this.optionSelected = newSelected;
696
- }
697
- }
698
-
699
- // Update UI state
700
- this.updateItemsState(new Map([
701
- [
702
- 'optionSelected',
703
- true
704
- ]
705
- ]));
706
-
707
- // Notify of changes
708
- if (this.optionSelected !== undefined) {
709
- const selected = Array.isArray(this.optionSelected) ? this.optionSelected : [this.optionSelected];
710
- selected.forEach((opt) => {
711
- if (opt.hasAttribute('event')) {
712
- this.handleCustomEvent(opt);
713
- }
714
- });
715
- }
1434
+ // Handle loading state changes
1435
+ if (changedProperties.has('loading')) {
1436
+ this.setLoadingState(this.loading);
716
1437
  }
717
1438
 
718
- // Process all other UI updates
719
1439
  if (changedProperties.has('multiSelect') && this.rootMenu) {
720
1440
  if (this.multiSelect) {
721
1441
  this.setAttribute('aria-multiselectable', 'true');
@@ -723,129 +1443,30 @@ class AuroMenu extends AuroElement {
723
1443
  this.removeAttribute('aria-multiselectable');
724
1444
  }
725
1445
  }
726
-
727
- this.updateItemsState(changedProperties);
728
1446
  }
729
1447
 
730
1448
  /**
731
- * Updates the UI state and appearance of menu items based on changed properties.
1449
+ * Sets an attribute that matches the default tag name if the tag name is not the default.
1450
+ * @param {string} tagName - The tag name to set as an attribute.
732
1451
  * @private
733
- * @param {Map<string, boolean>} changedProperties - LitElement's changed properties map.
734
1452
  */
735
- updateItemsState(changedProperties) {
736
- // Handle loading state changes
737
- if (changedProperties.has('loading')) {
738
- this.setAttribute("aria-busy", this.loading);
739
- dispatchMenuEvent(this, "auroMenu-loadingChange", {
740
- loading: this.loading,
741
- hasLoadingPlaceholder: this.hasLoadingPlaceholder
742
- });
743
- }
744
-
745
- if (!this.items) {
746
- return;
747
- }
748
-
749
- // Handle noCheckmark propagation to all menus and options.
750
- // Propagate in both directions so toggling back to false also clears nested elements
751
- // (otherwise nested menus/options would stay stuck in no-checkmark mode).
752
- if (changedProperties.has('noCheckmark')) {
753
- this.querySelectorAll('auro-menu, [auro-menu], auro-menuoption, [auro-menuoption]').forEach((element) => {
754
- element.noCheckmark = this.noCheckmark;
755
- });
756
- }
757
-
758
- // Handle layout propagation to all menus and options.
759
- // Skip elements that had size/shape set by the author (marked in initItems);
760
- // explicit per-option overrides must survive menu-level propagation.
761
- const propagationTargets = this.querySelectorAll('auro-menu, [auro-menu], auro-menuoption, [auro-menuoption]');
762
- [
763
- 'size',
764
- 'shape'
765
- ].forEach((prop) => {
766
- if (changedProperties.has(prop)) {
767
- const explicitKey = prop === 'size' ? '_explicitSize' : '_explicitShape';
768
- propagationTargets.forEach((el) => {
769
- if (el[explicitKey]) {
770
- return;
771
- }
772
- el.setAttribute(prop, this[prop]);
773
- });
774
- }
775
- });
776
-
777
- // Regex for matchWord if needed
778
- let regexWord = null;
779
-
780
- if (changedProperties.has('matchWord') && this.matchWord && this.matchWord.length) {
781
- const escapedWord = this.matchWord.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
782
- regexWord = new RegExp(escapedWord, 'giu');
1453
+ setTagAttribute(tagName) {
1454
+ if (this.tagName.toLowerCase() !== tagName) {
1455
+ this.setAttribute(tagName, true);
783
1456
  }
1457
+ }
784
1458
 
785
- // Handle direct item updates
786
- this.items.forEach((option) => {
787
- // Update selection if option or value changed
788
- if (changedProperties.has('optionSelected') || changedProperties.has('value')) {
789
- const isSelected = this.isOptionSelected(option);
790
- option.setAttribute('aria-selected', isSelected ? 'true' : 'false');
791
-
792
- // Add/remove selected attribute based on state
793
- if (isSelected) {
794
- option.setAttribute('selected', '');
795
- } else {
796
- option.removeAttribute('selected');
797
- }
798
- }
799
-
800
- // Update text highlighting if matchWord changed
801
- if (changedProperties.has('matchWord') && regexWord &&
802
- isOptionInteractive(option) && !option.hasAttribute('persistent')) {
803
- // Create nested spacers
804
- const nested = option.querySelectorAll('.nestingSpacer');
805
-
806
- const displayValueEl = option.querySelector('[slot="displayValue"]');
807
- if (displayValueEl) {
808
- option.removeChild(displayValueEl);
809
- }
810
-
811
- // Build highlighted content via DOM APIs rather than innerHTML so any
812
- // `<`, `>`, or `&` in the option text renders literally (prevents XSS).
813
- const originalText = option.textContent;
814
- option.textContent = '';
815
-
816
- nested.forEach(() => {
817
- const spacer = document.createElement('span');
818
- spacer.className = 'nestingSpacer';
819
- option.appendChild(spacer);
820
- });
821
-
822
- const matches = [...originalText.matchAll(regexWord)];
823
- let lastIndex = 0;
824
- matches.forEach((match) => {
825
- const [matchText] = match;
826
- if (match.index > lastIndex) {
827
- option.appendChild(document.createTextNode(originalText.slice(lastIndex, match.index)));
828
- }
829
- const strong = document.createElement('strong');
830
- strong.textContent = matchText;
831
- option.appendChild(strong);
832
- lastIndex = match.index + matchText.length;
833
- });
834
- if (lastIndex < originalText.length) {
835
- option.appendChild(document.createTextNode(originalText.slice(lastIndex)));
836
- }
837
-
838
- if (displayValueEl) {
839
- option.append(displayValueEl);
840
- }
841
- }
842
-
843
- // Update disabled state
844
- if (changedProperties.has('disabled')) {
845
- option.disabled = this.disabled;
846
- }
1459
+ /**
1460
+ * Sets the loading state and dispatches a loading change event.
1461
+ * @param {boolean} isLoading - Whether the menu is loading.
1462
+ * @protected
1463
+ */
1464
+ setLoadingState(isLoading) {
1465
+ this.setAttribute("aria-busy", isLoading);
1466
+ dispatchMenuEvent(this, "auroMenu-loadingChange", {
1467
+ loading: isLoading,
1468
+ hasLoadingPlaceholder: this.hasLoadingPlaceholder
847
1469
  });
848
-
849
1470
  }
850
1471
 
851
1472
  // Init Methods
@@ -855,7 +1476,6 @@ class AuroMenu extends AuroElement {
855
1476
  * @private
856
1477
  */
857
1478
  initializeMenu() {
858
- this.initItems();
859
1479
  if (this.rootMenu) {
860
1480
  this.setAttribute('role', 'listbox');
861
1481
  this.setAttribute('root', '');
@@ -865,117 +1485,15 @@ class AuroMenu extends AuroElement {
865
1485
  }
866
1486
  }
867
1487
 
868
- // Must run for nested menus too — sets level, role="group", and aria-label="submenu" based on parent.
869
1488
  this.handleNestedMenus(this);
870
1489
  }
871
1490
 
872
1491
  /**
873
- * Initializes menu items and their attributes.
874
- * @private
875
- */
876
- initItems() {
877
- const found = Array.from(this.querySelectorAll('auro-menuoption, [auro-menuoption]'));
878
- this.items = found.length > 0 ? found : undefined;
879
-
880
- // Record whether each propagation target had an author-set size/shape attribute
881
- // BEFORE menu has had a chance to propagate. Marker is set once per element so a
882
- // later menu-driven setAttribute doesn't re-flag the element as "explicit".
883
- this.querySelectorAll('auro-menu, [auro-menu], auro-menuoption, [auro-menuoption]').forEach((el) => {
884
- if (el._explicitSize === undefined) {
885
- el._explicitSize = el.hasAttribute('size');
886
- }
887
- if (el._explicitShape === undefined) {
888
- el._explicitShape = el.hasAttribute('shape');
889
- }
890
- });
891
-
892
- if (this.noCheckmark) {
893
- this.updateItemsState(new Map([
894
- [
895
- 'noCheckmark',
896
- true
897
- ]
898
- ]));
899
- }
900
-
901
- this.dispatchEvent(new CustomEvent('auroMenu-optionsChange', {
902
- detail: {
903
- options: this.items
904
- }
905
- }));
906
- }
907
-
908
- // Logic Methods
909
-
910
- /**
911
- * Updates menu state when an option is selected.
912
- * @private
913
- * @param {HTMLElement} option - The option element to select.
914
- */
915
- handleSelectState(option) {
916
- if (this.multiSelect) {
917
- const currentValue = this.formattedValue || [];
918
- const currentSelected = this.optionSelected || [];
919
-
920
- if (!currentValue.includes(option.value)) {
921
- this.value = JSON.stringify([
922
- ...currentValue,
923
- option.value
924
- ]);
925
- }
926
- if (!currentSelected.includes(option)) {
927
- this.optionSelected = [
928
- ...currentSelected,
929
- option
930
- ];
931
- }
932
- } else {
933
- this.value = option.value;
934
- this.optionSelected = option;
935
- }
936
-
937
- this._index = this.items.indexOf(option);
938
- }
939
-
940
- /**
941
- * Deselects a menu option and updates related state.
942
- * @private
943
- * @param {HTMLElement} option - The menuoption to be deselected.
1492
+ * Selects the currently highlighted option.
1493
+ * @protected
944
1494
  */
945
- handleDeselectState(option) {
946
- if (this.multiSelect) {
947
- // Remove this option from array
948
- const newFormattedValue = (this.formattedValue || []).filter((val) => val !== option.value);
949
-
950
- // If array is empty after removal, set back to undefined
951
- if (newFormattedValue && newFormattedValue.length === 0) {
952
- this.value = undefined;
953
- } else {
954
- this.value = JSON.stringify(newFormattedValue);
955
- }
956
-
957
- this.optionSelected = this.optionSelected.filter((val) => val !== option);
958
- if (this.optionSelected.length === 0) {
959
- this.optionSelected = undefined;
960
- }
961
- } else {
962
- // For single-select: Back to undefined when deselected
963
- this.value = undefined;
964
- this.optionSelected = undefined;
965
- }
966
-
967
- // Update the index tracking
968
- this._index = this.items.indexOf(option);
969
-
970
- // Update UI to reflect changes
971
- this.updateItemsState(new Map([
972
- [
973
- 'optionSelected',
974
- true
975
- ]
976
- ]));
977
-
978
- // Notification happens via updated() when optionSelected changes above.
1495
+ makeSelection() {
1496
+ this.menuService.selectHighlightedOption();
979
1497
  }
980
1498
 
981
1499
  /**
@@ -994,18 +1512,7 @@ class AuroMenu extends AuroElement {
994
1512
  * @public
995
1513
  */
996
1514
  reset() {
997
- // Reset to undefined - initial state
998
- this.value = undefined;
999
- this.optionSelected = undefined;
1000
- this._index = -1;
1001
-
1002
- // Reset UI state
1003
- this.updateItemsState(new Map([
1004
- [
1005
- 'optionSelected',
1006
- true
1007
- ]
1008
- ]));
1515
+ this.menuService.reset();
1009
1516
 
1010
1517
  // Dispatch reset event
1011
1518
  dispatchMenuEvent(this, 'auroMenu-selectValueReset');
@@ -1017,239 +1524,44 @@ class AuroMenu extends AuroElement {
1017
1524
  * @param {HTMLElement} menu - Root menu element.
1018
1525
  */
1019
1526
  handleNestedMenus(menu) {
1020
- // Slot changes can fire on a menu mid-teardown (e.g. while a parent menu
1021
- // is removing children to rebuild its content). In that window the menu
1022
- // is detached and parentElement is null. Skip — handleNestedMenus will
1023
- // run again when the menu is reattached.
1024
- if (!menu.parentElement) {
1025
- return;
1026
- }
1027
-
1028
1527
  menu.level = menu.parentElement.level >= 0 ? menu.parentElement.level + 1 : 0;
1029
1528
 
1030
- if (menu.level > 0) {
1031
- menu.setAttribute('role', 'group');
1032
- menu.removeAttribute("root");
1033
- if (!menu.hasAttribute('aria-label')) {
1034
- menu.setAttribute('aria-label', 'submenu');
1035
- }
1036
- }
1037
-
1038
- const options = menu.querySelectorAll(':scope > auro-menuoption, :scope > [auro-menuoption]');
1039
- options.forEach((option) => {
1040
- const regex = new RegExp(this.nestingSpacer, "gu");
1041
- option.innerHTML = this.nestingSpacer.repeat(menu.level) + option.innerHTML.replace(regex, '');
1042
- });
1043
- }
1044
-
1045
- // Event Handlers
1046
-
1047
- /**
1048
- * Makes a selection based on the current index.
1049
- * @private
1050
- */
1051
- makeSelection() {
1052
- if (!this.items) {
1053
- this.initItems();
1054
- }
1055
-
1056
- // Get currently selected menu option based on index
1057
- const option = this.items ? this.items[this._index] : undefined;
1058
-
1059
- // Return early if option is not interactive
1060
- if (!option || !isOptionInteractive(option)) {
1061
- return;
1062
- }
1063
-
1064
- // Handle custom events first
1065
- if (option.hasAttribute('event')) {
1066
- this.handleCustomEvent(option);
1067
- return;
1068
- }
1069
-
1070
- if (this.multiSelect) {
1071
- // In multiselect, toggle individual selections
1072
- this.toggleOption(option);
1073
- // In single select, only handle selection of new options
1074
- } else if (!this.isOptionSelected(option)) {
1075
- this.clearSelection();
1076
- this.handleSelectState(option);
1077
- } else {
1078
- // Re-selecting the already-selected option in single-select doesn't change
1079
- // state, so updated() won't fire. Notify explicitly so consumers (e.g.
1080
- // auro-select closing its dropdown on Enter) still get the event.
1081
- this.notifySelectionChange();
1082
- }
1083
- }
1084
-
1085
- /**
1086
- * Toggle the selection state of the menuoption.
1087
- * @private
1088
- * @param {HTMLElement} option - The menuoption to toggle.
1089
- */
1090
- toggleOption(option) {
1091
- const isCurrentlySelected = this.isOptionSelected(option);
1092
-
1093
- if (isCurrentlySelected) {
1094
- this.handleDeselectState(option);
1095
- } else if (option.value === undefined || option.value === '') {
1096
- dispatchMenuEvent(this, 'auroMenu-selectValueFailure');
1097
- } else {
1098
- this.handleSelectState(option);
1099
- }
1100
- }
1101
-
1102
- /**
1103
- * Handles keyboard navigation and selection.
1104
- * @private
1105
- * @param {KeyboardEvent} event - The keydown event.
1106
- */
1107
- handleKeyDown(event) {
1108
- switch (event.key) {
1109
- case "ArrowDown":
1110
- event.preventDefault();
1111
- this.navigateOptions('down');
1112
- break;
1113
- case "ArrowUp":
1114
- event.preventDefault();
1115
- this.navigateOptions('up');
1116
- break;
1117
- case "Tab":
1118
- // Do not preventDefault on Tab so focus can move out of the menu (a11y: avoid trapping keyboard users).
1119
- this.makeSelection();
1120
- break;
1121
- case "Enter":
1122
- event.preventDefault();
1123
- this.makeSelection();
1124
- break;
1125
- }
1126
- }
1127
-
1128
- /**
1129
- * Handles option selection via click events from menuoptions.
1130
- * @private
1131
- * @param {CustomEvent} event - The auroMenuOption-click event.
1132
- */
1133
- handleMouseSelect(event) {
1134
- if (!this.rootMenu || this.disabled) {
1135
- return;
1136
- }
1137
-
1138
- const option = event.detail;
1139
- if (option && this.items) {
1140
- const idx = this.items.indexOf(option);
1141
- if (idx >= 0) {
1142
- this._index = idx;
1143
- this.makeSelection();
1144
- }
1145
- }
1146
- }
1147
-
1148
- /**
1149
- * Handles option hover events.
1150
- * @private
1151
- * @param {CustomEvent} event - Event object from the browser.
1152
- */
1153
- handleOptionHover(event) {
1154
- const option = event.detail;
1155
- if (this.items) {
1156
- const idx = this.items.indexOf(option);
1157
- if (idx >= 0) {
1158
- this.updateActiveOption(idx);
1159
- }
1160
- }
1161
- }
1162
-
1163
- /**
1164
- * Handles slot change events.
1165
- * @private
1166
- */
1167
- handleSlotChange() {
1168
- if (this.parentElement && this.parentElement.closest('auro-menu, [auro-menu]')) {
1169
- this.rootMenu = false;
1170
- }
1171
-
1172
- // Nested menus must also reinitialize so items, level, role="group", and aria-label refresh on content changes.
1173
- // Root-specific attributes (listbox/root/aria-multiselectable) remain gated by `rootMenu` inside initializeMenu.
1174
- this.initializeMenu();
1175
-
1176
- // When options arrive after `value` was set (async option load), re-run matching
1177
- // against the now-populated items. The earlier updated('value') call deferred
1178
- // the failure dispatch because items were empty; this triggers the match now.
1179
- const hasPendingValue = this.value !== undefined &&
1180
- this.value !== null &&
1181
- !(typeof this.value === 'string' && this.value.trim() === '');
1182
- if (hasPendingValue && this.items && this.items.length > 0 && this.optionSelected === undefined) {
1183
- this.requestUpdate('value', undefined);
1529
+ if (menu.level > 0) {
1530
+ menu.setAttribute('role', 'group');
1531
+ menu.removeAttribute("root");
1532
+ if (!menu.hasAttribute('aria-label')) {
1533
+ menu.setAttribute('aria-label', 'submenu');
1534
+ }
1184
1535
  }
1536
+
1537
+ const options = menu.querySelectorAll(':scope > auro-menuoption, :scope > [auro-menuoption]');
1538
+ options.forEach((option) => {
1539
+ const regex = new RegExp(this.nestingSpacer, "gu");
1540
+ option.innerHTML = this.nestingSpacer.repeat(menu.level) + option.innerHTML.replace(regex, '');
1541
+ });
1185
1542
  }
1186
1543
 
1187
1544
  /**
1188
- * Navigates through options using keyboard.
1189
- * @param {string} direction - 'up' or 'down'.
1545
+ * Navigates the menu options in the specified direction.
1546
+ * @param {'up'|'down'} direction - The direction to navigate.
1547
+ * @protected
1190
1548
  */
1191
1549
  navigateOptions(direction) {
1192
- // Return early if no items exist
1193
- if (!this.items || !this.items.length) {
1194
- return;
1195
- }
1196
-
1197
- let newIndex = this._index;
1198
- if (newIndex === -1 && direction === 'up') {
1199
- newIndex = this.items.length;
1200
- }
1201
- const increment = direction === 'down' ? 1 : -1;
1202
- const maxIterations = this.items.length;
1203
- let iterations = 0;
1204
- let foundInteractiveOption = false;
1205
-
1206
- do {
1207
- newIndex = (newIndex + increment + this.items.length) % this.items.length;
1208
- iterations += 1;
1209
-
1210
- // Check if current option is interactive
1211
- const currentOption = this.items[newIndex];
1212
- if (isOptionInteractive(currentOption)) {
1213
- foundInteractiveOption = true;
1214
- break;
1215
- }
1216
-
1217
- // Break if all options were checked
1218
- if (iterations >= maxIterations) {
1219
- break;
1220
- }
1221
- } while (iterations < maxIterations);
1222
-
1223
- // Update only if an interactive option was found
1224
- if (foundInteractiveOption) {
1225
- this.updateActiveOption(newIndex);
1550
+ if (direction === 'up') {
1551
+ this.menuService.highlightPrevious();
1552
+ } else if (direction === 'down') {
1553
+ this.menuService.highlightNext();
1226
1554
  }
1227
1555
  }
1228
1556
 
1229
1557
  /**
1230
- * Updates the active option state and dispatches events.
1231
- * Accepts either a numeric index or an HTMLElement option.
1232
- * @param {number|HTMLElement} indexOrOption - Index of the option or the option element to make active.
1558
+ * Handles slot change events.
1559
+ * @private
1233
1560
  */
1234
- updateActiveOption(indexOrOption) {
1235
- let idx = -1;
1236
-
1237
- if (typeof indexOrOption === 'number') {
1238
- idx = indexOrOption;
1239
- } else {
1240
- idx = this.items ? this.items.indexOf(indexOrOption) : -1;
1241
- }
1242
-
1243
- if (!this.items || !this.items[idx]) {
1244
- return;
1561
+ handleSlotChange() {
1562
+ if (this.rootMenu) {
1563
+ this.initializeMenu();
1245
1564
  }
1246
-
1247
- this.items.forEach((item) => item.classList.remove('active'));
1248
- this.items[idx].classList.add('active');
1249
- this.optionActive = this.items[idx];
1250
- this._index = idx;
1251
-
1252
- dispatchMenuEvent(this, 'auroMenu-activatedOption', this.items[idx]);
1253
1565
  }
1254
1566
 
1255
1567
  /**
@@ -1259,8 +1571,8 @@ class AuroMenu extends AuroElement {
1259
1571
  */
1260
1572
  handleCustomEvent(option) {
1261
1573
  const eventName = option.getAttribute('event');
1262
- dispatchMenuEvent(this, eventName, { option });
1263
- dispatchMenuEvent(this, 'auroMenu-customEventFired', { option });
1574
+ dispatchMenuEvent(this, eventName);
1575
+ dispatchMenuEvent(this, 'auroMenu-customEventFired');
1264
1576
  }
1265
1577
 
1266
1578
  /**
@@ -1268,33 +1580,14 @@ class AuroMenu extends AuroElement {
1268
1580
  * @param {any} source - The source that triggers this event.
1269
1581
  * @private
1270
1582
  */
1271
- notifySelectionChange(source = undefined) {
1272
- dispatchMenuEvent(this, 'auroMenu-selectedOption', { source });
1273
- }
1274
-
1275
- /**
1276
- * @private
1277
- * @param {any} current - Current selection.
1278
- * @param {any} next - New selection to compare.
1279
- * @returns {boolean} Whether the selections are equal.
1280
- */
1281
- selectionEquals(current, next) {
1282
- if (current === next) {
1283
- return true;
1284
- }
1285
- if (!current || !next) {
1286
- return false;
1287
- }
1288
- if (Array.isArray(current) && Array.isArray(next)) {
1289
- if (current.length !== next.length) {
1290
- return false;
1291
- }
1292
- // Compare as sets — selection order may differ from DOM order after value-driven
1293
- // reconciliation, but the selected set is what matters for change detection.
1294
- const nextSet = new Set(next);
1295
- return current.every((item) => nextSet.has(item));
1296
- }
1297
- return false;
1583
+ notifySelectionChange({value, stringValue, keys, options, reason} = {}) {
1584
+ dispatchMenuEvent(this, 'auroMenu-selectedOption', {
1585
+ value,
1586
+ stringValue,
1587
+ keys,
1588
+ options,
1589
+ reason
1590
+ });
1298
1591
  }
1299
1592
 
1300
1593
  /**
@@ -1309,6 +1602,7 @@ class AuroMenu extends AuroElement {
1309
1602
  }
1310
1603
 
1311
1604
  if (this.multiSelect) {
1605
+ // In multi-select mode, check if the option is in the selected array
1312
1606
  return Array.isArray(this.optionSelected) && this.optionSelected.some((selectedOption) => selectedOption === option);
1313
1607
  }
1314
1608
 
@@ -1451,12 +1745,37 @@ let menuOptionIdCounter = 0;
1451
1745
  * @slot default - The default slot for the menu option text.
1452
1746
  *
1453
1747
  * @event auroMenuOption-mouseover - Notifies that this option has been hovered over.
1454
- * @event auroMenuOption-click - Notifies that this option has been clicked.
1455
1748
  */
1456
1749
  class AuroMenuOption extends AuroElement {
1750
+
1751
+ /**
1752
+ * This will register this element with the browser.
1753
+ * @param {string} [name="auro-menuoption"] - The name of the element that you want to register.
1754
+ *
1755
+ * @example
1756
+ * AuroMenuOption.register("custom-menuoption") // this will register this element to <custom-menuoption/>
1757
+ *
1758
+ */
1759
+ static register(name = "auro-menuoption") {
1760
+ AuroLibraryRuntimeUtils.prototype.registerComponent(name, AuroMenuOption);
1761
+ }
1762
+
1763
+ /**
1764
+ * Returns whether the menu option is currently active and selectable.
1765
+ * An option is considered active if it is not hidden, not disabled, and not static.
1766
+ * @returns {boolean} True if the option is active, false otherwise.
1767
+ */
1768
+ get isActive() {
1769
+ return !this.hasAttribute('hidden') &&
1770
+ !this.disabled &&
1771
+ !this.hasAttribute('static');
1772
+ }
1773
+
1457
1774
  constructor() {
1458
1775
  super();
1459
1776
 
1777
+ this.bindEvents();
1778
+
1460
1779
  /**
1461
1780
  * @private
1462
1781
  */
@@ -1481,57 +1800,109 @@ class AuroMenuOption extends AuroElement {
1481
1800
  /**
1482
1801
  * @private
1483
1802
  */
1484
- this.tabIndex = -1;
1803
+ this.runtimeUtils = new AuroLibraryRuntimeUtils();
1804
+
1805
+ // Initialize context-related properties
1806
+ this.menuService = null;
1807
+ this.unsubscribe = null;
1485
1808
 
1486
1809
  /**
1487
1810
  * @private
1488
1811
  */
1489
- this.runtimeUtils = new AuroLibraryRuntimeUtils();
1490
-
1491
- this.addEventListener('click', this.handleClick.bind(this));
1812
+ this.handleMenuChange = this.handleMenuChange.bind(this);
1492
1813
  }
1493
1814
 
1494
1815
  static get properties() {
1495
1816
  return {
1496
1817
  ...super.properties,
1497
- noCheckmark: {
1818
+
1819
+ /**
1820
+ * When true, disables the menu option.
1821
+ */
1822
+ disabled: {
1498
1823
  type: Boolean,
1499
1824
  reflect: true
1500
1825
  },
1501
- selected: {
1502
- type: Boolean,
1826
+
1827
+ /**
1828
+ * @private
1829
+ */
1830
+ event: {
1831
+ type: String,
1503
1832
  reflect: true
1504
1833
  },
1505
- disabled: {
1506
- type: Boolean,
1507
- reflect: true
1834
+
1835
+ /**
1836
+ * @private
1837
+ */
1838
+ layout: {
1839
+ type: String
1508
1840
  },
1509
- value: {
1841
+
1842
+ /**
1843
+ * Allows users to set a unique key for the menu option for specified option selection. If no key is provided, the value property will be used.
1844
+ */
1845
+ key: {
1510
1846
  type: String,
1511
1847
  reflect: true
1512
1848
  },
1513
- tabIndex: {
1514
- type: Number,
1515
- reflect: true
1849
+
1850
+ /**
1851
+ * @private
1852
+ */
1853
+ menuService: {
1854
+ type: Object,
1855
+ state: true
1516
1856
  },
1517
1857
 
1518
1858
  /**
1519
1859
  * @private
1520
1860
  */
1521
- event: {
1861
+ matchWord: {
1522
1862
  type: String,
1863
+ state: true
1864
+ },
1865
+
1866
+ /**
1867
+ * @private
1868
+ */
1869
+ noCheckmark: {
1870
+ type: Boolean,
1523
1871
  reflect: true
1524
1872
  },
1525
1873
 
1526
1874
  /**
1527
- * When true, marks this option as the "no matching results" placeholder shown by combobox
1528
- * when the user's input does not match any available options.
1875
+ * When true, marks this option as the "no matching results" placeholder shown by combobox when the user's input does not match any available options. Enables distinct styling and prevents the option from being treated as a selectable match.
1529
1876
  */
1530
1877
  noMatch: {
1531
1878
  type: Boolean,
1532
1879
  reflect: true,
1533
1880
  attribute: 'nomatch'
1534
1881
  },
1882
+
1883
+ /**
1884
+ * Specifies that an option is selected.
1885
+ */
1886
+ selected: {
1887
+ type: Boolean,
1888
+ reflect: true
1889
+ },
1890
+
1891
+ /**
1892
+ * Specifies the tab index of the menu option.
1893
+ */
1894
+ tabIndex: {
1895
+ type: Number,
1896
+ reflect: true
1897
+ },
1898
+
1899
+ /**
1900
+ * Specifies the value to be sent to a server.
1901
+ */
1902
+ value: {
1903
+ type: String,
1904
+ reflect: true
1905
+ },
1535
1906
  };
1536
1907
  }
1537
1908
 
@@ -1543,48 +1914,41 @@ class AuroMenuOption extends AuroElement {
1543
1914
  ];
1544
1915
  }
1545
1916
 
1546
- /**
1547
- * This will register this element with the browser.
1548
- * @param {string} [name="auro-menuoption"] - The name of element that you want to register to.
1549
- *
1550
- * @example
1551
- * AuroMenuOption.register("custom-menuoption") // this will register this element to <custom-menuoption/>
1552
- *
1553
- */
1554
- static register(name = "auro-menuoption") {
1555
- AuroLibraryRuntimeUtils.prototype.registerComponent(name, AuroMenuOption);
1556
- }
1557
-
1558
- /**
1559
- * Returns whether the menu option is currently active and selectable.
1560
- * @returns {boolean}
1561
- */
1562
- get isActive() {
1563
- return !this.hasAttribute('hidden') &&
1564
- !this.disabled &&
1565
- !this.hasAttribute('static');
1566
- }
1567
-
1568
1917
  connectedCallback() {
1569
1918
  super.connectedCallback();
1570
1919
 
1920
+ // Add the tag name as an attribute if it is different than the component name
1921
+ // Add this step soon as this node gets attached to the DOM to avoid racing condition with menu's value setting logic.
1571
1922
  this.runtimeUtils.handleComponentTagRename(this, 'auro-menuoption');
1923
+
1924
+ // Set up context consumption in connectedCallback
1925
+ this._contextConsumer = new ContextConsumer(this, {
1926
+ context: MenuContext,
1927
+ callback: this.attachTo.bind(this),
1928
+ subscribe: true
1929
+ });
1930
+
1931
+ // Establish the key property as early as possible.
1932
+ // When a framework (e.g. Svelte) inserts the element into the DOM before
1933
+ // setting its `value` property, both `getAttribute('value')` and
1934
+ // `getAttribute('key')` return null here. Setting `this.key = null`
1935
+ // would block the fallback in `updated()` that assigns key from the
1936
+ // value property (the guard checked `=== undefined`). Only assign key
1937
+ // if at least one source attribute is actually present so that the
1938
+ // `updated()` fallback can run when the value property arrives later.
1939
+ const valueAttr = this.getAttribute('value');
1940
+ const keyAttr = this.getAttribute('key');
1941
+ const resolvedKey = keyAttr !== null ? keyAttr : valueAttr;
1942
+ if (resolvedKey !== null) {
1943
+ this.key = resolvedKey;
1944
+ }
1572
1945
  }
1573
1946
 
1574
1947
  firstUpdated() {
1948
+ // Add the tag name as an attribute if it is different than the component name
1575
1949
  this.runtimeUtils.handleComponentTagRename(this, 'auro-menuoption');
1576
1950
 
1577
- // firstUpdated can fire on an option that was detached before the first
1578
- // render completed (e.g. dynamic-menu rebuilds clear and repopulate the
1579
- // menu mid-cycle). parentElement is null in that case — fall back to
1580
- // defaults; the next render after re-attachment will pick up real values.
1581
- if (!this.hasAttribute('size')) {
1582
- this.size = this.parentElement ? this.parentElement.getAttribute('size') || 'sm' : 'sm';
1583
- }
1584
- if (!this.hasAttribute('shape')) {
1585
- this.shape = this.parentElement ? this.parentElement.getAttribute('shape') || 'box' : 'box';
1586
- }
1587
-
1951
+ // Generate unique ID if not already set (required for aria-activedescendant)
1588
1952
  if (!this.id) {
1589
1953
  menuOptionIdCounter += 1;
1590
1954
  this.id = `menuoption-${menuOptionIdCounter}`;
@@ -1606,8 +1970,16 @@ class AuroMenuOption extends AuroElement {
1606
1970
  updated(changedProperties) {
1607
1971
  super.updated(changedProperties);
1608
1972
 
1973
+ // Update aria-selected attribute if selected changed
1609
1974
  if (changedProperties.has('selected')) {
1975
+
1976
+ // Update aria-selected attribute
1610
1977
  this.setAttribute('aria-selected', this.selected.toString());
1978
+
1979
+ // Update menu service selection state if this isn't an internal update
1980
+ if (this.internalUpdateInProgress !== true && this.menuService) {
1981
+ this.menuService[this.selected ? 'selectOption' : 'deselectOption'](this);
1982
+ }
1611
1983
  }
1612
1984
 
1613
1985
  if (changedProperties.has('disabled')) {
@@ -1617,48 +1989,231 @@ class AuroMenuOption extends AuroElement {
1617
1989
  this.removeAttribute('aria-disabled');
1618
1990
  }
1619
1991
  }
1992
+
1993
+ if (changedProperties.has('active')) {
1994
+ this.updateActiveClasses();
1995
+ }
1996
+
1997
+ // Update text highlight if matchWord changed
1998
+ if (changedProperties.has('matchWord')) {
1999
+ this.updateTextHighlight();
2000
+ }
2001
+
2002
+ // Set the key to be the passed value if no key is provided.
2003
+ // Loose equality (== null) is intentional: it catches both null AND
2004
+ // undefined. When a framework (e.g. Svelte, React) inserts the element
2005
+ // before setting its value property, connectedCallback skips key
2006
+ // assignment because both attributes are null at that point. The Lit
2007
+ // property default for `key` is undefined (not null), so strict
2008
+ // === null would miss the case and the fallback would never run.
2009
+ if (changedProperties.has('value') && this.key == null) { // eslint-disable-line eqeqeq, no-eq-null
2010
+ this.key = this.value;
2011
+ }
2012
+ }
2013
+
2014
+ disconnectedCallback() {
2015
+ if (this.menuService) {
2016
+ this.menuService.unsubscribe(this.handleMenuChange);
2017
+ this.menuService.removeMenuOption(this);
2018
+ }
1620
2019
  }
1621
2020
 
1622
- handleMenuChange() {
1623
- // no-op: menu owns state in the distributed architecture
2021
+ /**
2022
+ * Sets up event listeners for user interaction with the menu option.
2023
+ * This function enables click and mouse enter events to trigger selection and highlighting logic.
2024
+ */
2025
+ bindEvents() {
2026
+ this.addEventListener('click', this.handleClick.bind(this));
2027
+ this.addEventListener('mouseenter', this.handleMouseEnter.bind(this));
1624
2028
  }
1625
2029
 
1626
- setSelected(value) {
1627
- this.selected = value;
2030
+ /**
2031
+ * Attaches this menu option to a menu service and subscribes to its events.
2032
+ * This method enables the option to participate in menu selection and highlighting logic.
2033
+ * @param {Object} service - The menu service instance to attach to.
2034
+ */
2035
+ attachTo(service) {
2036
+ if (!service) {
2037
+ return;
2038
+ }
2039
+ this.menuService = service;
2040
+ this.menuService.addMenuOption(this);
2041
+ this.menuService.subscribe(this.handleMenuChange);
1628
2042
  }
1629
2043
 
1630
- updateActive(active) {
1631
- this.active = active;
1632
- if (active) {
1633
- this.classList.add('active');
1634
- } else {
1635
- this.classList.remove('active');
2044
+ /**
2045
+ * Handles changes from the menu service and updates the option's state.
2046
+ * This function synchronizes the option's properties and selection/highlight state with menu events.
2047
+ * @param {Object} event - The event object from the menu service.
2048
+ */
2049
+ handleMenuChange(event) {
2050
+
2051
+ // Ignore events without a type or property
2052
+ if (!event || (!event.type && !event.property)) {
2053
+ return;
2054
+ }
2055
+
2056
+ // Update reactive properties based on event type
2057
+ if (event.property && Object.keys(AuroMenuOption.properties).includes(event.property)) {
2058
+ this[event.property] = event.value;
2059
+ }
2060
+
2061
+ // Handle highlight changes
2062
+ if (event.type === 'highlightChange') {
2063
+ const isActive = event.option === this;
2064
+ this.active = isActive;
2065
+ this.updateActiveClasses();
2066
+ }
2067
+
2068
+ if (event.type === 'stateChange') {
2069
+ const isSelected = event.selectedOptions.includes(this);
2070
+ this.setInternalSelected(isSelected);
2071
+ }
2072
+ }
2073
+
2074
+ /**
2075
+ * Updates the internal selected state of the menu option bypassing 'updated' and triggers custom events if selected.
2076
+ * This function ensures the option's selection state is synchronized with menu logic and notifies listeners.
2077
+ * @param {boolean} isSelected - Whether the option should be marked as selected.
2078
+ */
2079
+ setInternalSelected(isSelected) {
2080
+ this.internalUpdateInProgress = true;
2081
+ this.selected = isSelected;
2082
+
2083
+ // Fire custom event if selected
2084
+ if (isSelected) {
2085
+ this.handleCustomEvent();
1636
2086
  }
2087
+
2088
+ setTimeout(() => {
2089
+ this.internalUpdateInProgress = false;
2090
+ }, 0);
2091
+ }
2092
+
2093
+ /**
2094
+ * Sets the selected state of the menu option.
2095
+ * This function updates whether the option is currently selected.
2096
+ * @param {boolean} isSelected - Whether the option should be marked as selected.
2097
+ * @deprecated Simply modify the `selected` property directly instead.
2098
+ */
2099
+ setSelected(isSelected) {
2100
+ this.selected = isSelected;
2101
+ }
2102
+
2103
+ /**
2104
+ * Updates the active state and visual highlighting of the menu option.
2105
+ * This function toggles the option's active status and applies or removes the active CSS class.
2106
+ * @param {boolean} isActive - Whether the option should be marked as active.
2107
+ * @deprecated Simply modify the `active` property directly instead.
2108
+ */
2109
+ updateActive(isActive) {
2110
+
2111
+ // Set active state
2112
+ this.active = isActive;
2113
+ this.updateActiveClasses();
2114
+ }
2115
+
2116
+ /**
2117
+ * Updates the CSS class for the menu option based on its active state.
2118
+ * This function adds or removes the 'active' class to visually indicate the option's active status.
2119
+ * @private
2120
+ */
2121
+ updateActiveClasses() {
2122
+ // Update class based on active state
2123
+ if (this.active) this.classList.add('active');
2124
+ else this.classList.remove('active');
1637
2125
  }
1638
2126
 
1639
- attachTo() {
1640
- // no-op: menu owns state in the distributed architecture
2127
+
2128
+ /**
2129
+ * Updates the visual highlighting of text within the menu option based on the current match word.
2130
+ * This function highlights matching text segments and manages nested spacers for display formatting.
2131
+ * @private
2132
+ */
2133
+ updateTextHighlight() {
2134
+
2135
+ // Regex for matchWord if needed
2136
+ let regexWord = null;
2137
+
2138
+ if (this.matchWord && this.matchWord.length) {
2139
+ const escapedWord = this.matchWord.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
2140
+ regexWord = new RegExp(escapedWord, 'giu');
2141
+ }
2142
+
2143
+ // Update text highlighting if matchWord changed
2144
+ if (regexWord &&
2145
+ this.isActive && !this.hasAttribute('persistent')) {
2146
+ const nested = this.querySelectorAll('.nestingSpacer');
2147
+
2148
+ const displayValueEl = this.querySelector('[slot="displayValue"]');
2149
+ if (displayValueEl) {
2150
+ this.removeChild(displayValueEl);
2151
+ }
2152
+
2153
+ // Create nested spacers
2154
+ const nestingSpacerBundle = [...nested].map(() => this.nestingSpacer).join('');
2155
+
2156
+ // Update with spacers and matchWord
2157
+ this.innerHTML = nestingSpacerBundle +
2158
+ this.textContent.replace(
2159
+ regexWord,
2160
+ (match) => `<strong>${match}</strong>`
2161
+ );
2162
+ if (displayValueEl) {
2163
+ this.append(displayValueEl);
2164
+ }
2165
+ }
1641
2166
  }
1642
2167
 
1643
2168
  /**
1644
- * Handles click events on the menu option.
2169
+ * Handles click events on the menu option, toggling its selected state.
2170
+ * This function dispatches a click event and updates selection if the option is not disabled.
1645
2171
  * @private
1646
2172
  */
1647
2173
  handleClick() {
2174
+ if (!this.disabled && !this.menuService?.disabled) {
2175
+ this.dispatchClickEvent();
2176
+ this.selected = !this.selected;
2177
+ }
2178
+ }
2179
+
2180
+ /**
2181
+ * Handles mouse enter events to highlight the menu option.
2182
+ * This function updates the menu service to set this option as the currently highlighted item if not disabled.
2183
+ * @private
2184
+ */
2185
+ handleMouseEnter() {
1648
2186
  if (!this.disabled) {
1649
- // Pure event emitter: the parent menu owns selection state and will
1650
- // update `selected` via setSelected(). Toggling here desyncs the option
1651
- // UI from auro-menu.optionSelected (e.g. single-select re-click on the
1652
- // already-selected option would flip the option off while the menu keeps it on).
1653
- this.dispatchEvent(new CustomEvent('auroMenuOption-click', {
1654
- bubbles: true,
1655
- cancelable: false,
1656
- composed: true,
1657
- detail: this
1658
- }));
2187
+ this.menuService.setHighlightedOption(this);
2188
+ }
2189
+ }
2190
+
2191
+ /**
2192
+ * Dispatches custom events defined for this menu option.
2193
+ * This function notifies listeners when a custom event is triggered by the option.
2194
+ * @private
2195
+ */
2196
+ handleCustomEvent() {
2197
+ if (this.event) {
2198
+ dispatchMenuEvent(this, this.event, { option: this });
2199
+ dispatchMenuEvent(this, 'auroMenu-customEventFired', { option: this });
1659
2200
  }
1660
2201
  }
1661
2202
 
2203
+ /**
2204
+ * Dispatches a click event for this menu option.
2205
+ * This function notifies listeners that the option has been clicked.
2206
+ * @private
2207
+ */
2208
+ dispatchClickEvent() {
2209
+ this.dispatchEvent(new CustomEvent('auroMenuOption-click', {
2210
+ bubbles: true,
2211
+ cancelable: false,
2212
+ composed: true,
2213
+ detail: this
2214
+ }));
2215
+ }
2216
+
1662
2217
  /**
1663
2218
  * Generates an HTML element containing an SVG icon based on the provided `svgContent`.
1664
2219
  *
@@ -1698,8 +2253,8 @@ class AuroMenuOption extends AuroElement {
1698
2253
  return html$1`
1699
2254
  <div class="${classes}">
1700
2255
  ${this.selected && !this.noCheckmark
1701
- ? this.generateIconHtml(checkmarkIcon.svg)
1702
- : undefined}
2256
+ ? this.generateIconHtml(checkmarkIcon.svg)
2257
+ : undefined}
1703
2258
  <slot></slot>
1704
2259
  </div>
1705
2260
  `;