diff --git a/packages/menu/src/Menu.ts b/packages/menu/src/Menu.ts index 70eaa74cca..5a08896eb1 100644 --- a/packages/menu/src/Menu.ts +++ b/packages/menu/src/Menu.ts @@ -24,10 +24,8 @@ import { } from '@spectrum-web-components/base/src/decorators.js'; import { MenuItem } from './MenuItem.js'; -import type { - MenuItemAddedOrUpdatedEvent, - MenuItemRemovedEvent, -} from './MenuItem.js'; +import type { MenuItemAddedOrUpdatedEvent } from './MenuItem.js'; +import { OverlayBase } from '@spectrum-web-components/overlay/src/OverlayBase.js'; import menuStyles from './menu.css.js'; export interface MenuChildItem { @@ -66,7 +64,9 @@ export class Menu extends SizedMixin(SpectrumElement) { return [menuStyles]; } - public isSubmenu = false; + private get isSubmenu(): boolean { + return this.slot === 'submenu'; + } @property({ type: String, reflect: true }) public label = ''; @@ -163,7 +163,7 @@ export class Menu extends SizedMixin(SpectrumElement) { /** * When a descendant `` element is added or updated it will dispatch - * this event to announce its presence in the DOM. During the capture phase the first + * this event to announce its presence in the DOM. During the CAPTURE phase the first * Menu based element that the event encounters will manage the focus state of the * dispatching `` element. * @param event @@ -171,11 +171,34 @@ export class Menu extends SizedMixin(SpectrumElement) { private onFocusableItemAddedOrUpdated( event: MenuItemAddedOrUpdatedEvent ): void { - if (event.item.menuData.focusRoot && !this.ignore) { + event.menuCascade.set(this, { + hadFocusRoot: !!event.item.menuData.focusRoot, + ancestorWithSelects: event.currentAncestorWithSelects, + }); + if (this.selects) { + event.currentAncestorWithSelects = this; + } + event.item.menuData.focusRoot = event.item.menuData.focusRoot || this; + } + + /** + * When a descendant `` element is added or updated it will dispatch + * this event to announce its presence in the DOM. During the BUBBLE phase the first + * Menu based element that the event encounters that does not inherit selection will + * manage the selection state of the dispatching `` element. + * @param event + */ + private onSelectableItemAddedOrUpdated( + event: MenuItemAddedOrUpdatedEvent + ): void { + const cascadeData = event.menuCascade.get(this); + if (!cascadeData) return; + + event.item.menuData.parentMenu = event.item.menuData.parentMenu || this; + if (cascadeData.hadFocusRoot && !this.ignore) { // Only have one tab stop per Menu tree this.tabIndex = -1; } - event.focusRoot = this; this.addChildItem(event.item); if (this.selects === 'inherit') { @@ -191,7 +214,6 @@ export class Menu extends SizedMixin(SpectrumElement) { ? 'none' : ((this.getAttribute('role') || undefined) as RoleType); this.resolvedSelects = this.selects; - event.currentAncestorWithSelects = this; } else { this.resolvedRole = this.ignore ? 'none' @@ -199,27 +221,26 @@ export class Menu extends SizedMixin(SpectrumElement) { this.resolvedSelects = this.resolvedRole === 'none' ? 'ignore' : 'none'; } - } - /** - * When a descendant `` element is added or updated it will dispatch - * this event to announce its presence in the DOM. During the bubble phase the first - * Menu based element that the event encounters that does not inherit selection will - * manage the selection state of the dispatching `` element. - * @param event - */ - private onSelectableItemAddedOrUpdated( - event: MenuItemAddedOrUpdatedEvent - ): void { const selects = this.resolvedSelects === 'single' || this.resolvedSelects === 'multiple'; + event.item.menuData.cleanupSteps.push((item: MenuItem) => + this.removeChildItem(item) + ); if ( (selects || (!this.selects && this.resolvedSelects !== 'ignore')) && !event.item.menuData.selectionRoot ) { event.item.setRole(this.childRole); - event.selectionRoot = this; + event.item.menuData.selectionRoot = + event.item.menuData.selectionRoot || this; + if (event.item.selected) { + this.selectedItemsMap.set(event.item, true); + this.selectedItems = [...this.selectedItems, event.item]; + this.selected = [...this.selected, event.item.value]; + this.value = this.selected.join(this.valueSeparator); + } } } @@ -228,10 +249,10 @@ export class Menu extends SizedMixin(SpectrumElement) { this.handleItemsChanged(); } - private async removeChildItem(event: MenuItemRemovedEvent): Promise { - this.childItemSet.delete(event.item); + private async removeChildItem(item: MenuItem): Promise { + this.childItemSet.delete(item); this.cachedChildItems = undefined; - if (event.item.focused) { + if (item.focused) { this.handleItemsChanged(); await this.updateComplete; this.focus(); @@ -253,9 +274,11 @@ export class Menu extends SizedMixin(SpectrumElement) { } ); - this.addEventListener('sp-menu-item-removed', this.removeChildItem); - this.addEventListener('click', this.onClick); + this.addEventListener('click', this.handleClick); this.addEventListener('focusin', this.handleFocusin); + this.addEventListener('focusout', this.handleFocusout); + this.addEventListener('sp-opened', this.handleSubmenuOpened); + this.addEventListener('sp-closed', this.handleSubmenuClosed); } public override focus({ preventScroll }: FocusOptions = {}): void { @@ -275,13 +298,13 @@ export class Menu extends SizedMixin(SpectrumElement) { } this.focusMenuItemByOffset(0); super.focus({ preventScroll }); - const selectedItem = this.querySelector('[selected]'); + const selectedItem = this.selectedItems[0]; if (selectedItem && !preventScroll) { selectedItem.scrollIntoView({ block: 'nearest' }); } } - private onClick(event: Event): void { + private handleClick(event: Event): void { if (event.defaultPrevented) { return; } @@ -319,12 +342,11 @@ export class Menu extends SizedMixin(SpectrumElement) { } public handleFocusin(event: FocusEvent): void { - const isOrContainsRelatedTarget = elementIsOrContains( + const wasOrContainedRelatedTarget = elementIsOrContains( this, event.relatedTarget as Node ); if ( - isOrContainsRelatedTarget || this.childItems.some( (childItem) => childItem.menuData.focusRoot !== this ) @@ -337,7 +359,10 @@ export class Menu extends SizedMixin(SpectrumElement) { const selectionRoot = this.childItems[this.focusedItemIndex]?.menuData.selectionRoot || this; - if (activeElement !== selectionRoot || !isOrContainsRelatedTarget) { + if ( + activeElement !== selectionRoot || + (!wasOrContainedRelatedTarget && event.target !== this) + ) { selectionRoot.focus({ preventScroll: true }); if (activeElement && this.focusedItemIndex === 0) { const offset = this.childItems.findIndex( @@ -353,34 +378,71 @@ export class Menu extends SizedMixin(SpectrumElement) { public startListeningToKeyboard(): void { this.addEventListener('keydown', this.handleKeydown); - this.addEventListener('focusout', this.handleFocusout); } public handleFocusout(event: FocusEvent): void { if (elementIsOrContains(this, event.relatedTarget as Node)) { - (event.composedPath()[0] as MenuItem).focused = false; return; } this.stopListeningToKeyboard(); - if ( - event.target === this && - this.childItems.some( - (childItem) => childItem.menuData.focusRoot === this - ) - ) { - const focusedItem = this.childItems[this.focusedItemIndex]; - if (focusedItem) { - focusedItem.focused = false; - } - } + this.childItems.forEach((child) => (child.focused = false)); this.removeAttribute('aria-activedescendant'); } public stopListeningToKeyboard(): void { this.removeEventListener('keydown', this.handleKeydown); - this.removeEventListener('focusout', this.handleFocusout); } + private descendentOverlays = new Map(); + + protected handleDescendentOverlayOpened(event: Event): void { + const target = event.composedPath()[0] as MenuItem; + if (!target.overlayElement) return; + this.descendentOverlays.set( + target.overlayElement, + target.overlayElement + ); + } + + protected handleDescendentOverlayClosed(event: Event): void { + const target = event.composedPath()[0] as MenuItem; + if (!target.overlayElement) return; + this.descendentOverlays.delete(target.overlayElement); + } + + public handleSubmenuClosed = (event: Event): void => { + event.stopPropagation(); + const target = event.composedPath()[0] as OverlayBase; + target.dispatchEvent( + new Event('sp-menu-submenu-closed', { + bubbles: true, + composed: true, + }) + ); + }; + + public handleSubmenuOpened = (event: Event): void => { + event.stopPropagation(); + const target = event.composedPath()[0] as OverlayBase; + target.dispatchEvent( + new Event('sp-menu-submenu-opened', { + bubbles: true, + composed: true, + }) + ); + const focusedItem = this.childItems[this.focusedItemIndex]; + if (focusedItem) { + focusedItem.focused = false; + } + const openedItem = event + .composedPath() + .find((el) => this.childItemSet.has(el as MenuItem)); + if (!openedItem) return; + const openedItemIndex = this.childItems.indexOf(openedItem as MenuItem); + this.focusedItemIndex = openedItemIndex; + this.focusInItemIndex = openedItemIndex; + }; + public async selectOrToggleItem(targetItem: MenuItem): Promise { const resolvedSelects = this.resolvedSelects; const oldSelectedItemsMap = new Map(this.selectedItemsMap); @@ -388,6 +450,7 @@ export class Menu extends SizedMixin(SpectrumElement) { const oldSelectedItems = this.selectedItems.slice(); const oldValue = this.value; this.childItems[this.focusedItemIndex].focused = false; + this.childItems[this.focusedItemIndex].active = false; this.focusedItemIndex = this.childItems.indexOf(targetItem); this.forwardFocusVisibleToItem(targetItem); @@ -461,10 +524,12 @@ export class Menu extends SizedMixin(SpectrumElement) { return; } event.preventDefault(); + event.stopPropagation(); itemToFocus.scrollIntoView({ block: 'nearest' }); } - protected navigateBetweenRelatedMenus(code: string): void { + protected navigateBetweenRelatedMenus(event: KeyboardEvent): void { + const { code } = event; const shouldOpenSubmenu = (this.isLTR && code === 'ArrowRight') || (!this.isLTR && code === 'ArrowLeft'); @@ -472,19 +537,28 @@ export class Menu extends SizedMixin(SpectrumElement) { (this.isLTR && code === 'ArrowLeft') || (!this.isLTR && code === 'ArrowRight'); if (shouldOpenSubmenu) { + event.stopPropagation(); const lastFocusedItem = this.childItems[this.focusedItemIndex]; if (lastFocusedItem?.hasSubmenu) { // Remove focus while opening overlay from keyboard or the visible focus // will slip back to the first item in the menu. - this.blur(); + // this.blur(); lastFocusedItem.openOverlay(); } } else if (shouldCloseSelfAsSubmenu && this.isSubmenu) { + event.stopPropagation(); this.dispatchEvent(new Event('close', { bubbles: true })); + this.updateSelectedItemIndex(); } } public handleKeydown(event: KeyboardEvent): void { + if ( + event.target !== this && + this !== (event.target as HTMLElement).parentElement + ) { + return; + } const { code } = event; if (code === 'Tab') { this.prepareToCleanUp(); @@ -495,7 +569,7 @@ export class Menu extends SizedMixin(SpectrumElement) { if (lastFocusedItem?.hasSubmenu) { // Remove focus while opening overlay from keyboard or the visible focus // will slip back to the first item in the menu. - this.blur(); + // this.blur(); lastFocusedItem.openOverlay(); return; } @@ -508,13 +582,16 @@ export class Menu extends SizedMixin(SpectrumElement) { this.navigateWithinMenu(event); return; } - this.navigateBetweenRelatedMenus(code); + this.navigateBetweenRelatedMenus(event); } public focusMenuItemByOffset(offset: number): MenuItem { const step = offset || 1; const focusedItem = this.childItems[this.focusedItemIndex]; - focusedItem.focused = false; + if (focusedItem) { + focusedItem.focused = false; + focusedItem.active = false; + } this.focusedItemIndex = (this.childItems.length + this.focusedItemIndex + offset) % this.childItems.length; @@ -551,6 +628,8 @@ export class Menu extends SizedMixin(SpectrumElement) { ); } + private _hasUpdatedSelectedItemIndex = false; + public updateSelectedItemIndex(): void { let firstOrFirstSelectedIndex = 0; const selectedItemsMap = new Map(); @@ -561,7 +640,11 @@ export class Menu extends SizedMixin(SpectrumElement) { itemIndex -= 1; const childItem = this.childItems[itemIndex]; if (childItem.menuData.selectionRoot === this) { - if (childItem.selected) { + if ( + childItem.selected || + (!this._hasUpdatedSelectedItemIndex && + this.selected.includes(childItem.value)) + ) { firstOrFirstSelectedIndex = itemIndex; selectedItemsMap.set(childItem, true); selected.unshift(childItem.value); @@ -594,25 +677,20 @@ export class Menu extends SizedMixin(SpectrumElement) { private handleItemsChanged(): void { this.cachedChildItems = undefined; if (!this._willUpdateItems) { - /* c8 ignore next 3 */ - let resolve = (): void => { - return; - }; - this.cacheUpdated = new Promise((res) => (resolve = res)); this._willUpdateItems = true; - // Debounce the update so we only update once - // if multiple items have changed - window.requestAnimationFrame(() => { - if (this.cachedChildItems === undefined) { - this.updateSelectedItemIndex(); - this.updateItemFocus(); - } - this._willUpdateItems = false; - resolve(); - }); + this.cacheUpdated = this.updateCache(); } } + private async updateCache(): Promise { + await new Promise((res) => requestAnimationFrame(() => res(true))); + if (this.cachedChildItems === undefined) { + this.updateSelectedItemIndex(); + this.updateItemFocus(); + } + this._willUpdateItems = false; + } + private updateItemFocus(): void { if (this.childItems.length == 0) { return; @@ -626,11 +704,24 @@ export class Menu extends SizedMixin(SpectrumElement) { } } + public closeDescendentOverlays(): void { + this.descendentOverlays.forEach((overlay) => { + overlay.open = false; + }); + this.descendentOverlays = new Map(); + } + private forwardFocusVisibleToItem(item: MenuItem): void { if (item.menuData.focusRoot !== this) { return; } - item.focused = this.hasVisibleFocusInTree(); + this.closeDescendentOverlays(); + const focused = + this.hasVisibleFocusInTree() || + !!this.childItems.find((child) => { + return child.hasVisibleFocusInTree(); + }); + item.focused = focused; this.setAttribute('aria-activedescendant', item.id); if ( item.menuData.selectionRoot && @@ -640,13 +731,34 @@ export class Menu extends SizedMixin(SpectrumElement) { } } - public override render(): TemplateResult { + private handleSlotchange({ + target, + }: Event & { target: HTMLSlotElement }): void { + const assignedElement = target.assignedElements({ + flatten: true, + }) as MenuItem[]; + if (this.childItems.length !== assignedElement.length) { + assignedElement.forEach((item) => { + if (typeof item.triggerUpdate !== 'undefined') { + item.triggerUpdate(); + } + }); + } + } + + protected renderMenuItemSlot(): TemplateResult { return html` - + `; } - private _notFirstUpdated = false; + public override render(): TemplateResult { + return this.renderMenuItemSlot(); + } protected override firstUpdated(changed: PropertyValues): void { super.firstUpdated(changed); @@ -671,17 +783,19 @@ export class Menu extends SizedMixin(SpectrumElement) { protected override updated(changes: PropertyValues): void { super.updated(changes); - if (changes.has('selects') && this._notFirstUpdated) { + if (changes.has('selects') && this.hasUpdated) { this.selectsChanged(); } - if (changes.has('label')) { + if ( + changes.has('label') && + (this.label || typeof changes.get('label') !== 'undefined') + ) { if (this.label) { this.setAttribute('aria-label', this.label); } else { this.removeAttribute('aria-label'); } } - this._notFirstUpdated = true; } protected selectsChanged(): void { @@ -708,6 +822,9 @@ export class Menu extends SizedMixin(SpectrumElement) { protected childItemsUpdated!: Promise; protected cacheUpdated = Promise.resolve(); + protected resolveCacheUpdated = (): void => { + return; + }; protected override async getUpdateComplete(): Promise { const complete = (await super.getUpdateComplete()) as boolean; diff --git a/packages/menu/src/MenuGroup.ts b/packages/menu/src/MenuGroup.ts index 63a265dc88..54fc12de7f 100644 --- a/packages/menu/src/MenuGroup.ts +++ b/packages/menu/src/MenuGroup.ts @@ -36,15 +36,7 @@ export class MenuGroup extends Menu { return [...super.styles, menuGroupStyles]; } - private static instances = 0; - - private headerId!: string; - - public constructor() { - super(); - MenuGroup.instances += 1; - this.headerId = `sp-menu-group-label-${MenuGroup.instances}`; - } + private headerId = ''; @queryAssignedNodes({ slot: 'header', @@ -75,6 +67,9 @@ export class MenuGroup extends Menu { this.headerElement.removeAttribute('id'); } if (headerElement) { + this.headerId = + this.headerId || + `sp-menu-group-label-${crypto.randomUUID().slice(0, 8)}`; const headerId = headerElement.id || this.headerId; if (!headerElement.id) { headerElement.id = headerId; @@ -92,9 +87,7 @@ export class MenuGroup extends Menu { - - - + ${this.renderMenuItemSlot()} `; } } diff --git a/packages/menu/src/MenuItem.ts b/packages/menu/src/MenuItem.ts index 764dde38e6..d0732333f2 100644 --- a/packages/menu/src/MenuItem.ts +++ b/packages/menu/src/MenuItem.ts @@ -30,15 +30,13 @@ import { LikeAnchor } from '@spectrum-web-components/shared/src/like-anchor.js'; 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'; import type { Menu } from './Menu.js'; -import type { OverlayOpenCloseDetail } from '@spectrum-web-components/overlay'; -import { reparentChildren } from '@spectrum-web-components/shared/src/reparent-children.js'; import { MutationController } from '@lit-labs/observers/mutation-controller.js'; +import '@spectrum-web-components/overlay/sp-overlay.js'; +import { OverlayBase } from 'overlay/src/OverlayBase.js'; /** * Duration during which a pointing device can leave an `` element @@ -46,84 +44,40 @@ import { MutationController } from '@lit-labs/observers/mutation-controller.js'; **/ const POINTERLEAVE_TIMEOUT = 100; -export class MenuItemRemovedEvent extends Event { - constructor() { - super('sp-menu-item-removed', { - bubbles: true, - composed: true, - }); - } - get item(): MenuItem { - return this._item; - } - _item!: MenuItem; - focused = false; - reset(item: MenuItem): void { - this._item = item; - } -} +type MenuCascadeItem = { + hadFocusRoot: boolean; + ancestorWithSelects?: HTMLElement; +}; export class MenuItemAddedOrUpdatedEvent extends Event { - constructor() { + constructor(item: MenuItem) { super('sp-menu-item-added-or-updated', { bubbles: true, composed: true, }); + this.clear(item); } - set focusRoot(root: Menu | undefined) { - this.item.menuData.focusRoot = this.item.menuData.focusRoot || root; - } - set selectionRoot(root: Menu) { - this.item.menuData.selectionRoot = - this.item.menuData.selectionRoot || root; - } - get item(): MenuItem { - return this._item; - } - _item!: MenuItem; - set currentAncestorWithSelects(ancestor: Menu | undefined) { - this._currentAncestorWithSelects = ancestor; - } - get currentAncestorWithSelects(): Menu | undefined { - return this._currentAncestorWithSelects; - } - _currentAncestorWithSelects?: Menu; - reset(item: MenuItem): void { + clear(item: MenuItem): void { this._item = item; - this._currentAncestorWithSelects = undefined; + this.currentAncestorWithSelects = undefined; item.menuData = { + cleanupSteps: [], focusRoot: undefined, selectionRoot: undefined, + parentMenu: undefined, }; + this.menuCascade = new WeakMap(); } + menuCascade = new WeakMap(); + get item(): MenuItem { + return this._item; + } + private _item!: MenuItem; + currentAncestorWithSelects?: Menu; } export type MenuItemChildren = { icon: Element[]; content: Node[] }; -let addOrUpdateEvent = new MenuItemAddedOrUpdatedEvent(); -let removeEvent = new MenuItemRemovedEvent(); -/** - * Code to cleanup these global events async in batches - */ -let addOrUpdateEventRafId = 0; -function resetAddOrUpdateEvent(): void { - if (addOrUpdateEventRafId === 0) { - addOrUpdateEventRafId = requestAnimationFrame(() => { - addOrUpdateEvent = new MenuItemAddedOrUpdatedEvent(); - addOrUpdateEventRafId = 0; - }); - } -} -let removeEventEventtRafId = 0; -function resetRemoveEvent(): void { - if (removeEventEventtRafId === 0) { - removeEventEventtRafId = requestAnimationFrame(() => { - removeEvent = new MenuItemRemovedEvent(); - removeEventEventtRafId = 0; - }); - } -} - /** * @element sp-menu-item * @@ -132,7 +86,6 @@ function resetRemoveEvent(): void { * @slot value - content placed at the end of the Menu Item like values, keyboard shortcuts, etc. * @slot submenu - content placed in a submenu * @fires sp-menu-item-added - announces the item has been added so a parent menu can take ownerships - * @fires sp-menu-item-removed - announces when removed from the DOM so the parent menu can remove ownership and update selected state */ export class MenuItem extends LikeAnchor( ObserveSlotText(ObserveSlotPresence(Focusable, '[slot="icon"]')) @@ -141,10 +94,6 @@ export class MenuItem extends LikeAnchor( return [menuItemStyles, checkmarkStyles, chevronStyles]; } - static instanceCount = 0; - - private isInSubmenu = false; - @property({ type: Boolean, reflect: true }) public active = false; @@ -186,6 +135,12 @@ export class MenuItem extends LikeAnchor( @property({ type: Boolean, reflect: true, attribute: 'has-submenu' }) public hasSubmenu = false; + @query('slot:not([name])') + contentSlot!: HTMLSlotElement; + + @query('slot[name="icon"]') + iconSlot!: HTMLSlotElement; + @property({ type: Boolean, reflect: true, @@ -199,6 +154,9 @@ export class MenuItem extends LikeAnchor( @query('.anchor') private anchorElement!: HTMLAnchorElement; + @query('sp-overlay') + public overlayElement!: OverlayBase; + public override get focusElement(): HTMLElement { return this; } @@ -211,24 +169,15 @@ export class MenuItem extends LikeAnchor( if (this._itemChildren) { return this._itemChildren; } - - const iconSlot = this.shadowRoot?.querySelector( - 'slot[name="icon"]' - ) as HTMLSlotElement; - const icon = !iconSlot - ? [] - : iconSlot.assignedElements().map((element) => { - const newElement = element.cloneNode(true) as HTMLElement; - newElement.removeAttribute('slot'); - newElement.classList.toggle('icon'); - return newElement; - }); - const contentSlot = this.shadowRoot?.querySelector( - 'slot:not([name])' - ) as HTMLSlotElement; - const content = !contentSlot - ? [] - : contentSlot.assignedNodes().map((node) => node.cloneNode(true)); + const icon = this.iconSlot.assignedElements().map((element) => { + const newElement = element.cloneNode(true) as HTMLElement; + newElement.removeAttribute('slot'); + newElement.classList.toggle('icon'); + return newElement; + }); + const content = this.contentSlot + .assignedNodes() + .map((node) => node.cloneNode(true)); this._itemChildren = { icon, content }; return this._itemChildren; @@ -238,8 +187,6 @@ export class MenuItem extends LikeAnchor( constructor() { super(); - this.proxyFocus = this.proxyFocus.bind(this); - this.addEventListener('click', this.handleClickCapture, { capture: true, }); @@ -256,7 +203,7 @@ export class MenuItem extends LikeAnchor( }); } - @property({ type: Boolean }) + @property({ type: Boolean, reflect: true }) public open = false; public override click(): void { @@ -280,9 +227,9 @@ export class MenuItem extends LikeAnchor( } } - private proxyFocus(): void { + private proxyFocus = (): void => { this.focus(); - } + }; private shouldProxyClick(): boolean { let handled = false; @@ -298,6 +245,52 @@ export class MenuItem extends LikeAnchor( this.triggerUpdate(); } + protected renderSubmenu(): TemplateResult { + const slot = html` + { + event.clear(event.item); + }, + capture: true, + }} + @focusin=${(event: Event) => event.stopPropagation()} + > + `; + if (!this.hasSubmenu) { + return slot; + } + return html` + event.stopPropagation()} + > + { + this.handleSubmenuChange(event); + this.open = false; + }} + @pointerenter=${this.handleSubmenuPointerenter} + @pointerleave=${this.handleSubmenuPointerleave} + @sp-menu-item-added-or-updated=${(event: Event) => + event.stopPropagation()} + > + ${slot} + + + + `; + } + protected override render(): TemplateResult { return html` ${this.selected @@ -325,21 +318,7 @@ export class MenuItem extends LikeAnchor( className: 'button anchor hidden', }) : html``} - - - ${this.hasSubmenu - ? html` - - ` - : html``} + ${this.renderSubmenu()} `; } @@ -347,18 +326,14 @@ export class MenuItem extends LikeAnchor( const assignedElements = event.target.assignedElements({ flatten: true, }); - this.hasSubmenu = this.open || !!assignedElements; + this.hasSubmenu = !!assignedElements.length; if (this.hasSubmenu) { this.setAttribute('aria-haspopup', 'true'); } } - private handleRemoveActive(event: Event): void { - if ( - (event.type === 'pointerleave' && this.hasSubmenu) || - this.hasSubmenu || - this.open - ) { + private handleRemoveActive(): void { + if (this.open) { return; } this.active = false; @@ -372,23 +347,21 @@ export class MenuItem extends LikeAnchor( super.firstUpdated(changes); this.setAttribute('tabindex', '-1'); this.addEventListener('pointerdown', this.handlePointerdown); + this.addEventListener('pointerenter', this.closeOverlaysForRoot); if (!this.hasAttribute('id')) { - this.id = `sp-menu-item-${MenuItem.instanceCount++}`; + this.id = `sp-menu-item-${crypto.randomUUID().slice(0, 8)}`; } - this.addEventListener('pointerenter', this.closeOverlaysForRoot); } protected closeOverlaysForRoot(): void { if (this.open) return; - const overalyCloseEvent = new OverlayCloseEvent({ - root: this.menuData.focusRoot, - }); - this.dispatchEvent(overalyCloseEvent); + this.menuData.parentMenu?.closeDescendentOverlays(); } - public closeOverlay?: () => Promise; - - protected handleSubmenuClick(): void { + protected handleSubmenuClick(event: Event): void { + if (event.composedPath().includes(this.overlayElement)) { + return; + } this.openOverlay(); } @@ -402,12 +375,13 @@ export class MenuItem extends LikeAnchor( } protected leaveTimeout?: ReturnType; + protected recentlyLeftChild = false; protected handlePointerleave(): void { - if (this.hasSubmenu && this.open) { + if (this.open && !this.recentlyLeftChild) { this.leaveTimeout = setTimeout(() => { delete this.leaveTimeout; - if (this.closeOverlay) this.closeOverlay(); + this.open = false; }, POINTERLEAVE_TIMEOUT); } } @@ -419,16 +393,36 @@ export class MenuItem extends LikeAnchor( * and the root of the tree to have their selection changes and * be closed. */ - protected handleSubmenuChange = (): void => { + protected handleSubmenuChange(event: Event): void { + event.stopPropagation(); this.menuData.selectionRoot?.selectOrToggleItem(this); - }; + } - protected handleSubmenuPointerenter = (): void => { - if (this.leaveTimeout) { - clearTimeout(this.leaveTimeout); - delete this.leaveTimeout; - } - }; + protected handleSubmenuPointerenter(): void { + this.recentlyLeftChild = true; + } + + protected async handleSubmenuPointerleave(): Promise { + requestAnimationFrame(() => { + this.recentlyLeftChild = false; + }); + } + + protected handleSubmenuOpen(event: Event): void { + this.focused = false; + const parentOverlay = event.composedPath().find((el) => { + return ( + el !== this.overlayElement && + (el as HTMLElement).localName === 'sp-overlay' + ); + }) as OverlayBase; + this.overlayElement.parentOverlayToForceClose = parentOverlay; + } + + protected cleanup(): void { + this.open = false; + this.active = false; + } public async openOverlay(): Promise { if (!this.hasSubmenu || this.open || this.disabled) { @@ -437,57 +431,9 @@ export class MenuItem extends LikeAnchor( this.open = true; this.active = true; this.setAttribute('aria-expanded', 'true'); - const submenu = ( - this.shadowRoot.querySelector( - 'slot[name="submenu"]' - ) as HTMLSlotElement - ).assignedElements()[0] as Menu; - submenu.addEventListener( - 'pointerenter', - this.handleSubmenuPointerenter - ); - submenu.addEventListener('change', this.handleSubmenuChange); - if (!submenu.id) { - submenu.setAttribute('id', `${this.id}-submenu`); - } - this.setAttribute('aria-controls', submenu.id); - const popover = document.createElement('sp-popover'); - const returnSubmenu = reparentChildren([submenu], popover, { - position: 'beforeend', - prepareCallback: (el) => { - 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', - root: this.menuData.focusRoot, - }); - const closeSubmenu = async (): Promise => { - this.setAttribute('aria-expanded', 'false'); - delete this.closeOverlay; - (await closeOverlay)(); - }; - this.closeOverlay = closeSubmenu; - const cleanup = (event: CustomEvent): void => { - event.stopPropagation(); - delete this.closeOverlay; - returnSubmenu(); - this.open = false; - this.active = false; - }; - this.addEventListener('sp-closed', cleanup as EventListener, { + this.addEventListener('sp-closed', this.cleanup, { once: true, }); - popover.addEventListener('change', closeSubmenu); } updateAriaSelected(): void { @@ -509,11 +455,18 @@ export class MenuItem extends LikeAnchor( protected override updated(changes: PropertyValues): void { super.updated(changes); - if (changes.has('label')) { + if ( + changes.has('label') && + (this.label || typeof changes.get('label') !== 'undefined') + ) { this.setAttribute('aria-label', this.label || ''); } - if (changes.has('active')) { + if ( + changes.has('active') && + (this.active || typeof changes.get('active') !== 'undefined') + ) { if (this.active) { + this.menuData.selectionRoot?.closeDescendentOverlays(); this.addEventListener('pointerup', this.handleRemoveActive); this.addEventListener('pointerleave', this.handleRemoveActive); this.addEventListener('pointercancel', this.handleRemoveActive); @@ -536,12 +489,17 @@ export class MenuItem extends LikeAnchor( if (changes.has('selected')) { this.updateAriaSelected(); } - if (changes.has('hasSubmenu')) { + if ( + changes.has('hasSubmenu') && + (this.hasSubmenu || + typeof changes.get('hasSubmenu') !== 'undefined') + ) { if (this.hasSubmenu) { this.addEventListener('click', this.handleSubmenuClick); this.addEventListener('pointerenter', this.handlePointerenter); this.addEventListener('pointerleave', this.handlePointerleave); - } else if (!this.closeOverlay) { + this.addEventListener('sp-opened', this.handleSubmenuOpen); + } else { this.removeEventListener('click', this.handleSubmenuClick); this.removeEventListener( 'pointerenter', @@ -551,57 +509,54 @@ export class MenuItem extends LikeAnchor( 'pointerleave', this.handlePointerleave ); + this.removeEventListener('sp-opened', this.handleSubmenuOpen); } } } public override connectedCallback(): void { super.connectedCallback(); - this.isInSubmenu = !!this.closest('[slot="submenu"]'); - if (this.isInSubmenu) { - return; - } - addOrUpdateEvent.reset(this); - this.dispatchEvent(addOrUpdateEvent); - resetAddOrUpdateEvent(); - this._parentElement = this.parentElement as HTMLElement; + this.triggerUpdate(); } _parentElement!: HTMLElement; public override disconnectedCallback(): void { - if (!this.isInSubmenu && this._parentElement) { - removeEvent.reset(this); - this._parentElement.dispatchEvent(removeEvent); - resetRemoveEvent(); - } - this.isInSubmenu = false; - this._itemChildren = undefined; + this.menuData.cleanupSteps.forEach((removal) => removal(this)); super.disconnectedCallback(); } + private willDispatchUpdate = false; + public async triggerUpdate(): Promise { - if (this.isInSubmenu) { + if (this.willDispatchUpdate) { return; } + this.willDispatchUpdate = true; await new Promise((ready) => requestAnimationFrame(ready)); - addOrUpdateEvent.reset(this); - this.dispatchEvent(addOrUpdateEvent); - resetAddOrUpdateEvent(); + this.dispatchUpdate(); + } + + public dispatchUpdate(): void { + this.dispatchEvent(new MenuItemAddedOrUpdatedEvent(this)); + this.willDispatchUpdate = false; } public menuData: { focusRoot?: Menu; + parentMenu?: Menu; selectionRoot?: Menu; + cleanupSteps: ((item: MenuItem) => void)[]; } = { focusRoot: undefined, + parentMenu: undefined, selectionRoot: undefined, + cleanupSteps: [], }; } declare global { interface GlobalEventHandlersEventMap { 'sp-menu-item-added-or-updated': MenuItemAddedOrUpdatedEvent; - 'sp-menu-item-removed': MenuItemRemovedEvent; } } diff --git a/packages/menu/src/menu-item.css b/packages/menu/src/menu-item.css index 36778838cb..84d1e55015 100644 --- a/packages/menu/src/menu-item.css +++ b/packages/menu/src/menu-item.css @@ -45,3 +45,8 @@ governing permissions and limitations under the License. forced-color-adjust: none; } } + +::slotted([slot='submenu']) { + width: max-content; + max-width: 100%; +} diff --git a/packages/menu/src/menu.css b/packages/menu/src/menu.css index 884e9b220c..a2b1a41a48 100644 --- a/packages/menu/src/menu.css +++ b/packages/menu/src/menu.css @@ -28,3 +28,10 @@ governing permissions and limitations under the License. :host(:focus) { outline: none; } + +::slotted(*) { + /** + * Remove when Spectrum CSS supports this correctly + */ + flex-shrink: 0; +} diff --git a/packages/menu/stories/submenu.stories.ts b/packages/menu/stories/submenu.stories.ts index 8be920bfad..fcc612fe77 100644 --- a/packages/menu/stories/submenu.stories.ts +++ b/packages/menu/stories/submenu.stories.ts @@ -13,6 +13,7 @@ governing permissions and limitations under the License. import { html, render, TemplateResult } from '@spectrum-web-components/base'; import '@spectrum-web-components/action-menu/sp-action-menu.js'; +import '@spectrum-web-components/menu/sp-menu.js'; import '@spectrum-web-components/menu/sp-menu-item.js'; import '@spectrum-web-components/menu/sp-menu-divider.js'; import '@spectrum-web-components/menu/sp-menu-group.js'; @@ -43,49 +44,55 @@ class SubmenuReady extends HTMLElement { }); } + menu!: ActionMenu; + submenu!: MenuItem; + submenuChild!: MenuItem; + async setup(): Promise { await nextFrame(); - const menu = document.querySelector(`sp-action-menu`) as ActionMenu; - menu.addEventListener('sp-opened', this.handleMenuOpened, { - once: true, - }); - menu.open = true; + this.menu = document.querySelector(`sp-action-menu`) as ActionMenu; + this.menu.addEventListener('sp-opened', this.handleMenuOpened); + this.menu.open = true; } handleMenuOpened = async (event: Event): Promise => { + this.menu.removeEventListener('sp-opened', this.handleMenuOpened); await nextFrame(); await (event.target as ActionMenu).updateComplete; - const submenu = document.querySelector('#submenu-item-1') as MenuItem; - if (!submenu) { + this.submenu = document.querySelector('#submenu-item-1') as MenuItem; + if (!this.submenu) { return; } - submenu.addEventListener('sp-opened', this.handleSubmenuOpened, { - once: true, - }); - submenu.dispatchEvent( - new PointerEvent('pointerenter', { bubbles: true, composed: true }) - ); + + this.submenu.addEventListener('sp-opened', this.handleSubmenuOpened); + this.submenu.click(); }; handleSubmenuOpened = async (event: Event): Promise => { + this.submenu.removeEventListener('sp-opened', this.handleSubmenuOpened); await nextFrame(); await (event.target as MenuItem).updateComplete; - const submenu = document.querySelector('#submenu-item-2') as MenuItem; - if (!submenu) { + this.submenuChild = document.querySelector( + '#submenu-item-2' + ) as MenuItem; + if (!this.submenuChild) { return; } - submenu.addEventListener('sp-opened', this.handleSubmenuChildOpened, { - once: true, - }); - submenu.dispatchEvent( - new PointerEvent('pointerenter', { bubbles: true, composed: true }) + this.submenuChild.addEventListener( + 'sp-opened', + this.handleSubmenuChildOpened ); + this.submenuChild.click(); }; handleSubmenuChildOpened = async (event: Event): Promise => { + this.submenuChild.removeEventListener( + 'sp-opened', + this.handleSubmenuChildOpened + ); await nextFrame(); await (event.target as MenuItem).updateComplete; @@ -293,6 +300,7 @@ export const contextMenu = (): TemplateResult => { placement: 'right-start', receivesFocus: 'auto', virtualTrigger, + notImmediatelyClosable: true, }); }; const getValueEls = (): { root: HTMLElement; first: HTMLElement } => { @@ -309,6 +317,9 @@ export const contextMenu = (): TemplateResult => { const handleRootChange = (event: Event & { target: ActionMenu }): void => { const valueEls = getValueEls(); valueEls.root.textContent = event.target.value; + event.target.parentElement?.dispatchEvent( + new Event('close', { bubbles: true }) + ); }; const handleFirstDescendantChange = ( event: Event & { target: Menu } diff --git a/packages/menu/test/menu-group.test.ts b/packages/menu/test/menu-group.test.ts index 74e03a9767..63e61f5e50 100644 --- a/packages/menu/test/menu-group.test.ts +++ b/packages/menu/test/menu-group.test.ts @@ -110,31 +110,46 @@ describe('Menu group', () => { html` First + Second + Multi1 + Multi2 + SubInherit1 + SubInherit2 + Single1 + Single2 + Inherit1 + Inherit2 + Inherit1 + Inherit2 + ` ); + // 1 & 3 should be menuitemradio + // 2 shouwl menuitemcheckbox + await waitUntil( () => managedItems(el).length === 4, `expected outer menu to manage 4 items (2 are inherited), got ${ @@ -257,6 +272,7 @@ describe('Menu group', () => { expect(el.selectedItems.length).to.equal(1); noneItem2.click(); + await elementUpdated(el); await elementUpdated(noneGroup); await elementUpdated(noneItem2); expect(inheritItem1.selected).to.be.true; @@ -268,8 +284,8 @@ describe('Menu group', () => { await elementUpdated(singleGroup); await elementUpdated(singleItem1); await elementUpdated(singleItem2); - expect(singleItem1.selected, 'first item not selected').to.be.false; expect(singleItem2.selected).to.be.true; + expect(singleItem1.selected, 'first item not selected').to.be.false; expect(inheritItem1.selected).to.be.true; expect(singleItem1.getAttribute('aria-checked')).to.equal('false'); expect(singleItem2.getAttribute('aria-checked')).to.equal('true'); diff --git a/packages/menu/test/menu-selects.test.ts b/packages/menu/test/menu-selects.test.ts index c5d6ed8f7a..29b51c8a17 100644 --- a/packages/menu/test/menu-selects.test.ts +++ b/packages/menu/test/menu-selects.test.ts @@ -660,14 +660,17 @@ describe('Menu w/ groups [selects]', () => { await sendKeys({ press: 'ArrowUp' }); await elementUpdated(el); + let optionCount = 0; for (const option of options) { const parentElement = option.parentElement as Menu; expect(document.activeElement === parentElement, 'parent focused') .to.be.true; - expect(option.focused, 'option visually focused').to.be.true; + expect(option.focused, `option ${optionCount} visually focused`).to + .be.true; await sendKeys({ press: 'Space' }); expect(parentElement.value).to.equal(option.value); await sendKeys({ press: 'ArrowDown' }); + optionCount += 1; } }); }); diff --git a/packages/menu/test/menu.test.ts b/packages/menu/test/menu.test.ts index 7aa4b6bf39..afb4d8aaf2 100644 --- a/packages/menu/test/menu.test.ts +++ b/packages/menu/test/menu.test.ts @@ -377,10 +377,17 @@ describe('Menu', () => { const selectedItem = el.querySelector('.selected') as MenuItem; await elementUpdated(el); - + await nextFrame(); el.focus(); expect(document.activeElement).to.equal(el); + // Enforce visible focus + await sendKeys({ + press: 'ArrowUp', + }); + await sendKeys({ + press: 'ArrowDown', + }); expect(selectedItem.focused).to.be.true; selectedItem.remove(); diff --git a/packages/menu/test/submenu.test.ts b/packages/menu/test/submenu.test.ts index 79f726b002..378e59d396 100644 --- a/packages/menu/test/submenu.test.ts +++ b/packages/menu/test/submenu.test.ts @@ -14,6 +14,7 @@ import '@spectrum-web-components/menu/sp-menu.js'; import '@spectrum-web-components/menu/sp-menu-item.js'; import { Menu, MenuItem } from '@spectrum-web-components/menu'; import { + aTimeout, elementUpdated, expect, fixture, @@ -32,14 +33,46 @@ 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'; -import { ActiveOverlay } from '@spectrum-web-components/overlay'; async function styledFixture( story: TemplateResult, dir: 'ltr' | 'rtl' | 'auto' = 'ltr' ): Promise { const test = await fixture(html` - ${story} + + ${story} + + `); document.documentElement.dir = dir; return test.children[0] as T; @@ -118,11 +151,11 @@ describe('Submenu', () => { ], }); await closed; - await nextFrame(); - expect(rootChanged.calledWith('Has submenu'), 'root changed').to.be - .true; - expect(submenuChanged.calledWith('Two'), 'submenu changed').to.be.true; + expect(submenuChanged.withArgs('Two').calledOnce, 'submenu changed').to + .be.true; + expect(rootChanged.withArgs('Has submenu').calledOnce, 'root changed') + .to.be.true; }); it('closes deep tree on selection', async () => { const rootChanged = spy(); @@ -175,13 +208,14 @@ describe('Submenu', () => { ` ); - - await elementUpdated(el); const rootItem = el.querySelector('.root') as MenuItem; const rootItemBoundingRect = rootItem.getBoundingClientRect(); + const item2 = document.querySelector('.submenu-item-2') as MenuItem; + const itemC = document.querySelector('.sub-submenu-item-3') as MenuItem; expect(rootItem.open).to.be.false; - const opened = oneEvent(rootItem, 'sp-opened'); + let opened = oneEvent(rootItem, 'sp-opened'); + // Hover the root menu item to open a submenu sendMouse({ steps: [ { @@ -199,10 +233,10 @@ describe('Submenu', () => { expect(rootItem.open).to.be.true; - const item2 = document.querySelector('.submenu-item-2') as MenuItem; const item2BoundingRect = item2.getBoundingClientRect(); - let closed = oneEvent(item2, 'sp-opened'); + opened = oneEvent(item2, 'sp-opened'); + // Click the submenu item to open a submenu sendMouse({ steps: [ { @@ -214,28 +248,14 @@ describe('Submenu', () => { }, ], }); - await closed; - await nextFrame(); + await opened; expect(item2.open).to.be.true; - const itemC = document.querySelector('.sub-submenu-item-3') as MenuItem; - const itemCBoundingRect = itemC.getBoundingClientRect(); - - closed = oneEvent(rootItem, 'sp-closed'); - sendMouse({ - steps: [ - { - type: 'click', - position: [ - itemCBoundingRect.left + itemCBoundingRect.width / 2, - itemCBoundingRect.top + itemCBoundingRect.height / 2, - ], - }, - ], - }); + const closed = oneEvent(rootItem, 'sp-closed'); + // click to select and close + itemC.click(); await closed; - await nextFrame(); expect(rootChanged.calledWith('Has submenu'), 'root changed').to.be .true; @@ -267,6 +287,7 @@ describe('Submenu', () => { const el = await styledFixture( html` { rootChanged(event.target.value); }} @@ -274,6 +295,7 @@ describe('Submenu', () => { Has submenu { submenuChanged(event.target.value); @@ -297,6 +319,7 @@ describe('Submenu', () => { await elementUpdated(el); const rootItem = el.querySelector('.root') as MenuItem; + const submenu = el.querySelector('[slot="submenu"]') as Menu; expect(rootItem.open).to.be.false; el.focus(); await elementUpdated(el); @@ -308,6 +331,10 @@ describe('Submenu', () => { await opened; expect(rootItem.open).to.be.true; + expect( + submenu === document.activeElement, + `${document.activeElement?.id}` + ).to.be.true; let closed = oneEvent(rootItem, 'sp-closed'); sendKeys({ @@ -316,6 +343,10 @@ describe('Submenu', () => { await closed; expect(rootItem.open).to.be.false; + expect( + el === document.activeElement, + `${document.activeElement?.id}` + ).to.be.true; opened = oneEvent(rootItem, 'sp-opened'); sendKeys({ @@ -335,10 +366,13 @@ describe('Submenu', () => { }); await closed; - expect(rootChanged.calledWith('Has submenu'), 'root changed').to.be - .true; expect(submenuChanged.calledWith('Two'), 'submenu changed').to.be .true; + expect(rootChanged.called, 'root has changed').to.be.true; + expect( + rootChanged.calledWith('Has submenu'), + 'root specifically changed' + ).to.be.true; }); }); it('closes on `pointerleave`', async () => { @@ -491,7 +525,8 @@ describe('Submenu', () => { }); await closed; }); - it('stays open when mousing between menu item and submenu', async () => { + it('continues to open when mousing between menu item and submenu', async () => { + const clickSpy = spy(); const el = await styledFixture( html` @@ -501,7 +536,10 @@ describe('Submenu', () => { One - + clickSpy()} + > Two @@ -515,6 +553,7 @@ describe('Submenu', () => { await elementUpdated(el); const rootItem = el.querySelector('.root') as MenuItem; + const subItem = el.querySelector('.submenu-item-2') as MenuItem; const rootItemBoundingRect = rootItem.getBoundingClientRect(); expect(rootItem.open).to.be.false; @@ -532,26 +571,87 @@ describe('Submenu', () => { }, ], }); + await nextFrame(); + await nextFrame(); + const subItemBoundingRect = subItem.getBoundingClientRect(); await sendMouse({ steps: [ { type: 'move', position: [ - rootItemBoundingRect.left + - rootItemBoundingRect.width / 2, - rootItemBoundingRect.top + - rootItemBoundingRect.height * 2, + subItemBoundingRect.left + + subItemBoundingRect.width / 2, + subItemBoundingRect.top + + subItemBoundingRect.height / 2, ], }, ], }); + await opened; + expect(rootItem.open).to.be.true; + // Ensure it _doesn't_ get closed. + await aTimeout(150); + + expect(rootItem.open).to.be.true; + + const closed = oneEvent(rootItem, 'sp-closed'); + sendMouse({ + steps: [ + { + type: 'click', + position: [ + subItemBoundingRect.left + + subItemBoundingRect.width / 2, + subItemBoundingRect.top + + subItemBoundingRect.height / 2, + ], + }, + ], + }); + await closed; + + expect(clickSpy.callCount).to.equal(1); + }); + it('stays open when mousing between menu item and submenu', async () => { + const clickSpy = spy(); + const el = await styledFixture( + html` + + + Has submenu + + + One + + clickSpy()} + > + Two + + + Three + + + + + ` + ); + + await elementUpdated(el); + const rootItem = el.querySelector('.root') as MenuItem; + const subItem = el.querySelector('.submenu-item-2') as MenuItem; + const rootItemBoundingRect = rootItem.getBoundingClientRect(); + expect(rootItem.open).to.be.false; + + const opened = oneEvent(rootItem, 'sp-opened'); await sendMouse({ steps: [ { type: 'move', position: [ rootItemBoundingRect.left + - rootItemBoundingRect.width * 1.5, + rootItemBoundingRect.width / 2, rootItemBoundingRect.top + rootItemBoundingRect.height / 2, ], @@ -559,8 +659,47 @@ describe('Submenu', () => { ], }); await opened; + await nextFrame(); + await nextFrame(); + const subItemBoundingRect = subItem.getBoundingClientRect(); + expect(rootItem.open).to.be.true; + + await sendMouse({ + steps: [ + { + type: 'move', + position: [ + subItemBoundingRect.left + + subItemBoundingRect.width / 2, + subItemBoundingRect.top + + subItemBoundingRect.height / 2, + ], + }, + ], + }); + expect(rootItem.open).to.be.true; + // Ensure it _doesn't_ get closed. + await aTimeout(150); expect(rootItem.open).to.be.true; + + const closed = oneEvent(rootItem, 'sp-closed'); + sendMouse({ + steps: [ + { + type: 'click', + position: [ + subItemBoundingRect.left + + subItemBoundingRect.width / 2, + subItemBoundingRect.top + + subItemBoundingRect.height / 2, + ], + }, + ], + }); + await closed; + + expect(clickSpy.callCount).to.equal(1); }); it('not opens if disabled', async () => { const el = await styledFixture( @@ -611,15 +750,15 @@ describe('Submenu', () => { const el = await styledFixture(html` - + New York Bronx Brooklyn - + Ft. Greene - + S. Oxford St S. Portland Ave S. Elliot Pl @@ -631,7 +770,7 @@ describe('Submenu', () => { Manhattan - + SoHo Union Square @@ -648,9 +787,9 @@ describe('Submenu', () => { `); - 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; + const rootMenu1 = el.querySelector('#submenu-item-1') as MenuItem; + const rootMenu2 = el.querySelector('#submenu-item-3') as MenuItem; + const childMenu2 = el.querySelector('#submenu-item-2') as MenuItem; expect(el.open).to.be.false; let opened = oneEvent(el, 'sp-opened'); @@ -658,35 +797,29 @@ describe('Submenu', () => { 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); + expect(rootMenu1.open).to.be.true; 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); + expect(childMenu2.open).to.be.true; - const overlaysManaged = Promise.all([ - oneEvent(childMenu2, 'sp-closed'), - oneEvent(rootMenu1, 'sp-closed'), - oneEvent(rootMenu2, 'sp-opened'), - ]); + const childMenu2Closed = oneEvent(childMenu2, 'sp-closed'); + const rootMenu1Closed = oneEvent(rootMenu1, 'sp-closed'); + const rootMenu2Opened = oneEvent(rootMenu2, 'sp-opened'); rootMenu2.dispatchEvent( new PointerEvent('pointerenter', { bubbles: true }) ); - await overlaysManaged; - activeOverlays = document.querySelectorAll('active-overlay'); - expect(activeOverlays.length).to.equal(2); + await childMenu2Closed; + await rootMenu1Closed; + await rootMenu2Opened; }); it('closes back to the first overlay without a `root` when clicking away', async () => { @@ -739,33 +872,31 @@ describe('Submenu', () => { 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(); + sendMouse({ + steps: [ + { + type: 'click', + position: [600, 5], + }, + ], + }); await closed; - activeOverlays = document.querySelectorAll('active-overlay'); - expect(activeOverlays.length).to.equal(0); }); it('closes decendent menus when Menu Item in ancestor without a submenu is pointerentered', async () => { @@ -808,23 +939,17 @@ describe('Submenu', () => { 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 () => { @@ -869,6 +994,7 @@ describe('Submenu', () => { `); + await nextFrame(); const rootMenu1 = el.querySelector('#submenu-item-1') as MenuItem; const childMenu2 = el.querySelector('#submenu-item-2') as MenuItem; @@ -880,35 +1006,41 @@ describe('Submenu', () => { 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(); + const rect = ancestorItem.getBoundingClientRect(); + await sendMouse({ + steps: [ + { + type: 'click', + position: [ + rect.left + rect.width / 2, + rect.top + rect.height / 2, + ], + }, + ], + }); await closed; - activeOverlays = document.querySelectorAll('active-overlay'); - expect(activeOverlays.length).to.equal(0); }); it('cleans up submenus that close before they are "open"', async () => { + if ('showPopover' in document.createElement('div')) { + return; + } await sendMouse({ steps: [ { @@ -960,7 +1092,6 @@ describe('Submenu', () => { const rootItemBoundingRect1 = rootItem1.getBoundingClientRect(); const rootItemBoundingRect2 = rootItem2.getBoundingClientRect(); - let activeOverlay!: ActiveOverlay | null; // Open the first submenu await sendMouse({ @@ -1018,6 +1149,12 @@ describe('Submenu', () => { }, ], }); + await nextFrame(); + await nextFrame(); + await nextFrame(); + await nextFrame(); + await nextFrame(); + await nextFrame(); const closed = oneEvent(rootItem2, 'sp-closed'); // Close the second submenu await sendMouse({ @@ -1028,22 +1165,13 @@ describe('Submenu', () => { rootItemBoundingRect2.left + rootItemBoundingRect2.width / 2, rootItemBoundingRect2.top + - rootItemBoundingRect2.top + - rootItemBoundingRect2.height / 2, + rootItemBoundingRect2.height * 2, ], }, ], }); - activeOverlay = document.querySelector( - 'active-overlay' - ) as ActiveOverlay; - expect(activeOverlay).to.not.be.null; await closed; - activeOverlay = document.querySelector( - 'active-overlay' - ) as ActiveOverlay; - expect(activeOverlay).to.be.null; expect(rootItem1.open, 'finally closed 1').to.be.false; expect(rootItem2.open, 'finally closed 2').to.be.false; });