import { FocusOrigin } from '@angular/cdk/a11y' ; import { ChangeDetectionStrategy , ChangeDetectorRef , Component , ElementRef , EventEmitter , HostBinding , HostListener , Input , OnChanges , OnDestroy , Output , SimpleChanges , TemplateRef , ViewChild } from '@angular/core' ; import { BehaviorSubject , combineLatest , Subject } from 'rxjs' ; import { distinctUntilChanged , takeUntil } from 'rxjs/operators' ; import { InfiniteScrollDirective , InfiniteScrollLoadedEvent , InfiniteScrollLoadFunction } from '../../directives/infinite-scroll/index' ; import { PopoverOrientation , PopoverOrientationListener , PopoverOrientationService } from '../../services/popover-orientation/popover-orientation.service' ; import { TypeaheadOptionEvent } from './typeahead-event' ; import { TypeaheadOptionApi } from './typeahead-option-api' ; import { TypeaheadOptionContext } from './typeahead-option-context' ; import { TypeaheadVisibleOption } from './typeahead-visible-option' ; import { TypeaheadService } from './typeahead.service' ; import { coerceArray } from '@angular/cdk/coercion' ; let uniqueId = 0 ; @ Component ( { selector : 'ux-typeahead' , templateUrl : 'typeahead.component.html' , providers : [ TypeaheadService , PopoverOrientationService ] , changeDetection : ChangeDetectionStrategy . OnPush , host : { 'role' : 'listbox' , '[class.open]' : 'open' , '[style.maxHeight]' : 'maxHeight' } } ) export class TypeaheadComponent < T = any > implements OnChanges , OnDestroy { @ ViewChild ( InfiniteScrollDirective ) infiniteScroll : InfiniteScrollDirective ; /** Define a unique id for the typeahead */ @ Input ( ) @ HostBinding ( 'attr.id' ) id : string = `ux-typeahead-${ ++ uniqueId }` ; /** Define the options or infinite load function */ @ Input ( ) options : T [ ] | InfiniteScrollLoadFunction ; /** Define the filter text */ @ Input ( ) filter : string ; /** Specify if the typeahead is open */ @ Input ( ) get open ( ) { return this . _service . open$ . getValue ( ) ; } set open ( value ) { this . _service . open$ . next ( value ) ; } /** Extract the test to display from an option */ @ Input ( ) display : ( ( option : T ) => string ) | string ; /** Extract the key from an option */ @ Input ( ) key : ( ( option : T ) => string ) | string ; /** Specify which options are disabled */ @ Input ( ) set disabledOptions ( options ) { this . _disabledOptions = coerceArray ( options ) ; } get disabledOptions ( ) : T | T [ ] { return this . _disabledOptions ; } _disabledOptions : T [ ] = [ ] ; /** Specify the drop direction */ @ Input ( ) dropDirection : 'auto' | 'up' | 'down' = 'down' ; /** Specify the max height of the dropdown */ @ Input ( ) get maxHeight ( ) : string { return this . _maxHeight ; } set maxHeight ( maxHeight ) { this . _maxHeight = maxHeight ; if ( this . maxHeight . endsWith ( 'px' ) ) { this . _popoverOrientationListener . maxHeight = Number ( this . maxHeight . slice ( 0 , - 2 ) ) ; } } /** Specify the aria multi selectable attribute value */ @ Input ( ) @ HostBinding ( 'attr.aria-multiselectable' ) multiselectable : boolean = false ; /** Specify if the dropdown should appear when the filter appears */ @ Input ( ) openOnFilterChange : boolean = true ; /** Specify the page size */ @ Input ( ) pageSize : number = 20 ; /** Specify if we should select the first item by default */ @ Input ( ) selectFirst : boolean = true ; /** Specify if we should select an item on enter key press */ @ Input ( ) selectOnEnter : boolean = false ; /** Specify the loading state */ @ Input ( ) loading = false ; /** Specify a custom loading template */ @ Input ( ) loadingTemplate : TemplateRef < void > ; /** Specify a custom option template */ @ Input ( ) optionTemplate : TemplateRef < TypeaheadOptionContext < T > > ; /** Specify a custom template to display when there are no options */ @ Input ( ) noOptionsTemplate : TemplateRef < void > ; /** Specify the currently active item */ @ Input ( ) set active ( item ) { this . activeKey = this . getKey ( item ) ; } /**
     * An initial list of recently selected options, to be presented above the full list of options.
     * Bind an empty array to `recentOptions` to enable this feature without providing an initial set.
     */ @ Input ( ) recentOptions : ReadonlyArray < T > ; /** Maximum number of displayed recently selected options. */ @ Input ( ) recentOptionsMaxCount : number = 5 ; @ Input ( ) recentOptionsHeadingTemplate : TemplateRef < void > ; @ Input ( ) optionsHeadingTemplate : TemplateRef < void > ; /** Emit when the open state changes */ @ Output ( ) openChange = new EventEmitter < boolean > ( ) ; /** Emit when an option is selected */ @ Output ( ) optionSelected = new EventEmitter < TypeaheadOptionEvent < T > > ( ) ; /** Emit whenever a highlighted item changes */ @ Output ( ) highlightedChange = new EventEmitter < T > ( ) ; /** Emit the highlighted element when it changes */ @ Output ( ) highlightedElementChange = new EventEmitter < HTMLElement > ( ) ; /** Emits when recently selected options change.*/ @ Output ( ) recentOptionsChange = new EventEmitter < ReadonlyArray < T > > ( ) ; activeKey : string = null ; clicking = false ; hasBeenOpened = false ; highlighted$ = new BehaviorSubject < TypeaheadVisibleOption < T > > ( null ) ; loadOptionsCallback : InfiniteScrollLoadFunction ; visibleOptions$ = new BehaviorSubject < TypeaheadVisibleOption < T > [ ] > ( [ ] ) ; visibleRecentOptions$ = new BehaviorSubject < TypeaheadVisibleOption < T > [ ] > ( [ ] ) ; allVisibleOptions : TypeaheadVisibleOption < T > [ ] = [ ] ; get highlighted ( ) : T { const value = this . highlighted$ . getValue ( ) ; return value ? value . value : null ; } /** Store the list of recent items */ private _recentOptions : T [ ] ; private _onDestroy = new Subject < void > ( ) ; private _popoverOrientationListener : PopoverOrientationListener ; private _maxHeight = '250px' ; @ HostBinding ( 'class.drop-up' ) dropUp : boolean ; optionApi : TypeaheadOptionApi < T > = { getKey : this . getKey . bind ( this ) , getDisplay : this . getDisplay . bind ( this ) , getDisplayHtml : this . getDisplayHtml . bind ( this ) , getDisabled : this . isDisabled . bind ( this ) } ; constructor ( public typeaheadElement , private _changeDetector , popoverOrientation , private _service ) { this . loadOptionsCallback = ( pageNum , pageSize , filter ) => { if ( typeof this . options === 'function' ) { const arrayOrPromise = this . options ( pageNum , pageSize , filter ) ; return Promise . resolve ( arrayOrPromise ) . then ( newOptions => { if ( ! Array . isArray ( newOptions ) ) { return newOptions ; } return this . getVisibleOptions ( newOptions , '' ) ; } ) ; } return null ; } ; this . _service . open$ . pipe ( distinctUntilChanged ( ) , takeUntil ( this . _onDestroy ) ) . subscribe ( isOpen => { this . openChange . emit ( isOpen ) ; if ( isOpen ) { this . hasBeenOpened = true ; this . initOptions ( ) ; } } ) ; this . _popoverOrientationListener = popoverOrientation . createPopoverOrientationListener ( this . typeaheadElement . nativeElement , this . typeaheadElement . nativeElement . parentElement ) ; this . _popoverOrientationListener . orientation$ . pipe ( takeUntil ( this . _onDestroy ) ) . subscribe ( direction => { if ( this . dropDirection === 'auto' ) { this . dropUp = direction === PopoverOrientation . Up ; } } ) ; this . highlighted$ . pipe ( takeUntil ( this . _onDestroy ) ) . subscribe ( next => { this . highlightedChange . emit ( next ? next . value : null ) ; } ) ; combineLatest ( [ this . visibleOptions$ , this . visibleRecentOptions$ ] ) . pipe ( takeUntil ( this . _onDestroy ) ) . subscribe ( ( [ visibleOptions , visibleRecentOptions ] ) => { this . allVisibleOptions = [ ... visibleRecentOptions , ... visibleOptions ] ; } ) ; combineLatest ( [ this . _service . open$ , this . _service . highlightedElement$ , this . visibleOptions$ ] ) . pipe ( takeUntil ( this . _onDestroy ) ) . subscribe ( ( [ open , highlightedElement , visibleOptions ] ) => { this . highlightedElementChange . emit ( open && visibleOptions . length > 0 ? highlightedElement : null ) ; } ) ; } ngOnChanges ( changes ) { if ( changes . filter ) { if ( this . openOnFilterChange && changes . filter . currentValue && changes . filter . currentValue . length > 0 ) { if ( changes . open && changes . open . previousValue === true && changes . open . currentValue === false ) { return ; } this . open = true ; } } if ( changes . recentOptions || changes . recentOptionsMaxCount ) { this . _recentOptions = this . recentOptions ? this . recentOptions . slice ( 0 , this . recentOptionsMaxCount ) : undefined ; if ( changes . recentOptionsMaxCount ) { setTimeout ( ( ) => { this . recentOptionsChange . emit ( this . _recentOptions ) ; } ) ; } } if ( changes . dropDirection ) { if ( changes . dropDirection . currentValue === 'auto' ) { this . dropUp = this . _popoverOrientationListener . orientation$ . getValue ( ) === PopoverOrientation . Up ; } else { this . dropUp = changes . dropDirection . currentValue === 'up' ; } } if ( changes . options && changes . options . firstChange === false ) { this . infiniteScroll . reset ( false ) ; this . infiniteScroll . reload ( ) ; } this . updateOptions ( ) ; } ngOnDestroy ( ) { this . _onDestroy . next ( ) ; this . _onDestroy . complete ( ) ; this . _popoverOrientationListener . destroy ( ) ; } @ HostListener ( 'mousedown' , [ '$event' , '$event.target' ] ) mousedownHandler ( event , target ) { this . clicking = true ; /**
         * Chrome Bug Workaround: (https://bugs.chromium.org/p/chromium/issues/detail?id=6759)
         * In Chrome, if the typeahead is within a tabbable element, e.g. it has a parent with
         * `tabindex="0"` like our side panel has, then clicking on a scrollbar will remove
         * focus from the input element.
         *
         * To avoid this we can determine if the mousedown event occurred within the scrollbar
         * region of the typeahead and if so preventDefault which will stop the input
         * from losing focus
         */ if ( event . clientX >= target . clientWidth || event . clientY >= target . clientHeight ) { event . preventDefault ( ) ; } } @ HostListener ( 'mouseup' ) mouseupHandler ( ) { this . clicking = false ; } optionMousedownHandler ( event ) { event . preventDefault ( ) ; } optionClickHandler ( _event , option ) { this . select ( option , 'mouse' ) ; } /**
     * Returns the unique key value of the given option.
     */ getKey ( option ) { if ( typeof this . key === 'function' ) { return this . key ( option ) ; } if ( typeof this . key === 'string' && option && option . hasOwnProperty ( this . key ) ) { return option [ this . key ] ; } return this . getDisplay ( option ) ; } /**
     * Returns the display value of the given option.
     */ getDisplay ( option ) { if ( typeof this . display === 'function' ) { return this . display ( option ) ; } if ( typeof this . display === 'string' && option && option . hasOwnProperty ( this . display ) ) { return option [ this . display ] ; } if ( typeof option === 'string' ) { return option ; } } param option getDisplayHtml ( option ) { const displayText = this . getDisplay ( option ) ; if ( ! displayText ) { return '' ; } let displayHtml = displayText . replace ( /&/g , '&amp;' ) . replace ( /</g , '&lt;' ) . replace ( />/g , '&gt;' ) ; if ( this . filter ) { const length = this . filter . length ; const matchIndex = displayText . toLowerCase ( ) . indexOf ( this . filter . toLowerCase ( ) ) ; if ( matchIndex >= 0 ) { const highlight = `<span class="ux-filter-match">${ displayText . substr ( matchIndex , length ) }</span>` ; displayHtml = displayText . substr ( 0 , matchIndex ) + highlight + displayText . substr ( matchIndex + length ) ; } } return displayHtml ; } /**
     * Returns true if the infinite scroll component should load
     */ isInfiniteScroll ( ) { return typeof this . options === 'function' ; } /**
     * Selects the given option, emitting the optionSelected event and closing the dropdown.
     */ select ( option , origin ? ) { if ( ! this . isDisabled ( option . value ) ) { this . optionSelected . emit ( new TypeaheadOptionEvent ( option . value , origin ) ) ; this . highlighted$ . next ( null ) ; this . open = false ; this . addToRecentOptions ( option . value ) ; } } addToRecentOptions ( value ) { if ( this . _recentOptions ) { this . _recentOptions = [ value , ... this . _recentOptions . filter ( recentOption => recentOption !== value ) ] . slice ( 0 , this . recentOptionsMaxCount ) ; this . recentOptionsChange . emit ( this . _recentOptions ) ; } } /**
     * Returns true if the given option is part of the disabledOptions array.
     */ isDisabled ( option ) { return Array . isArray ( this . disabledOptions ) ? this . disabledOptions . some ( selectedOption => this . getKey ( selectedOption ) === this . getKey ( option ) ) : false ; } /**
     * Set the given option as the current highlighted option, available in the highlightedOption parameter.
     */ highlight ( option ) { if ( ! this . isDisabled ( option . value ) ) { this . highlighted$ . next ( option ) ; this . _changeDetector . detectChanges ( ) ; } } param d moveHighlight ( d ) { const highlightIndex = this . indexOfVisibleOption ( this . highlighted$ . getValue ( ) ) ; let newIndex = highlightIndex ; let disabled = true ; let inBounds = true ; do { newIndex = newIndex + d ; inBounds = newIndex >= 0 && newIndex < this . allVisibleOptions . length ; disabled = inBounds && this . isDisabled ( this . allVisibleOptions [ newIndex ] . value ) ; } while ( inBounds && disabled ) ; if ( ! disabled && inBounds ) { this . highlight ( this . allVisibleOptions [ newIndex ] ) ; } return this . highlighted ; } selectHighlighted ( ) { if ( this . highlighted$ . getValue ( ) ) { this . select ( this . highlighted$ . getValue ( ) , 'keyboard' ) ; } } /**
     * Set up the options before the dropdown is displayed.
     */ initOptions ( ) { this . highlighted$ . next ( null ) ; if ( this . selectFirst ) { this . moveHighlight ( 1 ) ; } } /**
     * Display the first item as highlighted when there are several pages
     */ onLoadedHighlight ( event ) { if ( this . selectFirst && this . options && event . pageNumber === 0 ) { this . moveHighlight ( 1 ) ; } } /**
     * Update the visibleOptions and visibleRecentOptions arrays with the current filter.
     */ updateOptions ( ) { const normalisedInput = ( this . filter || '' ) . toLowerCase ( ) ; if ( typeof this . options === 'object' ) { this . visibleOptions$ . next ( this . getVisibleOptions ( this . options , normalisedInput ) ) ; } this . visibleRecentOptions$ . next ( this . getVisibleOptions ( this . _recentOptions , normalisedInput , true ) ) ; this . initOptions ( ) ; this . _changeDetector . detectChanges ( ) ; } param options param filter param isRecentOptions private getVisibleOptions ( options , filter = '' , isRecentOptions = false ) { if ( options ) { return options . filter ( option => this . getDisplay ( option ) . toLowerCase ( ) . indexOf ( filter ) >= 0 ) . map ( value => ( { value , key : this . getKey ( value ) , isRecentOption : isRecentOptions } ) ) ; } return [ ] ; } /**
     * Return the index of the given option in the allVisibleOptions array.
     * Returns -1 if the option is not currently visible.
     */ private indexOfVisibleOption ( option ) { if ( option ) { return this . allVisibleOptions . findIndex ( el => { return ( el . key === option . key && el . isRecentOption === option . isRecentOption ) ; } ) ; } return - 1 ; } }