@spectrum-web-components/menu 1.2.0-beta.8 → 1.2.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.
package/src/Menu.dev.js CHANGED
@@ -19,9 +19,7 @@ import {
19
19
  query
20
20
  } from "@spectrum-web-components/base/src/decorators.js";
21
21
  import menuStyles from "./menu.css.js";
22
- function elementIsOrContains(el, isOrContains) {
23
- return !!isOrContains && (el === isOrContains || el.contains(isOrContains));
24
- }
22
+ import { RovingTabindexController } from "@spectrum-web-components/reactive-controllers/src/RovingTabindex.js";
25
23
  export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
26
24
  constructor() {
27
25
  super();
@@ -58,15 +56,8 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
58
56
  composed: true
59
57
  })
60
58
  );
61
- const focusedItem = this.childItems[this.focusedItemIndex];
62
- if (focusedItem) {
63
- focusedItem.focused = false;
64
- }
65
59
  const openedItem = event.composedPath().find((el) => this.childItemSet.has(el));
66
60
  if (!openedItem) return;
67
- const openedItemIndex = this.childItems.indexOf(openedItem);
68
- this.focusedItemIndex = openedItemIndex;
69
- this.focusInItemIndex = openedItemIndex;
70
61
  };
71
62
  this._hasUpdatedSelectedItemIndex = false;
72
63
  this._willUpdateItems = false;
@@ -75,6 +66,26 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
75
66
  this.resolveCacheUpdated = () => {
76
67
  return;
77
68
  };
69
+ if (!this.rovingTabindexController && this.controlsRovingTabindex) {
70
+ this.rovingTabindexController = new RovingTabindexController(this, {
71
+ direction: "vertical",
72
+ focusInIndex: (elements) => {
73
+ let firstEnabledIndex = -1;
74
+ const firstSelectedIndex = elements == null ? void 0 : elements.findIndex(
75
+ (el, index) => {
76
+ if (!elements[firstEnabledIndex] && !el.disabled) {
77
+ firstEnabledIndex = index;
78
+ }
79
+ return el.selected && !el.disabled;
80
+ }
81
+ );
82
+ return elements && firstSelectedIndex && elements[firstSelectedIndex] ? firstSelectedIndex : firstEnabledIndex;
83
+ },
84
+ elements: () => this.childItems,
85
+ isFocusableElement: this.isFocusableElement.bind(this),
86
+ hostDelegatesFocus: true
87
+ });
88
+ }
78
89
  this.addEventListener(
79
90
  "sp-menu-item-added-or-updated",
80
91
  this.onSelectableItemAddedOrUpdated
@@ -87,9 +98,9 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
87
98
  }
88
99
  );
89
100
  this.addEventListener("click", this.handleClick);
101
+ this.addEventListener("focusout", this.handleFocusout);
102
+ this.addEventListener("sp-menu-item-keydown", this.handleKeydown);
90
103
  this.addEventListener("pointerup", this.handlePointerup);
91
- this.addEventListener("focusin", this.handleFocusin);
92
- this.addEventListener("blur", this.handleBlur);
93
104
  this.addEventListener("sp-opened", this.handleSubmenuOpened);
94
105
  this.addEventListener("sp-closed", this.handleSubmenuClosed);
95
106
  }
@@ -100,7 +111,7 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
100
111
  return this.slot === "submenu";
101
112
  }
102
113
  get selected() {
103
- return this._selected;
114
+ return !this.selects ? [] : this._selected;
104
115
  }
105
116
  set selected(selected) {
106
117
  if (selected === this.selected) {
@@ -122,6 +133,16 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
122
133
  });
123
134
  this.requestUpdate("selected", old);
124
135
  }
136
+ get focusInItem() {
137
+ var _a;
138
+ return (_a = this.rovingTabindexController) == null ? void 0 : _a.focusInElement;
139
+ }
140
+ get controlsRovingTabindex() {
141
+ return true;
142
+ }
143
+ /**
144
+ * child items managed by menu
145
+ */
125
146
  get childItems() {
126
147
  if (!this.cachedChildItems) {
127
148
  this.cachedChildItems = this.updateCachedMenuItems();
@@ -129,16 +150,17 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
129
150
  return this.cachedChildItems;
130
151
  }
131
152
  updateCachedMenuItems() {
132
- this.cachedChildItems = [];
153
+ var _a;
133
154
  if (!this.menuSlot) {
134
155
  return [];
135
156
  }
157
+ const itemsList = [];
136
158
  const slottedElements = this.menuSlot.assignedElements({
137
159
  flatten: true
138
160
  });
139
161
  for (const [i, slottedElement] of slottedElements.entries()) {
140
162
  if (this.childItemSet.has(slottedElement)) {
141
- this.cachedChildItems.push(slottedElement);
163
+ itemsList.push(slottedElement);
142
164
  continue;
143
165
  }
144
166
  const isHTMLSlotElement = slottedElement.localName === "slot";
@@ -152,6 +174,8 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
152
174
  ...flattenedChildren
153
175
  );
154
176
  }
177
+ this.cachedChildItems = [...itemsList];
178
+ (_a = this.rovingTabindexController) == null ? void 0 : _a.clearElementCache();
155
179
  return this.cachedChildItems;
156
180
  }
157
181
  /**
@@ -206,9 +230,6 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
206
230
  const cascadeData = event.menuCascade.get(this);
207
231
  if (!cascadeData) return;
208
232
  event.item.menuData.parentMenu = event.item.menuData.parentMenu || this;
209
- if (cascadeData.hadFocusRoot && !this.ignore) {
210
- this.tabIndex = -1;
211
- }
212
233
  this.addChildItem(event.item);
213
234
  if (this.selects === "inherit") {
214
235
  this.resolvedSelects = "inherit";
@@ -221,6 +242,9 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
221
242
  this.resolvedRole = this.ignore ? "none" : this.getAttribute("role") || void 0;
222
243
  this.resolvedSelects = this.resolvedRole === "none" ? "ignore" : "none";
223
244
  }
245
+ if (this.resolvedRole === "none") {
246
+ return;
247
+ }
224
248
  const selects = this.resolvedSelects === "single" || this.resolvedSelects === "multiple";
225
249
  event.item.menuData.cleanupSteps.push(
226
250
  (item) => this.removeChildItem(item)
@@ -241,30 +265,50 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
241
265
  this.handleItemsChanged();
242
266
  }
243
267
  async removeChildItem(item) {
268
+ if (item.focused || item.hasAttribute("focused") || item.active) {
269
+ this._updateFocus = this.getNeighboringFocusableElement(item);
270
+ }
244
271
  this.childItemSet.delete(item);
245
272
  this.cachedChildItems = void 0;
246
- if (item.focused) {
247
- this.handleItemsChanged();
248
- await this.updateComplete;
249
- this.focus();
250
- }
251
273
  }
252
- focus({ preventScroll } = {}) {
253
- if (!this.childItems.length || this.childItems.every((childItem) => childItem.disabled)) {
254
- return;
255
- }
256
- if (this.childItems.some(
257
- (childItem) => childItem.menuData.focusRoot !== this
258
- )) {
259
- super.focus({ preventScroll });
274
+ /**
275
+ * for picker elements, will set focus on first selected item
276
+ */
277
+ focusOnFirstSelectedItem({
278
+ preventScroll
279
+ } = {}) {
280
+ var _a;
281
+ if (!this.rovingTabindexController) return;
282
+ const selectedItem = this.selectedItems.find(
283
+ (el) => this.isFocusableElement(el)
284
+ );
285
+ if (!selectedItem) {
286
+ this.focus({ preventScroll });
260
287
  return;
261
288
  }
262
- this.focusMenuItemByOffset(0);
263
- super.focus({ preventScroll });
264
- const selectedItem = this.selectedItems[0];
265
289
  if (selectedItem && !preventScroll) {
266
290
  selectedItem.scrollIntoView({ block: "nearest" });
267
291
  }
292
+ (_a = this.rovingTabindexController) == null ? void 0 : _a.focusOnItem(selectedItem);
293
+ }
294
+ focus({ preventScroll } = {}) {
295
+ if (this.rovingTabindexController) {
296
+ if (!this.childItems.length || this.childItems.every((childItem) => childItem.disabled)) {
297
+ return;
298
+ }
299
+ if (this.childItems.some(
300
+ (childItem) => childItem.menuData.focusRoot !== this
301
+ )) {
302
+ super.focus({ preventScroll });
303
+ return;
304
+ }
305
+ this.rovingTabindexController.focus({ preventScroll });
306
+ }
307
+ }
308
+ handleFocusout() {
309
+ var _a;
310
+ if (!this.matches(":focus-within"))
311
+ (_a = this.rovingTabindexController) == null ? void 0 : _a.reset();
268
312
  }
269
313
  handleClick(event) {
270
314
  if (this.pointerUpTarget === event.target) {
@@ -315,40 +359,6 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
315
359
  }
316
360
  this.prepareToCleanUp();
317
361
  }
318
- handleFocusin(event) {
319
- var _a;
320
- if (this.childItems.some(
321
- (childItem) => childItem.menuData.focusRoot !== this
322
- )) {
323
- return;
324
- }
325
- const activeElement = this.getRootNode().activeElement;
326
- const selectionRoot = ((_a = this.childItems[this.focusedItemIndex]) == null ? void 0 : _a.menuData.selectionRoot) || this;
327
- if (activeElement !== selectionRoot || event.target !== this) {
328
- selectionRoot.focus({ preventScroll: true });
329
- if (activeElement && this.focusedItemIndex === 0) {
330
- const offset = this.childItems.findIndex(
331
- (childItem) => childItem === activeElement
332
- );
333
- this.focusMenuItemByOffset(Math.max(offset, 0));
334
- }
335
- }
336
- this.startListeningToKeyboard();
337
- }
338
- startListeningToKeyboard() {
339
- this.addEventListener("keydown", this.handleKeydown);
340
- }
341
- handleBlur(event) {
342
- if (elementIsOrContains(this, event.relatedTarget)) {
343
- return;
344
- }
345
- this.stopListeningToKeyboard();
346
- this.childItems.forEach((child) => child.focused = false);
347
- this.removeAttribute("aria-activedescendant");
348
- }
349
- stopListeningToKeyboard() {
350
- this.removeEventListener("keydown", this.handleKeydown);
351
- }
352
362
  handleDescendentOverlayOpened(event) {
353
363
  const target = event.composedPath()[0];
354
364
  if (!target.overlayElement) return;
@@ -362,19 +372,34 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
362
372
  if (!target.overlayElement) return;
363
373
  this.descendentOverlays.delete(target.overlayElement);
364
374
  }
375
+ /**
376
+ * given a menu item, returns the next focusable menu item before or after it;
377
+ * if no menu item is provided, returns the first focusable menu item
378
+ * @param menuItem {MenuItem}
379
+ * @param before {boolean} return the item before; default is false
380
+ * @returns {MenuItem}
381
+ */
382
+ getNeighboringFocusableElement(menuItem, before = false) {
383
+ var _a;
384
+ const diff = before ? -1 : 1;
385
+ const elements = ((_a = this.rovingTabindexController) == null ? void 0 : _a.elements) || [];
386
+ const index = !!menuItem ? elements.indexOf(menuItem) : -1;
387
+ let newIndex = Math.min(Math.max(0, index + diff), elements.length - 1);
388
+ while (!this.isFocusableElement(elements[newIndex]) && 0 < newIndex && newIndex < elements.length - 1) {
389
+ newIndex += diff;
390
+ }
391
+ return !!this.isFocusableElement(elements[newIndex]) ? elements[newIndex] : menuItem || elements[0];
392
+ }
365
393
  async selectOrToggleItem(targetItem) {
394
+ var _a;
366
395
  const resolvedSelects = this.resolvedSelects;
367
396
  const oldSelectedItemsMap = new Map(this.selectedItemsMap);
368
397
  const oldSelected = this.selected.slice();
369
398
  const oldSelectedItems = this.selectedItems.slice();
370
399
  const oldValue = this.value;
371
- const focusedChild = this.childItems[this.focusedItemIndex];
372
- if (focusedChild) {
373
- focusedChild.focused = false;
374
- focusedChild.active = false;
400
+ if (targetItem.menuData.selectionRoot !== this) {
401
+ return;
375
402
  }
376
- this.focusedItemIndex = this.childItems.indexOf(targetItem);
377
- this.forwardFocusVisibleToItem(targetItem);
378
403
  if (resolvedSelects === "multiple") {
379
404
  if (this.selectedItemsMap.has(targetItem)) {
380
405
  this.selectedItemsMap.delete(targetItem);
@@ -423,49 +448,37 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
423
448
  targetItem.selected = true;
424
449
  } else if (resolvedSelects === "multiple") {
425
450
  targetItem.selected = !targetItem.selected;
451
+ } else if (!targetItem.hasSubmenu && ((_a = targetItem == null ? void 0 : targetItem.menuData) == null ? void 0 : _a.focusRoot) === this) {
452
+ this.dispatchEvent(new Event("close", { bubbles: true }));
426
453
  }
427
454
  }
428
- navigateWithinMenu(event) {
429
- const { key } = event;
430
- const lastFocusedItem = this.childItems[this.focusedItemIndex];
431
- const direction = key === "ArrowDown" ? 1 : -1;
432
- const itemToFocus = this.focusMenuItemByOffset(direction);
433
- if (itemToFocus === lastFocusedItem) {
434
- return;
435
- }
436
- event.preventDefault();
437
- event.stopPropagation();
438
- itemToFocus.scrollIntoView({ block: "nearest" });
439
- }
440
455
  navigateBetweenRelatedMenus(event) {
441
- const { key } = event;
442
- event.stopPropagation();
456
+ const { key, root } = event;
443
457
  const shouldOpenSubmenu = this.isLTR && key === "ArrowRight" || !this.isLTR && key === "ArrowLeft";
444
- const shouldCloseSelfAsSubmenu = this.isLTR && key === "ArrowLeft" || !this.isLTR && key === "ArrowRight";
458
+ const shouldCloseSelfAsSubmenu = this.isLTR && key === "ArrowLeft" || !this.isLTR && key === "ArrowRight" || key === "Escape";
459
+ const lastFocusedItem = root;
445
460
  if (shouldOpenSubmenu) {
446
- const lastFocusedItem = this.childItems[this.focusedItemIndex];
447
461
  if (lastFocusedItem == null ? void 0 : lastFocusedItem.hasSubmenu) {
462
+ event.stopPropagation();
448
463
  lastFocusedItem.openOverlay();
449
464
  }
450
465
  } else if (shouldCloseSelfAsSubmenu && this.isSubmenu) {
466
+ event.stopPropagation();
451
467
  this.dispatchEvent(new Event("close", { bubbles: true }));
452
468
  this.updateSelectedItemIndex();
453
469
  }
454
470
  }
455
471
  handleKeydown(event) {
456
- if (event.defaultPrevented) {
472
+ var _a;
473
+ if (event.defaultPrevented || !this.rovingTabindexController) {
457
474
  return;
458
475
  }
459
- const lastFocusedItem = this.childItems[this.focusedItemIndex];
460
- if (lastFocusedItem) {
461
- lastFocusedItem.focused = true;
462
- }
463
- const { key } = event;
464
- if (event.shiftKey && event.target !== this && this.hasAttribute("tabindex")) {
476
+ const { key, root, shiftKey, target } = event;
477
+ const openSubmenuKey = ["Enter", " "].includes(key);
478
+ if (shiftKey && target !== this && this.hasAttribute("tabindex")) {
465
479
  this.removeAttribute("tabindex");
466
480
  const replaceTabindex = (event2) => {
467
481
  if (!event2.shiftKey && !this.hasAttribute("tabindex")) {
468
- this.tabIndex = 0;
469
482
  document.removeEventListener("keyup", replaceTabindex);
470
483
  this.removeEventListener("focusout", replaceTabindex);
471
484
  }
@@ -474,61 +487,33 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
474
487
  this.addEventListener("focusout", replaceTabindex);
475
488
  }
476
489
  if (key === "Tab") {
477
- this.prepareToCleanUp();
490
+ this.closeDescendentOverlays();
478
491
  return;
479
492
  }
480
- if (key === " ") {
481
- if (lastFocusedItem == null ? void 0 : lastFocusedItem.hasSubmenu) {
482
- lastFocusedItem.openOverlay();
483
- return;
484
- }
485
- }
486
- if (key === " " || key === "Enter") {
487
- const childItem = this.childItems[this.focusedItemIndex];
488
- if (childItem && childItem.menuData.selectionRoot === event.target) {
489
- event.preventDefault();
490
- childItem.click();
491
- }
493
+ if (openSubmenuKey && (root == null ? void 0 : root.hasSubmenu) && !root.open) {
494
+ event.preventDefault();
495
+ root.openOverlay();
492
496
  return;
493
497
  }
494
- if (key === "ArrowDown" || key === "ArrowUp") {
495
- const childItem = this.childItems[this.focusedItemIndex];
496
- if (childItem && childItem.menuData.selectionRoot === event.target) {
497
- this.navigateWithinMenu(event);
498
- }
498
+ if (key === " " || key === "Enter") {
499
+ event.preventDefault();
500
+ (_a = root == null ? void 0 : root.focusElement) == null ? void 0 : _a.click();
501
+ if (root) this.selectOrToggleItem(root);
499
502
  return;
500
503
  }
501
504
  this.navigateBetweenRelatedMenus(event);
502
505
  }
503
- focusMenuItemByOffset(offset) {
504
- const step = offset || 1;
505
- const focusedItem = this.childItems[this.focusedItemIndex];
506
- if (focusedItem) {
507
- focusedItem.focused = false;
508
- focusedItem.active = focusedItem.open;
509
- }
510
- this.focusedItemIndex = (this.childItems.length + this.focusedItemIndex + offset) % this.childItems.length;
511
- let itemToFocus = this.childItems[this.focusedItemIndex];
512
- let availableItems = this.childItems.length;
513
- while ((itemToFocus == null ? void 0 : itemToFocus.disabled) && availableItems) {
514
- availableItems -= 1;
515
- this.focusedItemIndex = (this.childItems.length + this.focusedItemIndex + step) % this.childItems.length;
516
- itemToFocus = this.childItems[this.focusedItemIndex];
517
- }
518
- if (!(itemToFocus == null ? void 0 : itemToFocus.disabled)) {
519
- this.forwardFocusVisibleToItem(itemToFocus);
520
- }
521
- return itemToFocus;
522
- }
506
+ /**
507
+ * on focus, removes focus from focus styling item, and updates the selected item index
508
+ */
523
509
  prepareToCleanUp() {
524
510
  document.addEventListener(
525
511
  "focusout",
526
512
  () => {
527
513
  requestAnimationFrame(() => {
528
- const focusedItem = this.childItems[this.focusedItemIndex];
514
+ const focusedItem = this.focusInItem;
529
515
  if (focusedItem) {
530
516
  focusedItem.focused = false;
531
- this.updateSelectedItemIndex();
532
517
  }
533
518
  });
534
519
  },
@@ -556,11 +541,6 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
556
541
  }
557
542
  }
558
543
  }
559
- selectedItems.map((item, i) => {
560
- if (i > 0) {
561
- item.focused = false;
562
- }
563
- });
564
544
  this.selectedItemsMap = selectedItemsMap;
565
545
  this._selected = selected;
566
546
  this.selectedItems = selectedItems;
@@ -591,13 +571,11 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
591
571
  this._willUpdateItems = false;
592
572
  }
593
573
  updateItemFocus() {
574
+ var _a;
575
+ (_a = this.focusInItem) == null ? void 0 : _a.setAttribute("tabindex", "0");
594
576
  if (this.childItems.length == 0) {
595
577
  return;
596
578
  }
597
- const focusInItem = this.childItems[this.focusInItemIndex];
598
- if (this.getRootNode().activeElement === focusInItem.menuData.focusRoot) {
599
- this.forwardFocusVisibleToItem(focusInItem);
600
- }
601
579
  }
602
580
  closeDescendentOverlays() {
603
581
  this.descendentOverlays.forEach((overlay) => {
@@ -605,23 +583,10 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
605
583
  });
606
584
  this.descendentOverlays = /* @__PURE__ */ new Map();
607
585
  }
608
- forwardFocusVisibleToItem(item) {
609
- if (!item || item.menuData.focusRoot !== this) {
610
- return;
611
- }
612
- this.closeDescendentOverlays();
613
- const focused = this.hasVisibleFocusInTree() || !!this.childItems.find((child) => {
614
- return child.hasVisibleFocusInTree();
615
- });
616
- item.focused = focused;
617
- this.setAttribute("aria-activedescendant", item.id);
618
- if (item.menuData.selectionRoot && item.menuData.selectionRoot !== this) {
619
- item.menuData.selectionRoot.focus();
620
- }
621
- }
622
586
  handleSlotchange({
623
587
  target
624
588
  }) {
589
+ var _a;
625
590
  const assignedElements = target.assignedElements({
626
591
  flatten: true
627
592
  });
@@ -636,6 +601,10 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
636
601
  }
637
602
  });
638
603
  }
604
+ if (!!this._updateFocus) {
605
+ (_a = this.rovingTabindexController) == null ? void 0 : _a.focusOnItem(this._updateFocus);
606
+ this._updateFocus = void 0;
607
+ }
639
608
  }
640
609
  renderMenuItemSlot() {
641
610
  return html`
@@ -651,14 +620,6 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
651
620
  }
652
621
  firstUpdated(changed) {
653
622
  super.firstUpdated(changed);
654
- if (!this.hasAttribute("tabindex") && !this.ignore) {
655
- const role = this.getAttribute("role");
656
- if (role === "group") {
657
- this.tabIndex = -1;
658
- } else {
659
- this.tabIndex = 0;
660
- }
661
- }
662
623
  const updates = [
663
624
  new Promise((res) => requestAnimationFrame(() => res(true)))
664
625
  ];
@@ -698,6 +659,9 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
698
659
  }
699
660
  this.updateComplete.then(() => this.updateItemFocus());
700
661
  }
662
+ isFocusableElement(el) {
663
+ return el ? !el.disabled : false;
664
+ }
701
665
  disconnectedCallback() {
702
666
  this.cachedChildItems = void 0;
703
667
  this.selectedItems = [];
@@ -713,6 +677,10 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
713
677
  return complete;
714
678
  }
715
679
  }
680
+ Menu.shadowRootOptions = {
681
+ ...SpectrumElement.shadowRootOptions,
682
+ delegatesFocus: true
683
+ };
716
684
  __decorateClass([
717
685
  property({ type: String, reflect: true })
718
686
  ], Menu.prototype, "label", 2);