Skip to content

Commit

Permalink
feat(menu): prepare menu to support md-select
Browse files Browse the repository at this point in the history
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
  • Loading branch information
material-web-copybara authored and copybara-github committed Mar 14, 2023
1 parent d005d72 commit 193b220
Show file tree
Hide file tree
Showing 6 changed files with 122 additions and 40 deletions.
99 changes: 70 additions & 29 deletions menu/lib/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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.
*/
Expand Down Expand Up @@ -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 {
Expand All @@ -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<SurfacePositionTarget>|null = null;
/**
* Makes the element use `position:fixed` instead of `position:absolute`. In
* most cases, the menu should position itself above most other
Expand Down Expand Up @@ -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 <block>_<inline>.
Expand Down Expand Up @@ -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();

/**
Expand All @@ -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
};
});

Expand Down Expand Up @@ -240,17 +256,16 @@ export abstract class Menu extends LitElement {
*/
protected renderList() {
return html`
<md-list
role="menu"
class="list"
<md-list
.ariaLabel=${this.ariaLabel}
.role=${this.role}
listTabIndex=${this.listTabIndex}
@focus=${this.onListFocus}
@blur=${this.onListBlur}
@click=${this.onListClick}
@keydown=${this.typeaheadController.onKeydown}>
${this.renderMenuItems()}
</md-list>`;
@focus=${this.handleListFocus}
@blur=${this.handleListBlur}
@click=${this.handleListClick}
@keydown=${this.handleListKeydown}>
${this.renderMenuItems()}
</md-list>`;
}

/**
Expand All @@ -259,7 +274,9 @@ export abstract class Menu extends LitElement {
protected renderMenuItems() {
return html`<slot
@close-menu=${this.onCloseMenu}
@deactivate-items=${this.onDeactivateItems}></slot>`;
@deactivate-items=${this.onDeactivateItems}
@deactivate-typeahead=${this.handleDeactivateTypeahead}
@activate-typeahead=${this.handleActivateTypeahead}></slot>`;
}

/**
Expand All @@ -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;
}

Expand Down Expand Up @@ -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();
Expand All @@ -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();
}
Expand All @@ -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?.();
}
};

/**
Expand Down Expand Up @@ -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});
}
}

Expand All @@ -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();
}
Expand Down
3 changes: 2 additions & 1 deletion menu/lib/menuitem/menu-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}));
Expand Down
3 changes: 2 additions & 1 deletion menu/lib/menuitemlink/menu-item-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}));
Expand Down
22 changes: 22 additions & 0 deletions menu/lib/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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.
*/
Expand Down
10 changes: 9 additions & 1 deletion menu/lib/submenuitem/sub-menu-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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';
Expand Down Expand Up @@ -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 &&
Expand All @@ -155,6 +160,7 @@ export class SubMenuItem extends MenuItemEl {
this.listItemRoot.focus();
return;
}

this.active = false;
}

Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
Loading

0 comments on commit 193b220

Please sign in to comment.