From 193b220a73718226ec15171197a393008f040caa Mon Sep 17 00:00:00 2001 From: Material Web Team Date: Tue, 14 Mar 2023 14:50:25 -0700 Subject: [PATCH] feat(menu): prepare menu to support md-select Prepare menu to support md-select by doing the following: - Export default values for typeahead configs - Allow setting the role of the menu - Support spaces in typeahead - Make the typeahead controller public so that one can bind it to another node - Listen to keydown events on capture rather than bubble - Allow disabling typeahead with events to prevent submenu typeahead clashes - Fire opening and closing events synchronously on quick = true - Fix focus restoration timing as it would fight with setting custom focus on items - Fix bug with `onWindowClick` not cleaning up - Add `focus()` as mart of the menuitem api - Prevent typeahead from messing with focus and leave that to the menu/list PiperOrigin-RevId: 516640007 --- menu/lib/menu.ts | 99 +++++++++++++++++-------- menu/lib/menuitem/menu-item.ts | 3 +- menu/lib/menuitemlink/menu-item-link.ts | 3 +- menu/lib/shared.ts | 22 ++++++ menu/lib/submenuitem/sub-menu-item.ts | 10 ++- menu/lib/typeaheadController.ts | 25 +++++-- 6 files changed, 122 insertions(+), 40 deletions(-) diff --git a/menu/lib/menu.ts b/menu/lib/menu.ts index 1e9478574f..a4900acce8 100644 --- a/menu/lib/menu.ts +++ b/menu/lib/menu.ts @@ -10,8 +10,8 @@ import '../../list/list.js'; import '../../focus/focus-ring.js'; import '../../elevation/elevation.js'; -import {html, LitElement} from 'lit'; -import {property, query} from 'lit/decorators.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'; @@ -20,13 +20,19 @@ 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 {MenuItem} from './shared.js'; -import {Corner, SurfacePositionController} from './surfacePositionController.js'; +import {ActivateTypeaheadEvent, DeactivateTypeaheadEvent, 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. */ @@ -55,9 +61,9 @@ function getFocusedElement(activeDoc: Document|ShadowRoot = document): } /** - * @fires opening Fired before the opening animation begins (not fired on quick) + * @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 (not fired on quick) + * @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 { @@ -73,7 +79,8 @@ export abstract class Menu extends LitElement { /** * The element in which the menu should align to. */ - @property({attribute: false}) anchor: HTMLElement|null = null; + @property({attribute: false}) + anchor: HTMLElement&Partial|null = null; /** * Makes the element use `position:fixed` instead of `position:absolute`. In * most cases, the menu should position itself above most other @@ -121,12 +128,18 @@ export abstract class Menu extends LitElement { * 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 = 200; + typeaheadBufferTime = DEFAULT_TYPEAHEAD_BUFFER_TIME; /** * The corner of the anchor which to align the menu in the standard logical * property style of _. @@ -156,6 +169,8 @@ export abstract class Menu extends LitElement { @property({type: String, attribute: 'default-focus'}) defaultFocus: DefaultFocusState = 'LIST_ROOT'; + @state() protected typeaheadActive = true; + protected openCloseAnimationSignal = createAnimationSignal(); /** @@ -175,10 +190,11 @@ export abstract class Menu extends LitElement { /** * Handles typeahead navigation through the menu. */ - protected typeaheadController = new TypeaheadController(() => { + typeaheadController = new TypeaheadController(() => { return { getItems: () => this.items, typeaheadBufferTime: this.typeaheadBufferTime, + active: this.typeaheadActive }; }); @@ -240,17 +256,16 @@ export abstract class Menu extends LitElement { */ protected renderList() { return html` - - ${this.renderMenuItems()} - `; + @focus=${this.handleListFocus} + @blur=${this.handleListBlur} + @click=${this.handleListClick} + @keydown=${this.handleListKeydown}> + ${this.renderMenuItems()} + `; } /** @@ -259,7 +274,9 @@ export abstract class Menu extends LitElement { protected renderMenuItems() { return html``; + @deactivate-items=${this.onDeactivateItems} + @deactivate-typeahead=${this.handleDeactivateTypeahead} + @activate-typeahead=${this.handleActivateTypeahead}>`; } /** @@ -284,16 +301,24 @@ export abstract class Menu extends LitElement { }; } - protected onListFocus() { + protected handleListFocus() { this.focusRing.visible = shouldShowStrongFocus(); } - protected onListClick() { + protected handleListClick() { pointerPress(); this.focusRing.visible = shouldShowStrongFocus(); } - protected onListBlur() { + // 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; } @@ -336,6 +361,7 @@ export abstract class Menu extends LitElement { } if (this.quick) { + this.dispatchEvent(new Event('opening')); this.dispatchEvent(new Event('opened')); } else { this.animateOpen(); @@ -348,6 +374,10 @@ export abstract class Menu extends LitElement { protected beforeClose = async () => { this.open = false; + if (!this.skipRestoreFocus) { + this.lastFocusedElement?.focus?.(); + } + if (!this.quick) { await this.animateClose(); } @@ -358,12 +388,9 @@ export abstract class Menu extends LitElement { */ protected onClosed = () => { if (this.quick) { + this.dispatchEvent(new Event('closing')); this.dispatchEvent(new Event('closed')); } - - if (!this.skipRestoreFocus) { - this.lastFocusedElement?.focus?.(); - } }; /** @@ -572,15 +599,15 @@ export abstract class Menu extends LitElement { override connectedCallback() { super.connectedCallback(); - if (window && window.addEventListener) { + if (!isServer) { window.addEventListener('click', this.onWindowClick, {capture: true}); } } override disconnectedCallback() { super.disconnectedCallback(); - if (window && window.removeEventListener) { - window.removeEventListener('click', this.onWindowClick); + if (!isServer) { + window.removeEventListener('click', this.onWindowClick, {capture: true}); } } @@ -602,6 +629,20 @@ export abstract class Menu extends LitElement { } } + 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(); } diff --git a/menu/lib/menuitem/menu-item.ts b/menu/lib/menuitem/menu-item.ts index 5314cd3174..b1d67cf80b 100644 --- a/menu/lib/menuitem/menu-item.ts +++ b/menu/lib/menuitem/menu-item.ts @@ -41,7 +41,8 @@ export class MenuItemEl extends ListItemEl implements MenuItem { protected override onKeydown(e: KeyboardEvent) { if (this.keepOpen) return; const keyCode = e.code; - if (isClosableKey(keyCode)) { + + if (!e.defaultPrevented && isClosableKey(keyCode)) { e.preventDefault(); this.dispatchEvent(new DefaultCloseMenuEvent( this, {kind: CLOSE_REASON.KEYDOWN, key: keyCode})); diff --git a/menu/lib/menuitemlink/menu-item-link.ts b/menu/lib/menuitemlink/menu-item-link.ts index a27cc6bbfa..f96daac2dd 100644 --- a/menu/lib/menuitemlink/menu-item-link.ts +++ b/menu/lib/menuitemlink/menu-item-link.ts @@ -40,7 +40,8 @@ export class MenuItemLink extends ListItemLink implements MenuItem { const keyCode = e.code; // Do not preventDefault on enter or else it will prevent from opening links - if (isClosableKey(keyCode) && keyCode !== SELECTION_KEY.ENTER) { + if (!e.defaultPrevented && isClosableKey(keyCode) && + keyCode !== SELECTION_KEY.ENTER) { e.preventDefault(); this.dispatchEvent(new DefaultCloseMenuEvent( this, {kind: CLOSE_REASON.KEYDOWN, key: keyCode})); diff --git a/menu/lib/shared.ts b/menu/lib/shared.ts index e5c9781eba..f729de732c 100644 --- a/menu/lib/shared.ts +++ b/menu/lib/shared.ts @@ -23,6 +23,10 @@ interface MenuItemSelf { * If it is a sub-menu-item, a method that can close the submenu. */ close?: () => void; + /** + * Focuses the item. + */ + focus: () => void; } /** @@ -90,6 +94,24 @@ export class DeactivateItemsEvent extends Event { } } +/** + * Requests the typeahead functionality of containing menu be deactivated. + */ +export class DeactivateTypeaheadEvent extends Event { + constructor() { + super('deactivate-typeahead', {bubbles: true, composed: true}); + } +} + +/** + * Requests the typeahead functionality of containing menu be activated. + */ +export class ActivateTypeaheadEvent extends Event { + constructor() { + super('activate-typeahead', {bubbles: true, composed: true}); + } +} + /** * Keys that are used to navigate menus. */ diff --git a/menu/lib/submenuitem/sub-menu-item.ts b/menu/lib/submenuitem/sub-menu-item.ts index 48707ec8a2..9b03ae2c71 100644 --- a/menu/lib/submenuitem/sub-menu-item.ts +++ b/menu/lib/submenuitem/sub-menu-item.ts @@ -11,7 +11,7 @@ import {List} from '../../../list/lib/list.js'; import {ARIARole} from '../../../types/aria.js'; import {Corner, Menu} from '../menu.js'; import {MenuItemEl} from '../menuitem/menu-item.js'; -import {CLOSE_REASON, CloseMenuEvent, DeactivateItemsEvent, KEYDOWN_CLOSE_KEYS, NAVIGABLE_KEY, SELECTION_KEY} from '../shared.js'; +import {ActivateTypeaheadEvent, CLOSE_REASON, CloseMenuEvent, DeactivateItemsEvent, DeactivateTypeaheadEvent, KEYDOWN_CLOSE_KEYS, NAVIGABLE_KEY, SELECTION_KEY} from '../shared.js'; function stopPropagation(e: Event) { e.stopPropagation(); @@ -20,6 +20,10 @@ function stopPropagation(e: Event) { /** * @fires deactivate-items {DeactivateItemsEvent} Requests the parent menu to * deselect other items when a submenu opens + * @fires deactivate-typeahead {DeactivateItemsEvent} Requests the parent menu + * to deactivate the typeahead functionality when a submenu opens + * @fires activate-typeahead {DeactivateItemsEvent} Requests the parent menu to + * activate the typeahead functionality when a submenu closes */ export class SubMenuItem extends MenuItemEl { override role: ARIARole = 'menuitem'; @@ -145,6 +149,7 @@ export class SubMenuItem extends MenuItemEl { protected onCloseSubmenu(e: CloseMenuEvent) { e.itemPath.push(this); + this.dispatchEvent(new ActivateTypeaheadEvent()); // Escape should only close one menu not all of the menus unlike space or // click selection which should close all menus. if (e.reason.kind === CLOSE_REASON.KEYDOWN && @@ -155,6 +160,7 @@ export class SubMenuItem extends MenuItemEl { this.listItemRoot.focus(); return; } + this.active = false; } @@ -203,6 +209,7 @@ export class SubMenuItem extends MenuItemEl { // 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(new DeactivateItemsEvent()); + this.dispatchEvent(new DeactivateTypeaheadEvent()); this.active = true; // This is the case of mouse hovering when already opened via keyboard or @@ -223,6 +230,7 @@ export class SubMenuItem extends MenuItemEl { const menu = this.submenuEl; if (!menu || !menu.open) return; + this.dispatchEvent(new ActivateTypeaheadEvent()); menu.quick = true; menu.close(); this.active = false; diff --git a/menu/lib/typeaheadController.ts b/menu/lib/typeaheadController.ts index adb543624f..2468b8266c 100644 --- a/menu/lib/typeaheadController.ts +++ b/menu/lib/typeaheadController.ts @@ -20,6 +20,10 @@ export interface TypeaheadControllerProperties { * alive. */ typeaheadBufferTime: number; + /** + * Whether or not the typeahead should listen for keystrokes or not. + */ + active: boolean; } /** @@ -102,6 +106,10 @@ export class TypeaheadController { return this.getProperties().getItems(); } + protected get active() { + return this.getProperties().active; + } + /** * Apply this listener to the element that will receive `keydown` events that * should trigger this controller. @@ -120,14 +128,15 @@ export class TypeaheadController { * Sets up typingahead */ protected beginTypeahead(e: KeyboardEvent) { + if (!this.active) { + return; + } + // We don't want to typeahead if the _beginning_ of the typeahead is a menu // navigation, or a selection. We will handle "Space" only if it's in the // middle of a typeahead if (e.code === 'Space' || e.code === 'Enter' || e.code.startsWith('Arrow') || e.code === 'Escape') { - if (this.lastActiveRecord) { - this.lastActiveRecord[TYPEAHEAD_ITEM].active = false; - } return; } @@ -197,6 +206,7 @@ export class TypeaheadController { // If Space is pressed, prevent it from selecting and closing the menu if (e.code === 'Space') { e.stopPropagation(); + e.preventDefault(); } // Start up a new keystroke buffer timeout @@ -213,15 +223,15 @@ export class TypeaheadController { * Sorting function that will resort the items starting with the given index * * @example - * - * this.typeaheadRecords = + * + * this.typeaheadRecords = * 0: [0, , 'apple'] * 1: [1, , 'apricot'] * 2: [2, , 'banana'] * 3: [3, , 'olive'] <-- lastActiveIndex * 4: [4, , 'orange'] * 5: [5, , 'strawberry'] - * + * * this.typeaheadRecords.sort((a,b) => rebaseIndexOnActive(a) * - rebaseIndexOnActive(b)) === * 0: [3, , 'olive'] <-- lastActiveIndex @@ -242,8 +252,7 @@ export class TypeaheadController { .filter( record => !record[TYPEAHEAD_ITEM].disabled && record[TYPEAHEAD_TEXT].startsWith(this.typaheadBuffer)) - .sort( - (a, b) => rebaseIndexOnActive(a) - rebaseIndexOnActive(b)); + .sort((a, b) => rebaseIndexOnActive(a) - rebaseIndexOnActive(b)); // Just leave if there's nothing that matches. Native select will just // choose the first thing that starts with the next letter in the alphabet