-
Notifications
You must be signed in to change notification settings - Fork 904
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(menu): Add menu foundation/adapter and Sass (forked from MDC).
PiperOrigin-RevId: 462177572
- Loading branch information
1 parent
c98019f
commit de29937
Showing
7 changed files
with
487 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.'); | ||
} | ||
} | ||
} |
Oops, something went wrong.