UNPKG

@material/web

Version:
364 lines 14.5 kB
/** * @license * Copyright 2023 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { __decorate } from "tslib"; 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 { CloseReason, createActivateTypeaheadEvent, createDeactivateTypeaheadEvent, KeydownCloseKey, 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 { get item() { return this.items[0] ?? null; } get menu() { return this.menus[0] ?? null; } constructor() { super(); /** * The anchorCorner to set on the submenu. */ this.anchorCorner = Corner.START_END; /** * The menuCorner to set on the submenu. */ this.menuCorner = Corner.START_START; /** * The delay between mouseenter and submenu opening. */ this.hoverOpenDelay = 400; /** * The delay between ponterleave and the submenu closing. */ this.hoverCloseDelay = 400; /** * READONLY: self-identifies as a menu item and sets its identifying attribute */ this.isSubMenu = true; this.previousOpenTimeout = 0; this.previousCloseTimeout = 0; /** * 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. */ this.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. */ this.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); } }; if (!isServer) { this.addEventListener('mouseenter', this.onMouseenter); this.addEventListener('mouseleave', this.onMouseleave); } } render() { return html ` <slot name="item" @click=${this.onClick} @keydown=${this.onKeydown} @slotchange=${this.onSlotchange}> </slot> <slot name="menu" @keydown=${this.onSubMenuKeydown} @close-menu=${this.onCloseSubmenu} @slotchange=${this.onSlotchange}> </slot> `; } 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 `<md-menu slot="submenu" positioning="document">` 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) => { }; 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) => { }; const closed = new Promise((resolve) => { close = resolve; }); menu.addEventListener('closed', close, { once: true }); await closed; } 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'; } onClick() { this.show(); } /** * On item keydown handles opening the submenu. */ async onKeydown(event) { 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; } } onCloseSubmenu(event) { 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()); } async onSubMenuKeydown(event) { 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. */ isSubmenuOpenKey(code) { 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. */ isSubmenuCloseKey(code) { 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 }; default: return { close: false }; } } } __decorate([ property({ attribute: 'anchor-corner' }) ], SubMenu.prototype, "anchorCorner", void 0); __decorate([ property({ attribute: 'menu-corner' }) ], SubMenu.prototype, "menuCorner", void 0); __decorate([ property({ type: Number, attribute: 'hover-open-delay' }) ], SubMenu.prototype, "hoverOpenDelay", void 0); __decorate([ property({ type: Number, attribute: 'hover-close-delay' }) ], SubMenu.prototype, "hoverCloseDelay", void 0); __decorate([ property({ type: Boolean, reflect: true, attribute: 'md-sub-menu' }) ], SubMenu.prototype, "isSubMenu", void 0); __decorate([ queryAssignedElements({ slot: 'item', flatten: true }) ], SubMenu.prototype, "items", void 0); __decorate([ queryAssignedElements({ slot: 'menu', flatten: true }) ], SubMenu.prototype, "menus", void 0); //# sourceMappingURL=sub-menu.js.map