Skip to content

Commit

Permalink
feat(menu): Add menu button component. This manages focus automatical…
Browse files Browse the repository at this point in the history
…ly on menu open, setting focus to menu item (rather than menu root) if the menu open originated from a keyboard event.

PiperOrigin-RevId: 469294184
  • Loading branch information
joyzhong authored and copybara-github committed Aug 22, 2022
1 parent d0d5340 commit a29ac8b
Show file tree
Hide file tree
Showing 8 changed files with 286 additions and 25 deletions.
14 changes: 14 additions & 0 deletions menu/lib/_menu-button.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// Copyright 2022 Google LLC
// SPDX-License-Identifier: Apache-2.0
//

// stylelint-disable selector-class-pattern --
// Selector '.md3-*' should only be used in this project.

@mixin static-styles() {
.md3-menu-button {
overflow: visible;
position: relative;
}
}
9 changes: 1 addition & 8 deletions menu/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,4 @@ const numbers = {
FOCUS_ROOT_INDEX: -1,
};

enum DefaultFocusState {
NONE = 'NONE',
LIST_ROOT = 'LIST_ROOT',
FIRST_ITEM = 'FIRST_ITEM',
LAST_ITEM = 'LAST_ITEM',
}

export {cssClasses, strings, numbers, DefaultFocusState};
export {cssClasses, strings, numbers};
9 changes: 9 additions & 0 deletions menu/lib/menu-button-styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

@use './menu-button';

@include menu-button.static-styles();
99 changes: 99 additions & 0 deletions menu/lib/menu-button.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {html, LitElement, PropertyValues} from 'lit';
import {queryAssignedElements} from 'lit/decorators';

import {Menu} from './menu';

/**
* Menu button component that automatically attaches a slotted menu to the
* slotted button.
*/
export class MenuButton extends LitElement {
@queryAssignedElements({slot: 'button', flatten: true})
protected readonly buttonAssignedElements!: HTMLElement[];

@queryAssignedElements({slot: 'menu', flatten: true})
protected readonly menuAssignedElements!: HTMLElement[];

get button(): HTMLElement {
if (this.buttonAssignedElements.length === 0) {
throw new Error('MenuButton: Missing a slot="button" element.')
}
return this.buttonAssignedElements[0];
}

get menu(): Menu {
if (this.menuAssignedElements.length === 0) {
throw new Error('MenuButton: Missing a slot="menu" element.')
}
if (!(this.menuAssignedElements[0] instanceof Menu)) {
throw new Error(
'MenuButton: The slot="menu" element must be an instance of the ' +
'Menu component.');
}
return this.menuAssignedElements[0];
}

protected override render() {
return html`
<div class="md3-menu-button">
<span>
<slot name="button"
@click=${this.handleButtonClick}
@keydown=${this.handleButtonKeydown}>
</slot>
</span>
<span><slot name="menu"></slot></span>
</div>
`;
}

protected override firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);

if (!this.menu.anchor) {
this.menu.anchor = this.button;
}
}

/**
* If key event is ArrowUp or ArrowDown, opens the menu.
*/
private handleButtonKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;

if (event.key === 'ArrowUp') {
this.menu.defaultFocus = 'LAST_ITEM';
} else if (event.key === 'ArrowDown') {
this.menu.defaultFocus = 'FIRST_ITEM';
}
this.menu.show();
}

/**
* Toggles the menu on button click.
*/
private handleButtonClick(event: PointerEvent) {
if (this.menu.open) {
this.menu.close();
return;
}

// Whether the click is from SPACE or ENTER keypress on a button, for which
// the browser fires a synthetic click event.
const isSyntheticClickEvent = event.pointerType === '';
if (isSyntheticClickEvent) {
// Key events should automatically focus on first menu item.
this.menu.defaultFocus = 'FIRST_ITEM';
} else {
this.menu.defaultFocus = 'LIST_ROOT';
}

this.menu.show();
}
}
18 changes: 8 additions & 10 deletions menu/lib/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/

// Style preference for leading underscores.
// tslint:disable:strip-private-property-underscore

import '../../list/list';
import '../../menusurface/menu-surface';

Expand All @@ -20,13 +17,15 @@ import {ListItem} from '../../list/lib/listitem/list-item';
import {Corner, MenuSurface} from '../../menusurface/lib/menu-surface';

import {MDCMenuAdapter} from './adapter';
import {DefaultFocusState} from './constants';
import {MDCMenuFoundation} from './foundation';

interface ActionDetail {
item: ListItem;
}

/** Element to focus on when menu is first opened. */
export type DefaultFocusState = 'NONE'|'LIST_ROOT'|'FIRST_ITEM'|'LAST_ITEM';

/**
* @fires selected {SelectedDetail}
* @fires action {ActionDetail}
Expand Down Expand Up @@ -75,8 +74,7 @@ export abstract class Menu extends LitElement {

@property({type: Boolean}) skipRestoreFocus = false;

@property({type: String})
defaultFocus: DefaultFocusState = DefaultFocusState.LIST_ROOT;
@property({type: String}) defaultFocus: DefaultFocusState = 'LIST_ROOT';

protected listUpdateComplete: null|Promise<unknown> = null;

Expand Down Expand Up @@ -288,16 +286,16 @@ export abstract class Menu extends LitElement {

this.listElement?.resetActiveListItem();
switch (this.defaultFocus) {
case DefaultFocusState.FIRST_ITEM:
case 'FIRST_ITEM':
this.listElement?.activateFirstItem();
break;
case DefaultFocusState.LAST_ITEM:
case 'LAST_ITEM':
this.listElement?.activateLastItem();
break;
case DefaultFocusState.NONE:
case 'NONE':
// Do nothing.
break;
case DefaultFocusState.LIST_ROOT:
case 'LIST_ROOT':
default:
this.listElement?.focus();
break;
Expand Down
21 changes: 21 additions & 0 deletions menu/menu-button.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {customElement} from 'lit/decorators';

import {MenuButton} from './lib/menu-button';
import {styles} from './lib/menu-button-styles.css';

declare global {
interface HTMLElementTagNameMap {
'md-menu-button': MdMenuButton;
}
}

@customElement('md-menu-button')
export class MdMenuButton extends MenuButton {
static override styles = [styles];
}
131 changes: 131 additions & 0 deletions menu/menu-button_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import './menu';
import './menu-button';
import './menu-item';
import '../button/filled-button';

import {Environment} from '@material/web/testing/environment';
import {html} from 'lit';

import {ButtonHarness} from '../button/harness';

import {MenuHarness} from './harness';

describe('menu tests', () => {
const env = new Environment();

it('on render, sets menu anchor to slotted button', async () => {
const el = env.render(getMenuButtonTemplate());
const menu = el.querySelector('md-menu')!;
const buttonSlot = el.querySelector<HTMLElement>('[slot="button"]')!;

await menu.updateComplete;
expect(menu.anchor).toBe(buttonSlot);
});

it('on button click, opens the menu if menu is not open', async () => {
const {buttonHarness, menu} = await setUp(env);

await buttonHarness.clickWithMouse();
await menu.updateComplete;
expect(menu.open).toBeTrue();
});

it('on button click, closes the menu if menu is open', async () => {
const {buttonHarness, menu} = await setUp(env);

// Click button to open menu.
await buttonHarness.clickWithMouse();
await menu.updateComplete;
expect(menu.open).toBeTrue();

// Click button again to close menu.
await buttonHarness.clickWithMouse();
await menu.updateComplete;
expect(menu.open).toBeFalse();
});

it('on button click, sets default focus to menu root', async () => {
const {buttonHarness, menu} = await setUp(env);

await buttonHarness.clickWithMouse();
await menu.updateComplete;
expect(document.activeElement).toBe(menu);
});

it(
'on synthetic button click, sets default focus to FIRST_ITEM',
async () => {
const {button, menu, menuHarness} = await setUp(env);

// Simulate synthetic click.
const buttonEl = button.renderRoot.querySelector('button')!;
buttonEl.dispatchEvent(
new PointerEvent('click', {bubbles: true, composed: true}));
await menu.updateComplete;
const firstMenuItem = menuHarness.getItems()[0].element;
expect(document.activeElement).toBe(firstMenuItem);
});

it('on non-ArrowUp/ArrowDown key event, does not open the menu', async () => {
const {buttonHarness, menu} = await setUp(env);

await buttonHarness.keypress('a');
await menu.updateComplete;
expect(menu.open).toBeFalse();
});

it('on ArrowUp key event, opens the menu and sets default focus to LAST_ITEM',
async () => {
const {buttonHarness, menu, menuHarness} = await setUp(env);

await buttonHarness.keypress('ArrowUp');
await menu.updateComplete;
const items = menuHarness.getItems();
const lastMenuItem = items[items.length - 1].element;
expect(menu.open).toBeTrue();
expect(document.activeElement).toBe(lastMenuItem);
});

it('on ArrowDown key event, opens the menu and sets default focus to FIRST_ITEM',
async () => {
const {buttonHarness, menu, menuHarness} = await setUp(env);

await buttonHarness.keypress('ArrowDown');
await menu.updateComplete;
const firstMenuItem = menuHarness.getItems()[0].element;
expect(menu.open).toBeTrue();
expect(document.activeElement).toBe(firstMenuItem);
});
});

async function setUp(env: Environment) {
const el = env.render(getMenuButtonTemplate());
const menu = el.querySelector('md-menu')!;
const menuHarness = await new MenuHarness(menu);
const button = el.querySelector('md-filled-button')!;
const buttonHarness = await new ButtonHarness(button);
await env.waitForStability();

return {menu, menuHarness, button, buttonHarness};
}

function getMenuButtonTemplate() {
return html`
<md-menu-button>
<button slot="button">
<md-filled-button .label=${'Open Menu'}></md-filled-button>
</button>
<md-menu slot="menu" .quick=${true}>
<md-menu-item .headline=${'One'}></md-menu-item>
<md-menu-item .headline=${'Two'}></md-menu-item>
<md-menu-item .headline=${'Three'}></md-menu-item>
</md-menu>
</md-menu-button>
`;
}
10 changes: 3 additions & 7 deletions menu/menu_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {Environment} from '@material/web/testing/environment';
import {html} from 'lit';

import {MenuHarness} from './harness';
import {DefaultFocusState} from './lib/constants';
import {MdMenu} from './menu';

describe('menu tests', () => {
Expand Down Expand Up @@ -81,8 +80,7 @@ describe('menu tests', () => {
describe('focus management', () => {
it('with `defaultFocus=FIRST_ITEM`, focuses on first menu item on open',
async () => {
({menu, harness} =
await setUp(env, {defaultFocus: DefaultFocusState.FIRST_ITEM}));
({menu, harness} = await setUp(env, {defaultFocus: 'FIRST_ITEM'}));
menu.show();
await menu.updateComplete;

Expand All @@ -92,8 +90,7 @@ describe('menu tests', () => {

it('with `defaultFocus=LAST_ITEM`, focuses on last menu item on open',
async () => {
({menu, harness} =
await setUp(env, {defaultFocus: DefaultFocusState.LAST_ITEM}));
({menu, harness} = await setUp(env, {defaultFocus: 'LAST_ITEM'}));
menu.show();
await menu.updateComplete;

Expand All @@ -104,8 +101,7 @@ describe('menu tests', () => {

it('with `defaultFocus=LIST_ROOT`, focuses on menu root on open',
async () => {
({menu} =
await setUp(env, {defaultFocus: DefaultFocusState.LIST_ROOT}));
({menu} = await setUp(env, {defaultFocus: 'LIST_ROOT'}));
menu.show();
await menu.updateComplete;

Expand Down

0 comments on commit a29ac8b

Please sign in to comment.