@spectrum-web-components/menu 0.36.0 → 0.37.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.
@@ -26,85 +26,39 @@ import { LikeAnchor } from "@spectrum-web-components/shared/src/like-anchor.js";
26
26
  import { Focusable } from "@spectrum-web-components/shared/src/focusable.js";
27
27
  import "@spectrum-web-components/icons-ui/icons/sp-icon-chevron100.js";
28
28
  import chevronStyles from "@spectrum-web-components/icon/src/spectrum-icon-chevron.css.js";
29
- import { openOverlay } from "@spectrum-web-components/overlay/src/loader.js";
30
- import { OverlayCloseEvent } from "@spectrum-web-components/overlay/src/overlay-events.js";
31
29
  import menuItemStyles from "./menu-item.css.js";
32
30
  import checkmarkStyles from "@spectrum-web-components/icon/src/spectrum-icon-checkmark.css.js";
33
- import { reparentChildren } from "@spectrum-web-components/shared/src/reparent-children.js";
34
31
  import { MutationController } from "@lit-labs/observers/mutation-controller.js";
35
32
  const POINTERLEAVE_TIMEOUT = 100;
36
- export class MenuItemRemovedEvent extends Event {
37
- constructor() {
38
- super("sp-menu-item-removed", {
39
- bubbles: true,
40
- composed: true
41
- });
42
- this.focused = false;
43
- }
44
- get item() {
45
- return this._item;
46
- }
47
- reset(item) {
48
- this._item = item;
49
- }
50
- }
51
33
  export class MenuItemAddedOrUpdatedEvent extends Event {
52
- constructor() {
34
+ constructor(item) {
53
35
  super("sp-menu-item-added-or-updated", {
54
36
  bubbles: true,
55
37
  composed: true
56
38
  });
39
+ this.menuCascade = /* @__PURE__ */ new WeakMap();
40
+ this.clear(item);
57
41
  }
58
- set focusRoot(root) {
59
- this.item.menuData.focusRoot = this.item.menuData.focusRoot || root;
60
- }
61
- set selectionRoot(root) {
62
- this.item.menuData.selectionRoot = this.item.menuData.selectionRoot || root;
63
- }
64
- get item() {
65
- return this._item;
66
- }
67
- set currentAncestorWithSelects(ancestor) {
68
- this._currentAncestorWithSelects = ancestor;
69
- }
70
- get currentAncestorWithSelects() {
71
- return this._currentAncestorWithSelects;
72
- }
73
- reset(item) {
42
+ clear(item) {
74
43
  this._item = item;
75
- this._currentAncestorWithSelects = void 0;
44
+ this.currentAncestorWithSelects = void 0;
76
45
  item.menuData = {
46
+ cleanupSteps: [],
77
47
  focusRoot: void 0,
78
- selectionRoot: void 0
48
+ selectionRoot: void 0,
49
+ parentMenu: void 0
79
50
  };
51
+ this.menuCascade = /* @__PURE__ */ new WeakMap();
80
52
  }
81
- }
82
- let addOrUpdateEvent = new MenuItemAddedOrUpdatedEvent();
83
- let removeEvent = new MenuItemRemovedEvent();
84
- let addOrUpdateEventRafId = 0;
85
- function resetAddOrUpdateEvent() {
86
- if (addOrUpdateEventRafId === 0) {
87
- addOrUpdateEventRafId = requestAnimationFrame(() => {
88
- addOrUpdateEvent = new MenuItemAddedOrUpdatedEvent();
89
- addOrUpdateEventRafId = 0;
90
- });
91
- }
92
- }
93
- let removeEventEventtRafId = 0;
94
- function resetRemoveEvent() {
95
- if (removeEventEventtRafId === 0) {
96
- removeEventEventtRafId = requestAnimationFrame(() => {
97
- removeEvent = new MenuItemRemovedEvent();
98
- removeEventEventtRafId = 0;
99
- });
53
+ get item() {
54
+ return this._item;
100
55
  }
101
56
  }
102
- const _MenuItem = class _MenuItem extends LikeAnchor(
57
+ export class MenuItem extends LikeAnchor(
103
58
  ObserveSlotText(ObserveSlotPresence(Focusable, '[slot="icon"]'))
104
59
  ) {
105
60
  constructor() {
106
61
  super();
107
- this.isInSubmenu = false;
108
62
  this.active = false;
109
63
  this.focused = false;
110
64
  this.selected = false;
@@ -112,28 +66,27 @@ const _MenuItem = class _MenuItem extends LikeAnchor(
112
66
  this.hasSubmenu = false;
113
67
  this.noWrap = false;
114
68
  this.open = false;
115
- /**
116
- * When there is a `change` event in the submenu for this item
117
- * then we "click" this item to cascade the selection up the
118
- * menu tree allowing all submenus between the initial selection
119
- * and the root of the tree to have their selection changes and
120
- * be closed.
121
- */
122
- this.handleSubmenuChange = () => {
123
- var _a;
124
- (_a = this.menuData.selectionRoot) == null ? void 0 : _a.selectOrToggleItem(this);
69
+ this.proxyFocus = () => {
70
+ this.focus();
125
71
  };
126
- this.handleSubmenuPointerenter = () => {
127
- if (this.leaveTimeout) {
128
- clearTimeout(this.leaveTimeout);
129
- delete this.leaveTimeout;
72
+ this.handleBeforetoggle = (event) => {
73
+ if (event.newState === "closed") {
74
+ this.open = true;
75
+ this.overlayElement.manuallyKeepOpen();
76
+ this.overlayElement.removeEventListener(
77
+ "beforetoggle",
78
+ this.handleBeforetoggle
79
+ );
130
80
  }
131
81
  };
82
+ this.recentlyLeftChild = false;
83
+ this.willDispatchUpdate = false;
132
84
  this.menuData = {
133
85
  focusRoot: void 0,
134
- selectionRoot: void 0
86
+ parentMenu: void 0,
87
+ selectionRoot: void 0,
88
+ cleanupSteps: []
135
89
  };
136
- this.proxyFocus = this.proxyFocus.bind(this);
137
90
  this.addEventListener("click", this.handleClickCapture, {
138
91
  capture: true
139
92
  });
@@ -181,23 +134,16 @@ const _MenuItem = class _MenuItem extends LikeAnchor(
181
134
  return this.slotContentIsPresent;
182
135
  }
183
136
  get itemChildren() {
184
- var _a, _b;
185
137
  if (this._itemChildren) {
186
138
  return this._itemChildren;
187
139
  }
188
- const iconSlot = (_a = this.shadowRoot) == null ? void 0 : _a.querySelector(
189
- 'slot[name="icon"]'
190
- );
191
- const icon = !iconSlot ? [] : iconSlot.assignedElements().map((element) => {
140
+ const icon = this.iconSlot.assignedElements().map((element) => {
192
141
  const newElement = element.cloneNode(true);
193
142
  newElement.removeAttribute("slot");
194
143
  newElement.classList.toggle("icon");
195
144
  return newElement;
196
145
  });
197
- const contentSlot = (_b = this.shadowRoot) == null ? void 0 : _b.querySelector(
198
- "slot:not([name])"
199
- );
200
- const content = !contentSlot ? [] : contentSlot.assignedNodes().map((node) => node.cloneNode(true));
146
+ const content = this.contentSlot.assignedNodes().map((node) => node.cloneNode(true));
201
147
  this._itemChildren = { icon, content };
202
148
  return this._itemChildren;
203
149
  }
@@ -218,9 +164,6 @@ const _MenuItem = class _MenuItem extends LikeAnchor(
218
164
  return false;
219
165
  }
220
166
  }
221
- proxyFocus() {
222
- this.focus();
223
- }
224
167
  shouldProxyClick() {
225
168
  let handled = false;
226
169
  if (this.anchorElement) {
@@ -233,6 +176,52 @@ const _MenuItem = class _MenuItem extends LikeAnchor(
233
176
  this._itemChildren = void 0;
234
177
  this.triggerUpdate();
235
178
  }
179
+ renderSubmenu() {
180
+ const slot = html`
181
+ <slot
182
+ name="submenu"
183
+ @slotchange=${this.manageSubmenu}
184
+ @sp-menu-item-added-or-updated=${{
185
+ handleEvent: (event) => {
186
+ event.clear(event.item);
187
+ },
188
+ capture: true
189
+ }}
190
+ @focusin=${(event) => event.stopPropagation()}
191
+ ></slot>
192
+ `;
193
+ if (!this.hasSubmenu) {
194
+ return slot;
195
+ }
196
+ import("@spectrum-web-components/overlay/sp-overlay.js");
197
+ import("@spectrum-web-components/popover/sp-popover.js");
198
+ return html`
199
+ <sp-overlay
200
+ .triggerElement=${this}
201
+ ?disabled=${!this.hasSubmenu}
202
+ ?open=${this.hasSubmenu && this.open}
203
+ .placement=${this.isLTR ? "right-start" : "left-start"}
204
+ .offset=${[-10, -4]}
205
+ .type=${"auto"}
206
+ @close=${(event) => event.stopPropagation()}
207
+ >
208
+ <sp-popover
209
+ @change=${(event) => {
210
+ this.handleSubmenuChange(event);
211
+ this.open = false;
212
+ }}
213
+ @pointerenter=${this.handleSubmenuPointerenter}
214
+ @pointerleave=${this.handleSubmenuPointerleave}
215
+ @sp-menu-item-added-or-updated=${(event) => event.stopPropagation()}
216
+ >
217
+ ${slot}
218
+ </sp-popover>
219
+ </sp-overlay>
220
+ <sp-icon-chevron100
221
+ class="spectrum-UIIcon-ChevronRight100 chevron icon"
222
+ ></sp-icon-chevron100>
223
+ `;
224
+ }
236
225
  render() {
237
226
  return html`
238
227
  ${this.selected ? html`
@@ -254,59 +243,62 @@ const _MenuItem = class _MenuItem extends LikeAnchor(
254
243
  ariaHidden: true,
255
244
  className: "button anchor hidden"
256
245
  }) : html``}
257
-
258
- <slot
259
- hidden
260
- name="submenu"
261
- @slotchange=${this.manageSubmenu}
262
- ></slot>
263
- ${this.hasSubmenu ? html`
264
- <sp-icon-chevron100
265
- class="spectrum-UIIcon-ChevronRight100
266
- chevron
267
- icon"
268
- ></sp-icon-chevron100>
269
- ` : html``}
246
+ ${this.renderSubmenu()}
270
247
  `;
271
248
  }
272
249
  manageSubmenu(event) {
273
250
  const assignedElements = event.target.assignedElements({
274
251
  flatten: true
275
252
  });
276
- this.hasSubmenu = this.open || !!assignedElements;
253
+ this.hasSubmenu = !!assignedElements.length;
277
254
  if (this.hasSubmenu) {
278
255
  this.setAttribute("aria-haspopup", "true");
279
256
  }
280
257
  }
281
- handleRemoveActive(event) {
282
- if (event.type === "pointerleave" && this.hasSubmenu || this.hasSubmenu || this.open) {
258
+ handleRemoveActive() {
259
+ if (this.open) {
283
260
  return;
284
261
  }
285
262
  this.active = false;
286
263
  }
287
- handlePointerdown() {
264
+ handlePointerdown(event) {
288
265
  this.active = true;
266
+ if (event.target === this && this.hasSubmenu && this.open) {
267
+ this.addEventListener("focus", this.handleSubmenuFocus, {
268
+ once: true
269
+ });
270
+ this.overlayElement.addEventListener(
271
+ "beforetoggle",
272
+ this.handleBeforetoggle
273
+ );
274
+ }
289
275
  }
290
276
  firstUpdated(changes) {
291
277
  super.firstUpdated(changes);
292
278
  this.setAttribute("tabindex", "-1");
293
279
  this.addEventListener("pointerdown", this.handlePointerdown);
280
+ this.addEventListener("pointerenter", this.closeOverlaysForRoot);
294
281
  if (!this.hasAttribute("id")) {
295
- this.id = `sp-menu-item-${_MenuItem.instanceCount++}`;
282
+ this.id = `sp-menu-item-${crypto.randomUUID().slice(0, 8)}`;
296
283
  }
297
- this.addEventListener("pointerenter", this.closeOverlaysForRoot);
298
284
  }
299
285
  closeOverlaysForRoot() {
286
+ var _a;
300
287
  if (this.open)
301
288
  return;
302
- const overalyCloseEvent = new OverlayCloseEvent({
303
- root: this.menuData.focusRoot
304
- });
305
- this.dispatchEvent(overalyCloseEvent);
289
+ (_a = this.menuData.parentMenu) == null ? void 0 : _a.closeDescendentOverlays();
306
290
  }
307
- handleSubmenuClick() {
291
+ handleSubmenuClick(event) {
292
+ if (event.composedPath().includes(this.overlayElement)) {
293
+ return;
294
+ }
308
295
  this.openOverlay();
309
296
  }
297
+ handleSubmenuFocus() {
298
+ requestAnimationFrame(() => {
299
+ this.overlayElement.open = this.open;
300
+ });
301
+ }
310
302
  handlePointerenter() {
311
303
  if (this.leaveTimeout) {
312
304
  clearTimeout(this.leaveTimeout);
@@ -316,14 +308,44 @@ const _MenuItem = class _MenuItem extends LikeAnchor(
316
308
  this.openOverlay();
317
309
  }
318
310
  handlePointerleave() {
319
- if (this.hasSubmenu && this.open) {
311
+ if (this.open && !this.recentlyLeftChild) {
320
312
  this.leaveTimeout = setTimeout(() => {
321
313
  delete this.leaveTimeout;
322
- if (this.closeOverlay)
323
- this.closeOverlay();
314
+ this.open = false;
324
315
  }, POINTERLEAVE_TIMEOUT);
325
316
  }
326
317
  }
318
+ /**
319
+ * When there is a `change` event in the submenu for this item
320
+ * then we "click" this item to cascade the selection up the
321
+ * menu tree allowing all submenus between the initial selection
322
+ * and the root of the tree to have their selection changes and
323
+ * be closed.
324
+ */
325
+ handleSubmenuChange(event) {
326
+ var _a;
327
+ event.stopPropagation();
328
+ (_a = this.menuData.selectionRoot) == null ? void 0 : _a.selectOrToggleItem(this);
329
+ }
330
+ handleSubmenuPointerenter() {
331
+ this.recentlyLeftChild = true;
332
+ }
333
+ async handleSubmenuPointerleave() {
334
+ requestAnimationFrame(() => {
335
+ this.recentlyLeftChild = false;
336
+ });
337
+ }
338
+ handleSubmenuOpen(event) {
339
+ this.focused = false;
340
+ const parentOverlay = event.composedPath().find((el) => {
341
+ return el !== this.overlayElement && el.localName === "sp-overlay";
342
+ });
343
+ this.overlayElement.parentOverlayToForceClose = parentOverlay;
344
+ }
345
+ cleanup() {
346
+ this.open = false;
347
+ this.active = false;
348
+ }
327
349
  async openOverlay() {
328
350
  if (!this.hasSubmenu || this.open || this.disabled) {
329
351
  return;
@@ -331,55 +353,9 @@ const _MenuItem = class _MenuItem extends LikeAnchor(
331
353
  this.open = true;
332
354
  this.active = true;
333
355
  this.setAttribute("aria-expanded", "true");
334
- const submenu = this.shadowRoot.querySelector(
335
- 'slot[name="submenu"]'
336
- ).assignedElements()[0];
337
- submenu.addEventListener(
338
- "pointerenter",
339
- this.handleSubmenuPointerenter
340
- );
341
- submenu.addEventListener("change", this.handleSubmenuChange);
342
- if (!submenu.id) {
343
- submenu.setAttribute("id", `${this.id}-submenu`);
344
- }
345
- this.setAttribute("aria-controls", submenu.id);
346
- const popover = document.createElement("sp-popover");
347
- const returnSubmenu = reparentChildren([submenu], popover, {
348
- position: "beforeend",
349
- prepareCallback: (el) => {
350
- const slotName = el.slot;
351
- el.tabIndex = 0;
352
- el.removeAttribute("slot");
353
- el.isSubmenu = true;
354
- return (el2) => {
355
- el2.tabIndex = -1;
356
- el2.slot = slotName;
357
- el2.isSubmenu = false;
358
- };
359
- }
360
- });
361
- const closeOverlay = openOverlay(this, "click", popover, {
362
- placement: this.isLTR ? "right-start" : "left-start",
363
- receivesFocus: "auto",
364
- root: this.menuData.focusRoot
365
- });
366
- const closeSubmenu = async () => {
367
- this.setAttribute("aria-expanded", "false");
368
- delete this.closeOverlay;
369
- (await closeOverlay)();
370
- };
371
- this.closeOverlay = closeSubmenu;
372
- const cleanup = (event) => {
373
- event.stopPropagation();
374
- delete this.closeOverlay;
375
- returnSubmenu();
376
- this.open = false;
377
- this.active = false;
378
- };
379
- this.addEventListener("sp-closed", cleanup, {
356
+ this.addEventListener("sp-closed", this.cleanup, {
380
357
  once: true
381
358
  });
382
- popover.addEventListener("change", closeSubmenu);
383
359
  }
384
360
  updateAriaSelected() {
385
361
  const role = this.getAttribute("role");
@@ -397,25 +373,33 @@ const _MenuItem = class _MenuItem extends LikeAnchor(
397
373
  this.updateAriaSelected();
398
374
  }
399
375
  updated(changes) {
376
+ var _a, _b, _c;
400
377
  super.updated(changes);
401
- if (changes.has("label")) {
378
+ if (changes.has("label") && (this.label || typeof changes.get("label") !== "undefined")) {
402
379
  this.setAttribute("aria-label", this.label || "");
403
380
  }
404
- if (changes.has("active")) {
381
+ if (changes.has("active") && (this.active || typeof changes.get("active") !== "undefined")) {
405
382
  if (this.active) {
406
- this.addEventListener("pointerup", this.handleRemoveActive);
407
- this.addEventListener("pointerleave", this.handleRemoveActive);
408
- this.addEventListener("pointercancel", this.handleRemoveActive);
409
- } else {
410
- this.removeEventListener("pointerup", this.handleRemoveActive);
411
- this.removeEventListener(
383
+ (_a = this.menuData.selectionRoot) == null ? void 0 : _a.closeDescendentOverlays();
384
+ this.abortControllerPointer = new AbortController();
385
+ const options = { signal: this.abortControllerPointer.signal };
386
+ this.addEventListener(
387
+ "pointerup",
388
+ this.handleRemoveActive,
389
+ options
390
+ );
391
+ this.addEventListener(
412
392
  "pointerleave",
413
- this.handleRemoveActive
393
+ this.handleRemoveActive,
394
+ options
414
395
  );
415
- this.removeEventListener(
396
+ this.addEventListener(
416
397
  "pointercancel",
417
- this.handleRemoveActive
398
+ this.handleRemoveActive,
399
+ options
418
400
  );
401
+ } else {
402
+ (_b = this.abortControllerPointer) == null ? void 0 : _b.abort();
419
403
  }
420
404
  }
421
405
  if (this.anchorElement) {
@@ -425,71 +409,77 @@ const _MenuItem = class _MenuItem extends LikeAnchor(
425
409
  if (changes.has("selected")) {
426
410
  this.updateAriaSelected();
427
411
  }
428
- if (changes.has("hasSubmenu")) {
412
+ if (changes.has("hasSubmenu") && (this.hasSubmenu || typeof changes.get("hasSubmenu") !== "undefined")) {
429
413
  if (this.hasSubmenu) {
430
- this.addEventListener("click", this.handleSubmenuClick);
431
- this.addEventListener("pointerenter", this.handlePointerenter);
432
- this.addEventListener("pointerleave", this.handlePointerleave);
433
- } else if (!this.closeOverlay) {
434
- this.removeEventListener("click", this.handleSubmenuClick);
435
- this.removeEventListener(
414
+ this.abortControllerSubmenu = new AbortController();
415
+ const options = { signal: this.abortControllerSubmenu.signal };
416
+ this.addEventListener(
417
+ "click",
418
+ this.handleSubmenuClick,
419
+ options
420
+ );
421
+ this.addEventListener(
436
422
  "pointerenter",
437
- this.handlePointerenter
423
+ this.handlePointerenter,
424
+ options
438
425
  );
439
- this.removeEventListener(
426
+ this.addEventListener(
440
427
  "pointerleave",
441
- this.handlePointerleave
428
+ this.handlePointerleave,
429
+ options
430
+ );
431
+ this.addEventListener(
432
+ "sp-opened",
433
+ this.handleSubmenuOpen,
434
+ options
442
435
  );
436
+ } else {
437
+ (_c = this.abortControllerSubmenu) == null ? void 0 : _c.abort();
443
438
  }
444
439
  }
445
440
  }
446
441
  connectedCallback() {
447
442
  super.connectedCallback();
448
- this.isInSubmenu = !!this.closest('[slot="submenu"]');
449
- if (this.isInSubmenu) {
450
- return;
451
- }
452
- addOrUpdateEvent.reset(this);
453
- this.dispatchEvent(addOrUpdateEvent);
454
- resetAddOrUpdateEvent();
455
- this._parentElement = this.parentElement;
443
+ this.triggerUpdate();
456
444
  }
457
445
  disconnectedCallback() {
458
- if (!this.isInSubmenu && this._parentElement) {
459
- removeEvent.reset(this);
460
- this._parentElement.dispatchEvent(removeEvent);
461
- resetRemoveEvent();
462
- }
463
- this.isInSubmenu = false;
464
- this._itemChildren = void 0;
446
+ this.menuData.cleanupSteps.forEach((removal) => removal(this));
465
447
  super.disconnectedCallback();
466
448
  }
467
449
  async triggerUpdate() {
468
- if (this.isInSubmenu) {
450
+ if (this.willDispatchUpdate) {
469
451
  return;
470
452
  }
453
+ this.willDispatchUpdate = true;
471
454
  await new Promise((ready) => requestAnimationFrame(ready));
472
- addOrUpdateEvent.reset(this);
473
- this.dispatchEvent(addOrUpdateEvent);
474
- resetAddOrUpdateEvent();
455
+ this.dispatchUpdate();
475
456
  }
476
- };
477
- _MenuItem.instanceCount = 0;
457
+ dispatchUpdate() {
458
+ this.dispatchEvent(new MenuItemAddedOrUpdatedEvent(this));
459
+ this.willDispatchUpdate = false;
460
+ }
461
+ }
478
462
  __decorateClass([
479
463
  property({ type: Boolean, reflect: true })
480
- ], _MenuItem.prototype, "active", 2);
464
+ ], MenuItem.prototype, "active", 2);
481
465
  __decorateClass([
482
466
  property({ type: Boolean, reflect: true })
483
- ], _MenuItem.prototype, "focused", 2);
467
+ ], MenuItem.prototype, "focused", 2);
484
468
  __decorateClass([
485
469
  property({ type: Boolean, reflect: true })
486
- ], _MenuItem.prototype, "selected", 2);
470
+ ], MenuItem.prototype, "selected", 2);
487
471
  __decorateClass([
488
472
  property({ type: String })
489
- ], _MenuItem.prototype, "value", 1);
473
+ ], MenuItem.prototype, "value", 1);
490
474
  __decorateClass([
491
475
  property({ type: Boolean, reflect: true, attribute: "has-submenu" })
492
- ], _MenuItem.prototype, "hasSubmenu", 2);
476
+ ], MenuItem.prototype, "hasSubmenu", 2);
477
+ __decorateClass([
478
+ query("slot:not([name])")
479
+ ], MenuItem.prototype, "contentSlot", 2);
480
+ __decorateClass([
481
+ query('slot[name="icon"]')
482
+ ], MenuItem.prototype, "iconSlot", 2);
493
483
  __decorateClass([
494
484
  property({
495
485
  type: Boolean,
@@ -499,12 +489,14 @@ __decorateClass([
499
489
  return false;
500
490
  }
501
491
  })
502
- ], _MenuItem.prototype, "noWrap", 2);
492
+ ], MenuItem.prototype, "noWrap", 2);
503
493
  __decorateClass([
504
494
  query(".anchor")
505
- ], _MenuItem.prototype, "anchorElement", 2);
495
+ ], MenuItem.prototype, "anchorElement", 2);
496
+ __decorateClass([
497
+ query("sp-overlay")
498
+ ], MenuItem.prototype, "overlayElement", 2);
506
499
  __decorateClass([
507
- property({ type: Boolean })
508
- ], _MenuItem.prototype, "open", 2);
509
- export let MenuItem = _MenuItem;
500
+ property({ type: Boolean, reflect: true })
501
+ ], MenuItem.prototype, "open", 2);
510
502
  //# sourceMappingURL=MenuItem.dev.js.map