mirror of
https://github.com/material-components/material-web.git
synced 2026-01-09 07:21:09 +08:00
feat(menu): implement md-sub-menu
md-sub-menu will succeed md-sub-menu-item. It allows for screen reader linear navigation PiperOrigin-RevId: 567057310
This commit is contained in:
parent
dc75fbc639
commit
54fbb2ed5e
@ -4,24 +4,34 @@
|
||||
aria-label="A filled button that says menu with submenus. Interact with the button to interact with a menu that has two sub menus."
|
||||
>
|
||||
<span style="position: relative">
|
||||
<md-filled-button id="usage-submenu-anchor">Menu with Submenus</md-filled-button>
|
||||
<md-filled-button id="usage-submenu-anchor"> Menu with Submenus </md-filled-button>
|
||||
<!-- Note the has-overflow attribute -->
|
||||
<md-menu has-overflow id="usage-submenu" anchor="usage-submenu-anchor">
|
||||
<md-sub-menu-item headline="Fruits with A">
|
||||
<md-menu slot="submenu">
|
||||
<md-sub-menu>
|
||||
<md-menu-item slot="item" headline="Fruits with A">
|
||||
<!-- Arrow icons are helpful affordances -->
|
||||
<md-icon slot="end-icon">arrow_right</md-icon>
|
||||
</md-menu-item>
|
||||
<!-- Submenu must be slotted into sub-menu's menu slot -->
|
||||
<md-menu slot="menu">
|
||||
<md-menu-item headline="Apricot"></md-menu-item>
|
||||
<md-menu-item headline="Avocado"></md-menu-item>
|
||||
<md-sub-menu-item headline="Apples" menu-corner="start-end" anchor-corner="start-start">
|
||||
<md-menu slot="submenu">
|
||||
<!-- Nest as many as you want and control menu anchoring -->
|
||||
<md-sub-menu menu-corner="start-end" anchor-corner="start-start">
|
||||
<md-menu-item slot="item" headline="Apples">
|
||||
<!-- Arrow icons are helpful affordances -->
|
||||
<md-icon slot="start-icon" style="font-size: 24px; height: 24px">
|
||||
arrow_left
|
||||
</md-icon>
|
||||
</md-menu-item>
|
||||
<md-menu slot="menu">
|
||||
<md-menu-item headline="Fuji"></md-menu-item>
|
||||
<md-menu-item headline="Granny Smith"></md-menu-item>
|
||||
<md-menu-item headline="Red Delicious"></md-menu-item>
|
||||
</md-menu>
|
||||
<md-icon slot="start-icon" style="font-size: 24px;height:24px;">arrow_left</md-icon>
|
||||
</md-sub-menu-item>
|
||||
</md-sub-menu>
|
||||
</md-menu>
|
||||
<md-icon slot="end">arrow_right</md-icon>
|
||||
</md-sub-menu-item>
|
||||
</md-sub-menu>
|
||||
<md-menu-item headline="Banana"></md-menu-item>
|
||||
<md-menu-item headline="Cucumber"></md-menu-item>
|
||||
</md-menu>
|
||||
|
||||
@ -119,7 +119,7 @@ Cucumber."](images/menu/usage.webp)
|
||||
|
||||
### Submenus
|
||||
|
||||
You can compose submenus inside of an `<md-sub-menu-item>`'s `submenu` slot, but
|
||||
You can compose `<md-menu>`s inside of an `<md-sub-menu>`'s `menu` slot, but
|
||||
first the `has-overflow` attribute must be set on the root `<md-menu>` to
|
||||
disable overflow scrolling and display the nested submenus.
|
||||
|
||||
@ -143,35 +143,37 @@ Granny Smith, and Red Delicious."](images/menu/usage-submenu.webp)
|
||||
</md-filled-button>
|
||||
<!-- Note the has-overflow attribute -->
|
||||
<md-menu has-overflow id="usage-submenu" anchor="usage-submenu-anchor">
|
||||
<md-sub-menu-item headline="Fruits with A">
|
||||
<!-- Submenu must be slotted into sub-menu-item's submenu slot -->
|
||||
<md-menu slot="submenu">
|
||||
<md-sub-menu>
|
||||
<md-menu-item slot="item" headline="Fruits with A">
|
||||
<!-- Arrow icons are helpful affordances -->
|
||||
<md-icon slot="end-icon">arrow_right</md-icon>
|
||||
</md-menu-item>
|
||||
<!-- Submenu must be slotted into sub-menu's menu slot -->
|
||||
<md-menu slot="menu">
|
||||
<md-menu-item headline="Apricot"></md-menu-item>
|
||||
<md-menu-item headline="Avocado"></md-menu-item>
|
||||
|
||||
<!-- Nest as many as you want and control menu anchoring -->
|
||||
<md-sub-menu-item
|
||||
headline="Apples"
|
||||
<md-sub-menu
|
||||
menu-corner="start-end"
|
||||
anchor-corner="start-start">
|
||||
<md-menu slot="submenu">
|
||||
<md-menu-item slot="item" headline="Apples">
|
||||
<!-- Arrow icons are helpful affordances -->
|
||||
<md-icon
|
||||
slot="start-icon"
|
||||
style="font-size: 24px;height:24px;">
|
||||
arrow_left
|
||||
</md-icon>
|
||||
</md-menu-item>
|
||||
<md-menu slot="menu">
|
||||
<md-menu-item headline="Fuji"></md-menu-item>
|
||||
<md-menu-item headline="Granny Smith"></md-menu-item>
|
||||
<md-menu-item headline="Red Delicious"></md-menu-item>
|
||||
</md-menu>
|
||||
|
||||
<!-- Arrow icons are helpful affordances -->
|
||||
<md-icon
|
||||
slot="start-icon"
|
||||
style="font-size: 24px;height:24px;">
|
||||
arrow_left
|
||||
</md-icon>
|
||||
</md-sub-menu-item>
|
||||
</md-sub-menu>
|
||||
</md-menu>
|
||||
|
||||
<!-- Arrow icons are helpful affordances -->
|
||||
<md-icon slot="end">arrow_right</md-icon>
|
||||
</md-sub-menu-item>
|
||||
</md-sub-menu>
|
||||
|
||||
<md-menu-item headline="Banana"></md-menu-item>
|
||||
<md-menu-item headline="Cucumber"></md-menu-item>
|
||||
|
||||
@ -56,8 +56,29 @@ export class List extends LitElement {
|
||||
* `HTMLSlotElement.queryAssignedElements` and thus will _only_ include direct
|
||||
* children / directly slotted elements.
|
||||
*/
|
||||
@queryAssignedElements({flatten: true, selector: '[md-list-item]'})
|
||||
items!: ListItem[];
|
||||
@queryAssignedElements({flatten: true})
|
||||
protected slotItems!: Array<ListItem|HTMLElement&{item?: ListItem}>;
|
||||
|
||||
/** @export */
|
||||
get items() {
|
||||
const items = [];
|
||||
|
||||
for (const itemOrParent of this.slotItems) {
|
||||
// if the item is a list item, add it to the list of items
|
||||
if (itemOrParent.hasAttribute('md-list-item')) {
|
||||
items.push(itemOrParent as ListItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the item exposes an `item` property check if it is a list item.
|
||||
const subItem = (itemOrParent as HTMLElement & {item?: ListItem}).item;
|
||||
if (subItem && subItem?.hasAttribute?.('md-list-item')) {
|
||||
items.push(subItem);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private readonly internals = polyfillElementInternalsAria(
|
||||
this, (this as HTMLElement /* needed for closure */).attachInternals());
|
||||
|
||||
@ -160,6 +160,8 @@ export class ListItemEl extends LitElement implements ListItem {
|
||||
role=${role}
|
||||
aria-selected=${(this as ARIAMixinStrict).ariaSelected || nothing}
|
||||
aria-checked=${(this as ARIAMixinStrict).ariaChecked || nothing}
|
||||
aria-expanded=${(this as ARIAMixinStrict).ariaExpanded || nothing}
|
||||
aria-haspopup=${(this as ARIAMixinStrict).ariaHasPopup || nothing}
|
||||
class="list-item ${classMap(this.getRenderClasses())}"
|
||||
href=${this.href || nothing}
|
||||
target=${target}
|
||||
|
||||
@ -90,11 +90,11 @@ const collection =
|
||||
ui: numberInput(),
|
||||
}),
|
||||
new Knob('listTabIndex', {
|
||||
defaultValue: 0,
|
||||
defaultValue: -1,
|
||||
ui: numberInput(),
|
||||
}),
|
||||
new Knob('ariaLabel', {
|
||||
defaultValue: '0',
|
||||
defaultValue: 'Menu of Fruit',
|
||||
ui: textInput(),
|
||||
}),
|
||||
|
||||
@ -123,7 +123,7 @@ const collection =
|
||||
|
||||
|
||||
// sub-menu-item knobs
|
||||
new Knob('sub-menu-item', {ui: title()}),
|
||||
new Knob('sub-menu', {ui: title()}),
|
||||
new Knob('submenu.anchorCorner', {
|
||||
defaultValue: Corner.START_END as Corner,
|
||||
ui: selectDropdown<Corner>({
|
||||
@ -154,7 +154,7 @@ const collection =
|
||||
defaultValue: 400,
|
||||
ui: numberInput(),
|
||||
}),
|
||||
new Knob('submenu icon', {
|
||||
new Knob('submenu item icon', {
|
||||
defaultValue: 'navigate_next',
|
||||
ui: textInput(),
|
||||
}),
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import '@material/web/menu/menu-item.js';
|
||||
import '@material/web/menu/sub-menu-item.js';
|
||||
import '@material/web/menu/sub-menu.js';
|
||||
import '@material/web/menu/menu.js';
|
||||
import '@material/web/button/filled-button.js';
|
||||
import '@material/web/divider/divider.js';
|
||||
@ -43,12 +44,12 @@ export interface StoryKnobs {
|
||||
target: string;
|
||||
'link icon': string;
|
||||
|
||||
'sub-menu-item': void;
|
||||
'sub-menu': void;
|
||||
'submenu.anchorCorner': Corner|undefined;
|
||||
'submenu.menuCorner': Corner|undefined;
|
||||
hoverOpenDelay: number;
|
||||
hoverCloseDelay: number;
|
||||
'submenu icon': string;
|
||||
'submenu item icon': string;
|
||||
}
|
||||
|
||||
const fruitNames = [
|
||||
@ -203,7 +204,7 @@ const linkable: MaterialStoryInit<StoryKnobs> = {
|
||||
};
|
||||
|
||||
const submenu: MaterialStoryInit<StoryKnobs> = {
|
||||
name: '<md-sub-menu-item>',
|
||||
name: '<md-sub-menu>',
|
||||
styles: sharedStyle,
|
||||
render(knobs) {
|
||||
let currentIndex = -1;
|
||||
@ -227,17 +228,23 @@ const submenu: MaterialStoryInit<StoryKnobs> = {
|
||||
currentIndex++;
|
||||
|
||||
return html`
|
||||
<md-sub-menu-item
|
||||
headline=${name}
|
||||
id=${currentIndex}
|
||||
.disabled=${knobs.disabled}
|
||||
<md-sub-menu
|
||||
.anchorCorner=${knobs['submenu.anchorCorner']!}
|
||||
.menuCorner=${knobs['submenu.menuCorner']!}
|
||||
.hoverOpenDelay=${knobs.hoverOpenDelay}
|
||||
.hoverCloseDelay=${knobs.hoverCloseDelay}>
|
||||
<md-menu-item
|
||||
slot="item"
|
||||
headline=${name}
|
||||
id=${currentIndex}
|
||||
.disabled=${knobs.disabled}>
|
||||
<md-icon slot="end-icon">
|
||||
${knobs['submenu item icon']}
|
||||
</md-icon>
|
||||
</md-menu-item>
|
||||
<!-- NOTE: slot=submenu -->
|
||||
<md-menu
|
||||
slot="submenu"
|
||||
slot="menu"
|
||||
.ariaLabel=${knobs.ariaLabel}
|
||||
.xOffset=${knobs.xOffset}
|
||||
.yOffset=${knobs.yOffset}
|
||||
@ -246,10 +253,7 @@ const submenu: MaterialStoryInit<StoryKnobs> = {
|
||||
.typeaheadDelay=${knobs.typeaheadDelay}>
|
||||
${layer2}
|
||||
</md-menu>
|
||||
<md-icon slot="end-icon">
|
||||
${knobs['submenu icon']}
|
||||
</md-icon>
|
||||
</md-sub-menu-item>`;
|
||||
</md-sub-menu>`;
|
||||
}),
|
||||
...fruitNames.slice(2, 5).map(name => {
|
||||
currentIndex++;
|
||||
@ -269,17 +273,23 @@ const submenu: MaterialStoryInit<StoryKnobs> = {
|
||||
currentIndex++;
|
||||
|
||||
return html`
|
||||
<md-sub-menu-item
|
||||
headline=${name}
|
||||
id=${currentIndex}
|
||||
.disabled=${knobs.disabled}
|
||||
.anchorCorner=${knobs['submenu.anchorCorner']!}
|
||||
.menuCorner=${knobs['submenu.menuCorner']!}
|
||||
.hoverOpenDelay=${knobs.hoverOpenDelay}
|
||||
.hoverCloseDelay=${knobs.hoverCloseDelay}>
|
||||
<md-sub-menu
|
||||
.anchorCorner=${knobs['submenu.anchorCorner']!}
|
||||
.menuCorner=${knobs['submenu.menuCorner']!}
|
||||
.hoverOpenDelay=${knobs.hoverOpenDelay}
|
||||
.hoverCloseDelay=${knobs.hoverCloseDelay}>
|
||||
<md-menu-item
|
||||
slot="item"
|
||||
headline=${name}
|
||||
id=${currentIndex}
|
||||
.disabled=${knobs.disabled}>
|
||||
<md-icon slot="end-icon">
|
||||
${knobs['submenu item icon']}
|
||||
</md-icon>
|
||||
</md-menu-item>
|
||||
<!-- NOTE: slot=submenu -->
|
||||
<md-menu
|
||||
slot="submenu"
|
||||
slot="menu"
|
||||
.ariaLabel=${knobs.ariaLabel}
|
||||
.xOffset=${knobs.xOffset}
|
||||
.yOffset=${knobs.yOffset}
|
||||
@ -288,10 +298,7 @@ const submenu: MaterialStoryInit<StoryKnobs> = {
|
||||
.typeaheadDelay=${knobs.typeaheadDelay}>
|
||||
${layer1}
|
||||
</md-menu>
|
||||
<md-icon slot="end-icon">
|
||||
${knobs['submenu icon']}
|
||||
</md-icon>
|
||||
</md-sub-menu-item>`;
|
||||
</md-sub-menu>`;
|
||||
});
|
||||
|
||||
return html`
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2022 Google LLC
|
||||
* Copyright 2023 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@ -197,6 +197,8 @@ export abstract class Menu extends LitElement {
|
||||
|
||||
@state() private typeaheadActive = true;
|
||||
|
||||
private isPointerDown = false;
|
||||
|
||||
private readonly openCloseAnimationSignal = createAnimationSignal();
|
||||
|
||||
/**
|
||||
@ -362,7 +364,7 @@ export abstract class Menu extends LitElement {
|
||||
}
|
||||
|
||||
private async handleFocusout(event: FocusEvent) {
|
||||
if (this.stayOpenOnFocusout) {
|
||||
if (this.stayOpenOnFocusout || !this.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -372,6 +374,14 @@ export abstract class Menu extends LitElement {
|
||||
if (isElementInSubtree(event.relatedTarget, this)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorEl = this.anchorElement!;
|
||||
const wasAnchorClickFocused =
|
||||
isElementInSubtree(event.relatedTarget, anchorEl) &&
|
||||
this.isPointerDown;
|
||||
if (wasAnchorClickFocused) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const oldRestoreFocus = this.skipRestoreFocus;
|
||||
@ -701,6 +711,8 @@ export abstract class Menu extends LitElement {
|
||||
super.connectedCallback();
|
||||
if (!isServer) {
|
||||
window.addEventListener('click', this.onWindowClick, {capture: true});
|
||||
window.addEventListener('pointerdown', this.onWindowPointerdown);
|
||||
window.addEventListener('pointerup', this.onWindowPointerup);
|
||||
}
|
||||
|
||||
// need to self-identify as an md-menu for submenu ripple identification.
|
||||
@ -711,11 +723,28 @@ export abstract class Menu extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
if (!isServer) {
|
||||
window.removeEventListener('click', this.onWindowClick, {capture: true});
|
||||
window.removeEventListener('pointerdown', this.onWindowPointerdown);
|
||||
window.removeEventListener('pointerup', this.onWindowPointerup);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly onWindowPointerdown = () => {
|
||||
this.isPointerDown = true;
|
||||
};
|
||||
|
||||
private readonly onWindowPointerup = () => {
|
||||
this.isPointerDown = false;
|
||||
};
|
||||
|
||||
private readonly onWindowClick = (event: MouseEvent) => {
|
||||
if (!this.stayOpenOnOutsideClick && !event.composedPath().includes(this)) {
|
||||
if (!this.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = event.composedPath();
|
||||
|
||||
if (!this.stayOpenOnOutsideClick && !path.includes(this) &&
|
||||
!path.includes(this.anchorElement!)) {
|
||||
this.open = false;
|
||||
}
|
||||
};
|
||||
|
||||
@ -27,6 +27,11 @@ export class MenuItemEl extends ListItemEl implements MenuItem {
|
||||
*/
|
||||
@property({type: Boolean, attribute: 'keep-open'}) keepOpen = false;
|
||||
|
||||
/**
|
||||
* Sets the item in the selected visual state when a submenu is opened.
|
||||
*/
|
||||
@property({type: Boolean}) selected = false;
|
||||
|
||||
@state() protected hasFocusRing = false;
|
||||
|
||||
/**
|
||||
@ -47,6 +52,7 @@ export class MenuItemEl extends ListItemEl implements MenuItem {
|
||||
return {
|
||||
...super.getRenderClasses(),
|
||||
'has-focus-ring': this.hasFocusRing,
|
||||
selected: this.selected
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,10 @@ interface MenuItemSelf {
|
||||
* The visible headline text of the item.
|
||||
*/
|
||||
headline: string;
|
||||
/**
|
||||
* Whether it should keep the menu open after click.
|
||||
*/
|
||||
keepOpen?: boolean;
|
||||
/**
|
||||
* Whether or not the item is in the selected visual state.
|
||||
*/
|
||||
|
||||
12
menu/internal/submenu/_sub-menu.scss
Normal file
12
menu/internal/submenu/_sub-menu.scss
Normal file
@ -0,0 +1,12 @@
|
||||
//
|
||||
// Copyright 2023 Google LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
|
||||
@mixin styles {
|
||||
:host {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
10
menu/internal/submenu/sub-menu-styles.scss
Normal file
10
menu/internal/submenu/sub-menu-styles.scss
Normal file
@ -0,0 +1,10 @@
|
||||
//
|
||||
// Copyright 2023 Google LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
|
||||
// go/keep-sorted start
|
||||
@use './sub-menu';
|
||||
// go/keep-sorted end
|
||||
|
||||
@include sub-menu.styles;
|
||||
364
menu/internal/submenu/sub-menu.ts
Normal file
364
menu/internal/submenu/sub-menu.ts
Normal file
@ -0,0 +1,364 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2023 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {html, isServer, LitElement} from 'lit';
|
||||
import {property, queryAssignedElements} from 'lit/decorators.js';
|
||||
|
||||
import {List} from '../../../list/internal/list.js';
|
||||
import {createDeactivateItemsEvent, createRequestActivationEvent} from '../../../list/internal/listitem/list-item.js';
|
||||
import {Corner, Menu} from '../menu.js';
|
||||
import {CLOSE_REASON, CloseMenuEvent, createActivateTypeaheadEvent, createDeactivateTypeaheadEvent, KEYDOWN_CLOSE_KEYS, MenuItem, NAVIGABLE_KEY, SELECTION_KEY} from '../shared.js';
|
||||
|
||||
/**
|
||||
* @fires deactivate-items Requests the parent menu to deselect other items when
|
||||
* a submenu opens
|
||||
* @fires request-activation Requests the parent make the slotted item focusable
|
||||
* and focuses the item.
|
||||
* @fires deactivate-typeahead Requests the parent menu to deactivate the
|
||||
* typeahead functionality when a submenu opens
|
||||
* @fires activate-typeahead Requests the parent menu to activate the typeahead
|
||||
* functionality when a submenu closes
|
||||
*/
|
||||
export class SubMenu extends LitElement {
|
||||
/**
|
||||
* The anchorCorner to set on the submenu.
|
||||
*/
|
||||
@property({attribute: 'anchor-corner'})
|
||||
anchorCorner: Corner = Corner.START_END;
|
||||
/**
|
||||
* The menuCorner to set on the submenu.
|
||||
*/
|
||||
@property({attribute: 'menu-corner'}) menuCorner: Corner = Corner.START_START;
|
||||
/**
|
||||
* The delay between mouseenter and submenu opening.
|
||||
*/
|
||||
@property({type: Number, attribute: 'hover-open-delay'}) hoverOpenDelay = 400;
|
||||
/**
|
||||
* The delay between ponterleave and the submenu closing.
|
||||
*/
|
||||
@property({type: Number, attribute: 'hover-close-delay'})
|
||||
hoverCloseDelay = 400;
|
||||
|
||||
/**
|
||||
* READONLY: self-identifies as a menu item and sets its identifying attribute
|
||||
*/
|
||||
@property({type: Boolean, reflect: true, attribute: 'md-sub-menu'})
|
||||
isSubMenu = true;
|
||||
|
||||
get item() {
|
||||
return this.items[0] ?? null;
|
||||
}
|
||||
|
||||
get menu() {
|
||||
return this.menus[0] ?? null;
|
||||
}
|
||||
|
||||
@queryAssignedElements({slot: 'item', flatten: true})
|
||||
private readonly items!: MenuItem[];
|
||||
|
||||
@queryAssignedElements({slot: 'menu', flatten: true})
|
||||
private readonly menus!: Menu[];
|
||||
|
||||
private previousOpenTimeout = 0;
|
||||
private previousCloseTimeout = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
if (!isServer) {
|
||||
this.addEventListener('mouseenter', this.onMouseenter);
|
||||
this.addEventListener('mouseleave', this.onMouseleave);
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<slot
|
||||
name="item"
|
||||
@click=${this.onClick}
|
||||
@keydown=${this.onKeydown}
|
||||
@slotchange=${this.onSlotchange}
|
||||
>
|
||||
</slot>
|
||||
<slot name="menu"
|
||||
@keydown=${this.onSubMenuKeydown}
|
||||
@close-menu=${this.onCloseSubmenu}
|
||||
@slotchange=${this.onSlotchange}>
|
||||
</slot>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the submenu.
|
||||
*/
|
||||
async show() {
|
||||
const menu = this.menu;
|
||||
if (!menu || menu.open) return;
|
||||
|
||||
// Ensures that we deselect items when the menu closes and reactivate
|
||||
// typeahead when the menu closes, so that we do not have dirty state of
|
||||
// selected sub-menu-items when we reopen.
|
||||
//
|
||||
// This cannot happen in `close()` because the menu may close via other
|
||||
// means Additionally, this cannot happen in onCloseSubmenu because
|
||||
// `close-menu` may not be called via focusout of outside click and not
|
||||
// triggered by an item
|
||||
menu.addEventListener('closed', () => {
|
||||
this.item.ariaExpanded = 'false';
|
||||
this.dispatchEvent(createActivateTypeaheadEvent());
|
||||
this.dispatchEvent(createDeactivateItemsEvent());
|
||||
}, {once: true});
|
||||
menu.quick = true;
|
||||
// Submenus are in overflow when not fixed. Can remove once we have native
|
||||
// popup support
|
||||
menu.hasOverflow = true;
|
||||
menu.anchorCorner = this.anchorCorner;
|
||||
menu.menuCorner = this.menuCorner;
|
||||
menu.anchorElement = this.item;
|
||||
menu.defaultFocus = 'first-item';
|
||||
// This is required in the case where we have a leaf menu open and and the
|
||||
// user hovers a parent menu's item which is not an md-sub-menu item.
|
||||
// If this were set to true, then the menu would close and focus would be
|
||||
// lost. That means the focusout event would have a `relatedTarget` of
|
||||
// `null` since nothing in the menu would be focused anymore due to the
|
||||
// leaf menu closing. restoring focus ensures that we keep focus in the
|
||||
// submenu tree.
|
||||
menu.skipRestoreFocus = false;
|
||||
|
||||
// Menu could already be opened because of mouse interaction
|
||||
const menuAlreadyOpen = menu.open;
|
||||
menu.show();
|
||||
this.item.ariaExpanded = 'true';
|
||||
this.item.ariaHasPopup = 'menu';
|
||||
if (menu.id) {
|
||||
this.item.setAttribute('aria-controls', menu.id);
|
||||
}
|
||||
|
||||
// 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(createDeactivateItemsEvent());
|
||||
this.dispatchEvent(createDeactivateTypeaheadEvent());
|
||||
this.item.selected = true;
|
||||
|
||||
// This is the case of mouse hovering when already opened via keyboard or
|
||||
// vice versa
|
||||
if (!menuAlreadyOpen) {
|
||||
let open = (value: unknown) => {};
|
||||
const opened = new Promise((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
menu.addEventListener('opened', open, {once: true});
|
||||
await opened;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the submenu.
|
||||
*/
|
||||
async close() {
|
||||
const menu = this.menu;
|
||||
if (!menu || !menu.open) return;
|
||||
|
||||
this.dispatchEvent(createActivateTypeaheadEvent());
|
||||
menu.quick = true;
|
||||
menu.close();
|
||||
this.dispatchEvent(createDeactivateItemsEvent());
|
||||
let close = (value: unknown) => {};
|
||||
const closed = new Promise((resolve) => {
|
||||
close = resolve;
|
||||
});
|
||||
menu.addEventListener('closed', close, {once: true});
|
||||
await closed;
|
||||
}
|
||||
|
||||
protected onSlotchange() {
|
||||
if (!this.item) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO(b/301296618): clean up old aria values on change
|
||||
this.item.ariaExpanded = 'false';
|
||||
this.item.ariaHasPopup = 'menu';
|
||||
if (this.menu?.id) {
|
||||
this.item.setAttribute('aria-controls', this.menu.id);
|
||||
}
|
||||
this.item.keepOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the default 400ms countdown to open the submenu.
|
||||
*
|
||||
* NOTE: We explicitly use mouse events and not pointer events because
|
||||
* pointer events apply to touch events. And if a user were to tap a
|
||||
* sub-menu-item, it would fire the "pointerenter", "pointerleave", "click"
|
||||
* events which would open the menu on click, and then set the timeout to
|
||||
* close the menu due to pointerleave.
|
||||
*/
|
||||
protected onMouseenter = () => {
|
||||
clearTimeout(this.previousOpenTimeout);
|
||||
clearTimeout(this.previousCloseTimeout);
|
||||
if (this.menu?.open) return;
|
||||
|
||||
// Open synchronously if delay is 0. (screenshot tests infra
|
||||
// would never resolve otherwise)
|
||||
if (!this.hoverOpenDelay) {
|
||||
this.show();
|
||||
} else {
|
||||
this.previousOpenTimeout = setTimeout(() => {
|
||||
this.show();
|
||||
}, this.hoverOpenDelay);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts the default 400ms countdown to close the submenu.
|
||||
*
|
||||
* NOTE: We explicitly use mouse events and not pointer events because
|
||||
* pointer events apply to touch events. And if a user were to tap a
|
||||
* sub-menu-item, it would fire the "pointerenter", "pointerleave", "click"
|
||||
* events which would open the menu on click, and then set the timeout to
|
||||
* close the menu due to pointerleave.
|
||||
*/
|
||||
protected onMouseleave = () => {
|
||||
clearTimeout(this.previousCloseTimeout);
|
||||
clearTimeout(this.previousOpenTimeout);
|
||||
|
||||
// Close synchronously if delay is 0. (screenshot tests infra
|
||||
// would never resolve otherwise)
|
||||
if (!this.hoverCloseDelay) {
|
||||
this.close();
|
||||
} else {
|
||||
this.previousCloseTimeout = setTimeout(() => {
|
||||
this.close();
|
||||
}, this.hoverCloseDelay);
|
||||
}
|
||||
};
|
||||
|
||||
protected onClick() {
|
||||
this.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* On item keydown handles opening the submenu.
|
||||
*/
|
||||
protected async onKeydown(event: KeyboardEvent) {
|
||||
const shouldOpenSubmenu = this.isSubmenuOpenKey(event.code);
|
||||
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
const openedWithLR = shouldOpenSubmenu &&
|
||||
(NAVIGABLE_KEY.LEFT === event.code ||
|
||||
NAVIGABLE_KEY.RIGHT === event.code);
|
||||
|
||||
if (event.code === SELECTION_KEY.SPACE || openedWithLR) {
|
||||
// prevent space from scrolling and Left + Right from selecting previous /
|
||||
// next items or opening / closing parent menus. Only open the submenu.
|
||||
event.preventDefault();
|
||||
|
||||
if (openedWithLR) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldOpenSubmenu) {
|
||||
return;
|
||||
}
|
||||
|
||||
const submenu = this.menu;
|
||||
if (!submenu) return;
|
||||
|
||||
const submenuItems = submenu.items;
|
||||
const firstActivatableItem = List.getFirstActivatableItem(submenuItems);
|
||||
|
||||
if (firstActivatableItem) {
|
||||
await this.show();
|
||||
|
||||
firstActivatableItem.tabIndex = 0;
|
||||
firstActivatableItem.focus();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private onCloseSubmenu(event: CloseMenuEvent) {
|
||||
const {itemPath, reason} = event.detail;
|
||||
itemPath.push(this.item);
|
||||
|
||||
this.dispatchEvent(createActivateTypeaheadEvent());
|
||||
// Escape should only close one menu not all of the menus unlike space or
|
||||
// click selection which should close all menus.
|
||||
if (reason.kind === CLOSE_REASON.KEYDOWN &&
|
||||
reason.key === KEYDOWN_CLOSE_KEYS.ESCAPE) {
|
||||
event.stopPropagation();
|
||||
this.item.dispatchEvent(createRequestActivationEvent());
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispatchEvent(createDeactivateItemsEvent());
|
||||
}
|
||||
|
||||
private async onSubMenuKeydown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented) return;
|
||||
const {close: shouldClose, keyCode} = this.isSubmenuCloseKey(event.code);
|
||||
if (!shouldClose) return;
|
||||
|
||||
// Communicate that it's handled so that we don't accidentally close every
|
||||
// parent menu. Additionally, we want to isolate things like the typeahead
|
||||
// keydowns from bubbling up to the parent menu and confounding things.
|
||||
event.preventDefault();
|
||||
|
||||
if (keyCode === NAVIGABLE_KEY.LEFT || keyCode === NAVIGABLE_KEY.RIGHT) {
|
||||
// Prevent this from bubbling to parents
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
await this.close();
|
||||
|
||||
List.deactivateActiveItem(this.menu.items);
|
||||
this.item?.focus();
|
||||
this.tabIndex = 0;
|
||||
this.item.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the given KeyboardEvent code is one that should open
|
||||
* the submenu. This is RTL-aware. By default, left, right, space, or enter.
|
||||
*
|
||||
* @param code The native KeyboardEvent code.
|
||||
* @return Whether or not the key code should open the submenu.
|
||||
*/
|
||||
private isSubmenuOpenKey(code: string) {
|
||||
const isRtl = getComputedStyle(this).direction === 'rtl';
|
||||
const arrowEnterKey = isRtl ? NAVIGABLE_KEY.LEFT : NAVIGABLE_KEY.RIGHT;
|
||||
switch (code) {
|
||||
case arrowEnterKey:
|
||||
case SELECTION_KEY.SPACE:
|
||||
case SELECTION_KEY.ENTER:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the given KeyboardEvent code is one that should close
|
||||
* the submenu. This is RTL-aware. By default right, left, or escape.
|
||||
*
|
||||
* @param code The native KeyboardEvent code.
|
||||
* @return Whether or not the key code should close the submenu.
|
||||
*/
|
||||
private isSubmenuCloseKey(code: string) {
|
||||
const isRtl = getComputedStyle(this).direction === 'rtl';
|
||||
const arrowEnterKey = isRtl ? NAVIGABLE_KEY.RIGHT : NAVIGABLE_KEY.LEFT;
|
||||
switch (code) {
|
||||
case arrowEnterKey:
|
||||
case KEYDOWN_CLOSE_KEYS.ESCAPE:
|
||||
return {close: true, keyCode: code} as const;
|
||||
default:
|
||||
return {close: false} as const;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -43,10 +43,6 @@ export class SubMenuItem extends MenuItemEl {
|
||||
*/
|
||||
@property({type: Number, attribute: 'hover-close-delay'})
|
||||
hoverCloseDelay = 400;
|
||||
/**
|
||||
* Sets the item in the selected visual state when a submenu is opened.
|
||||
*/
|
||||
@property({type: Boolean, reflect: true}) selected = false;
|
||||
|
||||
@state() protected submenuHover = false;
|
||||
|
||||
@ -120,7 +116,6 @@ export class SubMenuItem extends MenuItemEl {
|
||||
return {
|
||||
...super.getRenderClasses(),
|
||||
'submenu-hover': this.submenuHover,
|
||||
selected: this.selected
|
||||
};
|
||||
}
|
||||
|
||||
@ -265,10 +260,7 @@ export class SubMenuItem extends MenuItemEl {
|
||||
menu.anchorCorner = this.anchorCorner;
|
||||
menu.menuCorner = this.menuCorner;
|
||||
menu.anchorElement = this;
|
||||
// We manually set focus with `active` on keyboard navigation. And we
|
||||
// want to focus the root on hover, so the user can pick up navigation with
|
||||
// keyboard after hover.
|
||||
menu.defaultFocus = 'list-root';
|
||||
menu.defaultFocus = 'first-item';
|
||||
// This is required in the case where we have a leaf menu open and and the
|
||||
// user hovers a parent menu's item which is not an md-sub-menu item.
|
||||
// If this were set to true, then the menu would close and focus would be
|
||||
|
||||
20
menu/menu.ts
20
menu/menu.ts
@ -37,10 +37,6 @@ declare global {
|
||||
* @example
|
||||
* ```html
|
||||
* <div style="position:relative;">
|
||||
* <!--
|
||||
* The menu ref in this example can be achieved by any method such as the
|
||||
* preferred `@query` decorator in Lit or a ref in React.
|
||||
* -->
|
||||
* <button
|
||||
* id="anchor"
|
||||
* @click=${() => this.menuRef.value.show()}>
|
||||
@ -55,13 +51,17 @@ declare global {
|
||||
* necessary.
|
||||
* -->
|
||||
* <md-menu anchor="anchor" has-overflow ${ref(menuRef)}>
|
||||
* <md-menu-item header="This is a header"></md-menu-item>
|
||||
* <md-sub-menu-item header="this is a submenu item">
|
||||
* <md-menu slot="submenu">
|
||||
* <md-menu-item
|
||||
* header="This is an item inside a submenu"></md-menu-item>
|
||||
* <md-menu-item headline="This is a headline"></md-menu-item>
|
||||
* <md-sub-menu>
|
||||
* <md-menu-item
|
||||
* slot="item"
|
||||
* headline="this is a submenu item">
|
||||
* </md-menu-item>
|
||||
* <md-menu slot="menu">
|
||||
* <md-menu-item headline="This is an item inside a submenu">
|
||||
* </md-menu-item>
|
||||
* </md-menu>
|
||||
* </md-sub-menu-item>
|
||||
* </md-sub-menu>
|
||||
* </md-menu>
|
||||
* </div>
|
||||
* ```
|
||||
|
||||
@ -24,10 +24,11 @@ declare global {
|
||||
|
||||
/**
|
||||
* @summary Menus display a list of choices on a temporary surface.
|
||||
* @deprecated Use <md-submenu>
|
||||
*
|
||||
* @description
|
||||
* Menu items are the selectable choices within the menu. Menu items must
|
||||
* implement the `MenuItem` interface and also have the `md-menu-item`
|
||||
* implement the `Menu` interface and also have the `md-menu`
|
||||
* attribute. Additionally menu items are list items so they must also have the
|
||||
* `md-list-item` attribute.
|
||||
*
|
||||
@ -51,11 +52,15 @@ declare global {
|
||||
* menu's contents
|
||||
* -->
|
||||
* <md-menu anchor="anchor" has-overflow ${ref(menuRef)}>
|
||||
* <md-menu-item header="This is a header"></md-menu-item>
|
||||
* <md-sub-menu-item header="this is a submenu item">
|
||||
* <md-menu slot="submenu">
|
||||
* <md-menu-item
|
||||
* header="This is an item inside a submenu"></md-menu-item>
|
||||
* <md-menu-item headline="This is a headline"></md-menu-item>
|
||||
* <md-sub-menu>
|
||||
* <md-menu-item
|
||||
* slot="item"
|
||||
* headline="this is a submenu item">
|
||||
* </md-menu-item>
|
||||
* <md-menu slot="menu">
|
||||
* <md-menu-item headline="This is an item inside a submenu">
|
||||
* </md-menu-item>
|
||||
* </md-menu>
|
||||
* </md-sub-menu>
|
||||
* </md-menu>
|
||||
|
||||
68
menu/sub-menu.ts
Normal file
68
menu/sub-menu.ts
Normal file
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2023 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {customElement} from 'lit/decorators.js';
|
||||
|
||||
import {SubMenu} from './internal/submenu/sub-menu.js';
|
||||
import {styles} from './internal/submenu/sub-menu-styles.css.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'md-sub-menu': MdSubMenu;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Menus display a list of choices on a temporary surface.
|
||||
*
|
||||
* @description
|
||||
* Menu items are the selectable choices within the menu. Menu items must
|
||||
* implement the `Menu` interface and also have the `md-menu`
|
||||
* attribute. Additionally menu items are list items so they must also have the
|
||||
* `md-list-item` attribute.
|
||||
*
|
||||
* Menu items can control a menu by selectively firing the `close-menu` and
|
||||
* `deselect-items` events.
|
||||
*
|
||||
* This menu item will open a sub-menu that is slotted in the `submenu` slot.
|
||||
* Additionally, the containing menu must either have `has-overflow` or `fixed`
|
||||
* set to `true` in order to display the containing menu properly.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <div style="position:relative;">
|
||||
* <button
|
||||
* id="anchor"
|
||||
* @click=${() => this.menuRef.value.show()}>
|
||||
* Click to open menu
|
||||
* </button>
|
||||
* <!--
|
||||
* `has-overflow` is required when using a submenu which overflows the
|
||||
* menu's contents
|
||||
* -->
|
||||
* <md-menu anchor="anchor" has-overflow ${ref(menuRef)}>
|
||||
* <md-menu-item headline="This is a headline"></md-menu-item>
|
||||
* <md-sub-menu>
|
||||
* <md-menu-item
|
||||
* slot="item"
|
||||
* headline="this is a submenu item">
|
||||
* </md-menu-item>
|
||||
* <md-menu slot="menu">
|
||||
* <md-menu-item headline="This is an item inside a submenu">
|
||||
* </md-menu-item>
|
||||
* </md-menu>
|
||||
* </md-sub-menu>
|
||||
* </md-menu>
|
||||
* </div>
|
||||
* ```
|
||||
*
|
||||
* @final
|
||||
* @suppress {visibility}
|
||||
*/
|
||||
@customElement('md-sub-menu')
|
||||
export class MdSubMenu extends SubMenu {
|
||||
static override styles = styles;
|
||||
}
|
||||
@ -37,7 +37,7 @@ export const docsToElementMapping: {[key: string]: string[]} = {
|
||||
],
|
||||
'icon.md': ['icon/icon.ts'],
|
||||
'list.md': ['list/list.ts', 'list/list-item.ts'],
|
||||
'menu.md': ['menu/menu.ts', 'menu/menu-item.ts', 'menu/sub-menu-item.ts'],
|
||||
'menu.md': ['menu/menu.ts', 'menu/menu-item.ts', 'menu/sub-menu.ts'],
|
||||
'progress.md':
|
||||
['progress/linear-progress.ts', 'progress/circular-progress.ts'],
|
||||
'radio.md': ['radio/radio.ts'],
|
||||
|
||||
@ -23,11 +23,6 @@ export class SelectOptionEl extends MenuItemEl implements SelectOption {
|
||||
*/
|
||||
@property() value = '';
|
||||
|
||||
/**
|
||||
* Whether or not this option is selected.
|
||||
*/
|
||||
@property({type: Boolean}) selected = false;
|
||||
|
||||
override readonly type: ListItemRole = 'option';
|
||||
|
||||
override willUpdate(changed: PropertyValues<SelectOptionEl>) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user