chore(chips): Fork foundation, adapter, and Sass files: ChipSet

PiperOrigin-RevId: 468108061
This commit is contained in:
Material Web Team 2022-08-16 22:33:43 -07:00 committed by Copybara-Service
parent 9dce0bc59a
commit dc8045e568
6 changed files with 634 additions and 0 deletions

View File

@ -0,0 +1,48 @@
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@use 'sass:math';
// stylelint-disable selector-class-pattern -- MDC internal usage.
$space-between-chips: 8px;
///
/// Sets the horiontal space between the chips in the chip set.
/// @param {Number} $space - The horizontal space between the chips.
///
@mixin horizontal-space-between-chips($space) {
///
/// We should use the column-gap property when our browser matrix allows.
///
.md3-chip-set__chips {
// Set the margin to the negative horizontal space to account for chips
// being inset on the leading edge.
// TODO(kainby): Explore using CSS grid layout instead.
margin-inline-start: -$space;
}
.md3-chip {
margin-inline-start: $space;
}
}
///
/// Sets the vertical space between the chips in the chip set.
/// @param {Number} $space - The vertical space between the chips.
///
@mixin vertical-space-between-chips($space) {
///
/// We should use the row-gap property when our browser matrix allows.
///
.md3-chip {
// Set top and bottom to half the vertical space since there's no
// well supported method for vertical wrapping gaps.
margin-block: math.div($space, 2);
}
}

View File

@ -0,0 +1,46 @@
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@use './chip-set-theme';
// stylelint-disable selector-class-pattern -- MDC internal usage.
@mixin core-styles() {
@include _static-styles();
@include _theme-styles();
}
@mixin _static-styles() {
.md3-chip-set {
display: flex;
}
.md3-chip-set:focus {
outline: none;
}
.md3-chip-set__chips {
display: flex;
flex-flow: wrap;
min-width: 0;
}
.md3-chip-set--overflow .md3-chip-set__chips {
flex-flow: nowrap;
}
}
@mixin _theme-styles() {
.md3-chip-set {
@include chip-set-theme.horizontal-space-between-chips(
chip-set-theme.$space-between-chips
);
@include chip-set-theme.vertical-space-between-chips(
chip-set-theme.$space-between-chips
);
}
}

View File

@ -0,0 +1,66 @@
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {MDCChipActionFocusBehavior, MDCChipActionType} from '../../action/lib/constants';
import {MDCChipAnimation} from '../../chip/lib/constants';
import {MDCChipSetAttributes, MDCChipSetEvents} from './constants';
/**
* Defines the shape of the adapter expected by the foundation.
* 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 MDCChipSetAdapter {
/** Announces the message via an aria-live region */
announceMessage(message: string): void;
/** Emits the given event with the given detail. */
emitEvent<D extends object>(eventName: MDCChipSetEvents, eventDetail: D):
void;
/** Returns the value for the given attribute, if it exists. */
getAttribute(attrName: MDCChipSetAttributes): string|null;
/** Returns the actions provided by the child chip at the given index. */
getChipActionsAtIndex(index: number): MDCChipActionType[];
/** Returns the number of child chips. */
getChipCount(): number;
/** Returns the ID of the chip at the given index. */
getChipIdAtIndex(index: number): string;
/** Returns the index of the child chip with the matching ID. */
getChipIndexById(chipID: string): number;
/** Proxies to the MDCChip#isActionFocusable method. */
isChipFocusableAtIndex(index: number, actionType: MDCChipActionType): boolean;
/** Proxies to the MDCChip#isActionSelectable method. */
isChipSelectableAtIndex(index: number, actionType: MDCChipActionType):
boolean;
/** Proxies to the MDCChip#isActionSelected method. */
isChipSelectedAtIndex(index: number, actionType: MDCChipActionType): boolean;
/** Removes the chip at the given index. */
removeChipAtIndex(index: number): void;
/** Proxies to the MDCChip#setActionFocus method. */
setChipFocusAtIndex(
index: number, action: MDCChipActionType,
focus: MDCChipActionFocusBehavior): void;
/** Proxies to the MDCChip#setActionSelected method. */
setChipSelectedAtIndex(
index: number, actionType: MDCChipActionType, isSelected: boolean): void;
/** Starts the chip animation at the given index. */
startChipAnimationAtIndex(index: number, animation: MDCChipAnimation): void;
}

View File

@ -0,0 +1,29 @@
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* MDCChipSetAttributes provides the named constants for attributes used by the
* foundation.
*/
export enum MDCChipSetAttributes {
ARIA_MULTISELECTABLE = 'aria-multiselectable',
}
/**
* MDCChipSetCssClasses provides the named constants for class names.
*/
export enum MDCChipSetCssClasses {
CHIP = 'md3-chip',
}
/**
* MDCChipSetEvents provides the constants for emitted events.
*/
export enum MDCChipSetEvents {
INTERACTION = 'MDCChipSet:interaction',
REMOVAL = 'MDCChipSet:removal',
SELECTION = 'MDCChipSet:selection',
}

View File

@ -0,0 +1,397 @@
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {KEY} from '@material/web/compat/dom/keyboard';
import {MDCChipActionFocusBehavior, MDCChipActionType} from '../../action/lib/constants';
import {MDCChipAnimation} from '../../chip/lib/constants';
import {MDCChipSetAdapter} from './adapter';
import {MDCChipSetAttributes, MDCChipSetEvents} from './constants';
import {ChipAnimationEvent, ChipInteractionEvent, ChipNavigationEvent, MDCChipSetInteractionEventDetail, MDCChipSetRemovalEventDetail, MDCChipSetSelectionEventDetail} from './types';
interface FocusAction {
action: MDCChipActionType;
index: number;
}
enum Operator {
INCREMENT,
DECREMENT,
}
/**
* MDCChipSetFoundation provides a foundation for all chips.
*/
export class MDCChipSetFoundation {
private readonly adapter: MDCChipSetAdapter;
static get defaultAdapter(): MDCChipSetAdapter {
return {
announceMessage: () => undefined,
emitEvent: () => undefined,
getAttribute: () => null,
getChipActionsAtIndex: () => [],
getChipCount: () => 0,
getChipIdAtIndex: () => '',
getChipIndexById: () => 0,
isChipFocusableAtIndex: () => false,
isChipSelectableAtIndex: () => false,
isChipSelectedAtIndex: () => false,
removeChipAtIndex: () => {},
setChipFocusAtIndex: () => undefined,
setChipSelectedAtIndex: () => undefined,
startChipAnimationAtIndex: () => undefined,
};
}
constructor(adapter?: Partial<MDCChipSetAdapter>) {
this.adapter = {...MDCChipSetFoundation.defaultAdapter, ...adapter};
}
handleChipAnimation({detail}: ChipAnimationEvent) {
const {
chipID,
animation,
isComplete,
addedAnnouncement,
removedAnnouncement
} = detail;
const index = this.adapter.getChipIndexById(chipID);
if (animation === MDCChipAnimation.EXIT && isComplete) {
if (removedAnnouncement) {
this.adapter.announceMessage(removedAnnouncement);
}
this.removeAfterAnimation(index, chipID);
return;
}
if (animation === MDCChipAnimation.ENTER && isComplete && addedAnnouncement) {
this.adapter.announceMessage(addedAnnouncement);
return;
}
}
handleChipInteraction({detail}: ChipInteractionEvent) {
const {source, chipID, isSelectable, isSelected, shouldRemove} = detail;
const index = this.adapter.getChipIndexById(chipID);
if (shouldRemove) {
this.removeChip(index);
return;
}
this.focusChip(index, source, MDCChipActionFocusBehavior.FOCUSABLE);
this.adapter.emitEvent<MDCChipSetInteractionEventDetail>(
MDCChipSetEvents.INTERACTION, {
chipIndex: index,
chipID,
});
if (isSelectable) {
this.setSelection(index, source, !isSelected);
}
}
handleChipNavigation({detail}: ChipNavigationEvent) {
const {chipID, key, isRTL, source} = detail;
const index = this.adapter.getChipIndexById(chipID);
const toNextChip = (key === KEY.ARROW_RIGHT && !isRTL) ||
(key === KEY.ARROW_LEFT && isRTL);
if (toNextChip) {
// Start from the next chip so we increment the index
this.focusNextChipFrom(index + 1);
return;
}
const toPreviousChip = (key === KEY.ARROW_LEFT && !isRTL) ||
(key === KEY.ARROW_RIGHT && isRTL);
if (toPreviousChip) {
// Start from the previous chip so we decrement the index
this.focusPrevChipFrom(index - 1);
return;
}
if (key === KEY.ARROW_DOWN) {
// Start from the next chip so we increment the index
this.focusNextChipFrom(index + 1, source);
return;
}
if (key === KEY.ARROW_UP) {
// Start from the previous chip so we decrement the index
this.focusPrevChipFrom(index - 1, source);
return;
}
if (key === KEY.HOME) {
this.focusNextChipFrom(0, source);
return;
}
if (key === KEY.END) {
this.focusPrevChipFrom(this.adapter.getChipCount() - 1, source);
return;
}
}
/** Returns the unique selected indexes of the chips. */
getSelectedChipIndexes(): ReadonlySet<number> {
const selectedIndexes = new Set<number>();
const chipCount = this.adapter.getChipCount();
for (let i = 0; i < chipCount; i++) {
const actions = this.adapter.getChipActionsAtIndex(i);
for (const action of actions) {
if (this.adapter.isChipSelectedAtIndex(i, action)) {
selectedIndexes.add(i);
}
}
}
return selectedIndexes;
}
/** Sets the selected state of the chip at the given index and action. */
setChipSelected(
index: number, action: MDCChipActionType, isSelected: boolean) {
if (this.adapter.isChipSelectableAtIndex(index, action)) {
this.setSelection(index, action, isSelected);
}
}
/** Returns the selected state of the chip at the given index and action. */
isChipSelected(index: number, action: MDCChipActionType): boolean {
return this.adapter.isChipSelectedAtIndex(index, action);
}
/** Removes the chip at the given index. */
removeChip(index: number) {
// Early exit if the index is out of bounds
if (index >= this.adapter.getChipCount() || index < 0) return;
this.adapter.startChipAnimationAtIndex(index, MDCChipAnimation.EXIT);
this.adapter.emitEvent<MDCChipSetRemovalEventDetail>(
MDCChipSetEvents.REMOVAL, {
chipID: this.adapter.getChipIdAtIndex(index),
chipIndex: index,
isComplete: false,
});
}
addChip(index: number) {
// Early exit if the index is out of bounds
if (index >= this.adapter.getChipCount() || index < 0) return;
this.adapter.startChipAnimationAtIndex(index, MDCChipAnimation.ENTER);
}
/**
* Increments to find the first focusable chip.
*/
private focusNextChipFrom(
startIndex: number, targetAction?: MDCChipActionType) {
const chipCount = this.adapter.getChipCount();
for (let i = startIndex; i < chipCount; i++) {
const focusableAction =
this.getFocusableAction(i, Operator.INCREMENT, targetAction);
if (focusableAction) {
this.focusChip(
i, focusableAction,
MDCChipActionFocusBehavior.FOCUSABLE_AND_FOCUSED);
return;
}
}
}
/**
* Decrements to find the first focusable chip. Takes an optional target
* action that can be used to focus the first matching focusable action.
*/
private focusPrevChipFrom(
startIndex: number, targetAction?: MDCChipActionType) {
for (let i = startIndex; i > -1; i--) {
const focusableAction =
this.getFocusableAction(i, Operator.DECREMENT, targetAction);
if (focusableAction) {
this.focusChip(
i, focusableAction,
MDCChipActionFocusBehavior.FOCUSABLE_AND_FOCUSED);
return;
}
}
}
/** Returns the appropriate focusable action, or null if none exist. */
private getFocusableAction(
index: number, op: Operator,
targetAction?: MDCChipActionType): MDCChipActionType|null {
const actions = this.adapter.getChipActionsAtIndex(index);
// Reverse the actions if decrementing
if (op === Operator.DECREMENT) actions.reverse();
if (targetAction) {
return this.getMatchingFocusableAction(index, actions, targetAction);
}
return this.getFirstFocusableAction(index, actions);
}
/**
* Returs the first focusable action, regardless of type, or null if no
* focusable actions exist.
*/
private getFirstFocusableAction(index: number, actions: MDCChipActionType[]):
MDCChipActionType|null {
for (const action of actions) {
if (this.adapter.isChipFocusableAtIndex(index, action)) {
return action;
}
}
return null;
}
/**
* If the actions contain a focusable action that matches the target action,
* return that. Otherwise, return the first focusable action, or null if no
* focusable action exists.
*/
private getMatchingFocusableAction(
index: number, actions: MDCChipActionType[],
targetAction: MDCChipActionType): MDCChipActionType|null {
let focusableAction = null;
for (const action of actions) {
if (this.adapter.isChipFocusableAtIndex(index, action)) {
focusableAction = action;
}
// Exit and return the focusable action if it matches the target
if (focusableAction === targetAction) {
return focusableAction;
}
}
return focusableAction;
}
private focusChip(
index: number, action: MDCChipActionType,
focus: MDCChipActionFocusBehavior) {
this.adapter.setChipFocusAtIndex(index, action, focus);
const chipCount = this.adapter.getChipCount();
for (let i = 0; i < chipCount; i++) {
const actions = this.adapter.getChipActionsAtIndex(i);
for (const chipAction of actions) {
// Skip the action and index provided since we set it above
if (chipAction === action && i === index) continue;
this.adapter.setChipFocusAtIndex(
i, chipAction, MDCChipActionFocusBehavior.NOT_FOCUSABLE);
}
}
}
private supportsMultiSelect(): boolean {
return this.adapter.getAttribute(
MDCChipSetAttributes.ARIA_MULTISELECTABLE) === 'true';
}
private setSelection(
index: number, action: MDCChipActionType, isSelected: boolean) {
this.adapter.setChipSelectedAtIndex(index, action, isSelected);
this.adapter.emitEvent<MDCChipSetSelectionEventDetail>(
MDCChipSetEvents.SELECTION, {
chipID: this.adapter.getChipIdAtIndex(index),
chipIndex: index,
isSelected,
});
// Early exit if we support multi-selection
if (this.supportsMultiSelect()) {
return;
}
// If we get here, we ony support single selection. This means we need to
// unselect all chips
const chipCount = this.adapter.getChipCount();
for (let i = 0; i < chipCount; i++) {
const actions = this.adapter.getChipActionsAtIndex(i);
for (const chipAction of actions) {
// Skip the action and index provided since we set it above
if (chipAction === action && i === index) continue;
this.adapter.setChipSelectedAtIndex(i, chipAction, false);
}
}
}
private removeAfterAnimation(index: number, chipID: string) {
this.adapter.removeChipAtIndex(index);
this.adapter.emitEvent<MDCChipSetRemovalEventDetail>(
MDCChipSetEvents.REMOVAL, {
chipIndex: index,
isComplete: true,
chipID,
});
const chipCount = this.adapter.getChipCount();
// Early exit if we have an empty chip set
if (chipCount <= 0) return;
this.focusNearestFocusableAction(index);
}
/**
* Find the first focusable action by moving bidirectionally horizontally
* from the start index.
*
* Given chip set [A, B, C, D, E, F, G]...
* Let's say we remove chip "F". We don't know where the nearest focusable
* action is since any of them could be disabled. The nearest focusable
* action could be E, it could be G, it could even be A. To find it, we
* start from the source index (5 for "F" in this case) and move out
* horizontally, checking each chip at each index.
*
*/
private focusNearestFocusableAction(index: number) {
const chipCount = this.adapter.getChipCount();
let decrIndex = index;
let incrIndex = index;
while (decrIndex > -1 || incrIndex < chipCount) {
const focusAction = this.getNearestFocusableAction(
decrIndex, incrIndex, MDCChipActionType.TRAILING);
if (focusAction) {
this.focusChip(
focusAction.index, focusAction.action,
MDCChipActionFocusBehavior.FOCUSABLE_AND_FOCUSED);
return;
}
decrIndex--;
incrIndex++;
}
}
private getNearestFocusableAction(
decrIndex: number, incrIndex: number,
actionType?: MDCChipActionType): FocusAction|null {
const decrAction =
this.getFocusableAction(decrIndex, Operator.DECREMENT, actionType);
if (decrAction) {
return {
index: decrIndex,
action: decrAction,
};
}
// Early exit if the incremented and decremented indices are identical
if (incrIndex === decrIndex) return null;
const incrAction =
this.getFocusableAction(incrIndex, Operator.INCREMENT, actionType);
if (incrAction) {
return {
index: incrIndex,
action: incrAction,
};
}
return null;
}
}

View File

@ -0,0 +1,48 @@
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {MDCChipAnimationEventDetail, MDCChipInteractionEventDetail, MDCChipNavigationEventDetail} from '../../chip/lib/types';
/**
* MDCChipSetInteractionEventDetail provides detail about the interaction event.
*/
export interface MDCChipSetInteractionEventDetail {
chipID: string;
chipIndex: number;
}
/**
* MDCChipSetRemovalEventDetail provides detail about the removal event.
*/
export interface MDCChipSetRemovalEventDetail {
chipID: string;
chipIndex: number;
isComplete: boolean;
}
/**
* MDCChipSetSelectionEventDetail provides detail about the selection event.
*/
export interface MDCChipSetSelectionEventDetail {
chipID: string;
chipIndex: number;
isSelected: boolean;
}
/**
* ChipInteractionEvent is the custom event for the interaction event.
*/
export type ChipInteractionEvent = CustomEvent<MDCChipInteractionEventDetail>;
/**
* ChipNavigationEvent is the custom event for the navigation event.
*/
export type ChipNavigationEvent = CustomEvent<MDCChipNavigationEventDetail>;
/**
* ChipAnimationEvent is the custom event for the animation event.
*/
export type ChipAnimationEvent = CustomEvent<MDCChipAnimationEventDetail>;