From efe5fa1ff50c45487f370847444b940e1d6d8a4e Mon Sep 17 00:00:00 2001 From: Westbrook Johnson Date: Tue, 21 Jun 2022 07:33:12 -0400 Subject: [PATCH] fix: ensure that entering an ancestor Menu Item without a submen closes related submenus --- packages/menu/src/MenuItem.ts | 15 ++++++- packages/menu/test/submenu.test.ts | 59 ++++++++++++++++++++++++++ packages/overlay/src/overlay-events.ts | 25 +++++++++++ packages/overlay/src/overlay-stack.ts | 11 +++++ 4 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 packages/overlay/src/overlay-events.ts diff --git a/packages/menu/src/MenuItem.ts b/packages/menu/src/MenuItem.ts index fb32294a2d..1974ce4b91 100644 --- a/packages/menu/src/MenuItem.ts +++ b/packages/menu/src/MenuItem.ts @@ -27,6 +27,7 @@ import { Focusable } from '@spectrum-web-components/shared/src/focusable.js'; import '@spectrum-web-components/icons-ui/icons/sp-icon-chevron100.js'; import chevronStyles from '@spectrum-web-components/icon/src/spectrum-icon-chevron.css.js'; import { openOverlay } from '@spectrum-web-components/overlay/src/loader.js'; +import { OverlayCloseEvent } from '@spectrum-web-components/overlay/src/overlay-events.js'; import menuItemStyles from './menu-item.css.js'; import checkmarkStyles from '@spectrum-web-components/icon/src/spectrum-icon-checkmark.css.js'; @@ -331,9 +332,18 @@ export class MenuItem extends LikeAnchor(Focusable) { if (!this.hasAttribute('id')) { this.id = `sp-menu-item-${MenuItem.instanceCount++}`; } + this.addEventListener('pointerenter', this.closeOverlaysForRoot); } - public closeOverlay?: (leave?: boolean) => Promise; + protected closeOverlaysForRoot(): void { + if (this.open) return; + const overalyCloseEvent = new OverlayCloseEvent({ + root: this.menuData.focusRoot, + }); + this.dispatchEvent(overalyCloseEvent); + } + + public closeOverlay?: () => Promise; protected handleSubmenuClick(): void { this.openOverlay(); @@ -354,7 +364,7 @@ export class MenuItem extends LikeAnchor(Focusable) { if (this.hasSubmenu && this.open) { this.leaveTimeout = setTimeout(() => { delete this.leaveTimeout; - if (this.closeOverlay) this.closeOverlay(true); + if (this.closeOverlay) this.closeOverlay(); }, POINTERLEAVE_TIMEOUT); } } @@ -420,6 +430,7 @@ export class MenuItem extends LikeAnchor(Focusable) { this.closeOverlay = closeSubmenu; const cleanup = (event: CustomEvent): void => { event.stopPropagation(); + delete this.closeOverlay; returnSubmenu(); this.open = false; this.active = false; diff --git a/packages/menu/test/submenu.test.ts b/packages/menu/test/submenu.test.ts index 4caa1a642f..10785c234f 100644 --- a/packages/menu/test/submenu.test.ts +++ b/packages/menu/test/submenu.test.ts @@ -767,6 +767,65 @@ describe('Submenu', () => { expect(activeOverlays.length).to.equal(0); }); + it('closes decendent menus when Menu Item in ancestor without a submenu is pointerentered', async () => { + const el = await styledFixture(html` + + + + New York + Bronx + + Brooklyn + + + Ft. Greene + + Park Slope + + Williamsburg + + + + + Manhattan + + SoHo + Union Square + Upper East Side + + + + + `); + + const rootMenu = el.querySelector('#submenu-item-1') as MenuItem; + const noSubmenu = el.querySelector('#no-submenu') 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(rootMenu, 'sp-opened'); + rootMenu.dispatchEvent( + new PointerEvent('pointerenter', { bubbles: true }) + ); + await opened; + activeOverlays = document.querySelectorAll('active-overlay'); + expect(activeOverlays.length).to.equal(2); + + const closed = oneEvent(rootMenu, 'sp-closed'); + noSubmenu.dispatchEvent( + new PointerEvent('pointerenter', { bubbles: true }) + ); + await closed; + activeOverlays = document.querySelectorAll('active-overlay'); + expect(activeOverlays.length).to.equal(1); + }); + it('closes decendent menus when Menu Item in ancestor is clicked', async () => { const el = await styledFixture(html` diff --git a/packages/overlay/src/overlay-events.ts b/packages/overlay/src/overlay-events.ts new file mode 100644 index 0000000000..a0f707fea6 --- /dev/null +++ b/packages/overlay/src/overlay-events.ts @@ -0,0 +1,25 @@ +/* +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. +*/ + +export class OverlayCloseEvent extends Event { + root?: HTMLElement; + constructor({ root }: { root?: HTMLElement }) { + super('sp-overlay-close', { bubbles: true, composed: true }); + this.root = root; + } +} + +declare global { + interface GlobalEventHandlersEventMap { + 'sp-overlay-close': CustomEvent; + } +} diff --git a/packages/overlay/src/overlay-stack.ts b/packages/overlay/src/overlay-stack.ts index a0cb997fa8..d1766992f0 100644 --- a/packages/overlay/src/overlay-stack.ts +++ b/packages/overlay/src/overlay-stack.ts @@ -21,6 +21,7 @@ import { findOverlaysRootedInOverlay, parentOverlayOf, } from './overlay-utils.js'; +import { OverlayCloseEvent } from './overlay-events.js'; function isLeftClick(event: MouseEvent): boolean { return event.button === 0; @@ -223,9 +224,19 @@ export class OverlayStack { this.document.addEventListener('click', this.handleMouseCapture, true); this.document.addEventListener('click', this.handleMouse); this.document.addEventListener('keyup', this.handleKeyUp); + this.document.addEventListener( + 'sp-overlay-close', + this.handleOverlayClose as EventListener + ); window.addEventListener('resize', this.handleResize); } + handleOverlayClose = (event: OverlayCloseEvent): void => { + const { root } = event; + if (!root) return; + this.closeOverlaysForRoot(root); + }; + private isClickOverlayActiveForTrigger(trigger: HTMLElement): boolean { return this.overlays.some( (item) => trigger === item.trigger && item.interaction === 'click'