From de2993744d6f83850d0c80cf56a9ff245cbc4168 Mon Sep 17 00:00:00 2001 From: Joy Zhong Date: Wed, 20 Jul 2022 10:39:47 -0700 Subject: [PATCH] feat(menu): Add menu foundation/adapter and Sass (forked from MDC). PiperOrigin-RevId: 462177572 --- menu/lib/_mixins.scss | 87 ++++++++++++++++++ menu/lib/adapter.ts | 88 ++++++++++++++++++ menu/lib/constants.ts | 33 +++++++ menu/lib/foundation.ts | 188 ++++++++++++++++++++++++++++++++++++++ menu/lib/menu-styles.scss | 9 ++ menu/lib/types.ts | 30 ++++++ sass/_dom.scss | 52 +++++++++++ 7 files changed, 487 insertions(+) create mode 100644 menu/lib/_mixins.scss create mode 100644 menu/lib/adapter.ts create mode 100644 menu/lib/constants.ts create mode 100644 menu/lib/foundation.ts create mode 100644 menu/lib/menu-styles.scss create mode 100644 menu/lib/types.ts create mode 100644 sass/_dom.scss diff --git a/menu/lib/_mixins.scss b/menu/lib/_mixins.scss new file mode 100644 index 0000000000..d1ecf3d030 --- /dev/null +++ b/menu/lib/_mixins.scss @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// stylelint-disable selector-class-pattern -- +// Selector '.md3-*' should only be used in this project. + +@use 'sass:math'; +@use '../../sass/dom'; + +$ink-color: rgba(#fff, 0.87); +$width-base: 56px !default; +$min-width: 2 * $width-base !default; + +@mixin core-styles() { + .md3-menu { + @include min-width($min-width); + + .md3-list { + &::before { + @include dom.transparent-border(); + } + } + + .md3-deprecated-list-divider { + margin: 8px 0; + } + + .md3-deprecated-list-item { + user-select: none; + } + + .md3-deprecated-list-item--disabled { + cursor: auto; + } + + //stylelint-disable selector-no-qualifying-type + a.md3-deprecated-list-item .md3-deprecated-list-item__text, + a.md3-deprecated-list-item .md3-deprecated-list-item__graphic { + pointer-events: none; + } + // stylelint-enable selector-no-qualifying-type + } + + // postcss-bem-linter: define menu + .md3-menu__selection-group { + padding: 0; + fill: currentColor; + + .md3-deprecated-list-item { + padding-left: 56px; + padding-right: 16px; + } + + // Extra specificity required to override `display` property on `md3-deprecated-list-item__graphic`. + .md3-menu__selection-group-icon { + left: 16px; + display: none; + position: absolute; + // IE11 requires the icon to be vertically centered due to its absolute positioning + top: 50%; + transform: translateY(-50%); + } + } + // postcss-bem-linter: end + + // stylelint-disable-next-line plugin/selector-bem-pattern + .md3-menu-item--selected .md3-menu__selection-group-icon { + display: inline; + } +} + +@mixin width($width) { + @if math.is-unitless($width) { + width: $width * $width-base; + } @else { + width: $width; + } +} + +/// Sets the min-width of the menu. +/// @param {Number} $min-width - the desired min-width. +@mixin min-width($min-width) { + min-width: $min-width; +} diff --git a/menu/lib/adapter.ts b/menu/lib/adapter.ts new file mode 100644 index 0000000000..4b0f642785 --- /dev/null +++ b/menu/lib/adapter.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {MDCMenuItemEventDetail} from './types'; + +/** + * Implement this adapter for your framework of choice to delegate updates to + * the component in your framework of choice. See architecture documentation + * for more details. + * https://github.com/material-components/material-components-web/blob/master/docs/code/architecture.md + */ +export interface MDCMenuAdapter { + /** + * Adds a class to the element at the index provided. + */ + addClassToElementAtIndex(index: number, className: string): void; + + /** + * Removes a class from the element at the index provided + */ + removeClassFromElementAtIndex(index: number, className: string): void; + + /** + * Adds an attribute, with value, to the element at the index provided. + */ + addAttributeToElementAtIndex(index: number, attr: string, value: string): void; + + /** + * Removes an attribute from an element at the index provided. + */ + removeAttributeFromElementAtIndex(index: number, attr: string): void; + + /** + * @return the attribute string if present on an element at the index + * provided, null otherwise. + */ + getAttributeFromElementAtIndex(index: number, attr: string): string|null; + + /** + * @return true if the element contains the className. + */ + elementContainsClass(element: Element, className: string): boolean; + + /** + * Closes the menu-surface. + * @param skipRestoreFocus Whether to skip restoring focus to the previously + * focused element after the surface has been closed. + */ + closeSurface(skipRestoreFocus?: boolean): void; + + /** + * @return Index of the element in the list or -1 if it is not in the list. + */ + getElementIndex(element: Element): number; + + /** + * Emit an event when a menu item is selected. + */ + notifySelected(evtData: MDCMenuItemEventDetail): void; + + /** @return Returns the menu item count. */ + getMenuItemCount(): number; + + /** + * Focuses the menu item at given index. + * @param index Index of the menu item that will be focused every time the menu opens. + */ + focusItemAtIndex(index: number): void; + + /** Focuses the list root element. */ + focusListRoot(): void; + + /** + * @return Returns selected list item index within the same selection group which is + * a sibling of item at given `index`. + * @param index Index of the menu item with possible selected sibling. + */ + getSelectedSiblingOfItemAtIndex(index: number): number; + + /** + * @return Returns true if item at specified index is contained within an `.mdc-menu__selection-group` element. + * @param index Index of the selectable menu item. + */ + isSelectableItemAtIndex(index: number): boolean; +} diff --git a/menu/lib/constants.ts b/menu/lib/constants.ts new file mode 100644 index 0000000000..570a32fde2 --- /dev/null +++ b/menu/lib/constants.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const cssClasses = { + MENU_SELECTED_LIST_ITEM: 'md3-menu-item--selected', + MENU_SELECTION_GROUP: 'md3-menu__selection-group', + ROOT: 'md3-menu', +}; + +const strings = { + ARIA_CHECKED_ATTR: 'aria-checked', + ARIA_DISABLED_ATTR: 'aria-disabled', + CHECKBOX_SELECTOR: 'input[type="checkbox"]', + LIST_SELECTOR: '.md3-list,.md3-deprecated-list', + SELECTED_EVENT: 'MDCMenu:selected', + SKIP_RESTORE_FOCUS: 'data-menu-item-skip-restore-focus', +}; + +const numbers = { + FOCUS_ROOT_INDEX: -1, +}; + +enum DefaultFocusState { + NONE = 0, + LIST_ROOT = 1, + FIRST_ITEM = 2, + LAST_ITEM = 3, +} + +export {cssClasses, strings, numbers, DefaultFocusState}; diff --git a/menu/lib/foundation.ts b/menu/lib/foundation.ts new file mode 100644 index 0000000000..2165b270ba --- /dev/null +++ b/menu/lib/foundation.ts @@ -0,0 +1,188 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {MDCMenuSurfaceFoundation} from '../../menusurface/lib/foundation'; + +import {MDCMenuAdapter} from './adapter'; +import {cssClasses, DefaultFocusState, numbers, strings} from './constants'; + +const LIST_ITEM_DISABLED_CLASS = 'md3-list-item--disabled'; + +export class MDCMenuFoundation { + static get cssClasses() { + return cssClasses; + } + + static get strings() { + return strings; + } + + static get numbers() { + return numbers; + } + + private readonly adapter: MDCMenuAdapter; + private closeAnimationEndTimerId = 0; + private defaultFocusState = DefaultFocusState.LIST_ROOT; + private selectedIndex = -1; + + /** + * @see {@link MDCMenuAdapter} for typing information on parameters and return types. + */ + static get defaultAdapter(): MDCMenuAdapter { + // tslint:disable:object-literal-sort-keys Methods should be in the same order as the adapter interface. + return { + addClassToElementAtIndex: () => undefined, + removeClassFromElementAtIndex: () => undefined, + addAttributeToElementAtIndex: () => undefined, + removeAttributeFromElementAtIndex: () => undefined, + getAttributeFromElementAtIndex: () => null, + elementContainsClass: () => false, + closeSurface: () => undefined, + getElementIndex: () => -1, + notifySelected: () => undefined, + getMenuItemCount: () => 0, + focusItemAtIndex: () => undefined, + focusListRoot: () => undefined, + getSelectedSiblingOfItemAtIndex: () => -1, + isSelectableItemAtIndex: () => false, + }; + // tslint:enable:object-literal-sort-keys + } + + constructor(adapter: Partial) { + this.adapter = {...MDCMenuFoundation.defaultAdapter, ...adapter}; + } + + destroy() { + if (this.closeAnimationEndTimerId) { + clearTimeout(this.closeAnimationEndTimerId); + } + + this.adapter.closeSurface(); + } + + handleKeydown(evt: KeyboardEvent) { + const {key, keyCode} = evt; + const isTab = key === 'Tab' || keyCode === 9; + + if (isTab) { + this.adapter.closeSurface(/** skipRestoreFocus */ true); + } + } + + handleItemAction(listItem: Element) { + const index = this.adapter.getElementIndex(listItem); + if (index < 0) { + return; + } + + this.adapter.notifySelected({index}); + const skipRestoreFocus = this.adapter.getAttributeFromElementAtIndex( + index, strings.SKIP_RESTORE_FOCUS) === 'true'; + this.adapter.closeSurface(skipRestoreFocus); + + // Wait for the menu to close before adding/removing classes that affect + // styles. + this.closeAnimationEndTimerId = setTimeout(() => { + // Recompute the index in case the menu contents have changed. + const recomputedIndex = this.adapter.getElementIndex(listItem); + if (recomputedIndex >= 0 && + this.adapter.isSelectableItemAtIndex(recomputedIndex)) { + this.setSelectedIndex(recomputedIndex); + } + }, MDCMenuSurfaceFoundation.numbers.TRANSITION_CLOSE_DURATION); + } + + handleMenuSurfaceOpened() { + switch (this.defaultFocusState) { + case DefaultFocusState.FIRST_ITEM: + this.adapter.focusItemAtIndex(0); + break; + case DefaultFocusState.LAST_ITEM: + this.adapter.focusItemAtIndex(this.adapter.getMenuItemCount() - 1); + break; + case DefaultFocusState.NONE: + // Do nothing. + break; + default: + this.adapter.focusListRoot(); + break; + } + } + + /** + * Sets default focus state where the menu should focus every time when menu + * is opened. Focuses the list root (`DefaultFocusState.LIST_ROOT`) element by + * default. + */ + setDefaultFocusState(focusState: DefaultFocusState) { + this.defaultFocusState = focusState; + } + + /** @return Index of the currently selected list item within the menu. */ + getSelectedIndex() { + return this.selectedIndex; + } + + /** + * Selects the list item at `index` within the menu. + * @param index Index of list item within the menu. + */ + setSelectedIndex(index: number) { + this.validatedIndex(index); + + if (!this.adapter.isSelectableItemAtIndex(index)) { + throw new Error( + 'MDCMenuFoundation: No selection group at specified index.'); + } + + const prevSelectedIndex = + this.adapter.getSelectedSiblingOfItemAtIndex(index); + if (prevSelectedIndex >= 0) { + this.adapter.removeAttributeFromElementAtIndex( + prevSelectedIndex, strings.ARIA_CHECKED_ATTR); + this.adapter.removeClassFromElementAtIndex( + prevSelectedIndex, cssClasses.MENU_SELECTED_LIST_ITEM); + } + + this.adapter.addClassToElementAtIndex( + index, cssClasses.MENU_SELECTED_LIST_ITEM); + this.adapter.addAttributeToElementAtIndex( + index, strings.ARIA_CHECKED_ATTR, 'true'); + + this.selectedIndex = index; + } + + /** + * Sets the enabled state to isEnabled for the menu item at the given index. + * @param index Index of the menu item + * @param isEnabled The desired enabled state of the menu item. + */ + setEnabled(index: number, isEnabled: boolean): void { + this.validatedIndex(index); + + if (isEnabled) { + this.adapter.removeClassFromElementAtIndex( + index, LIST_ITEM_DISABLED_CLASS); + this.adapter.addAttributeToElementAtIndex( + index, strings.ARIA_DISABLED_ATTR, 'false'); + } else { + this.adapter.addClassToElementAtIndex(index, LIST_ITEM_DISABLED_CLASS); + this.adapter.addAttributeToElementAtIndex( + index, strings.ARIA_DISABLED_ATTR, 'true'); + } + } + + private validatedIndex(index: number): void { + const menuSize = this.adapter.getMenuItemCount(); + const isIndexInRange = index >= 0 && index < menuSize; + + if (!isIndexInRange) { + throw new Error('MDCMenuFoundation: No list item at specified index.'); + } + } +} diff --git a/menu/lib/menu-styles.scss b/menu/lib/menu-styles.scss new file mode 100644 index 0000000000..74499ba75a --- /dev/null +++ b/menu/lib/menu-styles.scss @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +@use './mixins'; + +@include mixins.core-styles(); diff --git a/menu/lib/types.ts b/menu/lib/types.ts new file mode 100644 index 0000000000..15bc464619 --- /dev/null +++ b/menu/lib/types.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Event properties used by the adapter and foundation. + */ +export interface MDCMenuItemEventDetail { + index: number; +} + +/** + * Event properties specific to the default component implementation. + */ +export interface MDCMenuItemComponentEventDetail extends + MDCMenuItemEventDetail { + item: Element; +} + +// Note: CustomEvent is not supported by Closure Compiler. + +export interface MDCMenuItemEvent extends Event { + readonly detail: MDCMenuItemEventDetail; +} + +export interface MDCMenuItemComponentEvent extends Event { + readonly detail: MDCMenuItemComponentEventDetail; +} diff --git a/sass/_dom.scss b/sass/_dom.scss new file mode 100644 index 0000000000..e9ea734406 --- /dev/null +++ b/sass/_dom.scss @@ -0,0 +1,52 @@ +// +// Copyright 2022 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +/// +/// Emits necessary layout styles to set a transparent border around an element +/// without interfering with the rest of its component layout. The border is +/// only visible in high-contrast mode. The target element should be a child of +/// a relatively positioned top-level element (i.e. a ::before pseudo-element). +/// +/// @param {number} $border-width - The width of the transparent border. +/// @param {string} $border-style - The style of the transparent border. +/// +@mixin transparent-border($border-width: 1px, $border-style: solid) { + position: absolute; + box-sizing: border-box; + width: 100%; + height: 100%; + top: 0; + left: 0; + border: $border-width $border-style transparent; + border-radius: inherit; + content: ''; + pointer-events: none; +} + +/// +/// Visually hides text content for accessibility. This text should only be +/// visible to screen reader users. +/// See https://a11yproject.com/posts/how-to-hide-content/ +/// +@mixin visually-hidden() { + clip: rect(1px, 1px, 1px, 1px); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; /* added line */ + width: 1px; +} + +/// +/// While in `forced-colors` mode, only system colors should be used. +/// +/// @link https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#system_colors +/// @link https://developer.mozilla.org/en-US/docs/Web/CSS/@media/forced-colors +/// @content styles to emit in `forced-colors` mode +@mixin forced-colors-mode() { + @media screen and (forced-colors: active) { + @content; + } +}