@smui/list
Version:
Svelte Material UI - List
966 lines • 40.6 kB
JavaScript
/**
* @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 { cssClasses, numbers, strings } from './constants';
import { preventDefaultEvent } from './events';
import * as typeahead from './typeahead';
function isNumberArray(selectedIndex) {
return selectedIndex instanceof Array;
}
/** List of modifier keys to consider while handling keyboard events. */
const handledModifierKeys = ['Alt', 'Control', 'Meta', 'Shift'];
/** Checks if the event has the given modifier keys. */
function createModifierChecker(event) {
const eventModifiers = new Set(event ? handledModifierKeys.filter((m) => event.getModifierState(m)) : []);
return (modifiers) => modifiers.every((m) => eventModifiers.has(m)) &&
modifiers.length === eventModifiers.size;
}
/** MDC List Foundation */
export class MDCListFoundation extends MDCFoundation {
static get strings() {
return strings;
}
static get cssClasses() {
return cssClasses;
}
static get numbers() {
return numbers;
}
static get defaultAdapter() {
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: () => '',
};
}
constructor(adapter) {
super(Object.assign(Object.assign({}, MDCListFoundation.defaultAdapter), adapter));
this.wrapFocus = false;
this.isVertical = true;
this.isSingleSelectionList = false;
this.areDisabledItemsFocusable = false;
this.selectedIndex = numbers.UNSET_INDEX;
this.focusedItemIndex = numbers.UNSET_INDEX;
this.useActivatedClass = false;
this.useSelectedAttr = false;
this.ariaCurrentAttrValue = null;
this.isCheckboxList = false;
this.isRadioList = false;
this.lastSelectedIndex = null;
this.hasTypeahead = false;
// Transiently holds current typeahead prefix from user.
this.typeaheadState = typeahead.initState();
this.sortedIndexByFirstChar = new Map();
}
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) {
this.wrapFocus = value;
}
/**
* Toggles orientation direction for keyboard navigation (true for vertical,
* false for horizontal).
*/
setVerticalOrientation(value) {
this.isVertical = value;
}
/** Toggles single-selection behavior. */
setSingleSelection(value) {
this.isSingleSelectionList = value;
if (value) {
this.maybeInitializeSingleSelection();
this.selectedIndex = this.getSelectedIndexFromDOM();
}
}
setDisabledItemsFocusable(value) {
this.areDisabledItemsFocusable = value;
}
/**
* Automatically determines whether the list is single selection list. If so,
* initializes the internal state to match the selected item.
*/
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. */
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) {
this.hasTypeahead = hasTypeahead;
if (hasTypeahead) {
this.sortedIndexByFirstChar = this.typeaheadInitSortedIndex();
}
}
/**
* @return Whether typeahead is currently matching a user-specified prefix.
*/
isTypeaheadInProgress() {
return (this.hasTypeahead && typeahead.isTypingInProgress(this.typeaheadState));
}
/** Toggle use of the "activated" CSS class. */
setUseActivatedClass(useActivated) {
this.useActivatedClass = useActivated;
}
/**
* Toggles use of the selected attribute (true for aria-selected, false for
* aria-checked).
*/
setUseSelectedAttribute(useSelected) {
this.useSelectedAttr = useSelected;
}
getSelectedIndex() {
return this.selectedIndex;
}
setSelectedIndex(index, options = {}) {
if (!this.isIndexValid(index)) {
return;
}
if (this.isCheckboxList) {
this.setCheckboxAtIndex(index, options);
}
else if (this.isRadioList) {
this.setRadioAtIndex(index, options);
}
else {
this.setSingleSelectionAtIndex(index, options);
}
}
/**
* Focus in handler for the list items.
*/
handleFocusIn(listItemIndex) {
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) {
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);
}
isIndexDisabled(index) {
return this.adapter.listItemAtIndexHasClass(index, cssClasses.LIST_ITEM_DISABLED_CLASS);
}
/**
* Key handler for the list.
*/
handleKeydown(event, isRootListItem, listItemIndex) {
var _a;
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 = {
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, 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;
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;
if (target && target.tagName === 'A' && isEnter) {
return;
}
preventDefaultEvent(event);
if (this.isIndexDisabled(currentIndex)) {
return;
}
if (!this.isTypeaheadInProgress()) {
this.toggleCheckboxRange((_a = this.lastSelectedIndex) !== null && _a !== void 0 ? _a : currentIndex, currentIndex, currentIndex);
this.adapter.notifyAction(currentIndex);
}
}
if (this.hasTypeahead) {
const 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, isCheckboxAlreadyUpdatedInAdapter, event) {
var _a;
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((_a = this.lastSelectedIndex) !== null && _a !== void 0 ? _a : index, index, index);
this.adapter.notifyAction(index);
}
}
/**
* Focuses the next element on the list.
*/
focusNextElement(index) {
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 !== null && firstChecked !== void 0 ? firstChecked : nextIndex;
} while (!this.areDisabledItemsFocusable &&
this.isIndexDisabled(nextIndex));
this.focusItemAtIndex(nextIndex);
return nextIndex;
}
/**
* Focuses the previous element on the list.
*/
focusPrevElement(index) {
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 !== null && firstChecked !== void 0 ? 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, isEnabled) {
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');
}
}
setSingleSelectionAtIndex(index, options = {}) {
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, 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.
*/
setAriaForSingleSelectionAtIndex(index) {
// 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, ariaAttribute, 'false');
}
if (index !== numbers.UNSET_INDEX) {
const ariaAttributeValue = isAriaCurrent
? this.ariaCurrentAttrValue
: 'true';
this.adapter.setAttributeForElementIndex(index, ariaAttribute, ariaAttributeValue);
}
}
/**
* Returns the attribute to use for indicating selection status.
*/
getSelectionAttribute() {
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.
*/
setRadioAtIndex(index, options = {}) {
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, 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]);
}
}
setCheckboxAtIndex(indices, options = {}) {
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)
: 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 === null || currentlySelected === void 0 ? void 0 : 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.
*/
resolveSelectedIndices(setCheckedItems) {
const currentlySelectedItems = this.selectedIndex === numbers.UNSET_INDEX
? []
: this.selectedIndex;
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.
*/
toggleCheckboxRange(fromIndex, toIndex, toggleIndex) {
this.lastSelectedIndex = toggleIndex;
const currentlySelected = new Set(this.selectedIndex === numbers.UNSET_INDEX
? []
: this.selectedIndex);
const newIsChecked = !(currentlySelected === null || currentlySelected === void 0 ? void 0 : 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);
}
}
setTabindexAtIndex(index) {
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.
*/
isSelectableList() {
return (this.isSingleSelectionList || this.isCheckboxList || this.isRadioList);
}
setTabindexToFirstSelectedOrFocusedItem() {
const targetIndex = this.getFirstSelectedOrFocusedItemIndex();
this.setTabindexAtIndex(targetIndex);
}
getFirstSelectedOrFocusedItemIndex() {
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;
}
getFirstEnabledItem() {
const listSize = this.adapter.getListItemCount();
let i = 0;
while (i < listSize) {
if (!this.isIndexDisabled(i)) {
break;
}
i++;
}
return i === listSize ? numbers.UNSET_INDEX : i;
}
isIndexValid(index, 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;
}
}
isIndexInRange(index) {
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.
*/
setSelectedIndexOnAction(index, isCheckboxAlreadyUpdatedInAdapter) {
this.lastSelectedIndex = index;
if (this.isCheckboxList) {
this.toggleCheckboxAtIndex(index, isCheckboxAlreadyUpdatedInAdapter);
this.adapter.notifySelectionChange([index]);
}
else {
this.setSelectedIndex(index, { isUserInteraction: true });
}
}
toggleCheckboxAtIndex(index, isCheckboxAlreadyUpdatedInAdapter) {
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.slice();
if (newCheckedValue) {
selectedIndexes.push(index);
}
else {
selectedIndexes = selectedIndexes.filter((i) => i !== index);
}
this.selectedIndex = selectedIndexes;
}
focusItemAtIndex(index) {
this.adapter.focusItemAtIndex(index);
this.focusedItemIndex = index;
}
getEnabledListItemCount() {
const listSize = this.adapter.getListItemCount();
let adjustedCount = 0;
for (let i = 0; i < listSize; i++) {
if (!this.isIndexDisabled(i)) {
adjustedCount++;
}
}
return adjustedCount;
}
checkboxListToggleAll(currentlySelectedIndices, isUserInteraction) {
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 = [];
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, startingIndex, skipFocus = false) {
const opts = {
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.
*/
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;
//# sourceMappingURL=foundation.js.map