diff --git a/packages/menu/src/Menu.ts b/packages/menu/src/Menu.ts index 8e6bc25e1d..4b652bb7ac 100644 --- a/packages/menu/src/Menu.ts +++ b/packages/menu/src/Menu.ts @@ -47,11 +47,6 @@ function elementIsOrContains( return !!isOrContains && (el === isOrContains || el.contains(isOrContains)); } -/* c8 ignore next 3 */ -const noop = (): void => { - return; -}; - /** * Spectrum Menu Component * @element sp-menu @@ -70,44 +65,8 @@ export class Menu extends SpectrumElement { return [menuStyles]; } - public get closeSelfAsSubmenu(): (leave?: boolean) => void { - this.isSubmenu = false; - return this._closeSelfAsSubmenu; - } - - public setCloseSelfAsSubmenu(cb: (leave?: boolean) => void): void { - if (cb === this._closeSelfAsSubmenu) { - this.isSubmenu = false; - this._closeSelfAsSubmenu = noop; - return; - } - this.isSubmenu = true; - this._closeSelfAsSubmenu = cb; - } - - _closeSelfAsSubmenu = noop; - public isSubmenu = false; - public get closeOpenSubmenu(): (leave?: boolean) => void { - this.hasOpenSubmenu = false; - return this._closeOpenSubmenu; - } - - public setCloseOpenSubmenu(cb: (leave?: boolean) => void): void { - if (cb === this._closeOpenSubmenu) { - this.hasOpenSubmenu = false; - this._closeOpenSubmenu = noop; - return; - } - this.hasOpenSubmenu = true; - this._closeOpenSubmenu = cb; - } - - _closeOpenSubmenu = noop; - - public hasOpenSubmenu = false; - @property({ type: String, reflect: true }) public label = ''; @@ -315,11 +274,6 @@ export class Menu extends SpectrumElement { } } - public submenuWillCloseOn(menuItem: MenuItem): void { - this.focusedItemIndex = this.childItems.indexOf(menuItem); - this.focusInItemIndex = this.focusedItemIndex; - } - private onClick(event: Event): void { if (event.defaultPrevented) { return; @@ -342,12 +296,6 @@ export class Menu extends SpectrumElement { if (target.hasSubmenu || target.open) { return; } - if (this.hasOpenSubmenu) { - this.closeOpenSubmenu(); - } - if (this.isSubmenu) { - this.closeSelfAsSubmenu(); - } this.selectOrToggleItem(target); } else { return; @@ -514,10 +462,10 @@ export class Menu extends SpectrumElement { // Remove focus while opening overlay from keyboard or the visible focus // will slip back to the first item in the menu. this.blur(); - lastFocusedItem.openOverlay({ immediate: true }); + lastFocusedItem.openOverlay(); } - } else if (this.isSubmenu && shouldCloseSelfAsSubmenu) { - this.closeSelfAsSubmenu(true); + } else if (shouldCloseSelfAsSubmenu && this.isSubmenu) { + this.dispatchEvent(new Event('close', { bubbles: true })); } } @@ -533,7 +481,7 @@ export class Menu extends SpectrumElement { // Remove focus while opening overlay from keyboard or the visible focus // will slip back to the first item in the menu. this.blur(); - lastFocusedItem.openOverlay({ immediate: true }); + lastFocusedItem.openOverlay(); return; } } diff --git a/packages/menu/src/MenuItem.ts b/packages/menu/src/MenuItem.ts index ca2594f891..0daf09f014 100644 --- a/packages/menu/src/MenuItem.ts +++ b/packages/menu/src/MenuItem.ts @@ -327,7 +327,7 @@ export class MenuItem extends LikeAnchor(Focusable) { public closeOverlay?: (leave?: boolean) => Promise; protected handleSubmenuClick(): void { - this.openOverlay({ immediate: true }); + this.openOverlay(); } protected handlePointerenter(): void { @@ -368,9 +368,7 @@ export class MenuItem extends LikeAnchor(Focusable) { } }; - public async openOverlay({ - immediate, - }: { immediate?: boolean } = {}): Promise { + public async openOverlay(): Promise { if (!this.hasSubmenu || this.open || this.disabled) { return; } @@ -393,60 +391,34 @@ export class MenuItem extends LikeAnchor(Focusable) { const slotName = el.slot; el.tabIndex = 0; el.removeAttribute('slot'); + el.isSubmenu = true; return (el) => { el.tabIndex = -1; el.slot = slotName; + el.isSubmenu = false; }; }, }); const closeOverlay = openOverlay(this, 'click', popover, { placement: this.isLTR ? 'right-start' : 'left-start', receivesFocus: 'auto', - delayed: !immediate && false, + root: this.menuData.focusRoot, }); - let closing = false; - const closeSubmenu = async (leave = false): Promise => { + const closeSubmenu = async (): Promise => { delete this.closeOverlay; - if (submenu.hasOpenSubmenu) { - await submenu.closeOpenSubmenu(leave); - } - if (!leave) { - closing = true; - } - this.menuData.focusRoot?.submenuWillCloseOn(this); (await closeOverlay)(); }; this.closeOverlay = closeSubmenu; - if (this.menuData.focusRoot?.hasOpenSubmenu) { - this.menuData.focusRoot.closeOpenSubmenu(true); - } - const setup = (): void => { - submenu.setCloseSelfAsSubmenu(closeSubmenu); - this.menuData.focusRoot?.setCloseOpenSubmenu(closeSubmenu); - }; const cleanup = (event: CustomEvent): void => { event.stopPropagation(); returnSubmenu(); - submenu.setCloseSelfAsSubmenu(closeSubmenu); - this.menuData.focusRoot?.setCloseOpenSubmenu(closeSubmenu); this.open = false; this.active = false; - if (closing || event.detail.reason === 'external-click') { - this.menuData.focusRoot?.dispatchEvent( - new CustomEvent('close', { - bubbles: true, - composed: true, - detail: { reason: 'external-click' }, - }) - ); - } }; - this.addEventListener('sp-opened', setup as EventListener, { - once: true, - }); this.addEventListener('sp-closed', cleanup as EventListener, { once: true, }); + popover.addEventListener('change', closeSubmenu); } updateAriaSelected(): void { diff --git a/packages/menu/test/submenu.test.ts b/packages/menu/test/submenu.test.ts index e1f43532b6..4caa1a642f 100644 --- a/packages/menu/test/submenu.test.ts +++ b/packages/menu/test/submenu.test.ts @@ -28,6 +28,10 @@ import { spy } from 'sinon'; import { Theme } from '@spectrum-web-components/theme'; import { TemplateResult } from '@spectrum-web-components/base'; import { sendKeys } from '@web/test-runner-commands'; +import { ActionMenu } from '@spectrum-web-components/action-menu'; +import '@spectrum-web-components/action-menu/sp-action-menu.js'; +import '@spectrum-web-components/menu/sp-menu-group.js'; +import '@spectrum-web-components/icons-workflow/icons/sp-icon-show-menu.js'; async function styledFixture( story: TemplateResult, @@ -602,4 +606,246 @@ describe('Submenu', () => { expect(rootItem.open).to.be.false; }); + it('closes all decendent submenus when closing a ancestor menu', async () => { + const el = await styledFixture(html` + + + + New York + Bronx + + Brooklyn + + + Ft. Greene + + S. Oxford St + S. Portland Ave + S. Elliot Pl + + + Park Slope + Williamsburg + + + + Manhattan + + SoHo + + Union Square + + 14th St + Broadway + Park Ave + + + Upper East Side + + + + + `); + + const rootMenu1 = el.querySelector('#submenu-item-1') as Menu; + const rootMenu2 = el.querySelector('#submenu-item-3') as Menu; + const childMenu2 = el.querySelector('#submenu-item-2') as Menu; + + expect(el.open).to.be.false; + let opened = oneEvent(el, 'sp-opened'); + el.click(); + await opened; + expect(el.open).to.be.true; + + let activeOverlays = document.querySelectorAll('active-overlay'); + expect(activeOverlays.length).to.equal(1); + opened = oneEvent(rootMenu1, 'sp-opened'); + rootMenu1.dispatchEvent( + new PointerEvent('pointerenter', { bubbles: true }) + ); + await opened; + activeOverlays = document.querySelectorAll('active-overlay'); + expect(activeOverlays.length).to.equal(2); + + opened = oneEvent(childMenu2, 'sp-opened'); + childMenu2.dispatchEvent( + new PointerEvent('pointerenter', { bubbles: true }) + ); + await opened; + activeOverlays = document.querySelectorAll('active-overlay'); + expect(activeOverlays.length).to.equal(3); + + const overlaysManaged = Promise.all([ + oneEvent(childMenu2, 'sp-closed'), + oneEvent(rootMenu1, 'sp-closed'), + oneEvent(rootMenu2, 'sp-opened'), + ]); + rootMenu2.dispatchEvent( + new PointerEvent('pointerenter', { bubbles: true }) + ); + await overlaysManaged; + activeOverlays = document.querySelectorAll('active-overlay'); + expect(activeOverlays.length).to.equal(2); + }); + + it('closes back to the first overlay without a `root` when clicking away', async () => { + const el = await styledFixture(html` + + + + New York + Bronx + + Brooklyn + + + Ft. Greene + + S. Oxford St + S. Portland Ave + S. Elliot Pl + + + Park Slope + Williamsburg + + + + Manhattan + + SoHo + + Union Square + + 14th St + Broadway + Park Ave + + + Upper East Side + + + + + `); + + const rootMenu1 = el.querySelector('#submenu-item-1') as Menu; + const childMenu2 = el.querySelector('#submenu-item-2') as Menu; + + expect(el.open).to.be.false; + let opened = oneEvent(el, 'sp-opened'); + el.click(); + await opened; + expect(el.open).to.be.true; + + let activeOverlays = document.querySelectorAll('active-overlay'); + expect(activeOverlays.length).to.equal(1); + opened = oneEvent(rootMenu1, 'sp-opened'); + rootMenu1.dispatchEvent( + new PointerEvent('pointerenter', { bubbles: true }) + ); + await opened; + activeOverlays = document.querySelectorAll('active-overlay'); + expect(activeOverlays.length).to.equal(2); + + opened = oneEvent(childMenu2, 'sp-opened'); + childMenu2.dispatchEvent( + new PointerEvent('pointerenter', { bubbles: true }) + ); + await opened; + activeOverlays = document.querySelectorAll('active-overlay'); + expect(activeOverlays.length).to.equal(3); + + const closed = Promise.all([ + oneEvent(childMenu2, 'sp-closed'), + oneEvent(rootMenu1, 'sp-closed'), + oneEvent(el, 'sp-closed'), + ]); + document.body.click(); + await closed; + activeOverlays = document.querySelectorAll('active-overlay'); + expect(activeOverlays.length).to.equal(0); + }); + + it('closes decendent menus when Menu Item in ancestor is clicked', async () => { + const el = await styledFixture(html` + + + + New York + Bronx + + Brooklyn + + + Ft. Greene + + S. Oxford St + S. Portland Ave + S. Elliot Pl + + + Park Slope + + Williamsburg + + + + + Manhattan + + SoHo + + Union Square + + 14th St + Broadway + Park Ave + + + Upper East Side + + + + + `); + + const rootMenu1 = el.querySelector('#submenu-item-1') as MenuItem; + const childMenu2 = el.querySelector('#submenu-item-2') as MenuItem; + const ancestorItem = el.querySelector('#ancestor-item') as MenuItem; + + expect(el.open).to.be.false; + let opened = oneEvent(el, 'sp-opened'); + el.click(); + await opened; + expect(el.open).to.be.true; + + let activeOverlays = document.querySelectorAll('active-overlay'); + expect(activeOverlays.length).to.equal(1); + opened = oneEvent(rootMenu1, 'sp-opened'); + rootMenu1.dispatchEvent( + new PointerEvent('pointerenter', { bubbles: true }) + ); + await opened; + activeOverlays = document.querySelectorAll('active-overlay'); + expect(activeOverlays.length).to.equal(2); + + opened = oneEvent(childMenu2, 'sp-opened'); + childMenu2.dispatchEvent( + new PointerEvent('pointerenter', { bubbles: true }) + ); + await opened; + activeOverlays = document.querySelectorAll('active-overlay'); + expect(activeOverlays.length).to.equal(3); + + const closed = Promise.all([ + oneEvent(childMenu2, 'sp-closed'), + oneEvent(rootMenu1, 'sp-closed'), + oneEvent(el, 'sp-closed'), + ]); + ancestorItem.click(); + await closed; + activeOverlays = document.querySelectorAll('active-overlay'); + expect(activeOverlays.length).to.equal(0); + }); }); diff --git a/packages/overlay/src/ActiveOverlay.ts b/packages/overlay/src/ActiveOverlay.ts index 8880947c57..2bd666f0cb 100644 --- a/packages/overlay/src/ActiveOverlay.ts +++ b/packages/overlay/src/ActiveOverlay.ts @@ -27,6 +27,7 @@ import type { ThemeVariant, } from '@spectrum-web-components/theme/src/Theme.js'; import styles from './active-overlay.css.js'; +import { parentOverlayOf } from './overlay-utils.js'; import { OverlayOpenCloseDetail, OverlayOpenDetail, @@ -105,18 +106,6 @@ const stateTransition = ( return stateMachine.states[state].on[event] || state; }; -const parentOverlayOf = (el: Element): ActiveOverlay | null => { - const closestOverlay = el.closest('active-overlay'); - if (closestOverlay) { - return closestOverlay; - } - const rootNode = el.getRootNode() as ShadowRoot; - if (rootNode.host) { - return parentOverlayOf(rootNode.host); - } - return null; -}; - const getFallbackPlacements = ( placement: FloatingUIPlacement ): FloatingUIPlacement[] => { @@ -146,6 +135,7 @@ export class ActiveOverlay extends SpectrumElement { public overlayContent!: HTMLElement; public overlayContentTip?: HTMLElement; public trigger!: HTMLElement; + public root?: HTMLElement; public virtualTrigger?: VirtualTrigger; protected childrenReady!: Promise; @@ -353,6 +343,7 @@ export class ActiveOverlay extends SpectrumElement { this.interaction = detail.interaction; this.theme = detail.theme; this.receivesFocus = detail.receivesFocus; + this.root = detail.root; } public dispose(): void { diff --git a/packages/overlay/src/overlay-stack.ts b/packages/overlay/src/overlay-stack.ts index 2937e64103..a0cb997fa8 100644 --- a/packages/overlay/src/overlay-stack.ts +++ b/packages/overlay/src/overlay-stack.ts @@ -12,12 +12,15 @@ governing permissions and limitations under the License. import { ActiveOverlay } from './ActiveOverlay.js'; import type { - OverlayCloseReasonDetail, OverlayOpenCloseDetail, OverlayOpenDetail, } from './overlay-types'; import { OverlayTimer } from './overlay-timer.js'; import '../active-overlay.js'; +import { + findOverlaysRootedInOverlay, + parentOverlayOf, +} from './overlay-utils.js'; function isLeftClick(event: MouseEvent): boolean { return event.button === 0; @@ -268,6 +271,9 @@ export class OverlayStack { } } + if (details.root) { + this.closeOverlaysForRoot(details.root); + } if (details.interaction === 'click') { this.closeAllHoverOverlays(); } else if ( @@ -315,13 +321,10 @@ export class OverlayStack { } public addOverlayEventListeners(activeOverlay: ActiveOverlay): void { - activeOverlay.addEventListener('close', (( - event: CustomEvent - ) => { + activeOverlay.addEventListener('close', (() => { this.hideAndCloseOverlay( activeOverlay, - true, // animated? - !!event.detail?.reason // clickAway? + true // animated? ); }) as EventListener); switch (activeOverlay.interaction) { @@ -390,8 +393,17 @@ export class OverlayStack { public closeOverlay(content: HTMLElement): void { this.overlayTimer.close(content); requestAnimationFrame(() => { - const overlay = this.findOverlayForContent(content); - this.hideAndCloseOverlay(overlay); + const overlayFromContent = this.findOverlayForContent(content); + const overlaysToClose = [overlayFromContent]; + overlaysToClose.push( + ...findOverlaysRootedInOverlay( + overlayFromContent, + this.overlays + ) + ); + overlaysToClose.forEach((overlay) => + this.hideAndCloseOverlay(overlay) + ); }); } @@ -426,11 +438,29 @@ export class OverlayStack { } } - private async manageFocusAfterCloseWhenOverlaysRemain(): Promise { + private closeOverlaysForRoot(root: HTMLElement): void { + const overlaysToClose: ActiveOverlay[] = []; + for (const overlay of this.overlays) { + if (overlay.root && overlay.root === root) { + overlaysToClose.push(overlay); + overlaysToClose.push( + ...findOverlaysRootedInOverlay(overlay, this.overlays) + ); + } + } + overlaysToClose.forEach((overlay) => + this.hideAndCloseOverlay(overlay, true, true) + ); + } + + private async manageFocusAfterCloseWhenOverlaysRemain( + returnBeforeFocus?: boolean + ): Promise { const topOverlay = this.overlays[this.overlays.length - 1]; topOverlay.feature(); // Push focus in the the next remaining overlay as needed when a `type="modal"` overlay exists. if (topOverlay.interaction === 'modal' || topOverlay.hasModalRoot) { + if (returnBeforeFocus) return; await topOverlay.focus(); } else { this.stopTabTrapping(); @@ -478,7 +508,7 @@ export class OverlayStack { private async hideAndCloseOverlay( overlay?: ActiveOverlay, animated?: boolean, - clickAway?: boolean + returnBeforeFocus?: boolean ): Promise { if (!overlay) { return; @@ -510,7 +540,9 @@ export class OverlayStack { } if (this.overlays.length) { - await this.manageFocusAfterCloseWhenOverlaysRemain(); + await this.manageFocusAfterCloseWhenOverlaysRemain( + returnBeforeFocus + ); } else { this.manageFocusAfterCloseWhenLastOverlay(overlay); } @@ -525,14 +557,13 @@ export class OverlayStack { cancelable: true, detail: { interaction: overlay.interaction, - reason: clickAway ? 'external-click' : undefined, }, }) ); } - private closeTopOverlay(clickAway?: boolean): Promise { - return this.hideAndCloseOverlay(this.topOverlay, true, clickAway); + private closeTopOverlay(): Promise { + return this.hideAndCloseOverlay(this.topOverlay, true); } /** @@ -550,7 +581,19 @@ export class OverlayStack { if (this.preventMouseRootClose || event.defaultPrevented) { return; } - this.closeTopOverlay(true); + this.closeTopOverlay(); + const overlaysToClose = []; + let root: HTMLElement | undefined = this.topOverlay?.root; + let overlay = parentOverlayOf(root); + while (root && overlay) { + overlaysToClose.push(overlay); + overlay = parentOverlayOf(root); + root = overlay?.root; + } + if (overlay) { + overlaysToClose.push(overlay); + } + overlaysToClose.forEach((overlay) => this.hideAndCloseOverlay(overlay)); }; private handleKeyUp = (event: KeyboardEvent): void => { diff --git a/packages/overlay/src/overlay-types.ts b/packages/overlay/src/overlay-types.ts index 57bc8af8e8..56beda96c9 100644 --- a/packages/overlay/src/overlay-types.ts +++ b/packages/overlay/src/overlay-types.ts @@ -33,6 +33,7 @@ export interface OverlayOpenDetail { receivesFocus?: 'auto'; virtualTrigger?: VirtualTrigger; trigger: HTMLElement; + root?: HTMLElement; interaction: TriggerInteractions; theme: ThemeData; notImmediatelyClosable?: boolean; @@ -61,6 +62,7 @@ export interface OverlayDisplayQueryDetail { export type Placement = FloatingUIPlacement | 'none'; export type OverlayOptions = { + root?: HTMLElement; delayed?: boolean; placement?: Placement; offset?: number; diff --git a/packages/overlay/src/overlay-utils.ts b/packages/overlay/src/overlay-utils.ts new file mode 100644 index 0000000000..6a7b706d1f --- /dev/null +++ b/packages/overlay/src/overlay-utils.ts @@ -0,0 +1,44 @@ +/* +Copyright 2022 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import type { ActiveOverlay } from './ActiveOverlay'; + +export const parentOverlayOf = (el?: Element): ActiveOverlay | null => { + if (!el) return null; + const closestOverlay = el.closest('active-overlay'); + if (closestOverlay) { + return closestOverlay; + } + const rootNode = el.getRootNode() as ShadowRoot; + if (rootNode.host) { + return parentOverlayOf(rootNode.host); + } + return null; +}; + +export const findOverlaysRootedInOverlay = ( + rootOverlay: ActiveOverlay | undefined, + activeOverlays: ActiveOverlay[] +): ActiveOverlay[] => { + const overlays: ActiveOverlay[] = []; + if (!rootOverlay) return []; + for (const overlay of activeOverlays) { + if (!overlay.root) continue; + if (parentOverlayOf(overlay.root) === rootOverlay) { + overlays.push(overlay); + overlays.push( + ...findOverlaysRootedInOverlay(overlay, activeOverlays) + ); + } + } + return overlays; +}; diff --git a/packages/overlay/src/overlay.ts b/packages/overlay/src/overlay.ts index 216e06c6d8..56e0d82a4e 100644 --- a/packages/overlay/src/overlay.ts +++ b/packages/overlay/src/overlay.ts @@ -97,6 +97,7 @@ export class Overlay { receivesFocus, notImmediatelyClosable, virtualTrigger, + root, }: OverlayOptions): Promise { /* c8 ignore next */ if (this.isOpen) return true; @@ -140,6 +141,7 @@ export class Overlay { interaction: this.interaction, theme: queryThemeDetail, receivesFocus, + root, notImmediatelyClosable, virtualTrigger, ...overlayDetailQuery,