license import { FocusKeyManager } from '@angular/cdk/a11y' ; import { coerceBooleanProperty } from '@angular/cdk/coercion' ; import { SelectionModel } from '@angular/cdk/collections' ; import { BACKSPACE , END , HOME } from '@angular/cdk/keycodes' ; import { AfterContentInit , ChangeDetectionStrategy , ChangeDetectorRef , Component , ContentChildren , ElementRef , EventEmitter , HostListener , Input , OnDestroy , OnInit , Optional , Output , QueryList , Self , ViewEncapsulation , } from '@angular/core' ; import { ControlValueAccessor , FormGroupDirective , NgControl , NgForm } from '@angular/forms' ; import { merge , Observable , Subject , Subscription } from 'rxjs' ; import { startWith , takeUntil } from 'rxjs/operators' ; import { FormFieldControl } from '../form-field/form-field-control' ; import { ChipDirective , ChipEvent , ChipSelectionChange } from './chip' ; import { ChipTextControl } from './chip-text-control' ; let nextUniqueId = 0 ; /** Change event object that is emitted when the chip list value has changed. */ export class ChipListChange { constructor ( /** Chip list that emitted the event. */ public source , /** Value of the chip list when the event was emitted. */ public value ) { } } @ Component ( { selector : 'gd-chip-list' , exportAs : 'gdChipList' , templateUrl : './chip-list.component.html' , styleUrls : [ './chip-list.component.scss' , './chips.scss' , ] , encapsulation : ViewEncapsulation . None , changeDetection : ChangeDetectionStrategy . OnPush , host : { '[attr.tabindex]' : 'disabled ? null : _tabIndex' , '[attr.aria-describedby]' : '_ariaDescribedby || null' , '[attr.aria-disabled]' : 'disabled.toString()' , '[attr.aria-multiselectable]' : 'multiple' , '[attr.role]' : 'role' , '[class.ChipList--disabled]' : 'disabled' , '[attr.aria-orientation]' : 'ariaOrientation' , 'class' : 'ChipList' , '[id]' : '_uid' , } , providers : [ { provide : FormFieldControl , useExisting : ChipListComponent } , ] , } ) export class ChipListComponent extends FormFieldControl implements ControlValueAccessor , AfterContentInit , OnInit , OnDestroy { /** Tab index for the chip list. */ _tabIndex = 0 ; @ Input ( ) set tabIndex ( value ) { this . _userTabIndex = value ; this . _tabIndex = value ; } /** The array of selected chips inside chip list. */ get selected ( ) : ChipDirective [ ] | ChipDirective { return this . multiple ? this . _selectionModel . selected : this . _selectionModel . selected [ 0 ] ; } /** The ARIA role applied to the chip list. */ get role ( ) : string | null { return this . empty ? null : 'listbox' ; } private _multiple : boolean = false ; /** Whether the user should be allowed to select multiple chips. */ @ Input ( ) get multiple ( ) : boolean { return this . _multiple ; } set multiple ( value ) { this . _multiple = coerceBooleanProperty ( value ) ; } private _compareWith = ( o1 , o2 ) => o1 === o2 ; /**
     * A function to compare the option values with the selected values. The first argument
     * is a value from an option. The second is a value from the selection. A boolean
     * should be returned.
     */ @ Input ( ) get compareWith ( ) : ( o1 : any , o2 : any ) => boolean { return this . _compareWith ; } set compareWith ( fn ) { this . _compareWith = fn ; if ( this . _selectionModel ) { this . _initializeSelection ( ) ; } } protected _value : any ; docs-private @ Input ( ) get value ( ) : any { return this . _value ; } set value ( value ) { this . writeValue ( value ) ; this . _value = value ; } docs-private get id ( ) : string { return this . _chipInput ? this . _chipInput . id : this . _uid ; } protected _placeholder : string ; docs-private @ Input ( ) get placeholder ( ) : string { return this . _chipInput ? this . _chipInput . placeholder : this . _placeholder ; } set placeholder ( value ) { this . _placeholder = value ; } /** Whether any chips or the matChipInput inside of this chip-list has focus. */ get focused ( ) : boolean { return ( this . _chipInput && this . _chipInput . focused ) || this . _hasFocusedChip ( ) ; } docs-private get empty ( ) : boolean { return ( ! this . _chipInput || this . _chipInput . empty ) && this . chips . length === 0 ; } docs-private get shouldLabelFloat ( ) : boolean { return ! this . empty || this . focused ; } protected _disabled : boolean = false ; docs-private @ Input ( ) get disabled ( ) : boolean { return this . ngControl ? ! ! this . ngControl . disabled : this . _disabled ; } set disabled ( value ) { this . _disabled = coerceBooleanProperty ( value ) ; if ( this . chips ) { this . chips . forEach ( chip => chip . disabled = this . _disabled ) ; } } protected _selectable : boolean = true ; /**
     * Whether or not this chip list is selectable. When a chip list is not selectable,
     * the selected states for all the chips inside the chip list are always ignored.
     */ @ Input ( ) get selectable ( ) : boolean { return this . _selectable ; } set selectable ( value ) { this . _selectable = coerceBooleanProperty ( value ) ; if ( this . chips ) { this . chips . forEach ( chip => chip . chipListSelectable = this . _selectable ) ; } } /** Combined stream of all of the child chips' selection change events. */ get chipSelectionChanges ( ) : Observable < ChipSelectionChange > { return merge ( ... this . chips . map ( chip => chip . selectionChange ) ) ; } /** Combined stream of all of the child chips' focus change events. */ get chipFocusChanges ( ) : Observable < ChipEvent > { return merge ( ... this . chips . map ( chip => chip . _onFocus ) ) ; } /** Combined stream of all of the child chips' blur change events. */ get chipBlurChanges ( ) : Observable < ChipEvent > { return merge ( ... this . chips . map ( chip => chip . _onBlur ) ) ; } /** Combined stream of all of the child chips' remove change events. */ get chipRemoveChanges ( ) : Observable < ChipEvent > { return merge ( ... this . chips . map ( chip => chip . destroyed ) ) ; } /** Uid of the chip list */ _uid : string = `gd-chip-list-${ nextUniqueId ++ }` ; /** The aria-describedby attribute on the chip list for improved a11y. */ _ariaDescribedby : string ; /**
     * User defined tab index.
     * When it is not null, use user defined tab index. Otherwise use _tabIndex
     */ _userTabIndex : number | null = null ; /** The FocusKeyManager which handles focus. */ _keyManager : FocusKeyManager < ChipDirective > ; _selectionModel : SelectionModel < ChipDirective > ; /** Orientation of the chip list. */ @ Input ( 'aria-orientation' ) ariaOrientation : 'horizontal' | 'vertical' = 'horizontal' ; /** Event emitted when the selected chip list value has been changed by the user. */ @ Output ( ) readonly change = new EventEmitter < ChipListChange > ( ) ; docs-private @ Output ( ) readonly valueChange : EventEmitter < any > = new EventEmitter < any > ( ) ; /** The chip components contained within this chip list. */ @ ContentChildren ( ChipDirective ) chips : QueryList < ChipDirective > ; /** The chip input to add more chips */ protected _chipInput : ChipTextControl ; /**
     * When a chip is destroyed, we store the index of the destroyed chip until the chips
     * query list notifies about the update. This is necessary because we cannot determine an
     * appropriate chip that should receive focus until the array of chips updated completely.
     */ private _lastDestroyedChipIndex : number | null = null ; /** Subject that emits when the component has been destroyed. */ private _destroyed = new Subject < void > ( ) ; /** Subscription to focus changes in the chips. */ private _chipFocusSubscription : Subscription | null ; /** Subscription to blur changes in the chips. */ private _chipBlurSubscription : Subscription | null ; /** Subscription to selection changes in chips. */ private _chipSelectionSubscription : Subscription | null ; /** Subscription to remove changes in chips. */ private _chipRemoveSubscription : Subscription | null ; constructor ( protected _elementRef , private _changeDetectorRef , @ Optional ( ) private _parentForm , @ Optional ( ) private _parentFormGroup , @ Optional ( ) @ Self ( ) public ngControl ) { super ( ) ; if ( this . ngControl ) { this . ngControl . valueAccessor = this ; } } /** Function when touched */ _onTouched = ( ) => { } ; /** Function when changed */ _onChange : ( value : any ) => void = ( ) => { } ; ngAfterContentInit ( ) { this . _keyManager = new FocusKeyManager < ChipDirective > ( this . chips ) . withWrap ( ) . withVerticalOrientation ( ) . withHorizontalOrientation ( 'ltr' ) ; this . _keyManager . tabOut . pipe ( takeUntil ( this . _destroyed ) ) . subscribe ( ( ) => { this . _tabIndex = - 1 ; setTimeout ( ( ) => this . _tabIndex = this . _userTabIndex || 0 ) ; } ) ; this . chips . changes . pipe ( startWith ( null ) , takeUntil ( this . _destroyed ) ) . subscribe ( ( ) => { this . _resetChips ( ) ; this . _initializeSelection ( ) ; this . _updateTabIndex ( ) ; this . _updateFocusForDestroyedChips ( ) ; } ) ; } ngOnInit ( ) { this . _selectionModel = new SelectionModel < ChipDirective > ( this . multiple , undefined , false ) ; } ngOnDestroy ( ) { this . _destroyed . next ( ) ; this . _destroyed . complete ( ) ; this . _dropSubscriptions ( ) ; } /** Associates an HTML input element with this chip list. */ registerInput ( inputElement ) { this . _chipInput = inputElement ; } docs-private setDescribedByIds ( ids ) { this . _ariaDescribedby = ids . join ( ' ' ) ; } writeValue ( value ) { if ( this . chips ) { this . _setSelectionByValue ( value , false ) ; } } registerOnChange ( fn ) { this . _onChange = fn ; } registerOnTouched ( fn ) { this . _onTouched = fn ; } setDisabledState ( isDisabled ) { this . disabled = isDisabled ; } docs-private onContainerClick ( event ) { if ( ! this . _originatesFromChip ( event ) ) { this . focus ( ) ; } } /**
     * Focuses the the first non-disabled chip in this chip list, or the associated input when there
     * are no eligible chips.
     */ @ HostListener ( 'focus' ) focus ( ) { if ( this . disabled ) { return ; } if ( this . _chipInput && this . _chipInput . focused ) { } else if ( this . chips . length > 0 ) { this . _keyManager . setFirstItemActive ( ) ; } else { this . _focusInput ( ) ; } } /** Attempt to focus an input if we have one. */ _focusInput ( ) { if ( this . _chipInput ) { this . _chipInput . focus ( ) ; } } /**
     * Pass events to the keyboard manager. Available here for tests.
     */ @ HostListener ( 'keydown' , [ '$event' ] ) _keydown ( event ) { const target = event . target as HTMLElement ; if ( event . keyCode === BACKSPACE && this . _isInputEmpty ( target ) ) { this . _keyManager . setLastItemActive ( ) ; event . preventDefault ( ) ; } else if ( target && target . classList . contains ( 'Chip' ) ) { if ( event . keyCode === HOME ) { this . _keyManager . setFirstItemActive ( ) ; event . preventDefault ( ) ; } else if ( event . keyCode === END ) { this . _keyManager . setLastItemActive ( ) ; event . preventDefault ( ) ; } else { this . _keyManager . onKeydown ( event ) ; } } } _setSelectionByValue ( value , isUserInput = true ) { this . _clearSelection ( ) ; this . chips . forEach ( chip => chip . deselect ( ) ) ; if ( Array . isArray ( value ) ) { value . forEach ( currentValue => this . _selectValue ( currentValue , isUserInput ) ) ; this . _sortValues ( ) ; } else { const correspondingChip = this . _selectValue ( value , isUserInput ) ; if ( correspondingChip ) { if ( isUserInput ) { this . _keyManager . setActiveItem ( correspondingChip ) ; } } } } /** When blurred, mark the field as touched when focus moved outside the chip list. */ @ HostListener ( 'blur' ) _blur ( ) { if ( ! this . _hasFocusedChip ( ) ) { this . _keyManager . setActiveItem ( - 1 ) ; } if ( ! this . disabled ) { if ( this . _chipInput ) { setTimeout ( ( ) => { if ( ! this . focused ) { this . _markAsTouched ( ) ; } } ) ; } else { this . _markAsTouched ( ) ; } } } /** Mark the field as touched */ _markAsTouched ( ) { this . _onTouched ( ) ; this . _changeDetectorRef . markForCheck ( ) ; } /**
     * Check the tab index as you should not be allowed to focus an empty list.
     */ protected _updateTabIndex ( ) { this . _tabIndex = this . _userTabIndex || ( this . chips . length === 0 ? - 1 : 0 ) ; } /**
     * If the amount of chips changed, we need to update the
     * key manager state and focus the next closest chip.
     */ protected _updateFocusForDestroyedChips ( ) { if ( this . _lastDestroyedChipIndex != null && this . chips . length ) { const newChipIndex = Math . min ( this . _lastDestroyedChipIndex , this . chips . length - 1 ) ; this . _keyManager . setActiveItem ( newChipIndex ) ; } this . _lastDestroyedChipIndex = null ; } param index returns private _isValidIndex ( index ) { return index >= 0 && index < this . chips . length ; } private _isInputEmpty ( element ) { if ( element && element . nodeName . toLowerCase ( ) === 'input' ) { const input = element as HTMLInputElement ; return ! input . value ; } return false ; } returns private _selectValue ( value , isUserInput = true ) { const correspondingChip = this . chips . find ( chip => { return chip . value != null && this . _compareWith ( chip . value , value ) ; } ) ; if ( correspondingChip ) { isUserInput ? correspondingChip . selectViaInteraction ( ) : correspondingChip . select ( ) ; this . _selectionModel . select ( correspondingChip ) ; } return correspondingChip ; } private _initializeSelection ( ) { Promise . resolve ( ) . then ( ( ) => { if ( this . ngControl || this . _value ) { this . _setSelectionByValue ( this . ngControl ? this . ngControl . value : this . _value , false ) ; } } ) ; } param skip private _clearSelection ( skip ? ) { this . _selectionModel . clear ( ) ; this . chips . forEach ( chip => { if ( chip !== skip ) { chip . deselect ( ) ; } } ) ; } /**
     * Sorts the model values, ensuring that they keep the same
     * order that they have in the panel.
     */ private _sortValues ( ) { if ( this . _multiple ) { this . _selectionModel . clear ( ) ; this . chips . forEach ( chip => { if ( chip . selected ) { this . _selectionModel . select ( chip ) ; } } ) ; } } /** Emits change event to set the model value. */ private _propagateChanges ( fallbackValue ? ) { let valueToEmit = null ; if ( Array . isArray ( this . selected ) ) { valueToEmit = this . selected . map ( chip => chip . value ) ; } else { valueToEmit = this . selected ? this . selected . value : fallbackValue ; } this . _value = valueToEmit ; this . change . emit ( new ChipListChange ( this , valueToEmit ) ) ; this . valueChange . emit ( valueToEmit ) ; this . _onChange ( valueToEmit ) ; this . _changeDetectorRef . markForCheck ( ) ; } private _resetChips ( ) { this . _dropSubscriptions ( ) ; this . _listenToChipsFocus ( ) ; this . _listenToChipsSelection ( ) ; this . _listenToChipsRemoved ( ) ; } private _dropSubscriptions ( ) { if ( this . _chipFocusSubscription ) { this . _chipFocusSubscription . unsubscribe ( ) ; this . _chipFocusSubscription = null ; } if ( this . _chipBlurSubscription ) { this . _chipBlurSubscription . unsubscribe ( ) ; this . _chipBlurSubscription = null ; } if ( this . _chipSelectionSubscription ) { this . _chipSelectionSubscription . unsubscribe ( ) ; this . _chipSelectionSubscription = null ; } if ( this . _chipRemoveSubscription ) { this . _chipRemoveSubscription . unsubscribe ( ) ; this . _chipRemoveSubscription = null ; } } /** Listens to user-generated selection events on each chip. */ private _listenToChipsSelection ( ) { this . _chipSelectionSubscription = this . chipSelectionChanges . subscribe ( event => { event . source . selected ? this . _selectionModel . select ( event . source ) : this . _selectionModel . deselect ( event . source ) ; if ( ! this . multiple ) { this . chips . forEach ( chip => { if ( ! this . _selectionModel . isSelected ( chip ) && chip . selected ) { chip . deselect ( ) ; } } ) ; } if ( event . isUserInput ) { this . _propagateChanges ( ) ; } } ) ; } /** Listens to user-generated selection events on each chip. */ private _listenToChipsFocus ( ) { this . _chipFocusSubscription = this . chipFocusChanges . subscribe ( event => { const chipIndex = this . chips . toArray ( ) . indexOf ( event . chip ) ; if ( this . _isValidIndex ( chipIndex ) ) { this . _keyManager . updateActiveItemIndex ( chipIndex ) ; } } ) ; this . _chipBlurSubscription = this . chipBlurChanges . subscribe ( ( ) => { this . _blur ( ) ; } ) ; } private _listenToChipsRemoved ( ) { this . _chipRemoveSubscription = this . chipRemoveChanges . subscribe ( event => { const chip = event . chip ; const chipIndex = this . chips . toArray ( ) . indexOf ( event . chip ) ; if ( this . _isValidIndex ( chipIndex ) && chip . _hasFocus ) { this . _lastDestroyedChipIndex = chipIndex ; } } ) ; } /** Checks whether an event comes from inside a chip element. */ private _originatesFromChip ( event ) { let currentElement = event . target as HTMLElement | null ; while ( currentElement && currentElement !== this . _elementRef . nativeElement ) { if ( currentElement . classList . contains ( 'Chip' ) ) { return true ; } currentElement = currentElement . parentElement ; } return false ; } /** Checks whether any of the chips is focused. */ private _hasFocusedChip ( ) { return this . chips . some ( chip => chip . _hasFocus ) ; } }