@smui/list
Version:
Svelte Material UI - List
1,252 lines (1,121 loc) • 39.3 kB
text/typescript
/**
* @license
* Copyright 2018 Google Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { MDCFoundation } from '@smui/common/base/foundation';
import { normalizeKey } from '@smui/common/dom/keyboard';
import type { MDCListAdapter } from './adapter';
import { cssClasses, numbers, strings } from './constants';
import { preventDefaultEvent } from './events';
import * as typeahead from './typeahead';
import type { MDCListIndex, MDCListTextAndIndex } from './types';
function isNumberArray(selectedIndex: MDCListIndex): selectedIndex is number[] {
return selectedIndex instanceof Array;
}
/**
* Options for configuring how to update a selectable list item.
*/
interface SelectionUpdateOptions {
/** Whether the update was triggered by a user interaction. */
isUserInteraction?: boolean;
/**
* Whether the UI should be updated regardless of whether the
* selection would be a noop according to the foundation state.
* https://github.com/material-components/material-components-web/commit/5d060518804437aa1ae3152562f1bb78b1af4aa6.
*/
forceUpdate?: boolean;
/**
* Whether disabled items should be omitted from updates. This is most
* relevant when trying to update all the items in a selection list.
*/
omitDisabledItems?: boolean;
}
/** List of modifier keys to consider while handling keyboard events. */
const handledModifierKeys = ['Alt', 'Control', 'Meta', 'Shift'] as const;
/** Type representing a modifier key we handle. */
type ModifierKey = NonNullable<(typeof handledModifierKeys)[number]>;
/** Checks if the event has the given modifier keys. */
function createModifierChecker(event?: KeyboardEvent | MouseEvent) {
const eventModifiers = new Set(
event ? handledModifierKeys.filter((m) => event.getModifierState(m)) : [],
);
return (modifiers: ModifierKey[]) =>
modifiers.every((m) => eventModifiers.has(m)) &&
modifiers.length === eventModifiers.size;
}
/** MDC List Foundation */
export class MDCListFoundation extends MDCFoundation<MDCListAdapter> {
static override get strings() {
return strings;
}
static override get cssClasses() {
return cssClasses;
}
static override get numbers() {
return numbers;
}
static override get defaultAdapter(): MDCListAdapter {
return {
addClassForElementIndex: () => undefined,
focusItemAtIndex: () => undefined,
getAttributeForElementIndex: () => null,
getFocusedElementIndex: () => 0,
getListItemCount: () => 0,
hasCheckboxAtIndex: () => false,
hasRadioAtIndex: () => false,
isCheckboxCheckedAtIndex: () => false,
isFocusInsideList: () => false,
isRootFocused: () => false,
listItemAtIndexHasClass: () => false,
notifyAction: () => undefined,
notifySelectionChange: () => {},
removeClassForElementIndex: () => undefined,
setAttributeForElementIndex: () => undefined,
setCheckedCheckboxOrRadioAtIndex: () => undefined,
setTabIndexForListItemChildren: () => undefined,
getPrimaryTextAtIndex: () => '',
};
}
private wrapFocus = false;
private isVertical = true;
private isSingleSelectionList = false;
private areDisabledItemsFocusable = false;
private selectedIndex: MDCListIndex = numbers.UNSET_INDEX;
private focusedItemIndex = numbers.UNSET_INDEX;
private useActivatedClass = false;
private useSelectedAttr = false;
private ariaCurrentAttrValue: string | null = null;
private isCheckboxList = false;
private isRadioList = false;
private lastSelectedIndex: number | null = null;
private hasTypeahead = false;
// Transiently holds current typeahead prefix from user.
private readonly typeaheadState = typeahead.initState();
private sortedIndexByFirstChar = new Map<string, MDCListTextAndIndex[]>();
constructor(adapter?: Partial<MDCListAdapter>) {
super({ ...MDCListFoundation.defaultAdapter, ...adapter });
}
layout() {
if (this.adapter.getListItemCount() === 0) {
return;
}
// TODO(b/172274142): consider all items when determining the list's type.
if (this.adapter.hasCheckboxAtIndex(0)) {
this.isCheckboxList = true;
this.selectedIndex = [];
} else if (this.adapter.hasRadioAtIndex(0)) {
this.isRadioList = true;
} else {
this.maybeInitializeSingleSelection();
}
if (this.hasTypeahead) {
this.sortedIndexByFirstChar = this.typeaheadInitSortedIndex();
}
}
/** Returns the index of the item that was last focused. */
getFocusedItemIndex() {
return this.focusedItemIndex;
}
/** Toggles focus wrapping with keyboard navigation. */
setWrapFocus(value: boolean) {
this.wrapFocus = value;
}
/**
* Toggles orientation direction for keyboard navigation (true for vertical,
* false for horizontal).
*/
setVerticalOrientation(value: boolean) {
this.isVertical = value;
}
/** Toggles single-selection behavior. */
setSingleSelection(value: boolean) {
this.isSingleSelectionList = value;
if (value) {
this.maybeInitializeSingleSelection();
this.selectedIndex = this.getSelectedIndexFromDOM();
}
}
setDisabledItemsFocusable(value: boolean) {
this.areDisabledItemsFocusable = value;
}
/**
* Automatically determines whether the list is single selection list. If so,
* initializes the internal state to match the selected item.
*/
private maybeInitializeSingleSelection() {
const selectedItemIndex = this.getSelectedIndexFromDOM();
if (selectedItemIndex === numbers.UNSET_INDEX) return;
const hasActivatedClass = this.adapter.listItemAtIndexHasClass(
selectedItemIndex,
cssClasses.LIST_ITEM_ACTIVATED_CLASS,
);
if (hasActivatedClass) {
this.setUseActivatedClass(true);
}
this.isSingleSelectionList = true;
this.selectedIndex = selectedItemIndex;
}
/** @return Index of the first selected item based on the DOM state. */
private getSelectedIndexFromDOM() {
let selectedIndex = numbers.UNSET_INDEX;
const listItemsCount = this.adapter.getListItemCount();
for (let i = 0; i < listItemsCount; i++) {
const hasSelectedClass = this.adapter.listItemAtIndexHasClass(
i,
cssClasses.LIST_ITEM_SELECTED_CLASS,
);
const hasActivatedClass = this.adapter.listItemAtIndexHasClass(
i,
cssClasses.LIST_ITEM_ACTIVATED_CLASS,
);
if (!(hasSelectedClass || hasActivatedClass)) {
continue;
}
selectedIndex = i;
break;
}
return selectedIndex;
}
/**
* Sets whether typeahead is enabled on the list.
* @param hasTypeahead Whether typeahead is enabled.
*/
setHasTypeahead(hasTypeahead: boolean) {
this.hasTypeahead = hasTypeahead;
if (hasTypeahead) {
this.sortedIndexByFirstChar = this.typeaheadInitSortedIndex();
}
}
/**
* @return Whether typeahead is currently matching a user-specified prefix.
*/
isTypeaheadInProgress(): boolean {
return (
this.hasTypeahead && typeahead.isTypingInProgress(this.typeaheadState)
);
}
/** Toggle use of the "activated" CSS class. */
setUseActivatedClass(useActivated: boolean) {
this.useActivatedClass = useActivated;
}
/**
* Toggles use of the selected attribute (true for aria-selected, false for
* aria-checked).
*/
setUseSelectedAttribute(useSelected: boolean) {
this.useSelectedAttr = useSelected;
}
getSelectedIndex(): MDCListIndex {
return this.selectedIndex;
}
setSelectedIndex(index: MDCListIndex, options: SelectionUpdateOptions = {}) {
if (!this.isIndexValid(index)) {
return;
}
if (this.isCheckboxList) {
this.setCheckboxAtIndex(index as number[], options);
} else if (this.isRadioList) {
this.setRadioAtIndex(index as number, options);
} else {
this.setSingleSelectionAtIndex(index as number, options);
}
}
/**
* Focus in handler for the list items.
*/
handleFocusIn(listItemIndex: number) {
if (listItemIndex >= 0) {
this.focusedItemIndex = listItemIndex;
this.adapter.setAttributeForElementIndex(listItemIndex, 'tabindex', '0');
this.adapter.setTabIndexForListItemChildren(listItemIndex, '0');
}
}
/**
* Focus out handler for the list items.
*/
handleFocusOut(listItemIndex: number) {
if (listItemIndex >= 0) {
this.adapter.setAttributeForElementIndex(listItemIndex, 'tabindex', '-1');
this.adapter.setTabIndexForListItemChildren(listItemIndex, '-1');
}
/**
* Between Focusout & Focusin some browsers do not have focus on any
* element. Setting a delay to wait till the focus is moved to next element.
*/
setTimeout(() => {
if (!this.adapter.isFocusInsideList()) {
this.setTabindexToFirstSelectedOrFocusedItem();
}
}, 0);
}
private isIndexDisabled(index: number) {
return this.adapter.listItemAtIndexHasClass(
index,
cssClasses.LIST_ITEM_DISABLED_CLASS,
);
}
/**
* Key handler for the list.
*/
handleKeydown(
event: KeyboardEvent,
isRootListItem: boolean,
listItemIndex: number,
) {
const isArrowLeft = normalizeKey(event) === 'ArrowLeft';
const isArrowUp = normalizeKey(event) === 'ArrowUp';
const isArrowRight = normalizeKey(event) === 'ArrowRight';
const isArrowDown = normalizeKey(event) === 'ArrowDown';
const isHome = normalizeKey(event) === 'Home';
const isEnd = normalizeKey(event) === 'End';
const isEnter = normalizeKey(event) === 'Enter';
const isSpace = normalizeKey(event) === 'Spacebar';
// The keys for forward and back differ based on list orientation.
const isForward =
(this.isVertical && isArrowDown) || (!this.isVertical && isArrowRight);
const isBack =
(this.isVertical && isArrowUp) || (!this.isVertical && isArrowLeft);
// Have to check both upper and lower case, because having caps lock on
// affects the value.
const isLetterA = event.key === 'A' || event.key === 'a';
const eventHasModifiers = createModifierChecker(event);
if (this.adapter.isRootFocused()) {
if ((isBack || isEnd) && eventHasModifiers([])) {
event.preventDefault();
this.focusLastElement();
} else if ((isForward || isHome) && eventHasModifiers([])) {
event.preventDefault();
this.focusFirstElement();
} else if (
isBack &&
eventHasModifiers(['Shift']) &&
this.isCheckboxList
) {
event.preventDefault();
const focusedIndex = this.focusLastElement();
if (focusedIndex !== -1) {
this.setSelectedIndexOnAction(focusedIndex, false);
}
} else if (
isForward &&
eventHasModifiers(['Shift']) &&
this.isCheckboxList
) {
event.preventDefault();
const focusedIndex = this.focusFirstElement();
if (focusedIndex !== -1) {
this.setSelectedIndexOnAction(focusedIndex, false);
}
}
if (this.hasTypeahead) {
const handleKeydownOpts: typeahead.HandleKeydownOpts = {
event,
focusItemAtIndex: (index) => {
this.focusItemAtIndex(index);
},
focusedItemIndex: -1,
isTargetListItem: isRootListItem,
sortedIndexByFirstChar: this.sortedIndexByFirstChar,
isItemAtIndexDisabled: (index) => this.isIndexDisabled(index),
};
typeahead.handleKeydown(handleKeydownOpts, this.typeaheadState);
}
return;
}
let currentIndex = this.adapter.getFocusedElementIndex();
if (currentIndex === -1) {
currentIndex = listItemIndex;
if (currentIndex < 0) {
// If this event doesn't have a mdc-list-item ancestor from the
// current list (not from a sublist), return early.
return;
}
}
if (isForward && eventHasModifiers([])) {
preventDefaultEvent(event);
this.focusNextElement(currentIndex);
} else if (isBack && eventHasModifiers([])) {
preventDefaultEvent(event);
this.focusPrevElement(currentIndex);
} else if (
isForward &&
eventHasModifiers(['Shift']) &&
this.isCheckboxList
) {
preventDefaultEvent(event);
const focusedIndex = this.focusNextElement(currentIndex);
if (focusedIndex !== -1) {
this.setSelectedIndexOnAction(focusedIndex, false);
}
} else if (isBack && eventHasModifiers(['Shift']) && this.isCheckboxList) {
preventDefaultEvent(event);
const focusedIndex = this.focusPrevElement(currentIndex);
if (focusedIndex !== -1) {
this.setSelectedIndexOnAction(focusedIndex, false);
}
} else if (isHome && eventHasModifiers([])) {
preventDefaultEvent(event);
this.focusFirstElement();
} else if (isEnd && eventHasModifiers([])) {
preventDefaultEvent(event);
this.focusLastElement();
} else if (
isHome &&
eventHasModifiers(['Control', 'Shift']) &&
this.isCheckboxList
) {
preventDefaultEvent(event);
if (this.isIndexDisabled(currentIndex)) {
return;
}
this.focusFirstElement();
this.toggleCheckboxRange(0, currentIndex, currentIndex);
} else if (
isEnd &&
eventHasModifiers(['Control', 'Shift']) &&
this.isCheckboxList
) {
preventDefaultEvent(event);
if (this.isIndexDisabled(currentIndex)) {
return;
}
this.focusLastElement();
this.toggleCheckboxRange(
currentIndex,
this.adapter.getListItemCount() - 1,
currentIndex,
);
} else if (
isLetterA &&
eventHasModifiers(['Control']) &&
this.isCheckboxList
) {
event.preventDefault();
this.checkboxListToggleAll(
this.selectedIndex === numbers.UNSET_INDEX
? []
: (this.selectedIndex as number[]),
true,
);
} else if (
(isEnter || isSpace) &&
(eventHasModifiers([]) || eventHasModifiers(['Alt']))
) {
if (isRootListItem) {
// Return early if enter key is pressed on anchor element which triggers
// synthetic MouseEvent event.
const target = event.target as Element | null;
if (target && target.tagName === 'A' && isEnter) {
return;
}
preventDefaultEvent(event);
if (this.isIndexDisabled(currentIndex)) {
return;
}
if (!this.isTypeaheadInProgress()) {
if (this.isSelectableList()) {
this.setSelectedIndexOnAction(currentIndex, false);
}
this.adapter.notifyAction(currentIndex);
}
}
} else if (
(isEnter || isSpace) &&
eventHasModifiers(['Shift']) &&
this.isCheckboxList
) {
// Return early if enter key is pressed on anchor element which triggers
// synthetic MouseEvent event.
const target = event.target as Element | null;
if (target && target.tagName === 'A' && isEnter) {
return;
}
preventDefaultEvent(event);
if (this.isIndexDisabled(currentIndex)) {
return;
}
if (!this.isTypeaheadInProgress()) {
this.toggleCheckboxRange(
this.lastSelectedIndex ?? currentIndex,
currentIndex,
currentIndex,
);
this.adapter.notifyAction(currentIndex);
}
}
if (this.hasTypeahead) {
const handleKeydownOpts: typeahead.HandleKeydownOpts = {
event,
focusItemAtIndex: (index) => {
this.focusItemAtIndex(index);
},
focusedItemIndex: this.focusedItemIndex,
isTargetListItem: isRootListItem,
sortedIndexByFirstChar: this.sortedIndexByFirstChar,
isItemAtIndexDisabled: (index) => this.isIndexDisabled(index),
};
typeahead.handleKeydown(handleKeydownOpts, this.typeaheadState);
}
}
/**
* Click handler for the list.
*
* @param index Index for the item that has been clicked.
* @param isCheckboxAlreadyUpdatedInAdapter Whether the checkbox for
* the list item has already been updated in the adapter. This attribute
* should be set to `true` when e.g. the click event directly landed on
* the underlying native checkbox element which would cause the checked
* state to be already toggled within `adapter.isCheckboxCheckedAtIndex`.
*/
handleClick(
index: number,
isCheckboxAlreadyUpdatedInAdapter: boolean,
event?: MouseEvent,
) {
const eventHasModifiers = createModifierChecker(event);
if (index === numbers.UNSET_INDEX) {
return;
}
if (this.isIndexDisabled(index)) {
return;
}
if (eventHasModifiers([])) {
if (this.isSelectableList()) {
this.setSelectedIndexOnAction(index, isCheckboxAlreadyUpdatedInAdapter);
}
this.adapter.notifyAction(index);
} else if (this.isCheckboxList && eventHasModifiers(['Shift'])) {
this.toggleCheckboxRange(this.lastSelectedIndex ?? index, index, index);
this.adapter.notifyAction(index);
}
}
/**
* Focuses the next element on the list.
*/
focusNextElement(index: number) {
const count = this.adapter.getListItemCount();
let nextIndex = index;
let firstChecked = null;
do {
nextIndex++;
if (nextIndex >= count) {
if (this.wrapFocus) {
nextIndex = 0;
} else {
// Return early because last item is already focused.
return index;
}
}
if (nextIndex === firstChecked) {
return -1;
}
firstChecked = firstChecked ?? nextIndex;
} while (
!this.areDisabledItemsFocusable &&
this.isIndexDisabled(nextIndex)
);
this.focusItemAtIndex(nextIndex);
return nextIndex;
}
/**
* Focuses the previous element on the list.
*/
focusPrevElement(index: number) {
const count = this.adapter.getListItemCount();
let prevIndex = index;
let firstChecked = null;
do {
prevIndex--;
if (prevIndex < 0) {
if (this.wrapFocus) {
prevIndex = count - 1;
} else {
// Return early because first item is already focused.
return index;
}
}
if (prevIndex === firstChecked) {
return -1;
}
firstChecked = firstChecked ?? prevIndex;
} while (
!this.areDisabledItemsFocusable &&
this.isIndexDisabled(prevIndex)
);
this.focusItemAtIndex(prevIndex);
return prevIndex;
}
focusFirstElement() {
// Pass -1 to `focusNextElement`, since it will incremement to 0 and focus
// the first element.
return this.focusNextElement(-1);
}
focusLastElement() {
// Pass the length of the list to `focusNextElement` since it will decrement
// to length - 1 and focus the last element.
return this.focusPrevElement(this.adapter.getListItemCount());
}
focusInitialElement() {
const initialIndex = this.getFirstSelectedOrFocusedItemIndex();
if (initialIndex !== numbers.UNSET_INDEX) {
this.focusItemAtIndex(initialIndex);
}
return initialIndex;
}
/**
* @param itemIndex Index of the list item
* @param isEnabled Sets the list item to enabled or disabled.
*/
setEnabled(itemIndex: number, isEnabled: boolean): void {
if (!this.isIndexValid(itemIndex, false)) {
return;
}
if (isEnabled) {
this.adapter.removeClassForElementIndex(
itemIndex,
cssClasses.LIST_ITEM_DISABLED_CLASS,
);
this.adapter.setAttributeForElementIndex(
itemIndex,
strings.ARIA_DISABLED,
'false',
);
} else {
this.adapter.addClassForElementIndex(
itemIndex,
cssClasses.LIST_ITEM_DISABLED_CLASS,
);
this.adapter.setAttributeForElementIndex(
itemIndex,
strings.ARIA_DISABLED,
'true',
);
}
}
private setSingleSelectionAtIndex(
index: number,
options: SelectionUpdateOptions = {},
) {
if (this.selectedIndex === index && !options.forceUpdate) {
return;
}
let selectedClassName = cssClasses.LIST_ITEM_SELECTED_CLASS;
if (this.useActivatedClass) {
selectedClassName = cssClasses.LIST_ITEM_ACTIVATED_CLASS;
}
if (this.selectedIndex !== numbers.UNSET_INDEX) {
this.adapter.removeClassForElementIndex(
this.selectedIndex as number,
selectedClassName,
);
}
this.setAriaForSingleSelectionAtIndex(index);
this.setTabindexAtIndex(index);
if (index !== numbers.UNSET_INDEX) {
this.adapter.addClassForElementIndex(index, selectedClassName);
}
this.selectedIndex = index;
// If the selected value has changed through user interaction,
// we want to notify the selection change to the adapter.
if (options.isUserInteraction && !options.forceUpdate) {
this.adapter.notifySelectionChange([index]);
}
}
/**
* Sets aria attribute for single selection at given index.
*/
private setAriaForSingleSelectionAtIndex(index: number) {
// Detect the presence of aria-current and get the value only during list
// initialization when it is in unset state.
if (
this.selectedIndex === numbers.UNSET_INDEX &&
index !== numbers.UNSET_INDEX
) {
this.ariaCurrentAttrValue = this.adapter.getAttributeForElementIndex(
index,
strings.ARIA_CURRENT,
);
}
const isAriaCurrent = this.ariaCurrentAttrValue !== null;
const ariaAttribute = isAriaCurrent
? strings.ARIA_CURRENT
: strings.ARIA_SELECTED;
if (this.selectedIndex !== numbers.UNSET_INDEX) {
this.adapter.setAttributeForElementIndex(
this.selectedIndex as number,
ariaAttribute,
'false',
);
}
if (index !== numbers.UNSET_INDEX) {
const ariaAttributeValue = isAriaCurrent
? this.ariaCurrentAttrValue
: 'true';
this.adapter.setAttributeForElementIndex(
index,
ariaAttribute,
ariaAttributeValue as string,
);
}
}
/**
* Returns the attribute to use for indicating selection status.
*/
private getSelectionAttribute(): string {
return this.useSelectedAttr ? strings.ARIA_SELECTED : strings.ARIA_CHECKED;
}
/**
* Toggles radio at give index. Radio doesn't change the checked state if it
* is already checked.
*/
private setRadioAtIndex(index: number, options: SelectionUpdateOptions = {}) {
const selectionAttribute = this.getSelectionAttribute();
this.adapter.setCheckedCheckboxOrRadioAtIndex(index, true);
if (this.selectedIndex === index && !options.forceUpdate) {
return;
}
if (this.selectedIndex !== numbers.UNSET_INDEX) {
this.adapter.setAttributeForElementIndex(
this.selectedIndex as number,
selectionAttribute,
'false',
);
}
this.adapter.setAttributeForElementIndex(index, selectionAttribute, 'true');
this.selectedIndex = index;
// If the selected value has changed through user interaction,
// we want to notify the selection change to the adapter.
if (options.isUserInteraction && !options.forceUpdate) {
this.adapter.notifySelectionChange([index]);
}
}
private setCheckboxAtIndex(
indices: number[],
options: SelectionUpdateOptions = {},
) {
const currentIndex = this.selectedIndex;
// If this update is not triggered by a user interaction, we do not
// need to know about the currently selected indices and can avoid
// constructing the `Set` for performance reasons.
const currentlySelected = options.isUserInteraction
? new Set(
currentIndex === numbers.UNSET_INDEX
? []
: (currentIndex as number[]),
)
: null;
const selectionAttribute = this.getSelectionAttribute();
const changedIndices = [];
for (let i = 0; i < this.adapter.getListItemCount(); i++) {
if (options.omitDisabledItems && this.isIndexDisabled(i)) {
continue;
}
const previousIsChecked = currentlySelected?.has(i);
const newIsChecked = indices.indexOf(i) >= 0;
// If the selection has changed for this item, we keep track of it
// so that we can notify the adapter.
if (newIsChecked !== previousIsChecked) {
changedIndices.push(i);
}
this.adapter.setCheckedCheckboxOrRadioAtIndex(i, newIsChecked);
this.adapter.setAttributeForElementIndex(
i,
selectionAttribute,
newIsChecked ? 'true' : 'false',
);
}
this.selectedIndex = options.omitDisabledItems
? this.resolveSelectedIndices(indices)
: indices;
// If the selected value has changed through user interaction,
// we want to notify the selection change to the adapter.
if (options.isUserInteraction && changedIndices.length) {
this.adapter.notifySelectionChange(changedIndices);
}
}
/**
* Helper method for ensuring that the list of selected indices remains
* accurate when calling setCheckboxAtIndex with omitDisabledItems set to
* true.
*/
private resolveSelectedIndices(setCheckedItems: number[]): number[] {
const currentlySelectedItems =
this.selectedIndex === numbers.UNSET_INDEX
? []
: (this.selectedIndex as number[]);
const currentlySelectedDisabledItems = currentlySelectedItems.filter((i) =>
this.isIndexDisabled(i),
);
const enabledSetCheckedItems = setCheckedItems.filter(
(i) => !this.isIndexDisabled(i),
);
// Updated selectedIndex should be the enabled setCheckedItems + any missing
// selected disabled items.
const updatedSelectedItems = [
...new Set([
...enabledSetCheckedItems,
...currentlySelectedDisabledItems,
]),
];
return updatedSelectedItems.sort((a, b) => a - b);
}
/**
* Toggles the state of all checkboxes in the given range (inclusive) based
* on the state of the checkbox at the `toggleIndex`. To determine whether
* to set the given range to checked or unchecked, read the value of the
* checkbox at the `toggleIndex` and negate it. Then apply that new checked
* state to all checkboxes in the range.
* @param fromIndex The start of the range of checkboxes to toggle
* @param toIndex The end of the range of checkboxes to toggle
* @param toggleIndex The index that will be used to determine the new state
* of the given checkbox range.
*/
private toggleCheckboxRange(
fromIndex: number,
toIndex: number,
toggleIndex: number,
) {
this.lastSelectedIndex = toggleIndex;
const currentlySelected = new Set(
this.selectedIndex === numbers.UNSET_INDEX
? []
: (this.selectedIndex as number[]),
);
const newIsChecked = !currentlySelected?.has(toggleIndex);
const [startIndex, endIndex] = [fromIndex, toIndex].sort();
const selectionAttribute = this.getSelectionAttribute();
const changedIndices = [];
for (let i = startIndex; i <= endIndex; i++) {
if (this.isIndexDisabled(i)) {
continue;
}
const previousIsChecked = currentlySelected.has(i);
// If the selection has changed for this item, we keep track of it
// so that we can notify the adapter.
if (newIsChecked !== previousIsChecked) {
changedIndices.push(i);
this.adapter.setCheckedCheckboxOrRadioAtIndex(i, newIsChecked);
this.adapter.setAttributeForElementIndex(
i,
selectionAttribute,
`${newIsChecked}`,
);
if (newIsChecked) {
currentlySelected.add(i);
} else {
currentlySelected.delete(i);
}
}
}
// If the selected value has changed, update and notify the selection
// change to the adapter.
if (changedIndices.length) {
this.selectedIndex = [...currentlySelected];
this.adapter.notifySelectionChange(changedIndices);
}
}
private setTabindexAtIndex(index: number) {
if (
this.focusedItemIndex === numbers.UNSET_INDEX &&
index !== 0 &&
index !== numbers.UNSET_INDEX
) {
// If some list item was selected set first list item's tabindex to -1.
// Generally, tabindex is set to 0 on first list item of list that has
// no preselected items.
this.adapter.setAttributeForElementIndex(0, 'tabindex', '-1');
} else if (this.focusedItemIndex >= 0 && this.focusedItemIndex !== index) {
this.adapter.setAttributeForElementIndex(
this.focusedItemIndex,
'tabindex',
'-1',
);
}
// Set the previous selection's tabindex to -1. We need this because
// in selection menus that are not visible, programmatically setting an
// option will not change focus but will change where tabindex should be
// 0.
if (
!(this.selectedIndex instanceof Array) &&
this.selectedIndex !== index &&
this.focusedItemIndex !== numbers.UNSET_INDEX
) {
this.adapter.setAttributeForElementIndex(
this.selectedIndex,
'tabindex',
'-1',
);
}
if (index !== numbers.UNSET_INDEX) {
this.adapter.setAttributeForElementIndex(index, 'tabindex', '0');
}
}
/**
* @return Return true if it is single selectin list, checkbox list or radio
* list.
*/
private isSelectableList() {
return (
this.isSingleSelectionList || this.isCheckboxList || this.isRadioList
);
}
private setTabindexToFirstSelectedOrFocusedItem() {
const targetIndex = this.getFirstSelectedOrFocusedItemIndex();
this.setTabindexAtIndex(targetIndex);
}
private getFirstSelectedOrFocusedItemIndex(): number {
const firstFocusableListItem = this.getFirstEnabledItem();
if (this.adapter.getListItemCount() === 0) {
return numbers.UNSET_INDEX;
}
// Action lists retain focus on the most recently focused item.
if (!this.isSelectableList()) {
return Math.max(this.focusedItemIndex, firstFocusableListItem);
}
// Single-selection lists focus the selected item.
if (
typeof this.selectedIndex === 'number' &&
this.selectedIndex !== numbers.UNSET_INDEX
) {
return this.areDisabledItemsFocusable &&
this.isIndexDisabled(this.selectedIndex)
? firstFocusableListItem
: this.selectedIndex;
}
// Multiple-selection lists focus the first enabled selected item.
if (isNumberArray(this.selectedIndex) && this.selectedIndex.length > 0) {
const sorted = [...this.selectedIndex].sort((a, b) => a - b);
for (const index of sorted) {
if (this.isIndexDisabled(index) && !this.areDisabledItemsFocusable) {
continue;
} else {
return index;
}
}
}
// Selection lists without a selection focus the first item.
return firstFocusableListItem;
}
private getFirstEnabledItem(): number {
const listSize = this.adapter.getListItemCount();
let i = 0;
while (i < listSize) {
if (!this.isIndexDisabled(i)) {
break;
}
i++;
}
return i === listSize ? numbers.UNSET_INDEX : i;
}
private isIndexValid(index: MDCListIndex, validateListType = true) {
if (index instanceof Array) {
if (!this.isCheckboxList && validateListType) {
throw new Error(
'MDCListFoundation: Array of index is only supported for checkbox based list',
);
}
if (index.length === 0) {
return true;
} else {
return index.some((i) => this.isIndexInRange(i));
}
} else if (typeof index === 'number') {
if (this.isCheckboxList && validateListType) {
throw new Error(
`MDCListFoundation: Expected array of index for checkbox based list but got number: ${
index
}`,
);
}
return (
this.isIndexInRange(index) ||
(this.isSingleSelectionList && index === numbers.UNSET_INDEX)
);
} else {
return false;
}
}
private isIndexInRange(index: number) {
const listSize = this.adapter.getListItemCount();
return index >= 0 && index < listSize;
}
/**
* Sets selected index on user action, toggles checkboxes in checkbox lists
* by default, unless `isCheckboxAlreadyUpdatedInAdapter` is set to `true`.
*
* In cases where `isCheckboxAlreadyUpdatedInAdapter` is set to `true`, the
* UI is just updated to reflect the value returned by the adapter.
*
* When calling this, make sure user interaction does not toggle disabled
* list items.
*/
private setSelectedIndexOnAction(
index: number,
isCheckboxAlreadyUpdatedInAdapter: boolean,
) {
this.lastSelectedIndex = index;
if (this.isCheckboxList) {
this.toggleCheckboxAtIndex(index, isCheckboxAlreadyUpdatedInAdapter);
this.adapter.notifySelectionChange([index]);
} else {
this.setSelectedIndex(index, { isUserInteraction: true });
}
}
private toggleCheckboxAtIndex(
index: number,
isCheckboxAlreadyUpdatedInAdapter: boolean,
) {
const selectionAttribute = this.getSelectionAttribute();
const adapterIsChecked = this.adapter.isCheckboxCheckedAtIndex(index);
// By default the checked value from the adapter is toggled unless the
// checked state in the adapter has already been updated beforehand.
// This can be happen when the underlying native checkbox has already
// been updated through the native click event.
let newCheckedValue;
if (isCheckboxAlreadyUpdatedInAdapter) {
newCheckedValue = adapterIsChecked;
} else {
newCheckedValue = !adapterIsChecked;
this.adapter.setCheckedCheckboxOrRadioAtIndex(index, newCheckedValue);
}
this.adapter.setAttributeForElementIndex(
index,
selectionAttribute,
newCheckedValue ? 'true' : 'false',
);
// If none of the checkbox items are selected and selectedIndex is not
// initialized then provide a default value.
let selectedIndexes =
this.selectedIndex === numbers.UNSET_INDEX
? []
: (this.selectedIndex as number[]).slice();
if (newCheckedValue) {
selectedIndexes.push(index);
} else {
selectedIndexes = selectedIndexes.filter((i) => i !== index);
}
this.selectedIndex = selectedIndexes;
}
private focusItemAtIndex(index: number) {
this.adapter.focusItemAtIndex(index);
this.focusedItemIndex = index;
}
private getEnabledListItemCount(): number {
const listSize = this.adapter.getListItemCount();
let adjustedCount = 0;
for (let i = 0; i < listSize; i++) {
if (!this.isIndexDisabled(i)) {
adjustedCount++;
}
}
return adjustedCount;
}
private checkboxListToggleAll(
currentlySelectedIndices: number[],
isUserInteraction: boolean,
) {
const enabledListItemCount = this.getEnabledListItemCount();
const totalListItemCount = this.adapter.getListItemCount();
const currentlyEnabledSelectedIndices = currentlySelectedIndices.filter(
(i) => !this.isIndexDisabled(i),
);
// If all items are selected, deselect everything.
// We check >= rather than === to `enabledListItemCount` since a disabled
// item could be selected, and we don't take that into consideration when
// toggling the other checkbox values.
if (currentlyEnabledSelectedIndices.length >= enabledListItemCount) {
// Use omitDisabledItems option to ensure disabled selected items are not
// de-selected.
this.setCheckboxAtIndex([], {
isUserInteraction,
omitDisabledItems: true,
});
} else {
// Otherwise select all enabled options.
const allIndexes: number[] = [];
for (let i = 0; i < totalListItemCount; i++) {
if (
!this.isIndexDisabled(i) ||
currentlySelectedIndices.indexOf(i) > -1
) {
allIndexes.push(i);
}
}
// Use omitDisabledItems option to ensure disabled selected items are not
// de-selected.
this.setCheckboxAtIndex(allIndexes, {
isUserInteraction,
omitDisabledItems: true,
});
}
}
/**
* Given the next desired character from the user, adds it to the typeahead
* buffer. Then, attempts to find the next option matching the buffer. Wraps
* around if at the end of options.
*
* @param nextChar The next character to add to the prefix buffer.
* @param startingIndex The index from which to start matching. Only
* relevant when starting a new match sequence. To start a new match
* sequence, clear the buffer using `clearTypeaheadBuffer`, or wait for
* the buffer to clear after a set interval defined in list foundation.
* Defaults to the currently focused index.
* @return The index of the matched item, or -1 if no match.
*/
typeaheadMatchItem(
nextChar: string,
startingIndex?: number,
skipFocus = false,
) {
const opts: typeahead.TypeaheadMatchItemOpts = {
focusItemAtIndex: (index) => {
this.focusItemAtIndex(index);
},
focusedItemIndex: startingIndex ? startingIndex : this.focusedItemIndex,
nextChar,
sortedIndexByFirstChar: this.sortedIndexByFirstChar,
skipFocus,
isItemAtIndexDisabled: (index) => this.isIndexDisabled(index),
};
return typeahead.matchItem(opts, this.typeaheadState);
}
/**
* Initializes the MDCListTextAndIndex data structure by indexing the
* current list items by primary text.
*
* @return The primary texts of all the list items sorted by first
* character.
*/
private typeaheadInitSortedIndex() {
return typeahead.initSortedIndex(
this.adapter.getListItemCount(),
this.adapter.getPrimaryTextAtIndex,
);
}
/**
* Clears the typeahead buffer.
*/
clearTypeaheadBuffer() {
typeahead.clearBuffer(this.typeaheadState);
}
}
// tslint:disable-next-line:no-default-export Needed for backward compatibility with MDC Web v0.44.0 and earlier.
export default MDCListFoundation;