Skip to content

Commit

Permalink
feat(menu): implement md-sub-menu
Browse files Browse the repository at this point in the history
md-sub-menu will succeed md-sub-menu-item. It allows for screen reader linear navigation

PiperOrigin-RevId: 567057310
  • Loading branch information
Elliott Marquez authored and copybara-github committed Sep 20, 2023
1 parent dc75fbc commit 54fbb2e
Show file tree
Hide file tree
Showing 18 changed files with 619 additions and 92 deletions.
28 changes: 19 additions & 9 deletions docs/components/figures/menu/usage-submenu.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
38 changes: 20 additions & 18 deletions docs/components/menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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>
Expand Down
25 changes: 23 additions & 2 deletions list/internal/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
2 changes: 2 additions & 0 deletions list/internal/listitem/list-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
8 changes: 4 additions & 4 deletions menu/demo/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}),

Expand Down Expand Up @@ -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>({
Expand Down Expand Up @@ -154,7 +154,7 @@ const collection =
defaultValue: 400,
ui: numberInput(),
}),
new Knob('submenu icon', {
new Knob('submenu item icon', {
defaultValue: 'navigate_next',
ui: textInput(),
}),
Expand Down
57 changes: 32 additions & 25 deletions menu/demo/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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;
Expand All @@ -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}
Expand All @@ -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++;
Expand All @@ -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}
Expand All @@ -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`
Expand Down
35 changes: 32 additions & 3 deletions menu/internal/menu.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2022 Google LLC
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

Expand Down Expand Up @@ -197,6 +197,8 @@ export abstract class Menu extends LitElement {

@state() private typeaheadActive = true;

private isPointerDown = false;

private readonly openCloseAnimationSignal = createAnimationSignal();

/**
Expand Down Expand Up @@ -362,7 +364,7 @@ export abstract class Menu extends LitElement {
}

private async handleFocusout(event: FocusEvent) {
if (this.stayOpenOnFocusout) {
if (this.stayOpenOnFocusout || !this.open) {
return;
}

Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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;
}
};
Expand Down
6 changes: 6 additions & 0 deletions menu/internal/menuitem/menu-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -47,6 +52,7 @@ export class MenuItemEl extends ListItemEl implements MenuItem {
return {
...super.getRenderClasses(),
'has-focus-ring': this.hasFocusRing,
selected: this.selected
};
}

Expand Down
Loading

0 comments on commit 54fbb2e

Please sign in to comment.