license import { MDCComponent } from '@material/base/component' ; import { SpecificEventListener } from '@material/base/types' ; import { closest , matches } from '@material/dom/ponyfill' ; import { MDCListAdapter } from './adapter' ; import { cssClasses , deprecatedClassNameMap , evolutionAttribute , evolutionClassNameMap , numbers , strings } from './constants' ; import { MDCListFoundation } from './foundation' ; import { MDCListActionEventDetail , MDCListIndex , MDCListSelectionChangeDetail } from './types' ; export type MDCListFactory = ( el : Element , foundation ? : MDCListFoundation ) => MDCList ; export class MDCList extends MDCComponent < MDCListFoundation > { set vertical ( value ) { this . foundation . setVerticalOrientation ( value ) ; } get listElements ( ) : Element [ ] { return Array . from ( this . root . querySelectorAll ( `.${ this . classNameMap [ cssClasses . LIST_ITEM_CLASS ] }` ) ) ; } set wrapFocus ( value ) { this . foundation . setWrapFocus ( value ) ; } return get typeaheadInProgress ( ) : boolean { return this . foundation . isTypeaheadInProgress ( ) ; } param hasTypeahead set hasTypeahead ( hasTypeahead ) { this . foundation . setHasTypeahead ( hasTypeahead ) ; } set singleSelection ( isSingleSelectionList ) { this . foundation . setSingleSelection ( isSingleSelectionList ) ; } get selectedIndex ( ) : MDCListIndex { return this . foundation . getSelectedIndex ( ) ; } set selectedIndex ( index ) { this . foundation . setSelectedIndex ( index ) ; } static override attachTo ( root ) { return new MDCList ( root ) ; } private handleKeydown ! : SpecificEventListener < 'keydown' > ; private handleClick ! : SpecificEventListener < 'click' > ; private focusInEventListener ! : SpecificEventListener < 'focus' > ; private focusOutEventListener ! : SpecificEventListener < 'focus' > ; private classNameMap ! : { [ className ] : string ; } ; private isEvolutionEnabled ! : boolean ; private isInteractive ! : boolean ; override initialSyncWithDOM ( ) { this . isEvolutionEnabled = evolutionAttribute in ( this . root as HTMLElement ) . dataset ; if ( this . isEvolutionEnabled ) { this . classNameMap = evolutionClassNameMap ; } else if ( matches ( this . root , strings . DEPRECATED_SELECTOR ) ) { this . classNameMap = deprecatedClassNameMap ; } else { this . classNameMap = Object . values ( cssClasses ) . reduce ( ( obj , className ) => { obj [ className ] = className ; return obj ; } , { } ) ; } this . handleClick = this . handleClickEvent . bind ( this ) ; this . handleKeydown = this . handleKeydownEvent . bind ( this ) ; this . focusInEventListener = this . handleFocusInEvent . bind ( this ) ; this . focusOutEventListener = this . handleFocusOutEvent . bind ( this ) ; this . listen ( 'keydown' , this . handleKeydown ) ; this . listen ( 'click' , this . handleClick ) ; this . listen ( 'focusin' , this . focusInEventListener ) ; this . listen ( 'focusout' , this . focusOutEventListener ) ; this . layout ( ) ; this . initializeListType ( ) ; this . ensureFocusable ( ) ; } override destroy ( ) { this . unlisten ( 'keydown' , this . handleKeydown ) ; this . unlisten ( 'click' , this . handleClick ) ; this . unlisten ( 'focusin' , this . focusInEventListener ) ; this . unlisten ( 'focusout' , this . focusOutEventListener ) ; } layout ( ) { const direction = this . root . getAttribute ( strings . ARIA_ORIENTATION ) ; this . vertical = direction !== strings . ARIA_ORIENTATION_HORIZONTAL ; const itemSelector = `.${ this . classNameMap [ cssClasses . LIST_ITEM_CLASS ] }:not([tabindex])` ; const childSelector = strings . FOCUSABLE_CHILD_ELEMENTS ; const itemEls = this . root . querySelectorAll ( itemSelector ) ; if ( itemEls . length ) { Array . prototype . forEach . call ( itemEls , ( el ) => { el . setAttribute ( 'tabindex' , '-1' ) ; } ) ; } const focusableChildEls = this . root . querySelectorAll ( childSelector ) ; if ( focusableChildEls . length ) { Array . prototype . forEach . call ( focusableChildEls , ( el ) => { el . setAttribute ( 'tabindex' , '-1' ) ; } ) ; } if ( this . isEvolutionEnabled ) { this . foundation . setUseSelectedAttribute ( true ) ; } this . foundation . layout ( ) ; } param item return getPrimaryText ( item ) { const primaryText = item . querySelector ( `.${ this . classNameMap [ cssClasses . LIST_ITEM_PRIMARY_TEXT_CLASS ] }` ) ; if ( this . isEvolutionEnabled || primaryText ) { return primaryText ?. textContent ?? '' ; } const singleLineText = item . querySelector ( `.${ this . classNameMap [ cssClasses . LIST_ITEM_TEXT_CLASS ] }` ) ; return ( singleLineText && singleLineText . textContent ) || '' ; } /**
     * Initialize selectedIndex value based on pre-selected list items.
     */ initializeListType ( ) { this . isInteractive = matches ( this . root , strings . ARIA_INTERACTIVE_ROLES_SELECTOR ) ; if ( this . isEvolutionEnabled && this . isInteractive ) { const selection = Array . from ( this . root . querySelectorAll ( strings . SELECTED_ITEM_SELECTOR ) , ( listItem ) => this . listElements . indexOf ( listItem ) ) ; if ( matches ( this . root , strings . ARIA_MULTI_SELECTABLE_SELECTOR ) ) { this . selectedIndex = selection ; } else if ( selection . length > 0 ) { this . selectedIndex = selection [ 0 ] ; } return ; } const checkboxListItems = this . root . querySelectorAll ( strings . ARIA_ROLE_CHECKBOX_SELECTOR ) ; const radioSelectedListItem = this . root . querySelector ( strings . ARIA_CHECKED_RADIO_SELECTOR ) ; if ( checkboxListItems . length ) { const preselectedItems = this . root . querySelectorAll ( strings . ARIA_CHECKED_CHECKBOX_SELECTOR ) ; this . selectedIndex = Array . from ( preselectedItems , ( listItem ) => this . listElements . indexOf ( listItem ) ) ; } else if ( radioSelectedListItem ) { this . selectedIndex = this . listElements . indexOf ( radioSelectedListItem ) ; } } param itemIndex param isEnabled setEnabled ( itemIndex , isEnabled ) { this . foundation . setEnabled ( itemIndex , isEnabled ) ; } param nextChar param startingIndex return typeaheadMatchItem ( nextChar , startingIndex ? ) { return this . foundation . typeaheadMatchItem ( nextChar , startingIndex , true ) ; } override getDefaultFoundation ( ) { const adapter = { addClassForElementIndex : ( index , className ) => { const element = this . listElements [ index ] ; if ( element ) { element . classList . add ( this . classNameMap [ className ] ) ; } } , focusItemAtIndex : ( index ) => { const element = this . listElements [ index ] as HTMLElement | undefined ; if ( element ) { element . focus ( ) ; } } , getAttributeForElementIndex : ( index , attr ) => this . listElements [ index ] . getAttribute ( attr ) , getFocusedElementIndex : ( ) => this . listElements . indexOf ( document . activeElement ! ) , getListItemCount : ( ) => this . listElements . length , getPrimaryTextAtIndex : ( index ) => this . getPrimaryText ( this . listElements [ index ] ) , hasCheckboxAtIndex : ( index ) => { const listItem = this . listElements [ index ] ; return ! ! listItem . querySelector ( strings . CHECKBOX_SELECTOR ) ; } , hasRadioAtIndex : ( index ) => { const listItem = this . listElements [ index ] ; return ! ! listItem . querySelector ( strings . RADIO_SELECTOR ) ; } , isCheckboxCheckedAtIndex : ( index ) => { const listItem = this . listElements [ index ] ; const toggleEl = listItem . querySelector < HTMLInputElement > ( strings . CHECKBOX_SELECTOR ) ; return toggleEl ! . checked ; } , isFocusInsideList : ( ) => { return this . root !== document . activeElement && this . root . contains ( document . activeElement ) ; } , isRootFocused : ( ) => document . activeElement === this . root , listItemAtIndexHasClass : ( index , className ) => this . listElements [ index ] . classList . contains ( this . classNameMap [ className ] ) , notifyAction : ( index ) => { this . emit < MDCListActionEventDetail > ( strings . ACTION_EVENT , { index } , true ) ; } , notifySelectionChange : ( changedIndices ) => { this . emit < MDCListSelectionChangeDetail > ( strings . SELECTION_CHANGE_EVENT , { changedIndices } , true ) ; } , removeClassForElementIndex : ( index , className ) => { const element = this . listElements [ index ] ; if ( element ) { element . classList . remove ( this . classNameMap [ className ] ) ; } } , setAttributeForElementIndex : ( index , attr , value ) => { const element = this . listElements [ index ] ; if ( element ) { element . setAttribute ( attr , value ) ; } } , setCheckedCheckboxOrRadioAtIndex : ( index , isChecked ) => { const listItem = this . listElements [ index ] ; const toggleEl = listItem . querySelector < HTMLInputElement > ( strings . CHECKBOX_RADIO_SELECTOR ) ; toggleEl ! . checked = isChecked ; const event = document . createEvent ( 'Event' ) ; event . initEvent ( 'change' , true , true ) ; toggleEl ! . dispatchEvent ( event ) ; } , setTabIndexForListItemChildren : ( listItemIndex , tabIndexValue ) => { const element = this . listElements [ listItemIndex ] ; const selector = strings . CHILD_ELEMENTS_TO_TOGGLE_TABINDEX ; Array . prototype . forEach . call ( element . querySelectorAll ( selector ) , ( el ) => { el . setAttribute ( 'tabindex' , tabIndexValue ) ; } ) ; } , } ; return new MDCListFoundation ( adapter ) ; } /**
     * Ensures that at least one item is focusable if the list is interactive and
     * doesn't specify a suitable tabindex.
     */ private ensureFocusable ( ) { if ( this . isEvolutionEnabled && this . isInteractive ) { if ( ! this . root . querySelector ( `.${ this . classNameMap [ cssClasses . LIST_ITEM_CLASS ] }[tabindex="0"]` ) ) { const index = this . initialFocusIndex ( ) ; if ( index !== - 1 ) { ( this . listElements [ index ] as HTMLElement ) . tabIndex = 0 ; } } } } private initialFocusIndex ( ) { if ( this . selectedIndex instanceof Array && this . selectedIndex . length > 0 ) { return this . selectedIndex [ 0 ] ; } if ( typeof this . selectedIndex === 'number' && this . selectedIndex !== numbers . UNSET_INDEX ) { return this . selectedIndex ; } const el = this . root . querySelector ( `.${ this . classNameMap [ cssClasses . LIST_ITEM_CLASS ] }:not(.${ this . classNameMap [ cssClasses . LIST_ITEM_DISABLED_CLASS ] })` ) ; if ( el === null ) { return - 1 ; } return this . getListItemIndex ( el ) ; } /**
     * Used to figure out which list item this event is targetting. Or returns -1
     * if there is no list item
     */ private getListItemIndex ( el ) { const nearestParent = closest ( el , `.${ this . classNameMap [ cssClasses . LIST_ITEM_CLASS ] }, .${ this . classNameMap [ cssClasses . ROOT ] }` ) ; if ( nearestParent && matches ( nearestParent , `.${ this . classNameMap [ cssClasses . LIST_ITEM_CLASS ] }` ) ) { return this . listElements . indexOf ( nearestParent ) ; } return - 1 ; } /**
     * Used to figure out which element was clicked before sending the event to
     * the foundation.
     */ private handleFocusInEvent ( evt ) { const index = this . getListItemIndex ( evt . target as Element ) ; this . foundation . handleFocusIn ( index ) ; } /**
     * Used to figure out which element was clicked before sending the event to
     * the foundation.
     */ private handleFocusOutEvent ( evt ) { const index = this . getListItemIndex ( evt . target as Element ) ; this . foundation . handleFocusOut ( index ) ; } /**
     * Used to figure out which element was focused when keydown event occurred
     * before sending the event to the foundation.
     */ private handleKeydownEvent ( evt ) { const index = this . getListItemIndex ( evt . target as Element ) ; const target = evt . target as Element ; this . foundation . handleKeydown ( evt , target . classList . contains ( this . classNameMap [ cssClasses . LIST_ITEM_CLASS ] ) , index ) ; } /**
     * Used to figure out which element was clicked before sending the event to
     * the foundation.
     */ private handleClickEvent ( evt ) { const index = this . getListItemIndex ( evt . target as Element ) ; const target = evt . target as Element ; const toggleCheckbox = ! matches ( target , strings . CHECKBOX_RADIO_SELECTOR ) ; this . foundation . handleClick ( index , toggleCheckbox ) ; } }