From 54fbb2ed5ee28d68d6d27b8121a8c4b3b1d10c8e Mon Sep 17 00:00:00 2001 From: Elliott Marquez Date: Wed, 20 Sep 2023 13:08:31 -0700 Subject: [PATCH] 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 --- .../figures/menu/usage-submenu.html | 28 +- docs/components/menu.md | 38 +- list/internal/list.ts | 25 +- list/internal/listitem/list-item.ts | 2 + menu/demo/demo.ts | 8 +- menu/demo/stories.ts | 57 +-- menu/internal/menu.ts | 35 +- menu/internal/menuitem/menu-item.ts | 6 + menu/internal/shared.ts | 4 + menu/internal/submenu/_sub-menu.scss | 12 + menu/internal/submenu/sub-menu-styles.scss | 10 + menu/internal/submenu/sub-menu.ts | 364 ++++++++++++++++++ menu/internal/submenuitem/sub-menu-item.ts | 10 +- menu/menu.ts | 20 +- menu/sub-menu-item.ts | 17 +- menu/sub-menu.ts | 68 ++++ scripts/analyzer/element-docs-map.ts | 2 +- select/internal/selectoption/select-option.ts | 5 - 18 files changed, 619 insertions(+), 92 deletions(-) create mode 100644 menu/internal/submenu/_sub-menu.scss create mode 100644 menu/internal/submenu/sub-menu-styles.scss create mode 100644 menu/internal/submenu/sub-menu.ts create mode 100644 menu/sub-menu.ts diff --git a/docs/components/figures/menu/usage-submenu.html b/docs/components/figures/menu/usage-submenu.html index b662009c4c..dde265544f 100644 --- a/docs/components/figures/menu/usage-submenu.html +++ b/docs/components/figures/menu/usage-submenu.html @@ -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." > - Menu with Submenus + Menu with Submenus - - + + + + arrow_right + + + - - + + + + + + arrow_left + + + - arrow_left - + - arrow_right - + diff --git a/docs/components/menu.md b/docs/components/menu.md index 1f2484f4cd..968996f742 100644 --- a/docs/components/menu.md +++ b/docs/components/menu.md @@ -119,7 +119,7 @@ Cucumber."](images/menu/usage.webp) ### Submenus -You can compose submenus inside of an ``'s `submenu` slot, but +You can compose ``s inside of an ``'s `menu` slot, but first the `has-overflow` attribute must be set on the root `` to disable overflow scrolling and display the nested submenus. @@ -143,35 +143,37 @@ Granny Smith, and Red Delicious."](images/menu/usage-submenu.webp) - - - + + + + arrow_right + + + - - + + + + arrow_left + + + - - - arrow_left - - + - - - arrow_right - + diff --git a/list/internal/list.ts b/list/internal/list.ts index 2015964f48..745eb1cdb9 100644 --- a/list/internal/list.ts +++ b/list/internal/list.ts @@ -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; + + /** @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()); diff --git a/list/internal/listitem/list-item.ts b/list/internal/listitem/list-item.ts index 24723af1d1..d1bf3b70d7 100644 --- a/list/internal/listitem/list-item.ts +++ b/list/internal/listitem/list-item.ts @@ -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} diff --git a/menu/demo/demo.ts b/menu/demo/demo.ts index 65884aad10..0be9e92d97 100644 --- a/menu/demo/demo.ts +++ b/menu/demo/demo.ts @@ -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({ @@ -154,7 +154,7 @@ const collection = defaultValue: 400, ui: numberInput(), }), - new Knob('submenu icon', { + new Knob('submenu item icon', { defaultValue: 'navigate_next', ui: textInput(), }), diff --git a/menu/demo/stories.ts b/menu/demo/stories.ts index cba96ff4e2..87d50c1877 100644 --- a/menu/demo/stories.ts +++ b/menu/demo/stories.ts @@ -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 = { }; const submenu: MaterialStoryInit = { - name: '', + name: '', styles: sharedStyle, render(knobs) { let currentIndex = -1; @@ -227,17 +228,23 @@ const submenu: MaterialStoryInit = { currentIndex++; return html` - + + + ${knobs['submenu item icon']} + + = { .typeaheadDelay=${knobs.typeaheadDelay}> ${layer2} - - ${knobs['submenu icon']} - - `; + `; }), ...fruitNames.slice(2, 5).map(name => { currentIndex++; @@ -269,17 +273,23 @@ const submenu: MaterialStoryInit = { currentIndex++; return html` - + + + + ${knobs['submenu item icon']} + + = { .typeaheadDelay=${knobs.typeaheadDelay}> ${layer1} - - ${knobs['submenu icon']} - - `; + `; }); return html` diff --git a/menu/internal/menu.ts b/menu/internal/menu.ts index 1e1b93227d..12095e2e49 100644 --- a/menu/internal/menu.ts +++ b/menu/internal/menu.ts @@ -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; } }; diff --git a/menu/internal/menuitem/menu-item.ts b/menu/internal/menuitem/menu-item.ts index 4fab4ed71d..233d4c6d94 100644 --- a/menu/internal/menuitem/menu-item.ts +++ b/menu/internal/menuitem/menu-item.ts @@ -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 }; } diff --git a/menu/internal/shared.ts b/menu/internal/shared.ts index 985bc868f8..04ba335619 100644 --- a/menu/internal/shared.ts +++ b/menu/internal/shared.ts @@ -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. */ diff --git a/menu/internal/submenu/_sub-menu.scss b/menu/internal/submenu/_sub-menu.scss new file mode 100644 index 0000000000..c6040f93a9 --- /dev/null +++ b/menu/internal/submenu/_sub-menu.scss @@ -0,0 +1,12 @@ +// +// Copyright 2023 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +@mixin styles { + :host { + position: relative; + display: flex; + flex-direction: column; + } +} diff --git a/menu/internal/submenu/sub-menu-styles.scss b/menu/internal/submenu/sub-menu-styles.scss new file mode 100644 index 0000000000..032fc814a8 --- /dev/null +++ b/menu/internal/submenu/sub-menu-styles.scss @@ -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; diff --git a/menu/internal/submenu/sub-menu.ts b/menu/internal/submenu/sub-menu.ts new file mode 100644 index 0000000000..63f8085fae --- /dev/null +++ b/menu/internal/submenu/sub-menu.ts @@ -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` + + + + + `; + } + + /** + * 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; + } + } +} diff --git a/menu/internal/submenuitem/sub-menu-item.ts b/menu/internal/submenuitem/sub-menu-item.ts index 00cccd395f..60e96884c2 100644 --- a/menu/internal/submenuitem/sub-menu-item.ts +++ b/menu/internal/submenuitem/sub-menu-item.ts @@ -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 diff --git a/menu/menu.ts b/menu/menu.ts index 5525370ce1..a5387f2ba3 100644 --- a/menu/menu.ts +++ b/menu/menu.ts @@ -37,10 +37,6 @@ declare global { * @example * ```html *
- * *
* ``` diff --git a/menu/sub-menu-item.ts b/menu/sub-menu-item.ts index 6e70ff6376..8aa5a325a1 100644 --- a/menu/sub-menu-item.ts +++ b/menu/sub-menu-item.ts @@ -24,10 +24,11 @@ declare global { /** * @summary Menus display a list of choices on a temporary surface. + * @deprecated Use * * @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 * --> * - * - * - * - * + * + * + * + * + * + * + * * * * diff --git a/menu/sub-menu.ts b/menu/sub-menu.ts new file mode 100644 index 0000000000..774336bf7c --- /dev/null +++ b/menu/sub-menu.ts @@ -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 + *
+ * + * + * + * + * + * + * + * + * + * + * + * + * + *
+ * ``` + * + * @final + * @suppress {visibility} + */ +@customElement('md-sub-menu') +export class MdSubMenu extends SubMenu { + static override styles = styles; +} diff --git a/scripts/analyzer/element-docs-map.ts b/scripts/analyzer/element-docs-map.ts index 14f095d89c..8f74560699 100644 --- a/scripts/analyzer/element-docs-map.ts +++ b/scripts/analyzer/element-docs-map.ts @@ -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'], diff --git a/select/internal/selectoption/select-option.ts b/select/internal/selectoption/select-option.ts index e328d6a9c0..0c0d6145b1 100644 --- a/select/internal/selectoption/select-option.ts +++ b/select/internal/selectoption/select-option.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) {