material-web/menu/lib/menu.ts
Material Web Team 3445b631fe fix(menu): close menu on focusout and make it configurable
PiperOrigin-RevId: 520502277
2023-03-29 18:52:12 -07:00

701 lines
21 KiB
TypeScript

/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Required for @ariaProperty
// tslint:disable:no-new-decorators
import '../../list/list.js';
import '../../focus/focus-ring.js';
import '../../elevation/elevation.js';
import {html, isServer, LitElement} from 'lit';
import {eventOptions, property, query, state} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {styleMap} from 'lit/directives/style-map.js';
import {ariaProperty} from '../../decorators/aria-property.js';
import {MdFocusRing} from '../../focus/focus-ring.js';
import {pointerPress, shouldShowStrongFocus} from '../../focus/strong-focus.js';
import {List} from '../../list/lib/list.js';
import {createAnimationSignal, EASING} from '../../motion/animation.js';
import {ARIARole} from '../../types/aria.js';
import {ActivateTypeaheadEvent, DeactivateTypeaheadEvent, isElementInSubtree, MenuItem} from './shared.js';
import {Corner, SurfacePositionController, SurfacePositionTarget} from './surfacePositionController.js';
import {TypeaheadController} from './typeaheadController.js';
export {Corner} from './surfacePositionController.js';
/**
* The default value for the typeahead buffer time in Milliseconds.
*/
export const DEFAULT_TYPEAHEAD_BUFFER_TIME = 200;
/**
* Element to focus on when menu is first opened.
*/
export type DefaultFocusState = 'NONE'|'LIST_ROOT'|'FIRST_ITEM'|'LAST_ITEM';
/**
* Gets the currently focused element on the page.
*
* @param activeDoc The document or shadowroot from which to start the search.
* Defaults to `window.document`
* @return Returns the currently deeply focused element or `null` if none.
*/
function getFocusedElement(activeDoc: Document|ShadowRoot = document):
HTMLElement|null {
const activeEl = activeDoc.activeElement as HTMLElement | null;
if (!activeEl) {
return null;
}
if (activeEl.shadowRoot) {
return getFocusedElement(activeEl.shadowRoot) ?? activeEl;
}
return activeEl;
}
/**
* @fires opening Fired before the opening animation begins
* @fires opened Fired once the menu is open, after any animations
* @fires closing Fired before the closing animation begins
* @fires closed Fired once the menu is closed, after any animations
*/
export abstract class Menu extends LitElement {
@query('md-list') protected readonly listElement!: List|null;
@query('.menu') protected readonly surfaceEl!: HTMLElement|null;
@query('slot') protected readonly slotEl!: HTMLSlotElement|null;
@query('md-focus-ring') protected readonly focusRing!: MdFocusRing;
@ariaProperty
@property({type: String, attribute: 'data-aria-label', noAccessor: true})
override ariaLabel!: string;
/**
* The element in which the menu should align to.
*/
@property({attribute: false})
anchor: HTMLElement&Partial<SurfacePositionTarget>|null = null;
/**
* Makes the element use `position:fixed` instead of `position:absolute`. In
* most cases, the menu should position itself above most other
* `position:absolute` or `position:fixed` elements when placed inside of
* them. e.g. using a menu inside of an `md-dialog`.
*
* __NOTE__: Fixed menus will not scroll with the page and will be fixed to
* the window instead.
*/
@property({type: Boolean}) fixed = false;
/**
* Skips the opening and closing animations.
*/
@property({type: Boolean}) quick = false;
/**
* Displays overflow content like a submenu.
*
* __NOTE__: This may cause adverse effects if you set
* `md-menu {max-height:...}`
* and have items overflowing items in the "y" direction.
*/
@property({type: Boolean, attribute: 'has-overflow'}) hasOverflow = false;
/**
* Opens the menu and makes it visible. Alternative to the `.show()` and
* `.close()` methods
*/
@property({type: Boolean, reflect: true}) open = false;
/**
* Offsets the menu's inline alignment from the anchor by the given number in
* pixels. This value is direction aware and will follow the LTR / RTL
* direction.
*
* e.g. LTR: positive -> right, negative -> left
* RTL: positive -> left, negative -> right
*/
@property({type: Number, attribute: 'x-offset'}) xOffset = 0;
/**
* Offsets the menu's block alignment from the anchor by the given number in
* pixels.
*
* e.g. positive -> down, negative -> up
*/
@property({type: Number, attribute: 'y-offset'}) yOffset = 0;
/**
* The tabindex of the underlying list element.
*/
@property({type: Number, attribute: 'list-tab-index'}) listTabIndex = 0;
/**
* The role of the underlying list element.
*/
@ariaProperty
@property({type: String, attribute: 'data-role', noAccessor: true})
override role: ARIARole = 'list';
/**
* The max time between the keystrokes of the typeahead menu behavior before
* it clears the typeahead buffer.
*/
@property({type: Number, attribute: 'typeahead-delay'})
typeaheadBufferTime = DEFAULT_TYPEAHEAD_BUFFER_TIME;
/**
* The corner of the anchor which to align the menu in the standard logical
* property style of <block>_<inline>.
*/
@property({type: String, attribute: 'anchor-corner'})
anchorCorner: Corner = 'END_START';
/**
* The corner of the menu which to align the anchor in the standard logical
* property style of <block>_<inline>.
*/
@property({type: String, attribute: 'menu-corner'})
menuCorner: Corner = 'START_START';
/**
* Keeps the user clicks outside the menu.
*
* NOTE: clicking outside may still cause focusout to close the menu so see
* `stayOpenOnFocusout`.
*/
@property({type: Boolean, attribute: 'stay-open-on-outside-click'})
stayOpenOnOutsideClick = false;
/**
* Keeps the menu open when focus leaves the menu's composed subtree.
*
* NOTE: Focusout behavior will stop propagation of the focusout event. Set
* this property to true to opt-out of menu's focuout handling altogether.
*/
@property({type: Boolean, attribute: 'stay-open-on-focusout'})
stayOpenOnFocusout = false;
/**
* After closing, does not restore focus to the last focused element before
* the menu was opened.
*/
@property({type: Boolean, attribute: 'skip-restore-focus'})
skipRestoreFocus = false;
/**
* The element that should be focused by default once opened.
*/
@property({type: String, attribute: 'default-focus'})
defaultFocus: DefaultFocusState = 'LIST_ROOT';
@state() protected typeaheadActive = true;
protected openCloseAnimationSignal = createAnimationSignal();
/**
* Whether the menu is animating upwards or downwards when opening. This is
* helpful for calculating some animation calculations.
*/
protected get openDirection(): 'UP'|'DOWN' {
const menuCornerBlock = this.menuCorner.split('_')[0];
return menuCornerBlock === 'START' ? 'DOWN' : 'UP';
}
/**
* The element that was focused before the menu opened.
*/
protected lastFocusedElement: HTMLElement|null = null;
/**
* Handles typeahead navigation through the menu.
*/
typeaheadController = new TypeaheadController(() => {
return {
getItems: () => this.items,
typeaheadBufferTime: this.typeaheadBufferTime,
active: this.typeaheadActive
};
});
/**
* Handles positioning the surface and aligning it to the anchor.
*/
protected menuPositionController = new SurfacePositionController(this, () => {
return {
anchorCorner: this.anchorCorner,
surfaceCorner: this.menuCorner,
surfaceEl: this.surfaceEl,
anchorEl: this.anchor,
isTopLayer: this.fixed,
isOpen: this.open,
xOffset: this.xOffset,
yOffset: this.yOffset,
onOpen: this.onOpened,
beforeClose: this.beforeClose,
onClose: this.onClosed,
};
});
/**
* The menu items associated with this menu. The items must be `MenuItem`s and
* have both the `md-menu-item` and `md-list-item` attributes.
*/
get items(): MenuItem[] {
const listElement = this.listElement;
if (listElement) {
return listElement.items.filter(el => el.hasAttribute('md-menu-item')) as
MenuItem[];
}
return [];
}
override render() {
return this.renderSurface();
}
/**
* Renders the positionable surface element and its contents.
*/
protected renderSurface() {
// TODO(b/274140618): elevation should be an underlay, not an overlay that
// tints content
return html`
<div
class="menu ${classMap(this.getSurfaceClasses())}"
style=${styleMap(this.menuPositionController.surfaceStyles)}
@focusout=${this.handleFocusout}>
${this.renderList()}
${this.renderElevation()}
${this.renderFocusRing()}
</div>
`;
}
/**
* Renders the List element and its items
*/
protected renderList() {
return html`
<md-list
.ariaLabel=${this.ariaLabel}
.role=${this.role}
listTabIndex=${this.listTabIndex}
@focus=${this.handleListFocus}
@blur=${this.handleListBlur}
@click=${this.handleListClick}
@keydown=${this.handleListKeydown}>
${this.renderMenuItems()}
</md-list>`;
}
/**
* Renders the menu items' slot
*/
protected renderMenuItems() {
return html`<slot
@close-menu=${this.onCloseMenu}
@deactivate-items=${this.onDeactivateItems}
@deactivate-typeahead=${this.handleDeactivateTypeahead}
@activate-typeahead=${this.handleActivateTypeahead}></slot>`;
}
/**
* Renders the elevation component.
*/
protected renderElevation() {
return html`<md-elevation shadow surface></md-elevation>`;
}
/**
* Renders the focus ring component.
*/
protected renderFocusRing() {
return html`<md-focus-ring></md-focus-ring>`;
}
protected getSurfaceClasses() {
return {
open: this.open,
fixed: this.fixed,
'has-overflow': this.hasOverflow,
};
}
protected async handleFocusout(e: FocusEvent) {
if (this.stayOpenOnFocusout) {
return;
}
// Stop propagation to prevent nested menus from interfering with each other
e.stopPropagation();
if (e.relatedTarget) {
// Don't close the menu if we are switching focus between menu,
// md-menu-item, and md-list
if (isElementInSubtree(e.relatedTarget, this)) {
return;
}
}
const oldRestoreFocus = this.skipRestoreFocus;
// allow focus to continue to the next focused object rather than returning
this.skipRestoreFocus = true;
this.close();
// await for close
await this.updateComplete;
// return to previous behavior
this.skipRestoreFocus = oldRestoreFocus;
}
protected handleListFocus() {
this.focusRing.visible = shouldShowStrongFocus();
}
protected handleListClick() {
pointerPress();
this.focusRing.visible = shouldShowStrongFocus();
}
// Capture so that we can grab the event before it reaches the list item
// istelf. Specifically useful for the case where typeahead encounters a space
// and we don't want the menu item to close the menu.
@eventOptions({capture: true})
protected handleListKeydown(e: KeyboardEvent) {
this.typeaheadController.onKeydown(e);
}
protected handleListBlur() {
this.focusRing.visible = false;
}
/**
* Saves the last focused element focuses the new element based on
* `defaultFocus`, and animates open.
*/
protected onOpened = () => {
this.lastFocusedElement = getFocusedElement();
if (!this.listElement) return;
const items = this.listElement.items;
const activeItemRecord = List.getActiveItem(items);
if (activeItemRecord && this.defaultFocus !== 'NONE') {
activeItemRecord.item.active = false;
}
switch (this.defaultFocus) {
case 'FIRST_ITEM':
const first = List.getFirstActivatableItem(items);
if (first) {
first.active = true;
}
break;
case 'LAST_ITEM':
const last = List.getLastActivatableItem(items);
if (last) {
last.active = true;
}
break;
case 'LIST_ROOT':
this.listElement?.focus();
break;
default:
case 'NONE':
// Do nothing.
break;
}
if (this.quick) {
this.dispatchEvent(new Event('opening'));
this.dispatchEvent(new Event('opened'));
} else {
this.animateOpen();
}
};
/**
* Animates closed.
*/
protected beforeClose = async () => {
this.open = false;
if (!this.skipRestoreFocus) {
this.lastFocusedElement?.focus?.();
}
if (!this.quick) {
await this.animateClose();
}
};
/**
* Focuses the last focused element.
*/
protected onClosed = () => {
if (this.quick) {
this.dispatchEvent(new Event('closing'));
this.dispatchEvent(new Event('closed'));
}
};
/**
* Performs the opening animation:
*
* https://direct.googleplex.com/#/spec/295000003+271060003
*/
protected animateOpen() {
const surfaceEl = this.surfaceEl;
const slotEl = this.slotEl;
if (!surfaceEl || !slotEl) return;
const openDirection = this.openDirection;
this.dispatchEvent(new Event('opening'));
// needs to be imperative because we don't want to mix animation and Lit
// render timing
surfaceEl.classList.toggle('animating', true);
const signal = this.openCloseAnimationSignal.start();
const height = surfaceEl.offsetHeight;
const openingUpwards = openDirection === 'UP';
const children = this.items;
const FULL_DURATION = 500;
const SURFACE_OPACITY_DURATION = 50;
const ITEM_OPACITY_DURATION = 250;
// We want to fit every child fade-in animation within the full duration of
// the animation.
const DELAY_BETWEEN_ITEMS =
(FULL_DURATION - ITEM_OPACITY_DURATION) / children.length;
const surfaceHeightAnimation =
surfaceEl.animate([{height: '0px'}, {height: `${height}px`}], {
duration: FULL_DURATION,
easing: EASING.EMPHASIZED,
});
// When we are opening upwards, we want to make sure the last item is always
// in view, so we need to translate it upwards the opposite direction of the
// height animation
const upPositionCorrectionAnimation = slotEl.animate(
[
{transform: openingUpwards ? `translateY(-${height}px)` : ''},
{transform: ''}
],
{duration: FULL_DURATION, easing: EASING.EMPHASIZED});
const surfaceOpacityAnimation = surfaceEl.animate(
[{opacity: 0}, {opacity: 1}], SURFACE_OPACITY_DURATION);
const childrenAnimations: Array<[HTMLElement, Animation]> = [];
for (let i = 0; i < children.length; i++) {
// If we are animating upwards, then reverse the children list.
const directionalIndex = openingUpwards ? children.length - 1 - i : i;
const child = children[directionalIndex];
const animation = child.animate([{opacity: 0}, {opacity: 1}], {
duration: ITEM_OPACITY_DURATION,
delay: DELAY_BETWEEN_ITEMS * i,
});
// Make them all initially hidden and then clean up at the end of each
// animation.
child.classList.toggle('hidden', true);
animation.addEventListener('finish', () => {
child.classList.toggle('hidden', false);
});
childrenAnimations.push([child, animation]);
}
signal.addEventListener('abort', () => {
surfaceHeightAnimation.cancel();
upPositionCorrectionAnimation.cancel();
surfaceOpacityAnimation.cancel();
childrenAnimations.forEach(([child, animation]) => {
child.classList.toggle('hidden', false);
animation.cancel();
});
});
surfaceHeightAnimation.addEventListener('finish', () => {
surfaceEl.classList.toggle('animating', false);
this.openCloseAnimationSignal.finish();
this.dispatchEvent(new Event('opened'));
});
}
/**
* Performs the closing animation:
*
* https://direct.googleplex.com/#/spec/295000003+271060003
*/
protected animateClose() {
let resolve!: (value: unknown) => void;
let reject!: () => void;
// This promise blocks the surface position controller from setting
// display: none on the surface which will interfere with this animation.
const animationEnded = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
const surfaceEl = this.surfaceEl;
const slotEl = this.slotEl;
if (!surfaceEl || !slotEl) {
reject();
return animationEnded;
}
const openDirection = this.openDirection;
const closingDownwards = openDirection === 'UP';
this.dispatchEvent(new Event('closing'));
// needs to be imperative because we don't want to mix animation and Lit
// render timing
surfaceEl.classList.toggle('animating', true);
const signal = this.openCloseAnimationSignal.start();
const height = surfaceEl.offsetHeight;
const children = this.items;
const FULL_DURATION = 150;
const SURFACE_OPACITY_DURATION = 50;
// The surface fades away at the very end
const SURFACE_OPACITY_DELAY = FULL_DURATION - SURFACE_OPACITY_DURATION;
const ITEM_OPACITY_DURATION = 50;
const ITEM_OPACITY_INITIAL_DELAY = 50;
const END_HEIGHT_PRECENTAGE = .35;
// We want to fit every child fade-out animation within the full duration of
// the animation.
const DELAY_BETWEEN_ITEMS =
(FULL_DURATION - ITEM_OPACITY_INITIAL_DELAY - ITEM_OPACITY_DURATION) /
children.length;
// The mock has the animation shrink to 35%
const surfaceHeightAnimation = surfaceEl.animate(
[
{height: `${height}px`},
{height: `${height * END_HEIGHT_PRECENTAGE}px`}
],
{
duration: FULL_DURATION,
easing: EASING.EMPHASIZED_ACCELERATE,
});
// When we are closing downwards, we want to make sure the last item is
// always in view, so we need to translate it upwards the opposite direction
// of the height animation
const downPositionCorrectionAnimation = slotEl.animate(
[
{transform: ''}, {
transform: closingDownwards ?
`translateY(-${height * (1 - END_HEIGHT_PRECENTAGE)}px)` :
''
}
],
{duration: FULL_DURATION, easing: EASING.EMPHASIZED_ACCELERATE});
const surfaceOpacityAnimation = surfaceEl.animate(
[{opacity: 1}, {opacity: 0}],
{duration: SURFACE_OPACITY_DURATION, delay: SURFACE_OPACITY_DELAY});
const childrenAnimations: Array<[HTMLElement, Animation]> = [];
for (let i = 0; i < children.length; i++) {
// If the animation is closing upwards, then reverse the list of
// children so that we animate in the opposite direction.
const directionalIndex = closingDownwards ? i : children.length - 1 - i;
const child = children[directionalIndex];
const animation = child.animate([{opacity: 1}, {opacity: 0}], {
duration: ITEM_OPACITY_DURATION,
delay: ITEM_OPACITY_INITIAL_DELAY + DELAY_BETWEEN_ITEMS * i,
});
// Make sure the items stay hidden at the end of each child animation.
// We clean this up at the end of the overall animation.
animation.addEventListener('finish', () => {
child.classList.toggle('hidden', true);
});
childrenAnimations.push([child, animation]);
}
signal.addEventListener('abort', () => {
surfaceHeightAnimation.cancel();
downPositionCorrectionAnimation.cancel();
surfaceOpacityAnimation.cancel();
childrenAnimations.forEach(([child, animation]) => {
animation.cancel();
child.classList.toggle('hidden', false);
});
reject();
});
surfaceHeightAnimation.addEventListener('finish', () => {
surfaceEl.classList.toggle('animating', false);
childrenAnimations.forEach(([child]) => {
child.classList.toggle('hidden', false);
});
this.openCloseAnimationSignal.finish();
this.dispatchEvent(new Event('closed'));
resolve(true);
});
return animationEnded;
}
override connectedCallback() {
super.connectedCallback();
if (!isServer) {
window.addEventListener('click', this.onWindowClick, {capture: true});
}
}
override disconnectedCallback() {
super.disconnectedCallback();
if (!isServer) {
window.removeEventListener('click', this.onWindowClick, {capture: true});
}
}
protected onWindowClick = (e: MouseEvent) => {
if (!this.stayOpenOnOutsideClick && !e.composedPath().includes(this)) {
this.open = false;
}
};
protected onCloseMenu(e: Event) {
this.close();
}
protected onDeactivateItems(e: Event) {
e.stopPropagation();
const items = this.items;
for (const item of items) {
item.active = false;
}
}
protected handleDeactivateTypeahead(e: DeactivateTypeaheadEvent) {
// stopPropagation so that this does not deactivate any typeaheads in menus
// nested above it e.g. md-sub-menu-item
e.stopPropagation();
this.typeaheadActive = false;
}
protected handleActivateTypeahead(e: ActivateTypeaheadEvent) {
// stopPropagation so that this does not activate any typeaheads in menus
// nested above it e.g. md-sub-menu-item
e.stopPropagation();
this.typeaheadActive = true;
}
override focus() {
this.listElement?.focus();
}
close() {
this.open = false;
this.items.forEach(item => {
item.close?.();
});
}
show() {
this.open = true;
}
}