license import { MDCFoundation } from '@material/base/foundation' ; import { normalizeKey } from '@material/dom/keyboard' ; import { MDCListAdapter } from './adapter' ; import { cssClasses , numbers , strings } from './constants' ; import { preventDefaultEvent } from './events' ; import * as typeahead from './typeahead' ; import { MDCListIndex , MDCListTextAndIndex } from './types' ; function isNumberArray ( selectedIndex ) { return selectedIndex instanceof Array ; } type 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 ; } ; 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 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 hasTypeahead = false ; private readonly typeaheadState = typeahead . initState ( ) ; private sortedIndexByFirstChar = new Map < string , MDCListTextAndIndex [ ] > ( ) ; constructor ( adapter ? ) { super ( { ... MDCListFoundation . defaultAdapter , ... adapter } ) ; } layout ( ) { if ( this . adapter . getListItemCount ( ) === 0 ) { return ; } if ( this . adapter . hasCheckboxAtIndex ( 0 ) ) { this . isCheckboxList = true ; } 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 ( ) ; } } /**
     * 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 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 ; } param hasTypeahead setHasTypeahead ( hasTypeahead ) { this . hasTypeahead = hasTypeahead ; if ( hasTypeahead ) { this . sortedIndexByFirstChar = this . typeaheadInitSortedIndex ( ) ; } } return 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 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 ) { 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 ) ; } /**
     * Key handler for the list.
     */ handleKeydown ( event , isRootListItem , listItemIndex ) { 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' ; const isLetterA = event . key === 'A' || event . key === 'a' ; if ( this . adapter . isRootFocused ( ) ) { if ( isArrowUp || isEnd ) { event . preventDefault ( ) ; this . focusLastElement ( ) ; } else if ( isArrowDown || isHome ) { event . preventDefault ( ) ; this . focusFirstElement ( ) ; } if ( this . hasTypeahead ) { const handleKeydownOpts = { event , focusItemAtIndex : ( index ) => { this . focusItemAtIndex ( index ) ; } , focusedItemIndex : - 1 , isTargetListItem : isRootListItem , sortedIndexByFirstChar : this . sortedIndexByFirstChar , isItemAtIndexDisabled : ( index ) => this . adapter . listItemAtIndexHasClass ( index , cssClasses . LIST_ITEM_DISABLED_CLASS ) , } ; typeahead . handleKeydown ( handleKeydownOpts , this . typeaheadState ) ; } return ; } let currentIndex = this . adapter . getFocusedElementIndex ( ) ; if ( currentIndex === - 1 ) { currentIndex = listItemIndex ; if ( currentIndex < 0 ) { return ; } } if ( ( this . isVertical && isArrowDown ) || ( ! this . isVertical && isArrowRight ) ) { preventDefaultEvent ( event ) ; this . focusNextElement ( currentIndex ) ; } else if ( ( this . isVertical && isArrowUp ) || ( ! this . isVertical && isArrowLeft ) ) { preventDefaultEvent ( event ) ; this . focusPrevElement ( currentIndex ) ; } else if ( isHome ) { preventDefaultEvent ( event ) ; this . focusFirstElement ( ) ; } else if ( isEnd ) { preventDefaultEvent ( event ) ; this . focusLastElement ( ) ; } else if ( isLetterA && event . ctrlKey && this . isCheckboxList ) { event . preventDefault ( ) ; this . checkboxListToggleAll ( this . selectedIndex === numbers . UNSET_INDEX ? [ ] : this . selectedIndex as number [ ] , true ) ; } else if ( isEnter || isSpace ) { if ( isRootListItem ) { const target = event . target as Element | null ; if ( target && target . tagName === 'A' && isEnter ) { return ; } preventDefaultEvent ( event ) ; if ( this . adapter . listItemAtIndexHasClass ( currentIndex , cssClasses . LIST_ITEM_DISABLED_CLASS ) ) { return ; } if ( ! this . isTypeaheadInProgress ( ) ) { if ( this . isSelectableList ( ) ) { this . setSelectedIndexOnAction ( currentIndex , false ) ; } 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 . adapter . listItemAtIndexHasClass ( index , cssClasses . LIST_ITEM_DISABLED_CLASS ) , } ; typeahead . handleKeydown ( handleKeydownOpts , this . typeaheadState ) ; } } param index param isCheckboxAlreadyUpdatedInAdapter handleClick ( index , isCheckboxAlreadyUpdatedInAdapter ) { if ( index === numbers . UNSET_INDEX ) { return ; } if ( this . adapter . listItemAtIndexHasClass ( index , cssClasses . LIST_ITEM_DISABLED_CLASS ) ) { return ; } if ( this . isSelectableList ( ) ) { this . setSelectedIndexOnAction ( index , isCheckboxAlreadyUpdatedInAdapter ) ; } this . adapter . notifyAction ( index ) ; } /**
     * Focuses the next element on the list.
     */ focusNextElement ( index ) { const count = this . adapter . getListItemCount ( ) ; let nextIndex = index + 1 ; if ( nextIndex >= count ) { if ( this . wrapFocus ) { nextIndex = 0 ; } else { return index ; } } this . focusItemAtIndex ( nextIndex ) ; return nextIndex ; } /**
     * Focuses the previous element on the list.
     */ focusPrevElement ( index ) { let prevIndex = index - 1 ; if ( prevIndex < 0 ) { if ( this . wrapFocus ) { prevIndex = this . adapter . getListItemCount ( ) - 1 ; } else { return index ; } } this . focusItemAtIndex ( prevIndex ) ; return prevIndex ; } focusFirstElement ( ) { this . focusItemAtIndex ( 0 ) ; return 0 ; } focusLastElement ( ) { const lastIndex = this . adapter . getListItemCount ( ) - 1 ; this . focusItemAtIndex ( lastIndex ) ; return lastIndex ; } focusInitialElement ( ) { const initialIndex = this . getFirstSelectedOrFocusedItemIndex ( ) ; this . focusItemAtIndex ( initialIndex ) ; return initialIndex ; } param itemIndex param isEnabled 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' ) ; } } private 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 as number , selectedClassName ) ; } this . setAriaForSingleSelectionAtIndex ( index ) ; this . setTabindexAtIndex ( index ) ; if ( index !== numbers . UNSET_INDEX ) { this . adapter . addClassForElementIndex ( index , selectedClassName ) ; } this . selectedIndex = index ; if ( options . isUserInteraction && ! options . forceUpdate ) { this . adapter . notifySelectionChange ( [ index ] ) ; } } /**
     * Sets aria attribute for single selection at given index.
     */ private setAriaForSingleSelectionAtIndex ( index ) { if ( this . selectedIndex === 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 ( ) { 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 , 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 as number , selectionAttribute , 'false' ) ; } this . adapter . setAttributeForElementIndex ( index , selectionAttribute , 'true' ) ; this . selectedIndex = index ; if ( options . isUserInteraction && ! options . forceUpdate ) { this . adapter . notifySelectionChange ( [ index ] ) ; } } private setCheckboxAtIndex ( index , options = { } ) { const currentIndex = this . selectedIndex ; 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 ++ ) { const previousIsChecked = currentlySelected ?. has ( i ) ; const newIsChecked = index . indexOf ( i ) >= 0 ; if ( newIsChecked !== previousIsChecked ) { changedIndices . push ( i ) ; } this . adapter . setCheckedCheckboxOrRadioAtIndex ( i , newIsChecked ) ; this . adapter . setAttributeForElementIndex ( i , selectionAttribute , newIsChecked ? 'true' : 'false' ) ; } this . selectedIndex = index ; if ( options . isUserInteraction && changedIndices . length ) { this . adapter . notifySelectionChange ( changedIndices ) ; } } private setTabindexAtIndex ( index ) { if ( this . focusedItemIndex === numbers . UNSET_INDEX && index !== 0 ) { this . adapter . setAttributeForElementIndex ( 0 , 'tabindex' , '-1' ) ; } else if ( this . focusedItemIndex >= 0 && this . focusedItemIndex !== index ) { this . adapter . setAttributeForElementIndex ( this . focusedItemIndex , 'tabindex' , '-1' ) ; } if ( ! ( this . selectedIndex instanceof Array ) && this . selectedIndex !== index ) { this . adapter . setAttributeForElementIndex ( this . selectedIndex , 'tabindex' , '-1' ) ; } if ( index !== numbers . UNSET_INDEX ) { this . adapter . setAttributeForElementIndex ( index , 'tabindex' , '0' ) ; } } return private isSelectableList ( ) { return this . isSingleSelectionList || this . isCheckboxList || this . isRadioList ; } private setTabindexToFirstSelectedOrFocusedItem ( ) { const targetIndex = this . getFirstSelectedOrFocusedItemIndex ( ) ; this . setTabindexAtIndex ( targetIndex ) ; } private getFirstSelectedOrFocusedItemIndex ( ) { if ( ! this . isSelectableList ( ) ) { return Math . max ( this . focusedItemIndex , 0 ) ; } if ( typeof this . selectedIndex === 'number' && this . selectedIndex !== numbers . UNSET_INDEX ) { return this . selectedIndex ; } if ( isNumberArray ( this . selectedIndex ) && this . selectedIndex . length > 0 ) { return this . selectedIndex . reduce ( ( minIndex , currentIndex ) => Math . min ( minIndex , currentIndex ) ) ; } return 0 ; } private 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 ; } } private 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.
     */ private setSelectedIndexOnAction ( index , isCheckboxAlreadyUpdatedInAdapter ) { if ( this . isCheckboxList ) { this . toggleCheckboxAtIndex ( index , isCheckboxAlreadyUpdatedInAdapter ) ; this . adapter . notifySelectionChange ( [ index ] ) ; } else { this . setSelectedIndex ( index , { isUserInteraction : true } ) ; } } private toggleCheckboxAtIndex ( index , isCheckboxAlreadyUpdatedInAdapter ) { const selectionAttribute = this . getSelectionAttribute ( ) ; const adapterIsChecked = this . adapter . isCheckboxCheckedAtIndex ( index ) ; let newCheckedValue ; if ( isCheckboxAlreadyUpdatedInAdapter ) { newCheckedValue = adapterIsChecked ; } else { newCheckedValue = ! adapterIsChecked ; this . adapter . setCheckedCheckboxOrRadioAtIndex ( index , newCheckedValue ) ; } this . adapter . setAttributeForElementIndex ( index , selectionAttribute , newCheckedValue ? 'true' : 'false' ) ; 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 ) { this . adapter . focusItemAtIndex ( index ) ; this . focusedItemIndex = index ; } private checkboxListToggleAll ( currentlySelectedIndexes , isUserInteraction ) { const count = this . adapter . getListItemCount ( ) ; if ( currentlySelectedIndexes . length === count ) { this . setCheckboxAtIndex ( [ ] , { isUserInteraction } ) ; } else { const allIndexes = [ ] ; for ( let i = 0 ; i < count ; i ++ ) { if ( ! this . adapter . listItemAtIndexHasClass ( i , cssClasses . LIST_ITEM_DISABLED_CLASS ) || currentlySelectedIndexes . indexOf ( i ) > - 1 ) { allIndexes . push ( i ) ; } } this . setCheckboxAtIndex ( allIndexes , { isUserInteraction } ) ; } } param nextChar param startingIndex return 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 . adapter . listItemAtIndexHasClass ( index , cssClasses . LIST_ITEM_DISABLED_CLASS ) } ; return typeahead . matchItem ( opts , this . typeaheadState ) ; } return private typeaheadInitSortedIndex ( ) { return typeahead . initSortedIndex ( this . adapter . getListItemCount ( ) , this . adapter . getPrimaryTextAtIndex ) ; } /**
     * Clears the typeahead buffer.
     */ clearTypeaheadBuffer ( ) { typeahead . clearBuffer ( this . typeaheadState ) ; } } export default MDCListFoundation ;