-
Notifications
You must be signed in to change notification settings - Fork 907
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(menu): Add menu button component. This manages focus automatical…
…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
1 parent
d0d5340
commit a29ac8b
Showing
8 changed files
with
286 additions
and
25 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
`; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters