/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {html, isServer, LitElement} from 'lit';
import {property, queryAssignedElements} from 'lit/decorators.js';
import {
createDeactivateItemsEvent,
createRequestActivationEvent,
deactivateActiveItem,
getFirstActivatableItem,
} from '../../../list/internal/list-navigation-helpers.js';
import {MenuItem} from '../controllers/menuItemController.js';
import {
CloseMenuEvent,
CloseReason,
createActivateTypeaheadEvent,
createDeactivateTypeaheadEvent,
KeydownCloseKey,
Menu,
NavigableKey,
SelectionKey,
} from '../controllers/shared.js';
import {Corner} from '../menu.js';
/**
* @fires deactivate-items {Event} Requests the parent menu to deselect other
* items when a submenu opens. --bubbles --composed
* @fires request-activation {Event} Requests the parent to make the slotted item
* focusable and focus the item. --bubbles --composed
* @fires deactivate-typeahead {Event} Requests the parent menu to deactivate
* the typeahead functionality when a submenu opens. --bubbles --composed
* @fires activate-typeahead {Event} Requests the parent menu to activate the
* typeahead functionality when a submenu closes. --bubbles --composed
*/
export class SubMenu extends LitElement {
/**
* The anchorCorner to set on the submenu.
*/
@property({attribute: 'anchor-corner'})
anchorCorner: Corner = Corner.START_END;
/**
* The menuCorner to set on the submenu.
*/
@property({attribute: 'menu-corner'}) menuCorner: Corner = Corner.START_START;
/**
* The delay between mouseenter and submenu opening.
*/
@property({type: Number, attribute: 'hover-open-delay'}) hoverOpenDelay = 400;
/**
* The delay between ponterleave and the submenu closing.
*/
@property({type: Number, attribute: 'hover-close-delay'})
hoverCloseDelay = 400;
/**
* READONLY: self-identifies as a menu item and sets its identifying attribute
*/
@property({type: Boolean, reflect: true, attribute: 'md-sub-menu'})
isSubMenu = true;
get item() {
return this.items[0] ?? null;
}
get menu() {
return this.menus[0] ?? null;
}
@queryAssignedElements({slot: 'item', flatten: true})
private readonly items!: MenuItem[];
@queryAssignedElements({slot: 'menu', flatten: true})
private readonly menus!: Menu[];
private previousOpenTimeout = 0;
private previousCloseTimeout = 0;
constructor() {
super();
if (!isServer) {
this.addEventListener('mouseenter', this.onMouseenter);
this.addEventListener('mouseleave', this.onMouseleave);
}
}
override render() {
return html`
`;
}
protected override firstUpdated() {
// slotchange is not fired if the contents have been SSRd
this.onSlotchange();
}
/**
* Shows the submenu.
*/
async show() {
const menu = this.menu;
if (!menu || menu.open) return;
// Ensures that we deselect items when the menu closes and reactivate
// typeahead when the menu closes, so that we do not have dirty state of
// `sub-menu > menu-item[selected]` when we reopen.
//
// This cannot happen in `close()` because the menu may close via other
// means Additionally, this cannot happen in onCloseSubmenu because
// `close-menu` may not be called via focusout of outside click and not
// triggered by an item
menu.addEventListener(
'closed',
() => {
this.item.ariaExpanded = 'false';
this.dispatchEvent(createActivateTypeaheadEvent());
this.dispatchEvent(createDeactivateItemsEvent());
// aria-hidden required so ChromeVox doesn't announce the closed menu
menu.ariaHidden = 'true';
},
{once: true},
);
// Parent menu is `position: absolute` – this creates a new CSS relative
// positioning context (similar to doing `position: relative`), so the
// submenu's `` would be
// wrong even if we change `md-sub-menu` from `position: relative` to
// `position: static` because the submenu it would still be positioning
// itself relative to the parent menu.
if (menu.positioning === 'document') {
menu.positioning = 'absolute';
}
menu.quick = true;
// Submenus are in overflow when not fixed. Can remove once we have native
// popup support
menu.hasOverflow = true;
menu.anchorCorner = this.anchorCorner;
menu.menuCorner = this.menuCorner;
menu.anchorElement = this.item;
menu.defaultFocus = 'first-item';
// aria-hidden management required so ChromeVox doesn't announce the closed
// menu. Remove it here since we are about to show and focus it.
menu.removeAttribute('aria-hidden');
// This is required in the case where we have a leaf menu open and and the
// user hovers a parent menu's item which is not an md-sub-menu item.
// If this were set to true, then the menu would close and focus would be
// lost. That means the focusout event would have a `relatedTarget` of
// `null` since nothing in the menu would be focused anymore due to the
// leaf menu closing. restoring focus ensures that we keep focus in the
// submenu tree.
menu.skipRestoreFocus = false;
// Menu could already be opened because of mouse interaction
const menuAlreadyOpen = menu.open;
menu.show();
this.item.ariaExpanded = 'true';
this.item.ariaHasPopup = 'menu';
if (menu.id) {
this.item.setAttribute('aria-controls', menu.id);
}
// Deactivate other items. This can be the case if the user has tabbed
// around the menu and then mouses over an md-sub-menu.
this.dispatchEvent(createDeactivateItemsEvent());
this.dispatchEvent(createDeactivateTypeaheadEvent());
this.item.selected = true;
// This is the case of mouse hovering when already opened via keyboard or
// vice versa
if (!menuAlreadyOpen) {
let open = (value: unknown) => {};
const opened = new Promise((resolve) => {
open = resolve;
});
menu.addEventListener('opened', open, {once: true});
await opened;
}
}
/**
* Closes the submenu.
*/
async close() {
const menu = this.menu;
if (!menu || !menu.open) return;
this.dispatchEvent(createActivateTypeaheadEvent());
menu.quick = true;
menu.close();
this.dispatchEvent(createDeactivateItemsEvent());
let close = (value: unknown) => {};
const closed = new Promise((resolve) => {
close = resolve;
});
menu.addEventListener('closed', close, {once: true});
await closed;
}
protected onSlotchange() {
if (!this.item) {
return;
}
// TODO(b/301296618): clean up old aria values on change
this.item.ariaExpanded = 'false';
this.item.ariaHasPopup = 'menu';
if (this.menu?.id) {
this.item.setAttribute('aria-controls', this.menu.id);
}
this.item.keepOpen = true;
const menu = this.menu;
if (!menu) return;
menu.isSubmenu = true;
// Required for ChromeVox to not linearly navigate to the menu while closed
menu.ariaHidden = 'true';
}
/**
* Starts the default 400ms countdown to open the submenu.
*
* NOTE: We explicitly use mouse events and not pointer events because
* pointer events apply to touch events. And if a user were to tap a
* sub-menu, it would fire the "pointerenter", "pointerleave", "click" events
* which would open the menu on click, and then set the timeout to close the
* menu due to pointerleave.
*/
protected onMouseenter = () => {
clearTimeout(this.previousOpenTimeout);
clearTimeout(this.previousCloseTimeout);
if (this.menu?.open) return;
// Open synchronously if delay is 0. (screenshot tests infra
// would never resolve otherwise)
if (!this.hoverOpenDelay) {
this.show();
} else {
this.previousOpenTimeout = setTimeout(() => {
this.show();
}, this.hoverOpenDelay);
}
};
/**
* Starts the default 400ms countdown to close the submenu.
*
* NOTE: We explicitly use mouse events and not pointer events because
* pointer events apply to touch events. And if a user were to tap a
* sub-menu, it would fire the "pointerenter", "pointerleave", "click" events
* which would open the menu on click, and then set the timeout to close the
* menu due to pointerleave.
*/
protected onMouseleave = () => {
clearTimeout(this.previousCloseTimeout);
clearTimeout(this.previousOpenTimeout);
// Close synchronously if delay is 0. (screenshot tests infra
// would never resolve otherwise)
if (!this.hoverCloseDelay) {
this.close();
} else {
this.previousCloseTimeout = setTimeout(() => {
this.close();
}, this.hoverCloseDelay);
}
};
protected onClick() {
this.show();
}
/**
* On item keydown handles opening the submenu.
*/
protected async onKeydown(event: KeyboardEvent) {
const shouldOpenSubmenu = this.isSubmenuOpenKey(event.code);
if (event.defaultPrevented) return;
const openedWithLR =
shouldOpenSubmenu &&
(NavigableKey.LEFT === event.code || NavigableKey.RIGHT === event.code);
if (event.code === SelectionKey.SPACE || openedWithLR) {
// prevent space from scrolling and Left + Right from selecting previous /
// next items or opening / closing parent menus. Only open the submenu.
event.preventDefault();
if (openedWithLR) {
event.stopPropagation();
}
}
if (!shouldOpenSubmenu) {
return;
}
const submenu = this.menu;
if (!submenu) return;
const submenuItems = submenu.items;
const firstActivatableItem = getFirstActivatableItem(submenuItems);
if (firstActivatableItem) {
await this.show();
firstActivatableItem.tabIndex = 0;
firstActivatableItem.focus();
return;
}
}
private onCloseSubmenu(event: CloseMenuEvent) {
const {itemPath, reason} = event.detail;
itemPath.push(this.item);
this.dispatchEvent(createActivateTypeaheadEvent());
// Escape should only close one menu not all of the menus unlike space or
// click selection which should close all menus.
if (
reason.kind === CloseReason.KEYDOWN &&
reason.key === KeydownCloseKey.ESCAPE
) {
event.stopPropagation();
this.item.dispatchEvent(createRequestActivationEvent());
return;
}
this.dispatchEvent(createDeactivateItemsEvent());
}
private async onSubMenuKeydown(event: KeyboardEvent) {
if (event.defaultPrevented) return;
const {close: shouldClose, keyCode} = this.isSubmenuCloseKey(event.code);
if (!shouldClose) return;
// Communicate that it's handled so that we don't accidentally close every
// parent menu. Additionally, we want to isolate things like the typeahead
// keydowns from bubbling up to the parent menu and confounding things.
event.preventDefault();
if (keyCode === NavigableKey.LEFT || keyCode === NavigableKey.RIGHT) {
// Prevent this from bubbling to parents
event.stopPropagation();
}
await this.close();
deactivateActiveItem(this.menu.items);
this.item?.focus();
this.item.tabIndex = 0;
this.item.focus();
}
/**
* Determines whether the given KeyboardEvent code is one that should open
* the submenu. This is RTL-aware. By default, left, right, space, or enter.
*
* @param code The native KeyboardEvent code.
* @return Whether or not the key code should open the submenu.
*/
private isSubmenuOpenKey(code: string) {
const isRtl = getComputedStyle(this).direction === 'rtl';
const arrowEnterKey = isRtl ? NavigableKey.LEFT : NavigableKey.RIGHT;
switch (code) {
case arrowEnterKey:
case SelectionKey.SPACE:
case SelectionKey.ENTER:
return true;
default:
return false;
}
}
/**
* Determines whether the given KeyboardEvent code is one that should close
* the submenu. This is RTL-aware. By default right, left, or escape.
*
* @param code The native KeyboardEvent code.
* @return Whether or not the key code should close the submenu.
*/
private isSubmenuCloseKey(code: string) {
const isRtl = getComputedStyle(this).direction === 'rtl';
const arrowEnterKey = isRtl ? NavigableKey.RIGHT : NavigableKey.LEFT;
switch (code) {
case arrowEnterKey:
case KeydownCloseKey.ESCAPE:
return {close: true, keyCode: code} as const;
default:
return {close: false} as const;
}
}
}