@spectrum-web-components/menu 1.12.0-testing.20260223092154 → 1.12.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
@@ -11,15 +11,20 @@ var __decorateClass = (decorators, target, key, kind) => {
11
11
  };
12
12
  import {
13
13
  html,
14
+ render,
14
15
  SizedMixin,
15
16
  SpectrumElement
16
17
  } from "@spectrum-web-components/base";
17
18
  import {
18
19
  property,
19
- query
20
+ query,
21
+ state
20
22
  } from "@spectrum-web-components/base/src/decorators.js";
21
23
  import { RovingTabindexController } from "@spectrum-web-components/reactive-controllers/src/RovingTabindex.js";
24
+ import "@spectrum-web-components/icons-ui/icons/sp-icon-arrow500.js";
25
+ import "../sp-menu-divider.dev.js";
22
26
  import menuStyles from "./menu.css.js";
27
+ import { MenuItem } from "./MenuItem.dev.js";
23
28
  export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
24
29
  constructor() {
25
30
  super();
@@ -81,8 +86,78 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
81
86
  * @see {@link packages/overlay/src/LongpressController.ts} for longpress duration
82
87
  */
83
88
  this.scrollTimeThreshold = 300;
89
+ /**
90
+ * Capture-phase keydown handler that overrides the default
91
+ * `RovingTabindexController` wrap behavior at the mobile drill-down
92
+ * boundary:
93
+ * - ArrowUp on the first nested item moves focus to the back row.
94
+ * - ArrowDown on the back row moves focus to the first nested item.
95
+ *
96
+ * Runs in the capture phase so it preempts the projected submenu's
97
+ * controller, which handles arrow keys in the bubble phase.
98
+ */
99
+ this.handleMobileDrilldownKeydownCapture = (event) => {
100
+ if (event.defaultPrevented || !this.rovingTabindexController) {
101
+ return;
102
+ }
103
+ const { key, target } = event;
104
+ if (!(target instanceof MenuItem)) {
105
+ return;
106
+ }
107
+ const mobileRoot = this._mobileViewRoot;
108
+ if ((mobileRoot == null ? void 0 : mobileRoot.currentMobileSubmenu) && key === "ArrowUp") {
109
+ const items = this.childItems.filter((c) => this.isFocusableElement(c));
110
+ const firstNonBack = items.find(
111
+ (c) => !c.classList.contains("mobile-back-button")
112
+ );
113
+ if (firstNonBack === target) {
114
+ event.preventDefault();
115
+ event.stopImmediatePropagation();
116
+ mobileRoot._focusMobileBackRow();
117
+ }
118
+ return;
119
+ }
120
+ if (this.mobileView && this._mobileSubmenuStack.length > 0 && key === "ArrowDown" && target.classList.contains("mobile-back-button")) {
121
+ event.preventDefault();
122
+ event.stopImmediatePropagation();
123
+ this._focusFirstItemInCurrentNestedSubmenu();
124
+ }
125
+ };
126
+ /**
127
+ * Cleans up the `mobile-transition` attribute once the CSS slide
128
+ * animation finishes, preventing the animation from replaying on
129
+ * subsequent layout changes. Bound declaratively via `@animationend`
130
+ * on the wrapper, so it only fires for that element.
131
+ */
132
+ this._handleAnimationEnd = (event) => {
133
+ const target = event.currentTarget;
134
+ target == null ? void 0 : target.removeAttribute("mobile-transition");
135
+ };
136
+ /**
137
+ * Handles click on the mobile back button. Stops the event from
138
+ * reaching parent menus and closes the current mobile submenu.
139
+ */
140
+ this.handleMobileBackClick = (event) => {
141
+ event.stopPropagation();
142
+ event.preventDefault();
143
+ this.closeMobileSubmenu();
144
+ };
145
+ /**
146
+ * Maps each projected submenu element to its Lit render container so
147
+ * that back button elements can be individually cleaned up when a
148
+ * submenu is restored, without affecting other levels in the stack.
149
+ */
150
+ this._mobileBackContainers = /* @__PURE__ */ new Map();
151
+ /**
152
+ * Maps each projected submenu element to its original parent so the
153
+ * element can be moved back when the submenu is closed.
154
+ */
155
+ this._mobileSubmenuOriginalParents = /* @__PURE__ */ new Map();
84
156
  this.label = "";
85
157
  this.ignore = false;
158
+ this.mobileView = false;
159
+ this.mobileBackLabel = "Back";
160
+ this._mobileSubmenuStack = [];
86
161
  this.value = "";
87
162
  this.valueSeparator = ",";
88
163
  this._selected = [];
@@ -104,6 +179,10 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
104
179
  this.descendentOverlays = /* @__PURE__ */ new Map();
105
180
  this.handleSubmenuClosed = (event) => {
106
181
  event.stopPropagation();
182
+ if (this.mobileView) {
183
+ this.resetMobileSubmenus();
184
+ return;
185
+ }
107
186
  const target = event.composedPath()[0];
108
187
  target.dispatchEvent(
109
188
  new Event("sp-menu-submenu-closed", {
@@ -170,6 +249,11 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
170
249
  this.addEventListener("touchend", this.handlePointerup);
171
250
  this.addEventListener("focusout", this.handleFocusout);
172
251
  this.addEventListener("sp-menu-item-keydown", this.handleKeydown);
252
+ this.addEventListener(
253
+ "keydown",
254
+ this.handleMobileDrilldownKeydownCapture,
255
+ true
256
+ );
173
257
  this.addEventListener("pointerup", this.handlePointerup);
174
258
  this.addEventListener("sp-opened", this.handleSubmenuOpened);
175
259
  this.addEventListener("sp-closed", this.handleSubmenuClosed);
@@ -186,6 +270,12 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
186
270
  get isSubmenu() {
187
271
  return this.slot === "submenu";
188
272
  }
273
+ asMenu(element) {
274
+ return element;
275
+ }
276
+ get _mobileViewRoot() {
277
+ return this.closest("sp-menu[mobile-view]");
278
+ }
189
279
  // milliseconds
190
280
  /**
191
281
  * Public getter for scrolling state
@@ -197,6 +287,328 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
197
287
  set isScrolling(value) {
198
288
  this.isCurrentlyScrolling = value;
199
289
  }
290
+ /**
291
+ * Returns the MenuItem whose submenu is currently displayed at the
292
+ * top of the mobile drill-down stack, or `undefined` when no submenu
293
+ * is open.
294
+ */
295
+ get currentMobileSubmenu() {
296
+ return this._mobileSubmenuStack[this._mobileSubmenuStack.length - 1];
297
+ }
298
+ /**
299
+ * Opens a mobile submenu by projecting its content into this menu's
300
+ * light DOM, pushing it onto the submenu stack, triggering the
301
+ * slide-in animation, and focusing the back button.
302
+ *
303
+ * @param item - The MenuItem whose submenu should be opened.
304
+ */
305
+ openMobileSubmenu(item) {
306
+ this._projectMobileSubmenu(item);
307
+ this._mobileSubmenuStack = [...this._mobileSubmenuStack, item];
308
+ this._triggerMobileTransition("forward");
309
+ this._focusProjectedSubmenu(item);
310
+ }
311
+ /**
312
+ * Closes the topmost mobile submenu by restoring it to its original
313
+ * parent MenuItem, popping it from the stack, and either re-focusing
314
+ * the previous submenu's back button or returning focus to the
315
+ * triggering MenuItem when no deeper level remains.
316
+ */
317
+ closeMobileSubmenu() {
318
+ const closedItem = this.currentMobileSubmenu;
319
+ if (closedItem) {
320
+ this._restoreMobileSubmenu(closedItem);
321
+ }
322
+ this._mobileSubmenuStack = this._mobileSubmenuStack.slice(0, -1);
323
+ const previous = this.currentMobileSubmenu;
324
+ if (previous == null ? void 0 : previous.submenuElement) {
325
+ previous.submenuElement.setAttribute("slot", "mobile-submenu");
326
+ this._focusProjectedSubmenu(previous);
327
+ } else if (closedItem) {
328
+ this.updateComplete.then(() => {
329
+ closedItem.focus();
330
+ });
331
+ }
332
+ this._triggerMobileTransition("back");
333
+ }
334
+ /**
335
+ * Focuses the mobile back button inside the given item's projected
336
+ * submenu. Explicitly resets `tabIndex` and `focused` on every other
337
+ * child of the submenu so only the back row is in the tab order
338
+ * after the drill-down opens. We avoid delegating to the projected
339
+ * submenu's `RovingTabindexController` here because the back button
340
+ * is appended dynamically via `render()` and may not yet be in the
341
+ * controller's element cache when this runs, which would cause
342
+ * focus to fall back to the first nested item instead.
343
+ *
344
+ * @param item - The MenuItem whose projected submenu should receive focus.
345
+ */
346
+ async _focusProjectedSubmenu(item) {
347
+ const submenuEl = item.submenuElement;
348
+ if (!submenuEl) {
349
+ return;
350
+ }
351
+ const backItem = submenuEl.querySelector(
352
+ ".mobile-back-button"
353
+ );
354
+ if (!backItem) {
355
+ return;
356
+ }
357
+ await backItem.updateComplete;
358
+ const submenu = this.asMenu(submenuEl);
359
+ await submenu.updateComplete;
360
+ submenu.childItems.forEach((child) => {
361
+ child.tabIndex = -1;
362
+ child.focused = false;
363
+ });
364
+ backItem.tabIndex = 0;
365
+ backItem.focused = true;
366
+ if (submenu.rovingTabindexController) {
367
+ submenu.rovingTabindexController.currentIndex = submenu.childItems.indexOf(backItem);
368
+ }
369
+ backItem.focus();
370
+ }
371
+ /**
372
+ * Focuses the mobile back button of the currently visible projected
373
+ * submenu. Used when the user presses ArrowUp from the first nested
374
+ * item so focus moves to the back row instead of wrapping. Manages
375
+ * `tabIndex`/`focused` explicitly to keep the back row as the only
376
+ * tabbable element in the projected submenu.
377
+ */
378
+ _focusMobileBackRow() {
379
+ var _a;
380
+ const submenuEl = (_a = this.currentMobileSubmenu) == null ? void 0 : _a.submenuElement;
381
+ if (!submenuEl) {
382
+ return;
383
+ }
384
+ const backItem = submenuEl.querySelector(
385
+ ".mobile-back-button"
386
+ );
387
+ if (!backItem) {
388
+ return;
389
+ }
390
+ const submenu = this.asMenu(submenuEl);
391
+ submenu.childItems.forEach((child) => {
392
+ child.tabIndex = -1;
393
+ child.focused = false;
394
+ });
395
+ backItem.tabIndex = 0;
396
+ backItem.focused = true;
397
+ if (submenu.rovingTabindexController) {
398
+ submenu.rovingTabindexController.currentIndex = submenu.childItems.indexOf(backItem);
399
+ }
400
+ backItem.focus();
401
+ }
402
+ /**
403
+ * Focuses the first focusable item inside the currently visible
404
+ * projected submenu, skipping the back row. Used when the user
405
+ * presses ArrowDown from the back row. Manages `tabIndex`/`focused`
406
+ * explicitly so only the focused item is in the tab order.
407
+ */
408
+ _focusFirstItemInCurrentNestedSubmenu() {
409
+ var _a;
410
+ const submenuEl = (_a = this.currentMobileSubmenu) == null ? void 0 : _a.submenuElement;
411
+ if (!submenuEl) {
412
+ return;
413
+ }
414
+ const submenu = this.asMenu(submenuEl);
415
+ const firstItem = submenu.childItems.find(
416
+ (child) => !child.disabled && !child.classList.contains("mobile-back-button")
417
+ );
418
+ if (!firstItem) {
419
+ return;
420
+ }
421
+ submenu.childItems.forEach((child) => {
422
+ child.tabIndex = -1;
423
+ child.focused = false;
424
+ });
425
+ firstItem.tabIndex = 0;
426
+ firstItem.focused = true;
427
+ if (submenu.rovingTabindexController) {
428
+ submenu.rovingTabindexController.currentIndex = submenu.childItems.indexOf(firstItem);
429
+ }
430
+ firstItem.focus();
431
+ }
432
+ /**
433
+ * Triggers a CSS slide animation on the mobile submenu wrapper.
434
+ * Waits for the current Lit update cycle, then sets the
435
+ * `mobile-transition` attribute so the keyframe animation plays.
436
+ *
437
+ * @param direction - `'forward'` slides content in from the right,
438
+ * `'back'` slides content in from the left.
439
+ */
440
+ _triggerMobileTransition(direction) {
441
+ this.updateComplete.then(() => {
442
+ var _a;
443
+ const wrapper = (_a = this.shadowRoot) == null ? void 0 : _a.querySelector(
444
+ ".mobile-submenu-animation-wrapper"
445
+ );
446
+ if (!wrapper) {
447
+ return;
448
+ }
449
+ wrapper.removeAttribute("mobile-transition");
450
+ requestAnimationFrame(() => {
451
+ wrapper.setAttribute("mobile-transition", direction);
452
+ });
453
+ });
454
+ }
455
+ /**
456
+ * Restores every projected submenu back to its original parent and
457
+ * clears the submenu stack. Called during `disconnectedCallback` or
458
+ * when the menu overlay closes to ensure a clean state.
459
+ */
460
+ resetMobileSubmenus() {
461
+ for (let i = this._mobileSubmenuStack.length - 1; i >= 0; i--) {
462
+ this._restoreMobileSubmenu(this._mobileSubmenuStack[i]);
463
+ }
464
+ this._mobileSubmenuStack = [];
465
+ }
466
+ /**
467
+ * Moves the submenu element from its MenuItem parent into this Menu's
468
+ * light DOM with a `mobile-submenu` slot, so it projects through the
469
+ * named slot in the shadow DOM. Any previously visible projected submenu
470
+ * is moved to a non-rendered slot to avoid both showing at once.
471
+ */
472
+ _projectMobileSubmenu(item) {
473
+ var _a;
474
+ const submenuEl = item.submenuElement;
475
+ if (!submenuEl) {
476
+ return;
477
+ }
478
+ const currentlyVisible = (_a = this.currentMobileSubmenu) == null ? void 0 : _a.submenuElement;
479
+ if (currentlyVisible) {
480
+ currentlyVisible.setAttribute("slot", "mobile-submenu-stacked");
481
+ }
482
+ const parentElement = submenuEl.parentElement;
483
+ if (!parentElement) {
484
+ return;
485
+ }
486
+ item._mobileSubmenuProjected = true;
487
+ this._mobileSubmenuOriginalParents.set(submenuEl, parentElement);
488
+ const submenu = this.asMenu(submenuEl);
489
+ const savedChildItems = new Set(submenu.childItemSet);
490
+ submenuEl.setAttribute("slot", "mobile-submenu");
491
+ this.appendChild(submenuEl);
492
+ this._restoreSubmenuChildState(submenu, savedChildItems);
493
+ this._renderMobileBackElements(submenuEl);
494
+ }
495
+ /**
496
+ * Restores the submenu element back to its original MenuItem parent
497
+ * and resets the slot attribute.
498
+ */
499
+ _restoreMobileSubmenu(item) {
500
+ const submenuEl = item.submenuElement;
501
+ if (!submenuEl) {
502
+ return;
503
+ }
504
+ this._removeMobileBackElements(submenuEl);
505
+ const submenu = this.asMenu(submenuEl);
506
+ const originalParent = this._mobileSubmenuOriginalParents.get(submenuEl);
507
+ if (originalParent) {
508
+ const savedChildItems = new Set(submenu.childItemSet);
509
+ submenuEl.setAttribute("slot", "submenu");
510
+ originalParent.appendChild(submenuEl);
511
+ this._mobileSubmenuOriginalParents.delete(submenuEl);
512
+ this._restoreSubmenuChildState(submenu, savedChildItems);
513
+ }
514
+ item._mobileSubmenuProjected = false;
515
+ }
516
+ /**
517
+ * Declaratively renders the mobile back button and divider into the
518
+ * projected submenu element using Lit's `render()`, so the back button
519
+ * lives inside the same `<sp-menu>` and participates in its
520
+ * `RovingTabindexController` for keyboard navigation. Re-renders
521
+ * whenever `dir` or `mobileBackLabel` change so the icon orientation
522
+ * and label stay in sync.
523
+ */
524
+ _renderMobileBackElements(submenuEl) {
525
+ var _a;
526
+ this._removeMobileBackElements(submenuEl);
527
+ const container = document.createElement("div");
528
+ container.className = "mobile-back-container";
529
+ submenuEl.insertBefore(container, submenuEl.firstChild);
530
+ this._mobileBackContainers.set(submenuEl, container);
531
+ render(
532
+ html`
533
+ <sp-menu-item
534
+ class="mobile-back-button"
535
+ data-mobile-back
536
+ @click=${this.handleMobileBackClick}
537
+ >
538
+ <sp-icon-arrow500
539
+ slot="icon"
540
+ class="mobile-back-icon"
541
+ ></sp-icon-arrow500>
542
+ ${this.mobileBackLabel}
543
+ </sp-menu-item>
544
+ <sp-menu-divider data-mobile-back></sp-menu-divider>
545
+ `,
546
+ container
547
+ );
548
+ const submenu = this.asMenu(submenuEl);
549
+ const backItem = submenuEl.querySelector(
550
+ ".mobile-back-button"
551
+ );
552
+ if (backItem) {
553
+ submenu.childItemSet.add(backItem);
554
+ }
555
+ submenu.cachedChildItems = void 0;
556
+ (_a = submenu.rovingTabindexController) == null ? void 0 : _a.clearElementCache();
557
+ if (submenu.rovingTabindexController) {
558
+ submenu.rovingTabindexController.currentIndex = 0;
559
+ }
560
+ }
561
+ /**
562
+ * Re-render any open mobile back containers so changes to
563
+ * `mobileBackLabel` (and other reactive values consumed by the
564
+ * back-button template) propagate without needing to close
565
+ * and re-open the drill-down.
566
+ */
567
+ _refreshMobileBackElements() {
568
+ this._mobileBackContainers.forEach((_container, submenuEl) => {
569
+ this._renderMobileBackElements(submenuEl);
570
+ });
571
+ }
572
+ /**
573
+ * Removes the mobile back button render container from the given
574
+ * projected submenu element.
575
+ */
576
+ _removeMobileBackElements(submenuEl) {
577
+ var _a;
578
+ const container = this._mobileBackContainers.get(submenuEl);
579
+ if (container) {
580
+ const backItem = submenuEl.querySelector(
581
+ ".mobile-back-button"
582
+ );
583
+ if (backItem) {
584
+ this.asMenu(submenuEl).childItemSet.delete(backItem);
585
+ }
586
+ container.remove();
587
+ this._mobileBackContainers.delete(submenuEl);
588
+ const submenu = this.asMenu(submenuEl);
589
+ submenu.cachedChildItems = void 0;
590
+ (_a = submenu.rovingTabindexController) == null ? void 0 : _a.clearElementCache();
591
+ if (submenu.rovingTabindexController) {
592
+ submenu.rovingTabindexController.currentIndex = 0;
593
+ }
594
+ }
595
+ }
596
+ /**
597
+ * Re-adds saved child items to the submenu's `childItemSet` and
598
+ * invalidates cached references after DOM re-parenting, so the
599
+ * `RovingTabindexController` picks up the correct set of focusable
600
+ * children.
601
+ *
602
+ * @param submenu - The submenu whose child state needs restoring.
603
+ * @param savedChildItems - The set of MenuItem children captured
604
+ * before the DOM move.
605
+ */
606
+ _restoreSubmenuChildState(submenu, savedChildItems) {
607
+ var _a;
608
+ savedChildItems.forEach((child) => submenu.childItemSet.add(child));
609
+ submenu.cachedChildItems = void 0;
610
+ (_a = submenu.rovingTabindexController) == null ? void 0 : _a.clearElementCache();
611
+ }
200
612
  get selected() {
201
613
  return !this.selects ? [] : this._selected;
202
614
  }
@@ -615,6 +1027,33 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
615
1027
  const shouldOpenSubmenu = dir === "ltr" && key === "ArrowRight" || dir === "rtl" && key === "ArrowLeft";
616
1028
  const shouldCloseSelfAsSubmenu = dir === "ltr" && key === "ArrowLeft" || dir === "rtl" && key === "ArrowRight" || key === "Escape";
617
1029
  const lastFocusedItem = root;
1030
+ if (this.mobileView) {
1031
+ if (shouldOpenSubmenu && (lastFocusedItem == null ? void 0 : lastFocusedItem.hasSubmenu)) {
1032
+ event.stopPropagation();
1033
+ this.openMobileSubmenu(lastFocusedItem);
1034
+ return;
1035
+ }
1036
+ if (shouldCloseSelfAsSubmenu && this._mobileSubmenuStack.length > 0) {
1037
+ event.stopPropagation();
1038
+ this.closeMobileSubmenu();
1039
+ return;
1040
+ }
1041
+ return;
1042
+ }
1043
+ const mobileRoot = this._mobileViewRoot;
1044
+ if (mobileRoot) {
1045
+ if (shouldOpenSubmenu && (lastFocusedItem == null ? void 0 : lastFocusedItem.hasSubmenu)) {
1046
+ event.stopPropagation();
1047
+ mobileRoot.openMobileSubmenu(lastFocusedItem);
1048
+ return;
1049
+ }
1050
+ if (shouldCloseSelfAsSubmenu) {
1051
+ event.stopPropagation();
1052
+ mobileRoot.closeMobileSubmenu();
1053
+ return;
1054
+ }
1055
+ return;
1056
+ }
618
1057
  if (shouldOpenSubmenu) {
619
1058
  if (lastFocusedItem == null ? void 0 : lastFocusedItem.hasSubmenu) {
620
1059
  event.stopPropagation();
@@ -631,6 +1070,17 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
631
1070
  if (event.defaultPrevented || !this.rovingTabindexController) {
632
1071
  return;
633
1072
  }
1073
+ if (this.mobileView && this._mobileSubmenuStack.length > 0) {
1074
+ const { key: key2 } = event;
1075
+ const dir = this.dir;
1076
+ const shouldClose = dir === "ltr" && key2 === "ArrowLeft" || dir === "rtl" && key2 === "ArrowRight" || key2 === "Escape";
1077
+ if (shouldClose) {
1078
+ event.stopPropagation();
1079
+ event.preventDefault();
1080
+ this.closeMobileSubmenu();
1081
+ }
1082
+ return;
1083
+ }
634
1084
  const { key, root, shiftKey, target } = event;
635
1085
  const openSubmenuKey = ["Enter", " "].includes(key);
636
1086
  if (shiftKey && target !== this && this.hasAttribute("tabindex")) {
@@ -650,7 +1100,14 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
650
1100
  }
651
1101
  if (openSubmenuKey && (root == null ? void 0 : root.hasSubmenu) && !root.open) {
652
1102
  event.preventDefault();
653
- root.openOverlay(true);
1103
+ const mobileRoot = this._mobileViewRoot;
1104
+ if (this.mobileView) {
1105
+ this.openMobileSubmenu(root);
1106
+ } else if (mobileRoot) {
1107
+ mobileRoot.openMobileSubmenu(root);
1108
+ } else {
1109
+ root.openOverlay(true);
1110
+ }
654
1111
  return;
655
1112
  }
656
1113
  if (key === " " || key === "Enter") {
@@ -754,8 +1211,8 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
754
1211
  assignedElements.forEach((item) => {
755
1212
  if (typeof item.triggerUpdate !== "undefined") {
756
1213
  item.triggerUpdate();
757
- } else if (typeof item.childItems !== "undefined") {
758
- item.childItems.forEach((child) => {
1214
+ } else if (typeof this.asMenu(item).childItems !== "undefined") {
1215
+ this.asMenu(item).childItems.forEach((child) => {
759
1216
  child.triggerUpdate();
760
1217
  });
761
1218
  }
@@ -776,7 +1233,22 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
776
1233
  `;
777
1234
  }
778
1235
  render() {
779
- return this.renderMenuItemSlot();
1236
+ const hasMobileSubmenu = this.mobileView && this._mobileSubmenuStack.length > 0;
1237
+ return html`
1238
+ <div
1239
+ class=${hasMobileSubmenu ? "mobile-slot-hidden" : "mobile-slot-wrapper"}
1240
+ >
1241
+ ${this.renderMenuItemSlot()}
1242
+ </div>
1243
+ ${hasMobileSubmenu ? html`
1244
+ <div
1245
+ class="mobile-submenu-animation-wrapper"
1246
+ @animationend=${this._handleAnimationEnd}
1247
+ >
1248
+ <slot name="mobile-submenu"></slot>
1249
+ </div>
1250
+ ` : ""}
1251
+ `;
780
1252
  }
781
1253
  firstUpdated(changed) {
782
1254
  super.firstUpdated(changed);
@@ -802,6 +1274,14 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
802
1274
  this.removeAttribute("aria-label");
803
1275
  }
804
1276
  }
1277
+ if (changes.has("mobileBackLabel") && this.hasUpdated) {
1278
+ this._refreshMobileBackElements();
1279
+ }
1280
+ if (changes.has("mobileView") && this.hasUpdated) {
1281
+ if (!this.mobileView && this._mobileSubmenuStack.length > 0) {
1282
+ this.resetMobileSubmenus();
1283
+ }
1284
+ }
805
1285
  }
806
1286
  selectsChanged() {
807
1287
  const updates = [
@@ -828,6 +1308,8 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
828
1308
  this.selectedItemsMap.clear();
829
1309
  this.childItemSet.clear();
830
1310
  this.descendentOverlays = /* @__PURE__ */ new Map();
1311
+ this.resetMobileSubmenus();
1312
+ this._mobileSubmenuOriginalParents.clear();
831
1313
  super.disconnectedCallback();
832
1314
  }
833
1315
  async getUpdateComplete() {
@@ -847,6 +1329,15 @@ __decorateClass([
847
1329
  __decorateClass([
848
1330
  property({ type: Boolean, reflect: true })
849
1331
  ], Menu.prototype, "ignore", 2);
1332
+ __decorateClass([
1333
+ property({ type: Boolean, attribute: "mobile-view", reflect: true })
1334
+ ], Menu.prototype, "mobileView", 2);
1335
+ __decorateClass([
1336
+ property({ type: String, attribute: "mobile-back-label" })
1337
+ ], Menu.prototype, "mobileBackLabel", 2);
1338
+ __decorateClass([
1339
+ state()
1340
+ ], Menu.prototype, "_mobileSubmenuStack", 2);
850
1341
  __decorateClass([
851
1342
  property({ type: String, reflect: true })
852
1343
  ], Menu.prototype, "selects", 2);