refactor(menu)!: pull logic out of menuitem into a controller & change enum vals

BREAKING CHANGE: Several enums in menu had their values changed from SCREAM_CASE to kebab-case to follow style guide. They are NAVIGABLE_KEYS -> NavigableKey, SELECTION_KEY -> SelectionKey, CLOSE_REASON -> CloseReason, KEYDOWN_CLOSE_KEY -> KeydownCloseKey

PiperOrigin-RevId: 567727434
This commit is contained in:
Elliott Marquez 2023-09-22 15:08:42 -07:00 committed by Copybara-Service
parent 63b01425e7
commit 1217b62ef2
12 changed files with 244 additions and 137 deletions

View File

@ -13,7 +13,7 @@ import '@material/web/divider/divider.js';
import '@material/web/icon/icon.js';
import {MaterialStoryInit} from './material-collection.js';
import {CloseMenuEvent} from '@material/web/menu/internal/shared.js';
import {CloseMenuEvent} from '@material/web/menu/internal/controllers/shared.js';
import {Corner, FocusState, MdMenu, MenuItem} from '@material/web/menu/menu.js';
import {css, html} from 'lit';

View File

@ -0,0 +1,183 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {ReactiveController, ReactiveControllerHost} from 'lit';
import {CloseReason, createDefaultCloseMenuEvent, isClosableKey} from './shared.js';
/**
* Interface specific to menu item and not HTMLElement.
*
* NOTE: required properties are expected to be reactive.
*/
interface MenuItemAdditions {
/**
* Whether or not the item is in the disabled state.
*/
disabled: boolean;
/**
* The text of the item that will be used for typeahead. If not set, defaults
* to the textContent of the element slotted into the headline.
*/
typeaheadText: string;
/**
* Whether or not the item is in the selected visual state.
*/
selected: boolean;
/**
* Sets the behavior and role of the menu item, defaults to "menuitem".
*/
type: MenuItemType;
/**
* Whether it should keep the menu open after click.
*/
keepOpen?: boolean;
/**
* Sets the underlying `HTMLAnchorElement`'s `href` resource attribute.
*/
href?: string;
/**
* Focuses the item.
*/
focus: () => void;
}
/**
* The interface of every menu item interactive with a menu. All menu items
* should implement this interface to be compatible with md-menu. Additionally
* it should have the `md-menu-item` attribute set.
*
* NOTE, the required properties are recommended to be reactive properties.
*/
export type MenuItem = MenuItemAdditions&HTMLElement;
/**
* Supported behaviors for a menu item.
*/
export type MenuItemType = 'menuitem'|'option'|'button'|'link';
/**
* The options used to inialize MenuItemController.
*/
export interface MenuItemControllerConfig {
/**
* A function that returns the headline element of the menu item.
*/
getHeadlineElements: () => HTMLElement[];
}
/**
* A controller that provides most functionality of an element that implements
* the MenuItem interface.
*/
export class MenuItemController implements ReactiveController {
private internalTypeaheadText: string|null = null;
private readonly getHeadlineElements:
MenuItemControllerConfig['getHeadlineElements'];
/**
* @param host The MenuItem in which to attach this controller to.
* @param config The object that configures this controller's behavior.
*/
constructor(
private readonly host: ReactiveControllerHost&MenuItem,
config: MenuItemControllerConfig) {
const {
getHeadlineElements,
} = config;
this.getHeadlineElements = getHeadlineElements;
this.host.addController(this);
}
/**
* The text that is selectable via typeahead. If not set, defaults to the
* innerText of the item slotted into the `"headline"` slot.
*/
get typeaheadText() {
if (this.internalTypeaheadText !== null) {
return this.internalTypeaheadText;
}
const headlineElements = this.getHeadlineElements();
let text = '';
headlineElements.forEach((headlineElement) => {
if (headlineElement.textContent && headlineElement.textContent.trim()) {
text += ` ${headlineElement.textContent.trim()}`;
}
});
return '';
}
/**
* The recommended tag name to render as the list item.
*/
get tagName() {
const type = this.host.type;
switch (type) {
case 'link':
return 'a' as const;
case 'button':
return 'button' as const;
default:
case 'menuitem':
case 'option':
return 'li' as const;
}
}
/**
* The recommended role of the menu item.
*/
get role() {
return this.host.type === 'option' ? 'option' : 'menuitem';
}
hostConnected() {
this.host.toggleAttribute('md-menu-item', true);
}
hostUpdate() {
if (this.host.href) {
this.host.type = 'link';
}
}
/**
* Bind this click listener to the interactive element. Handles closing the
* menu.
*/
onClick = () => {
if (this.host.keepOpen) return;
this.host.dispatchEvent(createDefaultCloseMenuEvent(
this.host, {kind: CloseReason.CLICK_SELECTION}));
};
/**
* Bind this click listener to the interactive element. Handles closing the
* menu.
*/
onKeydown = (event: KeyboardEvent) => {
if (this.host.keepOpen || event.defaultPrevented) return;
const keyCode = event.code;
if (!event.defaultPrevented && isClosableKey(keyCode)) {
event.preventDefault();
this.host.dispatchEvent(createDefaultCloseMenuEvent(
this.host, {kind: CloseReason.KEYDOWN, key: keyCode}));
}
};
/**
* Use to set the typeaheadText when it changes.
*/
setTypeaheadText(text: string) {
this.internalTypeaheadText = text;
}
}

View File

@ -4,39 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Interface specific to menu item and not HTMLElement.
*/
interface MenuItemAdditions {
/**
* Whether or not the item is in the disabled state.
*/
disabled: boolean;
/**
* The text of the item that will be used for typeahead. If not set, defaults
* to the textContent of the element slotted into the headline.
*/
typeaheadText: string;
/**
* Whether it should keep the menu open after click.
*/
keepOpen: boolean;
/**
* Whether or not the item is in the selected visual state.
*/
selected: boolean;
/**
* Focuses the item.
*/
focus: () => void;
}
/**
* The interface of every menu item interactive with a menu. All menu items
* should implement this interface to be compatible with md-menu. Additionally
* they should have both the `md-menu-item` and `md-list-item` attributes set.
*/
export type MenuItem = MenuItemAdditions&HTMLElement;
import {MenuItem} from './menuItemController.js';
/**
* The reason the `close-menu` event was dispatched.
@ -50,7 +18,7 @@ export interface Reason {
* because an item was selected via user click.
*/
export interface ClickReason extends Reason {
kind: typeof CLOSE_REASON.CLICK_SELECTION;
kind: typeof CloseReason.CLICK_SELECTION;
}
/**
@ -59,7 +27,7 @@ export interface ClickReason extends Reason {
* `md-menu-item` are, Space, Enter or Escape.
*/
export interface KeydownReason extends Reason {
kind: typeof CLOSE_REASON.KEYDOWN;
kind: typeof CloseReason.KEYDOWN;
key: string;
}
@ -141,7 +109,8 @@ export type ActivateTypeaheadEvent =
/**
* Keys that are used to navigate menus.
*/
export const NAVIGABLE_KEY = {
// tslint:disable-next-line:enforce-name-casing We are mimicking enum style
export const NavigableKey = {
UP: 'ArrowUp',
DOWN: 'ArrowDown',
RIGHT: 'ArrowRight',
@ -151,7 +120,8 @@ export const NAVIGABLE_KEY = {
/**
* Keys that are used for selection in menus.
*/
export const SELECTION_KEY = {
// tslint:disable-next-line:enforce-name-casing We are mimicking enum style
export const SelectionKey = {
SPACE: 'Space',
ENTER: 'Enter',
} as const;
@ -159,18 +129,20 @@ export const SELECTION_KEY = {
/**
* Default close `Reason` kind values.
*/
export const CLOSE_REASON = {
CLICK_SELECTION: 'CLICK_SELECTION',
KEYDOWN: 'KEYDOWN',
// tslint:disable-next-line:enforce-name-casing We are mimicking enum style
export const CloseReason = {
CLICK_SELECTION: 'click-selection',
KEYDOWN: 'keydown',
} as const;
/**
* Keys that can close menus.
*/
export const KEYDOWN_CLOSE_KEYS = {
// tslint:disable-next-line:enforce-name-casing We are mimicking enum style
export const KeydownCloseKey = {
ESCAPE: 'Escape',
SPACE: SELECTION_KEY.SPACE,
ENTER: SELECTION_KEY.ENTER,
SPACE: SelectionKey.SPACE,
ENTER: SelectionKey.ENTER,
} as const;
type Values<T> = T[keyof T];
@ -184,8 +156,8 @@ type Values<T> = T[keyof T];
* menu.
*/
export function isClosableKey(code: string):
code is Values<typeof KEYDOWN_CLOSE_KEYS> {
return Object.values(KEYDOWN_CLOSE_KEYS).some(value => (value === code));
code is Values<typeof KeydownCloseKey> {
return Object.values(KeydownCloseKey).some(value => (value === code));
}
/**
@ -197,8 +169,8 @@ export function isClosableKey(code: string):
* menu item.
*/
export function isSelectableKey(code: string):
code is Values<typeof SELECTION_KEY> {
return Object.values(SELECTION_KEY).some(value => (value === code));
code is Values<typeof SelectionKey> {
return Object.values(SelectionKey).some(value => (value === code));
}
/**

View File

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {MenuItem} from './shared.js';
import {MenuItem} from './menuItemController.js';
/**
* The options that are passed to the typeahead controller.

View File

@ -15,13 +15,14 @@ import {styleMap} from 'lit/directives/style-map.js';
import {polyfillElementInternalsAria, setupHostAria} from '../../internal/aria/aria.js';
import {createAnimationSignal, EASING} from '../../internal/motion/animation.js';
import {MenuItem} from './controllers/menuItemController.js';
import {ActivateTypeaheadEvent, DeactivateTypeaheadEvent, FocusState, isClosableKey, isElementInSubtree} from './controllers/shared.js';
import {Corner, SurfacePositionController, SurfacePositionTarget} from './controllers/surfacePositionController.js';
import {TypeaheadController} from './controllers/typeaheadController.js';
import {ListController, NavigableKeys} from './list-controller.js';
import {getActiveItem, getFirstActivatableItem, getLastActivatableItem} from './list-navigation-helpers.js';
import {ActivateTypeaheadEvent, DeactivateTypeaheadEvent, FocusState, isClosableKey, isElementInSubtree, MenuItem} from './shared.js';
import {Corner, SurfacePositionController, SurfacePositionTarget} from './surfacePositionController.js';
import {TypeaheadController} from './typeaheadController.js';
export {Corner} from './surfacePositionController.js';
export {Corner} from './controllers/surfacePositionController.js';
/**
* The default value for the typeahead buffer time in Milliseconds.

View File

@ -8,19 +8,14 @@ import '../../../ripple/ripple.js';
import '../../../focus/md-focus-ring.js';
import '../../../item/item.js';
import {html, LitElement, nothing, PropertyValues, TemplateResult} from 'lit';
import {html, LitElement, nothing, TemplateResult} from 'lit';
import {property, query, queryAssignedElements} from 'lit/decorators.js';
import {ClassInfo, classMap} from 'lit/directives/class-map.js';
import {html as staticHtml, literal, StaticValue} from 'lit/static-html.js';
import {ARIAMixinStrict} from '../../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../../internal/aria/delegate.js';
import {CLOSE_REASON, createDefaultCloseMenuEvent, isClosableKey, MenuItem} from '../shared.js';
/**
* Supported behaviors for a menu item.
*/
export type MenuItemType = 'menuitem'|'option'|'button'|'link';
import {MenuItem, MenuItemController, MenuItemType} from '../controllers/menuItemController.js';
/**
* @fires close-menu {CloseMenuEvent}
@ -57,12 +52,6 @@ export class MenuItemEl extends LitElement implements MenuItem {
*/
@property() target: '_blank'|'_parent'|'_self'|'_top'|'' = '';
/**
* READONLY: self-identifies as a menu item and sets its identifying attribute
*/
@property({type: Boolean, attribute: 'md-menu-item', reflect: true})
isMenuItem = true;
/**
* Keeps the menu open if clicked or keyboard selected.
*/
@ -83,35 +72,18 @@ export class MenuItemEl extends LitElement implements MenuItem {
* innerText of the item slotted into the `"headline"` slot.
*/
get typeaheadText() {
if (this.internalTypeaheadText !== null) {
return this.internalTypeaheadText;
}
const headlineElements = this.headlineElements;
let text = '';
headlineElements.forEach((headlineElement) => {
if (headlineElement.textContent && headlineElement.textContent.trim()) {
text += ` ${headlineElement.textContent.trim()}`;
}
});
return '';
return this.menuItemController.typeaheadText;
}
set typeaheadText(text: string) {
this.internalTypeaheadText = text;
this.menuItemController.setTypeaheadText(text);
}
private internalTypeaheadText: string|null = null;
protected override willUpdate(changed: PropertyValues<MenuItemEl>) {
if (this.href) {
this.type = 'link';
private readonly menuItemController = new MenuItemController(this, {
getHeadlineElements: () => {
return this.headlineElements;
}
super.willUpdate(changed);
}
});
protected override render() {
return this.renderListItem(html`
@ -135,22 +107,17 @@ export class MenuItemEl extends LitElement implements MenuItem {
protected renderListItem(content: unknown) {
const isAnchor = this.type === 'link';
let tag: StaticValue;
let role: 'menuitem'|'option' = 'menuitem';
switch (this.type) {
case 'link':
switch (this.menuItemController.tagName) {
case 'a':
tag = literal`a`;
break;
case 'button':
tag = literal`button`;
break;
default:
case 'menuitem':
case 'li':
tag = literal`li`;
break;
case 'option':
tag = literal`li`;
role = 'option';
break;
}
// TODO(b/265339866): announce "button"/"link" inside of a list item. Until
@ -160,7 +127,7 @@ export class MenuItemEl extends LitElement implements MenuItem {
<${tag}
id="item"
tabindex=${this.disabled && !isAnchor ? -1 : 0}
role=${role}
role=${this.menuItemController.role}
aria-label=${(this as ARIAMixinStrict).ariaLabel || nothing}
aria-selected=${(this as ARIAMixinStrict).ariaSelected || nothing}
aria-checked=${(this as ARIAMixinStrict).ariaChecked || nothing}
@ -169,8 +136,8 @@ export class MenuItemEl extends LitElement implements MenuItem {
class="list-item ${classMap(this.getRenderClasses())}"
href=${this.href || nothing}
target=${target}
@click=${this.onClick}
@keydown=${this.onKeydown}
@click=${this.menuItemController.onClick}
@keydown=${this.menuItemController.onKeydown}
>${content}</${tag}>
`;
}
@ -226,22 +193,4 @@ export class MenuItemEl extends LitElement implements MenuItem {
// work programmatically like in FF and select-option
this.listItemRoot?.focus();
}
protected onClick() {
if (this.keepOpen) return;
this.dispatchEvent(createDefaultCloseMenuEvent(
this, {kind: CLOSE_REASON.CLICK_SELECTION}));
}
protected onKeydown(event: KeyboardEvent) {
if (this.keepOpen || event.defaultPrevented) return;
const keyCode = event.code;
if (!event.defaultPrevented && isClosableKey(keyCode)) {
event.preventDefault();
this.dispatchEvent(createDefaultCloseMenuEvent(
this, {kind: CLOSE_REASON.KEYDOWN, key: keyCode}));
}
}
}

View File

@ -7,9 +7,10 @@
import {html, isServer, LitElement} from 'lit';
import {property, queryAssignedElements} from 'lit/decorators.js';
import {MenuItem} from '../controllers/menuItemController.js';
import {CloseMenuEvent, CloseReason, createActivateTypeaheadEvent, createDeactivateTypeaheadEvent, KeydownCloseKey, NavigableKey, SelectionKey} from '../controllers/shared.js';
import {createDeactivateItemsEvent, createRequestActivationEvent, deactivateActiveItem, getFirstActivatableItem} from '../list-navigation-helpers.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
@ -261,10 +262,9 @@ export class SubMenu extends LitElement {
if (event.defaultPrevented) return;
const openedWithLR = shouldOpenSubmenu &&
(NAVIGABLE_KEY.LEFT === event.code ||
NAVIGABLE_KEY.RIGHT === event.code);
(NavigableKey.LEFT === event.code || NavigableKey.RIGHT === event.code);
if (event.code === SELECTION_KEY.SPACE || openedWithLR) {
if (event.code === SelectionKey.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();
@ -301,8 +301,8 @@ export class SubMenu extends LitElement {
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) {
if (reason.kind === CloseReason.KEYDOWN &&
reason.key === KeydownCloseKey.ESCAPE) {
event.stopPropagation();
this.item.dispatchEvent(createRequestActivationEvent());
return;
@ -321,7 +321,7 @@ export class SubMenu extends LitElement {
// keydowns from bubbling up to the parent menu and confounding things.
event.preventDefault();
if (keyCode === NAVIGABLE_KEY.LEFT || keyCode === NAVIGABLE_KEY.RIGHT) {
if (keyCode === NavigableKey.LEFT || keyCode === NavigableKey.RIGHT) {
// Prevent this from bubbling to parents
event.stopPropagation();
}
@ -343,11 +343,11 @@ export class SubMenu extends LitElement {
*/
private isSubmenuOpenKey(code: string) {
const isRtl = getComputedStyle(this).direction === 'rtl';
const arrowEnterKey = isRtl ? NAVIGABLE_KEY.LEFT : NAVIGABLE_KEY.RIGHT;
const arrowEnterKey = isRtl ? NavigableKey.LEFT : NavigableKey.RIGHT;
switch (code) {
case arrowEnterKey:
case SELECTION_KEY.SPACE:
case SELECTION_KEY.ENTER:
case SelectionKey.SPACE:
case SelectionKey.ENTER:
return true;
default:
return false;
@ -363,10 +363,10 @@ export class SubMenu extends LitElement {
*/
private isSubmenuCloseKey(code: string) {
const isRtl = getComputedStyle(this).direction === 'rtl';
const arrowEnterKey = isRtl ? NAVIGABLE_KEY.RIGHT : NAVIGABLE_KEY.LEFT;
const arrowEnterKey = isRtl ? NavigableKey.RIGHT : NavigableKey.LEFT;
switch (code) {
case arrowEnterKey:
case KEYDOWN_CLOSE_KEYS.ESCAPE:
case KeydownCloseKey.ESCAPE:
return {close: true, keyCode: code} as const;
default:
return {close: false} as const;

View File

@ -10,7 +10,8 @@ import {styles as forcedColorsStyles} from './internal/menuitem/forced-colors-st
import {MenuItemEl} from './internal/menuitem/menu-item.js';
import {styles} from './internal/menuitem/menu-item-styles.css.js';
export {CloseMenuEvent, MenuItem} from './internal/shared.js';
export {MenuItem} from './internal/controllers/menuItemController.js';
export {CloseMenuEvent} from './internal/controllers/shared.js';
declare global {
interface HTMLElementTagNameMap {

View File

@ -11,8 +11,9 @@ import {Menu} from './internal/menu.js';
import {styles} from './internal/menu-styles.css.js';
export {ListItem} from '../list/internal/listitem/list-item.js';
export {MenuItem} from './internal/controllers/menuItemController.js';
export {CloseMenuEvent, FocusState} from './internal/controllers/shared.js';
export {Corner} from './internal/menu.js';
export {CloseMenuEvent, FocusState, MenuItem} from './internal/shared.js';
declare global {
interface HTMLElementTagNameMap {

View File

@ -14,9 +14,9 @@ import {html as staticHtml, StaticValue} from 'lit/static-html.js';
import {Field} from '../../field/internal/field.js';
import {redispatchEvent} from '../../internal/controller/events.js';
import {List} from '../../list/internal/list.js';
import {CloseMenuEvent, isElementInSubtree, isSelectableKey} from '../../menu/internal/controllers/shared.js';
import {TYPEAHEAD_RECORD} from '../../menu/internal/controllers/typeaheadController.js';
import {DEFAULT_TYPEAHEAD_BUFFER_TIME, Menu} from '../../menu/internal/menu.js';
import {CloseMenuEvent, isElementInSubtree, isSelectableKey} from '../../menu/internal/shared.js';
import {TYPEAHEAD_RECORD} from '../../menu/internal/typeaheadController.js';
import {createRequestDeselectionEvent, createRequestSelectionEvent, getSelectedItems, SelectOption, SelectOptionRecord} from './shared.js';
@ -666,9 +666,9 @@ export abstract class Select extends LitElement {
this.open = false;
let hasChanged = false;
if (reason.kind === 'CLICK_SELECTION') {
if (reason.kind === 'click-selection') {
hasChanged = this.selectItem(item);
} else if (reason.kind === 'KEYDOWN' && isSelectableKey(reason.key)) {
} else if (reason.kind === 'keydown' && isSelectableKey(reason.key)) {
hasChanged = this.selectItem(item);
} else {
// This can happen on ESC being pressed

View File

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {MenuItem} from '../../menu/internal/shared.js';
import {MenuItem} from '../../menu/internal/controllers/menuItemController.js';
/**
* The interface specific to a Select Option