Skip to content

Commit

Permalink
feat(menu): Add menu foundation/adapter and Sass (forked from MDC).
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 462177572
  • Loading branch information
joyzhong authored and copybara-github committed Jul 20, 2022
1 parent c98019f commit de29937
Show file tree
Hide file tree
Showing 7 changed files with 487 additions and 0 deletions.
87 changes: 87 additions & 0 deletions menu/lib/_mixins.scss
Original file line number Diff line number Diff line change
@@ -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;
}
88 changes: 88 additions & 0 deletions menu/lib/adapter.ts
Original file line number Diff line number Diff line change
@@ -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;
}
33 changes: 33 additions & 0 deletions menu/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -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};
188 changes: 188 additions & 0 deletions menu/lib/foundation.ts
Original file line number Diff line number Diff line change
@@ -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<MDCMenuAdapter>) {
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.');
}
}
}
Loading

0 comments on commit de29937

Please sign in to comment.