import m from 'mithril' ; import classnames from 'classnames' ; import debounce from 'lodash.debounce' ; import { Classes , isFunction , Keys , safeCall , IAttrs , getClosest } from '../../_shared' ; import { AbstractComponent } from '../abstract-component' ; import { Icon , Icons } from '../icon' ; import { List , IListAttrs , IListItemAttrs , ListItem } from '../list' ; import { Input , IInputAttrs } from '../input' ; import { ControlGroup , IControlGroupAttrs } from '../control-group' ; type Direction = 'up' | 'down' ; export interface IQueryListEvents { handleKeyDown : ( e : KeyboardEvent ) => void ; } export interface IFilterableAttrs { default controlGroupAttrs ? : IControlGroupAttrs ; /** Right-justified content in relation to Input component  */ contentRight ? : m . Children ; /** Left-justified content in relation to Input component  */ contentLeft ? : m . Children ; /** Initial query value (uncontrolled mode) */ defaultQuery ? : string ; default filterable ? : boolean ; /** Callback invoked on input query change; only called when `query` is defined */ onQueryChange ? : ( query : string ) => void ; /** Input query value (controlled mode) */ query ? : string ; } export interface IQueryableAttrs < T > extends IAttrs { /** Current index position (controlled mode)  */ activeIndex ? : number ; default cacheItems ? : boolean ; default checkmark ? : boolean ; /** Initial active index (uncontrolled mode)  */ defaultActiveIndex ? : number ; /** Disables arrow key navigation and prevents highlighting of active item */ disableArrowKeys ? : boolean ; /**
     * Content rendered when input query is empty. If defined, items will only be rendered
     * when a search query is provided.
     */ initialContent ? : m . Children ; default inputAttrs ? : IInputAttrs ; /**
     * Custom render function for the entire list. If undefined, returns a List
     * component that calls `itemRender` for each item.
     */ itemListRender ? : ( items : T [ ] ) => m . Vnode ; /**
     * Predicate function used to filter all items.
     * Takes predecent over `itemPredicate`
     */ itemListPredicate ? : ( query : string , items : T [ ] ) => T [ ] ; /** Render function applied to each item  */ itemRender : ( item : T , index : number ) => m . Vnode < any , any > ; /** Predicate function applied to filter individual items  */ itemPredicate ? : ( query : string , item : T , index : number ) => boolean ; /** Array of T items */ items : T [ ] ; default emptyContent ? : m . Children ; /** Callback invoked on active item change; only called when `activeIndex` is defined */ onActiveItemChange ? : ( activeItem : T , index : number ) => void ; /** Callback invoked when child item is clicked */ onSelect ? : ( item : T , e : Event , index : number ) => void ; default listAttrs ? : IListAttrs ; eventCallbacks ? : ( events : IQueryListEvents ) => void ; } export interface IQueryListAttrs < T > extends IQueryableAttrs < T > , IFilterableAttrs { } export class QueryList < T > extends AbstractComponent < IQueryListAttrs < T > > { private filteredItems : T [ ] = [ ] ; private activeIndex : number ; private itemNodes : m . Vnode < IListItemAttrs > [ ] ; private inputEl : HTMLElement ; private query : string ; private listEl : HTMLElement ; public static ofType < T > ( ) { return QueryList as new ( ) => QueryList < T > ; } public getDefaultAttrs ( ) { return { cacheItems : true , checkmark : true , inputAttrs : { } , listAttrs : { } , filterable : true , controlGroupAttrs : { } , emptyContent : 'No items available.' } as IQueryListAttrs < T > ; } public oninit ( vnode ) { super . oninit ( vnode ) ; this . query = this . attrs . defaultQuery || '' ; this . activeIndex = this . attrs . defaultActiveIndex || 0 ; this . setControlledAttrs ( ) ; this . filteredItems = this . getFilteredItems ( ) ; } public oncreate ( { dom } ) { this . listEl = dom . querySelector ( `.${ Classes . LIST }` ) as HTMLElement ; this . inputEl = dom . querySelector ( `.${ Classes . INPUT }` ) as HTMLElement ; this . scrollToActiveItem ( ) ; } public onbeforeupdate ( vnode , old ) { super . onbeforeupdate ( vnode , old ) ; this . setControlledAttrs ( ) ; if ( vnode . attrs . items !== old . attrs . items || vnode . attrs . query !== old . attrs . query || vnode . attrs . activeIndex !== old . attrs . activeIndex || ! vnode . attrs . cacheItems ) { this . filteredItems = this . getFilteredItems ( ) ; this . scrollToActiveItem ( ) ; } } public view ( ) { const { activeIndex , cacheItems , checkmark , class : className , controlGroupAttrs , contentLeft , contentRight , defaultActiveIndex , defaultQuery , emptyContent , eventCallbacks , filterable , initialContent , inputAttrs , itemPredicate , itemListPredicate , itemListRender , itemRender , items , listAttrs , onActiveItemChange , onSelect , query , onQueryChange , ... htmlAttrs } = this . attrs ; const classes = classnames ( Classes . QUERY_LIST , checkmark && Classes . QUERY_LIST_CHECKMARK , className ) ; safeCall ( eventCallbacks , { handleKeyDown : this . handleKeyDown } ) ; const innerContent = [ filterable && this . renderControlGroup ( ) , this . renderList ( ) ] ; return m ( '' , { ... htmlAttrs , class : classes , onkeydown : this . handleKeyDown , tabindex : 0 } , innerContent ) ; } private renderControlGroup ( ) { const { inputAttrs , controlGroupAttrs , contentLeft , contentRight } = this . attrs ; return m ( ControlGroup , { ... this . attrs . controlGroupAttrs , class : classnames ( Classes . FLUID , controlGroupAttrs ! . class ) } , [ contentLeft , m ( Input , { placeholder : 'Search items...' , ... inputAttrs , oninput : this . handleInput , contentRight : ( this . query . length !== 0 ) ? m ( Icon , { name : Icons . X , onclick : this . handleInputClear } ) : inputAttrs ! . contentRight , value : this . query } ) , contentRight ] ) ; } private renderList ( ) { const { listAttrs , emptyContent , initialContent } = this . attrs ; this . itemNodes = this . filteredItems . map ( this . renderItem ) ; const isEmpty = this . filteredItems . length === 0 ; const hasInitialContent = initialContent && this . query === '' ; const classes = classnames ( isEmpty && Classes . QUERY_LIST_EMPTY , hasInitialContent && Classes . QUERY_LIST_INITIAL , listAttrs ! . class ) ; const emptyOrInitialContent = m ( `.${ Classes . QUERY_LIST_MESSAGE }` , ( hasInitialContent && ! isEmpty ) && initialContent , isEmpty && emptyContent ) ; const content = ( hasInitialContent || isEmpty ) ? emptyOrInitialContent : this . itemNodes ; return m ( List , { ... listAttrs , class : classes } , content ) ; } private renderItem = ( item , index ) => { const { itemRender , disableArrowKeys , checkmark , listAttrs } = this . attrs ; const listItem = itemRender ( item , index ) as m . Vnode < IListItemAttrs > ; listItem . attrs = listItem . attrs || { } ; listItem . attrs . onclick = ( e ) => this . handleSelect ( index , listItem . attrs . disabled ! , e ) ; if ( ! disableArrowKeys && this . activeIndex === index ) { listItem . attrs . class = classnames ( listItem . attrs . className , listItem . attrs . class , Classes . ACTIVE ) ; } if ( listItem . tag === ListItem ) { if ( listItem . attrs . selected && checkmark ) { listItem . attrs . contentLeft = m ( Icon , { name : Icons . CHECK , size : listAttrs ! . size } ) ; } } return listItem ; } ; private setControlledAttrs ( ) { const { activeIndex , query } = this . attrs ; if ( query != null ) { this . query = query ; } if ( activeIndex != null ) { this . activeIndex = activeIndex === - 1 ? 0 : activeIndex ; } } public scrollToActiveItem ( ) { const { listEl , activeIndex } = this ; if ( listEl && activeIndex >= 0 ) { const activeEl = listEl . children [ activeIndex ] as HTMLElement ; if ( ! activeEl ) return ; const { offsetTop : activeTop , offsetHeight : activeHeight } = activeEl ; const { scrollTop : listScrollTop , clientHeight : listHeight } = listEl ; const activeBottomEdge = activeTop + activeHeight ; const activeTopEdge = activeTop ; if ( activeBottomEdge >= listScrollTop + listHeight ) { listEl . scrollTop = activeBottomEdge + activeHeight - listHeight ; } else if ( activeTopEdge <= listScrollTop ) { listEl . scrollTop = activeTopEdge - activeHeight ; } } } private get activeItem ( ) { return this . filteredItems [ this . activeIndex ] ; } private updateQuery ( text ) { const { query , onQueryChange } = this . attrs ; if ( query != null ) { safeCall ( onQueryChange , text ) ; } else this . query = text ; } private handleInput = ( e ) => { this . handleSearchDebounce ( e ) ; ( e as any ) . redraw = false ; } ; private handleSearchDebounce = debounce ( ( e ) => { const value = ( e . target as HTMLInputElement ) . value ; this . updateQuery ( value ) ; this . filteredItems = this . getFilteredItems ( ) ; this . updateActiveIndex ( 0 ) ; m . redraw ( ) ; } , 200 ) ; private handleInputClear = ( ) => { this . updateQuery ( '' ) ; this . updateActiveIndex ( 0 ) ; this . filteredItems = this . getFilteredItems ( ) ; this . scrollToActiveItem ( ) ; if ( this . inputEl ) { this . inputEl . focus ( ) ; } } ; private handleSelect = ( index , isDisabled , e ) => { const { onSelect } = this . attrs ; const target = e . target as HTMLElement ; const selectedItem = this . filteredItems [ index ] ; const actionsEl = getClosest ( target , `.${ Classes . LIST_ITEM_CONTENT_RIGHT }` ) ; if ( selectedItem != null && ! actionsEl && ! isDisabled ) { this . updateActiveIndex ( index ) ; safeCall ( onSelect , selectedItem , e , index ) ; } else ( e as any ) . redraw = false ; } ; private handleKeyDown = ( e ) => { const key = e . which ; switch ( key ) { case Keys . ARROW_UP : case Keys . ARROW_DOWN : if ( ! this . attrs . disableArrowKeys ) { e . preventDefault ( ) ; this . moveActiveIndex ( key === Keys . ARROW_UP ? 'up' : 'down' ) ; m . redraw ( ) ; } break ; case Keys . ESCAPE : if ( this . query ) { this . handleInputClear ( ) ; m . redraw ( ) ; } break ; case Keys . ENTER : this . handleEnterKey ( e ) ; m . redraw ( ) ; break ; default : break ; } ( e as any ) . redraw = false ; } ; private moveActiveIndex ( direction ) { const { activeIndex } = this ; const index = getNextIndex ( activeIndex , this . itemNodes , direction ) ; this . updateActiveIndex ( index ) ; this . scrollToActiveItem ( ) ; } private updateActiveIndex ( index ) { const { activeIndex , onActiveItemChange } = this . attrs ; const currentIndex = index > this . filteredItems . length ? 0 : index ; if ( activeIndex != null ) { safeCall ( onActiveItemChange , this . activeItem , currentIndex ) ; } else this . activeIndex = currentIndex ; } private handleEnterKey ( e ) { const item = this . activeItem ; if ( item != null ) { safeCall ( this . attrs . onSelect , item , e ) ; } } private getFilteredItems ( ) { const { items , itemPredicate , itemListPredicate } = this . attrs ; if ( isFunction ( itemListPredicate ) ) { return itemListPredicate ( this . query , items ) ; } if ( isFunction ( itemPredicate ) ) { return items . filter ( ( item , index ) => itemPredicate ( this . query , item , index ) ) ; } return items ; } } function getNextIndex ( currentIndex , vnodes , direction ) { const maxIndex = vnodes . length - 1 ; let index = currentIndex ; let flag = true ; if ( index < 0 || maxIndex <= 0 ) { return 0 ; } while ( flag ) { index = direction === 'up' ? index === 0 ? maxIndex : index - 1 : index === maxIndex ? 0 : index + 1 ; const vnode = vnodes [ index ] ; const attrs = vnode && vnode . attrs ; if ( attrs && ! attrs . disabled ) { flag = false ; } } return index ; }