UNPKG

@smui/list

Version:
966 lines 40.6 kB
/** * @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