File

libs/ui/selection-list/src/lib/selection-list/selection-list.component.ts

Description

The selection list UI Component

Implements

OnInit AfterViewInit OnDestroy TsFormFieldControl

Example

<ts-selection-list
  [allowMultiple]="allowMultiple"
  [displayFormatter]="formatterFunc"
  [valueComparator]="comparatorFunc"
  debounceDelay="300"
  [formControl]="myFormControl"
  hint="Begin typing to search.."
  [isDisabled]="isDisabled"
  label="Select options:"
  name="product selections"
  options="[{}, {}, ...]"
  [showProgress]="true"
  theme="primary"
  (closed)="panelWasClosed($event)"
  (duplicateSelection)="duplicateWasSelected($event)"
  (opened)="panelWasOpened($event)"
  (optionSelected)="mySelected($event)"
  (optionDeselected)="myDeselected($event)"
  (queryChange)="myQueryChange($event)"
  (selectionChange)="mySelection($event)"
></ts-selection-list>

<example-url>https://getterminus.github.io/ui-demos-release/components/selection-list</example-url>

Metadata

changeDetection ChangeDetectionStrategy.OnPush
encapsulation ViewEncapsulation.None
exportAs tsSelectionList
host {
}
providers { provide: TsFormFieldControl, useExisting: TsSelectionListComponent, } { provide: TS_OPTION_PARENT_COMPONENT, useExisting: TsSelectionListComponent, }
selector ts-selection-list
styleUrls ./selection-list.component.scss
templateUrl ./selection-list.component.html

Index

Properties
Methods
Inputs
Outputs
Accessors

Constructor

constructor(changeDetectorRef: ChangeDetectorRef, documentService: TsDocumentService, elementRef: ElementRef, ngControl: NgControl)
Parameters :
Name Type Optional
changeDetectorRef ChangeDetectorRef No
documentService TsDocumentService No
elementRef ElementRef No
ngControl NgControl No

Inputs

allowDuplicateSelections
Default value : false

Define if should allow duplicate selections

allowMultiple
Default value : false

Define if multiple selections are allowed

allowUserInput
Default value : true

Define if the user is allowed to type to search/filter

debounceDelay
Type : number

Define a debounce delay for the query stream

displayFormatter

Define the formatter for the selected items.

hideRequiredMarker
Default value : false

Define if the required marker should be hidden

hint

Define a hint for the input

id
Type : string

Define an ID for the component

isDisabled
Default value : false

Define if the control should be disabled

isRequired
Type : boolean

Define if the control is required

label
Type : string | undefined

Define the placeholder/label

minimumCharacters
Type : number

Define a minimum character count for queries

name
Type : string | undefined

Define the name attribute value

noValidationOrHint
Default value : false

Define whether a validation or a hint needed.

reopenAfterSelection
Default value : false

Define if the panel should reopen after a selection is made

NOTE: Though it is technically 're-opening', it happens fast enough so that it doesn't appear to close at all.

showProgress
Default value : false

Define if the input should currently be showing a progress spinner

theme
Type : TsStyleThemeTypes
Default value : 'primary'

Define the component theme

validateOnChange
Default value : false

Define if validation messages should be shown immediately or on blur

value

Value of the select control

valueComparator
Type : TsSelectionListComparator
Default value : (a: unknown, b: unknown) => a === b

Define the comparator for the values of the options

Outputs

backdropClicked
Type : EventEmitter

Emit when the backdrop is clicked

closed
Type : EventEmitter

Emit when the panel is closed

duplicateSelection
Type : EventEmitter

Emit the option when a duplicate selection is made

opened
Type : EventEmitter

Emit when the panel is opened

optionDeselected
Type : EventEmitter

Emit the deselected option

optionSelected
Type : EventEmitter

Emit the selected option

queryChange
Type : EventEmitter

Emit the new query

selectionChange
Type : EventEmitter

Emit the current selections when any selection changes

valueChange
Type : EventEmitter

Event that emits whenever the raw value of the select changes. This is here primarily to facilitate the two-way binding for the value input.

Needed for TsFormFieldComponent.

Methods

Public close
close()

Close the overlay panel

Returns : void
Public deselectItem
deselectItem(option: TsChipEvent)

Deselect an item

Parameters :
Name Type Optional Description
option TsChipEvent No
  • The option to select
Returns : void
Public focusInput
focusInput()

Focus the native input element

Returns : void
Public handleInputBlur
handleInputBlur(event: KeyboardEvent | MouseEvent | FocusEvent)

Close the dropdown and reset the query when the user leaves the input

Parameters :
Name Type Optional Description
event KeyboardEvent | MouseEvent | FocusEvent No
  • The keyboard or mouse event
Returns : void
Public onContainerClick
onContainerClick()

Ensure the correct element gets focus when the primary container is clicked.

Implemented as part of TsFormFieldControl.

Returns : void
Public open
open()

Open the overlay panel

Returns : void
Public registerOnChange
registerOnChange(fn: (value: string) => void)

Save a callback function to be invoked when the select's value changes from user input. Part of the ControlValueAccessor interface required to integrate with Angular's core forms API.

Parameters :
Name Type Optional Description
fn function No
  • Callback to be triggered when the value changes
Returns : void
Public registerOnTouched
registerOnTouched(fn: () => void)

Save a callback function to be invoked when the select is blurred by the user. Part of the ControlValueAccessor interface required to integrate with Angular's core forms API.

Parameters :
Name Type Optional Description
fn function No
  • Callback to be triggered when the component has been touched
Returns : void
Public selectItem
selectItem(selection: TsSelectionListPanelSelectedEvent)

Select an item

Parameters :
Name Type Optional Description
selection TsSelectionListPanelSelectedEvent No
  • The item to select
Returns : void
Public setDisabledState
setDisabledState(isDisabled: boolean)

Disables the select. Part of the ControlValueAccessor interface required to integrate with Angular's core forms API.

Parameters :
Name Type Optional Description
isDisabled boolean No
  • If the component is disabled
Returns : void
Public trackByFn
trackByFn(index)

Function for tracking for-loops changes

Parameters :
Name Optional Description
index No
  • The item index
Returns : number

The unique ID

Public writeValue
writeValue(value: string)

Sets the select's value. Part of the ControlValueAccessor interface required to integrate with Angular's core forms API.

NOTE: Currently we are not using this, but it still must be present since this component is acting as a CVA.

Parameters :
Name Type Optional Description
value string No
  • New value to be written to the model
Returns : void

Properties

Protected _id
Type : string
Default value : this.uid
Public chipList
Type : TsChipCollectionComponent | undefined
Decorators :
@ViewChild('chipList')

Access the chip list

Public Readonly componentName
Type : string
Default value : 'TsSelectionListComponent'

Give the component an explicit name TODO: remove once select & autocomplete have been removed https://github.com/GetTerminus/terminus-ui/issues/1678

Public elementRef
Type : ElementRef
Public iconArrow
Default value : faCaretDown

Define the dropdown arrow

Public inputElement
Type : ElementRef<HTMLInputElement>
Decorators :
@ViewChild('input')

Access the actual HTMLInputElement

Public Readonly labelChanges
Type : Subject<void>
Default value : new Subject<void>()

Subject used to alert the parent TsFormFieldComponent when the label gap should be recalculated

Implemented as part of TsFormFieldControl.

Public ngControl
Type : NgControl
Decorators :
@Self()
@Optional()
Public onChange
Type : function
Default value : () => {...}

Stub in onChange

Needed for ControlValueAccessor (View -> model callback called when value changes)

Public onTouched
Default value : () => {...}

Stub in onTouched

Needed for ControlValueAccessor (View -> model callback called when select has been touched)

Public optionGroups
Type : QueryList<TsOptgroupComponent>
Decorators :
@ContentChildren(TsOptgroupComponent)

Access all of the defined optgroups

Public optionIds
Type : string
Default value : ''

The IDs of child options to be passed to the aria-owns attribute.

Public options
Type : QueryList<TsOptionComponent>
Decorators :
@ContentChildren(TsOptionComponent, {descendants: true})

Access a list of all the defined select options

Public panel
Type : TsSelectionListPanelComponent
Decorators :
@ViewChild('auto', {static: true})

Access the panel

Public panelOpen
Default value : false

Whether or not the overlay panel is open

Public progressIcon
Default value : faSpinnerThird

Define the icon for progress indication

Public querySubject
Type : BehaviorSubject<string>
Default value : new BehaviorSubject('')

Management of the query string

Public searchQuery
Type : string
Default value : ''

Store the search query

Public selectionListFormControl
Default value : new FormControl([])

Define the internal FormControl

Public selfReference
Default value : this

Since the FormFieldComponent is inside this template, we cannot use a provider to pass this component instance to the form field. Instead, we pass it manually through the template with this reference.

Public showArrow
Type : boolean

Determine if the dropdown arrow icon should be visible

Public Readonly stateChanges
Type : Subject<void>
Default value : new Subject<void>()
Public trigger
Type : TsSelectionListTriggerDirective
Decorators :
@ViewChild(TsSelectionListTriggerDirective)

Access the trigger

Public Readonly uid
Default value : `ts-selection-list-${nextUniqueId++}`

Define the default component ID

Accessors

empty
getempty()

Determines whether the select or the input has a value

Returns : boolean
focused
getfocused()

Determines whether the input has focus

Returns : boolean
shouldLabelFloat
getshouldLabelFloat()

Determine if the label should float

Returns : boolean
staticTriggerDisplay
getstaticTriggerDisplay()

Determine the trigger display when no user input is allowed

Returns : string
debounceDelay
getdebounceDelay()
setdebounceDelay(value: number)

Define a debounce delay for the query stream

Parameters :
Name Type Optional
value number No
Returns : void
hint
gethint()
sethint(value)

Define a hint for the input

Parameters :
Name Optional
value No
Returns : void
id
getid()
setid(value: string)

Define an ID for the component

Parameters :
Name Type Optional
value string No
Returns : void
isRequired
getisRequired()
setisRequired(value: boolean)

Define if the control is required

Parameters :
Name Type Optional
value boolean No
Returns : void
minimumCharacters
getminimumCharacters()
setminimumCharacters(value: number)

Define a minimum character count for queries

Parameters :
Name Type Optional
value number No
Returns : void
value
getvalue()
setvalue(newValue)

Value of the select control

Parameters :
Name Optional
newValue No
Returns : void
displayFormatter
getdisplayFormatter()
setdisplayFormatter(value)

Define the formatter for the selected items.

Parameters :
Name Optional
value No
Returns : void
import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
import {
  AfterViewInit,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ContentChildren,
  ElementRef,
  EventEmitter,
  Input,
  OnDestroy,
  OnInit,
  Optional,
  Output,
  QueryList,
  Self,
  ViewChild,
  ViewEncapsulation,
} from '@angular/core';
import {
  FormControl,
  NgControl,
} from '@angular/forms';
import { faSpinnerThird } from '@fortawesome/pro-solid-svg-icons';
import { faCaretDown } from '@fortawesome/pro-solid-svg-icons/faCaretDown';
import {
  BehaviorSubject,
  of,
  Subject,
} from 'rxjs';
import {
  debounceTime,
  distinctUntilChanged,
  switchMap,
} from 'rxjs/operators';

import {
  coerceNumberProperty,
  hasRequiredControl,
  isArray,
  TsDocumentService,
  untilComponentDestroyed,
} from '@terminus/fe-utilities';
import {
  TsChipCollectionComponent,
  TsChipEvent,
} from '@terminus/ui-chip';
import { TsFormFieldControl } from '@terminus/ui-form-field';
import {
  getOptionScrollPosition,
  TS_OPTION_PARENT_COMPONENT,
  TsOptgroupComponent,
  TsOptionComponent,
} from '@terminus/ui-option';
import {
  TsStyleThemeTypes,
  TsUILibraryError,
} from '@terminus/ui-utilities';

import {
  TsSelectionListPanelComponent,
  TsSelectionListPanelSelectedEvent,
} from '../selection-list-panel/selection-list-panel.component';
import {
  SELECTION_LIST_PANEL_MAX_HEIGHT,
  TsSelectionListTriggerDirective,
} from '../trigger/selection-list-trigger.directive';


// Unique ID for each instance
let nextUniqueId = 0;
const DEFAULT_MINIMUM_CHARACTER_COUNT = 2;
const DEFAULT_DEBOUNCE_DELAY = 200;

/**
 * The event object that is emitted when the select value has changed
 */
export class TsSelectionListChange<T = unknown> {
  constructor(
    public source: TsSelectionListComponent,
    public value: T,
  ) { }
}

export type TsSelectionListFormatter = (v: unknown) => string;
export type TsSelectionListComparator = (a: unknown, b: unknown) => boolean;


/**
 * The selection list UI Component
 *
 * @example
 * <ts-selection-list
 *              [allowMultiple]="allowMultiple"
 *              [displayFormatter]="formatterFunc"
 *              [valueComparator]="comparatorFunc"
 *              debounceDelay="300"
 *              [formControl]="myFormControl"
 *              hint="Begin typing to search.."
 *              [isDisabled]="isDisabled"
 *              label="Select options:"
 *              name="product selections"
 *              options="[{}, {}, ...]"
 *              [showProgress]="true"
 *              theme="primary"
 *              (closed)="panelWasClosed($event)"
 *              (duplicateSelection)="duplicateWasSelected($event)"
 *              (opened)="panelWasOpened($event)"
 *              (optionSelected)="mySelected($event)"
 *              (optionDeselected)="myDeselected($event)"
 *              (queryChange)="myQueryChange($event)"
 *              (selectionChange)="mySelection($event)"
 * ></ts-selection-list>
 *
 * <example-url>https://getterminus.github.io/ui-demos-release/components/selection-list</example-url>
 */
@Component({
  selector: 'ts-selection-list',
  templateUrl: './selection-list.component.html',
  styleUrls: ['./selection-list.component.scss'],
  host: {
    'class': 'ts-selection-list',
    '[class.ts-selection-list--required]': 'isRequired',
    '[class.ts-selection-list--disabled]': 'isDisabled',
    '[class.ts-selection-list--single]': '!allowMultiple',
    '[class.ts-selection-list--multiple]': 'allowMultiple',
    '[attr.aria-owns]': 'panelOpen ? optionIds : null',
    '[attr.aria-required]': 'isRequired.toString()',
    '[attr.aria-multiselectable]': 'allowMultiple',
  },
  providers: [
    {
      provide: TsFormFieldControl,
      useExisting: TsSelectionListComponent,
    },
    {
      provide: TS_OPTION_PARENT_COMPONENT,
      useExisting: TsSelectionListComponent,
    },
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  exportAs: 'tsSelectionList',
})
export class TsSelectionListComponent implements
  OnInit,
  AfterViewInit,
  OnDestroy,
  TsFormFieldControl<unknown> {

  /**
   * Determine if the dropdown arrow icon should be visible
   */
  public showArrow: boolean;

  /**
   * Give the component an explicit name
   * TODO: remove once select & autocomplete have been removed https://github.com/GetTerminus/terminus-ui/issues/1678
   */
  public readonly componentName = 'TsSelectionListComponent';

  /**
   * Define the dropdown arrow
   */
  public iconArrow = faCaretDown;

  /**
   * Define the internal FormControl
   */
  public selectionListFormControl = new FormControl([]);

  /**
   * Store a reference to the document object
   */
  private document: Document;

  /**
   * Subject used to alert the parent {@link TsFormFieldComponent} when the label gap should be recalculated
   *
   * Implemented as part of TsFormFieldControl.
   */
  public readonly labelChanges: Subject<void> = new Subject<void>();

  /**
   * Manages keyboard events for options in the panel.
   */
  private keyManager!: ActiveDescendantKeyManager<TsOptionComponent>;

  /**
   * The IDs of child options to be passed to the aria-owns attribute.
   */
  public optionIds = '';

  /**
   * Whether or not the overlay panel is open
   */
  public panelOpen = false;

  /**
   * Define the icon for progress indication
   */
  public progressIcon = faSpinnerThird;

  /**
   * Since the FormFieldComponent is inside this template, we cannot use a provider to pass this component instance to the form field.
   * Instead, we pass it manually through the template with this reference.
   */
  public selfReference = this;

  /*
   * Implemented as part of TsFormFieldControl.
   */
  public readonly stateChanges: Subject<void> = new Subject<void>();

  /**
   * Define the default component ID
   */
  public readonly uid = `ts-selection-list-${nextUniqueId++}`;

  /**
   * Management of the query string
   */
  public querySubject: BehaviorSubject<string> = new BehaviorSubject('');

  /**
   * Store the search query
   */
  public searchQuery = '';

  /**
   * Access the panel
   */
  @ViewChild('auto', { static: true })
  public panel!: TsSelectionListPanelComponent;

  /**
   * Access the trigger
   */
  @ViewChild(TsSelectionListTriggerDirective)
  public trigger!: TsSelectionListTriggerDirective;

  /**
   * Access the chip list
   */
  @ViewChild('chipList')
  public chipList: TsChipCollectionComponent | undefined;

  /**
   * Access the actual HTMLInputElement
   */
  @ViewChild('input')
  public inputElement!: ElementRef<HTMLInputElement>;

  /**
   * Access a list of all the defined select options
   */
  @ContentChildren(TsOptionComponent, { descendants: true })
  public options!: QueryList<TsOptionComponent>;

  /**
   * Access all of the defined optgroups
   */
  @ContentChildren(TsOptgroupComponent)
  public optionGroups!: QueryList<TsOptgroupComponent>;

  /**
   * Determines whether the select or the input has a value
   */
  public get empty(): boolean {
    // Since we are using ViewChild, we need to verify the existence of the element
    const input = this.inputElement && this.inputElement.nativeElement;

    return input
      ? !this.selectionListFormControl.value.length && !this.inputElement.nativeElement.value.length
      : !this.selectionListFormControl.value.length;
  }

  /**
   * Determines whether the input has focus
   */
  public get focused(): boolean {
    if (this.isDisabled) {
      return false;
    }
    const el = this.inputElement && this.inputElement.nativeElement;
    return (this.document.activeElement === el) || this.panelOpen;
  }

  /**
   * Determine if the label should float
   */
  public get shouldLabelFloat(): boolean {
    return this.focused || !this.empty;
  }

  /**
   * Determine the trigger display when no user input is allowed
   */
  public get staticTriggerDisplay(): string {
    const selection = this.ngControl.value[0];
    const display = selection ? this.displayFormatter(selection) : '';
    if (this.allowMultiple || !display) {
      return '';
    }
    return display;
  }

  /**
   * Define if multiple selections are allowed
   */
  @Input()
  public allowMultiple = false;

  /**
   * Define if should allow duplicate selections
   */
  @Input()
  public allowDuplicateSelections = false;

  /**
   * Define if the user is allowed to type to search/filter
   */
  @Input()
  public allowUserInput = true;

  /**
   * Define a debounce delay for the query stream
   *
   * @param value
   */
  @Input()
  public set debounceDelay(value: number) {
    this._debounceDelay = coerceNumberProperty(value, DEFAULT_DEBOUNCE_DELAY);
  }
  public get debounceDelay(): number {
    return this._debounceDelay;
  }
  private _debounceDelay = DEFAULT_DEBOUNCE_DELAY;

  /**
   * Define if the required marker should be hidden
   */
  @Input()
  public hideRequiredMarker = false;

  /**
   * Define a hint for the input
   *
   * @param value
   */
  @Input()
  public set hint(value: string | undefined) {
    this._hint = value;
  }
  public get hint(): string | undefined {
    return this._hint;
  }
  private _hint: string | undefined;

  /**
   * Define an ID for the component
   *
   * @param value
   */
  @Input()
  public set id(value: string) {
    this._id = value || this.uid;
  }
  public get id(): string {
    return this._id;
  }
  protected _id: string = this.uid;

  /**
   * Define if the control should be disabled
   */
  @Input()
  public isDisabled = false;

  /**
   * Define if the control is required
   *
   * @param value
   */
  @Input()
  public set isRequired(value: boolean) {
    this._isRequired = value;
  }
  public get isRequired(): boolean {
    const ctrl = this.ngControl && this.ngControl.control;
    const requiredFormControl = !!ctrl && hasRequiredControl(ctrl);
    return this._isRequired || requiredFormControl;
  }
  private _isRequired = false;

  /**
   * Define a minimum character count for queries
   *
   * @param value
   */
  @Input()
  public set minimumCharacters(value: number) {
    this._minimumCharacters = coerceNumberProperty(value, DEFAULT_MINIMUM_CHARACTER_COUNT);
  }
  public get minimumCharacters(): number {
    return this._minimumCharacters;
  }
  private _minimumCharacters = DEFAULT_MINIMUM_CHARACTER_COUNT;

  /**
   * Define whether a validation or a hint needed.
   */
  @Input()
  public noValidationOrHint = false;

  /**
   * Define if the panel should reopen after a selection is made
   *
   * NOTE: Though it is technically 're-opening', it happens fast enough so that it doesn't appear to close at all.
   */
  @Input()
  public reopenAfterSelection = false;

  /**
   * Define if the input should currently be showing a progress spinner
   */
  @Input()
  public showProgress = false;

  /**
   * Define the component theme
   */
  @Input()
  public theme: TsStyleThemeTypes = 'primary';

  /**
   * Define if validation messages should be shown immediately or on blur
   */
  @Input()
  public validateOnChange = false;

  /**
   * Value of the select control
   *
   * @param newValue
   */
  @Input()
  public set value(newValue: string | undefined) {
    // istanbul ignore else
    if (newValue !== this._value) {
      this._value = newValue;
    }
  }
  public get value(): string | undefined {
    return this._value;
  }
  private _value: string | undefined;

  /**
   * Define the placeholder/label
   */
  @Input()
  public label: string | undefined;

  /**
   * Define the name attribute value
   */
  @Input()
  public name: string | undefined;

  /**
   * Define the formatter for the selected items.
   *
   * @param value
   */
  @Input()
  public set displayFormatter(value: TsSelectionListFormatter) {
    this._displayFormatter = value ? value : v => v as string;
  }
  public get displayFormatter(): TsSelectionListFormatter {
    return this._displayFormatter;
  }
  private _displayFormatter: TsSelectionListFormatter = v => v as string;

  /**
   * Define the comparator for the values of the options
   *
   * @param a
   * @param b
   */
  @Input()
  public valueComparator: TsSelectionListComparator = (a: unknown, b: unknown) => a === b;

  /**
   * Emit when the backdrop is clicked
   */
  @Output()
  public readonly backdropClicked = new EventEmitter<void>();

  /**
   * Emit when the panel is closed
   */
  @Output()
  public readonly closed = new EventEmitter<void>();

  /**
   * Emit the option when a duplicate selection is made
   */
  @Output()
  public readonly duplicateSelection = new EventEmitter<TsSelectionListChange>();

  /**
   * Emit when the panel is opened
   */
  @Output()
  public readonly opened = new EventEmitter<void>();

  /**
   * Emit the selected option
   */
  @Output()
  public readonly optionSelected = new EventEmitter<TsSelectionListChange>();

  /**
   * Emit the deselected option
   */
  @Output()
  public readonly optionDeselected = new EventEmitter<TsSelectionListChange>();

  /**
   * Emit the new query
   */
  @Output()
  public readonly queryChange = new EventEmitter<string>();

  /**
   * Emit the current selections when any selection changes
   */
  @Output()
  public readonly selectionChange = new EventEmitter<TsSelectionListChange>();

  /**
   * Event that emits whenever the raw value of the select changes. This is here primarily to facilitate the two-way binding for the `value`
   * input.
   *
   * Needed for {@link TsFormFieldComponent}.
   */
  @Output()
  public readonly valueChange = new EventEmitter<string>();


  constructor(
    private changeDetectorRef: ChangeDetectorRef,
    private documentService: TsDocumentService,
    public elementRef: ElementRef,
    @Self() @Optional() public ngControl: NgControl,
  ) {
    this.document = this.documentService.document;

    // This is the assigned FormControl or NgModel
    // istanbul ignore else
    if (this.ngControl) {
      // Note: we provide the value accessor through here, instead of the `providers` to avoid running into a circular import.
      this.ngControl.valueAccessor = this;
    }
  }


  /**
   * Seed initial values and set up watchers
   */
  public ngOnInit(): void {
    // Seed the control value
    // NOTE: When the consumer is using an ngModel, the value is not set on the first cycle.
    // We need to push it to the next event loop. When using a FormControl, the value is there on the first run.
    // eslint-disable-next-line dot-notation
    if (this.ngControl && this.ngControl['form']) {
      // istanbul ignore else
      if (this.ngControl.value && !isArray(this.ngControl.value)) {
        throw new TsUILibraryError(`TsSelectionListComponent: Form control values must be an array of values`);
      } else if (this.ngControl.value && this.ngControl.value.length) {
        this.selectionListFormControl.setValue(this.ngControl.value);
        if (!this.allowMultiple) {
          this.searchQuery = this.displayFormatter(this.ngControl.value[0]);
        }
      }

      // Support dynamic form control updates
      // istanbul ignore else
      if (this.ngControl.valueChanges) {
        this.ngControl.valueChanges
          .pipe(untilComponentDestroyed(this))
          .subscribe(newValue => {
            // istanbul ignore else
            if (newValue) {
              if (newValue[0]) {
                this.searchQuery = this.displayFormatter(newValue[0]);
              }

              if (this.allowMultiple) {
                this.selectionListFormControl.setValue(newValue, { emitEvent: false });
              } else {
                this.selectionListFormControl.setValue([newValue[0]], { emitEvent: false });
              }
              this.changeDetectorRef.detectChanges();
            }
          });
      }
    } else {
      // HACK: Wait until the next detection cycle to set the value from an ngModel.
      // NOTE: Using CDR.detectChanges causes errors in children that expect TsOptionComponent to exist.
      Promise.resolve().then(() => {
        // istanbul ignore else
        if (this.ngControl && this.ngControl.value) {
          if (!isArray(this.ngControl.value)) {
            throw new TsUILibraryError(`TsSelectionListComponent: ngModel must be an array of values`);
          }
          this.selectionListFormControl.setValue(this.ngControl.value);
          // istanbul ignore else
          if (!this.allowMultiple) {
            this.searchQuery = this.displayFormatter(this.ngControl.value[0]);
          }
        }
      });
    }

    // Propagate changes from form control
    this.selectionListFormControl.valueChanges.pipe(
      untilComponentDestroyed(this),
    ).subscribe(() => {
      this.propagateChanges();
    });
  }

  /**
   * Subscribe to panel events and query subject changes
   */
  public ngAfterViewInit(): void {
    // Initialize arrow icon based on options length
    this.showArrow = !!this.options.length;
    // Subscribe to options change to determine arrow icon visibility
    this.options.changes.pipe(untilComponentDestroyed(this)).subscribe(v => {
      this.showArrow = !!v.length;
      this.changeDetectorRef.detectChanges();
    });
    // Seed any initial value into the query subject
    this.querySubject.next(this.inputElement.nativeElement.value);

    // Wire up listeners for panel events
    this.trigger.selectionListPanel.opened.pipe(untilComponentDestroyed(this)).subscribe(() => {
      this.opened.emit();
    });
    // Wire up listeners for panel opened event
    this.opened.pipe(untilComponentDestroyed(this)).subscribe(() => {
      // NOTE: setTimeout is added to resolve a timing issue. Promise.resolve().then does not work in this case.
      // Without setTimeout when opened event emitted, panel is still undefined.
      setTimeout(() => {
        this.scrollActiveOptionIntoView();
      });
    });
    this.trigger.selectionListPanel.closed.pipe(untilComponentDestroyed(this)).subscribe(() => {
      this.closed.emit();
    });
    this.trigger.backdropClicked.pipe(untilComponentDestroyed(this)).subscribe(() => {
      this.backdropClicked.emit();
    });

    // Take a stream of query changes
    this.querySubject.pipe(
      untilComponentDestroyed(this),
      debounceTime(this.debounceDelay),
      // If the query is shorter than allowed, convert to an empty string
      // eslint-disable-next-line deprecation/deprecation
      switchMap(query => ((query.length >= this.minimumCharacters) ? of(query) : of(''))),
      distinctUntilChanged(),
    ).subscribe((query: string) => {
      const inputValue = this.inputElement.nativeElement.value;

      // NOTE: If the input value is valid but the query is too short, an option was likely just selected and a new query has started
      if (query.length < this.minimumCharacters && inputValue.length >= this.minimumCharacters) {
        query = inputValue;
      }
      this.queryChange.emit(query);

      // istanbul ignore else
      if (!this.panelOpen) {
        this.open();
      }
    });
  }

  /**
   * Needed for untilComponentDestroyed
   */
  public ngOnDestroy(): void {}

  /**
   * Stub in onChange
   *
   * Needed for ControlValueAccessor (View -> model callback called when value changes)
   */
  // istanbul ignore next
  public onChange: (value: string) => void = () => { };

  /**
   * Stub in onTouched
   *
   * Needed for ControlValueAccessor (View -> model callback called when select has been touched)
   */
  // istanbul ignore next
  public onTouched = () => { };

  /**
   * Close the overlay panel
   */
  public close(): void {
    if (this.trigger.panelOpen) {
      this.panelOpen = false;
      this.changeDetectorRef.markForCheck();
      this.onTouched();

      if (!this.allowMultiple) {
        const inputValue = this.inputElement.nativeElement.value;
        const controlValue = this.ngControl.value;
        // If the input value doesn't match the selection, then the user must have edited the input value
        // istanbul ignore else
        if (controlValue && inputValue !== ((controlValue && controlValue.length > 0) ? this.displayFormatter(controlValue[0]) : '')) {
          this.ngControl.value.length = 0;
        }
      }

      this.updateValueAndValidity();
    }
  }

  /**
   * Focus the native input element
   */
  public focusInput(): void {
    this.inputElement.nativeElement.focus();
  }

  /**
   * Open the overlay panel
   */
  public open(): void {
    if (this.isDisabled || !this.options.length || this.panelOpen) {
      return;
    }
    this.opened.emit();
  }

  /**
   * Sets the select's value. Part of the ControlValueAccessor interface required to integrate with Angular's core forms API.
   *
   * NOTE: Currently we are not using this, but it still must be present since this component is acting as a CVA.
   *
   * @param value - New value to be written to the model
   */
  public writeValue(value: string): void { }

  /**
   * Save a callback function to be invoked when the select's value changes from user input.
   * Part of the ControlValueAccessor interface required to integrate with Angular's core forms API.
   *
   * @param fn - Callback to be triggered when the value changes
   */
  public registerOnChange(fn: (value: string) => void): void {
    this.onChange = fn;
  }

  /**
   * Save a callback function to be invoked when the select is blurred by the user.
   * Part of the ControlValueAccessor interface required to integrate with Angular's core forms API.
   *
   * @param fn - Callback to be triggered when the component has been touched
   */
  public registerOnTouched(fn: () => {}): void {
    this.onTouched = fn;
  }

  /**
   * Disables the select.
   * Part of the ControlValueAccessor interface required to integrate with Angular's core forms API.
   *
   * @param isDisabled - If the component is disabled
   */
  public setDisabledState(isDisabled: boolean): void {
    this.isDisabled = isDisabled;
    this.changeDetectorRef.markForCheck();
    this.stateChanges.next();
  }

  /**
   * Ensure the correct element gets focus when the primary container is clicked.
   *
   * Implemented as part of TsFormFieldControl.
   */
  public onContainerClick(): void {
    this.focusInput();
  }

  /**
   * Close the dropdown and reset the query when the user leaves the input
   *
   * @param event - The keyboard or mouse event
   */
  public handleInputBlur(event: KeyboardEvent | MouseEvent | FocusEvent): void {
    // NOTE(B$): cannot use dot syntax here since 'relatedTarget' doesn't exist on a KeyboardEvent
    // eslint-disable-next-line dot-notation
    const hasRelatedTarget = !!(event && event['relatedTarget']);
    // eslint-disable-next-line dot-notation
    const hasNodeName = !!(hasRelatedTarget && event['relatedTarget'].nodeName);

    if (hasRelatedTarget && hasNodeName) {
      // If the blur event comes from the user clicking an option, `event.relatedTarget.nodeName`
      // will be `TS-OPTION`.
      // istanbul ignore else
      // NOTE: TypeScript warns `Property 'nodeName' does not exist on type 'EventTarget'.`
      // eslint-disable-next-line dot-notation
      if (event['relatedTarget'].nodeName !== 'TS-OPTION') {
        if (this.allowMultiple) {
          this.resetAutocompleteQuery();
        }
        this.close();
        this.trigger.closePanel(true);
      }
    } else {
      // NOTE: The lonely if is the only way to correctly ignore 'else' coverage
      // istanbul ignore else
      // eslint-disable-next-line no-lonely-if
      if (this.trigger.panelOpen) {
        this.close();
        this.trigger.closePanel(true);
      }
    }

    // Mark this control as 'touched' to trigger any validations needed on blur
    this.onTouched();
    this.updateValueAndValidity();
  }

  /**
   * Select an item
   *
   * @param selection - The item to select
   */
  public selectItem(selection: TsSelectionListPanelSelectedEvent): void {
    const ctrlValue = this.selectionListFormControl.value || [];
    const isDuplicate = ctrlValue.findIndex(o => this.valueComparator(o, selection.option.value)) >= 0;

    // istanbul ignore else
    if (isDuplicate) {
      this.duplicateSelection.emit(new TsSelectionListChange(this, selection.option.value));
    }

    // Stop the flow if the selection already exists in the array and duplicates aren't allowed
    if (!this.allowDuplicateSelections && isDuplicate) {
      return;
    }

    if (this.allowMultiple) {
      // If supporting multiple selections, reset the input text value as long as the panel should NOT reopen
      // istanbul ignore else
      if (!this.reopenAfterSelection) {
        this.close();
        this.resetAutocompleteQuery();
      }

      // Update the form control
      const options = ctrlValue.concat(selection.option.value);
      this.selectionListFormControl.setValue(options);
    } else {
      // Update the form control
      this.selectionListFormControl.setValue([selection.option.value]);

      // In single selection mode, set the query input to the selection so the user can see what was selected
      this.inputElement.nativeElement.value = selection.option.viewValue;
    }

    // Update the panel position in case the addition of a chip causes the select height to change
    // istanbul ignore else
    if (this.trigger.overlayRef) {
      this.trigger.overlayRef.updatePosition();
      this.changeDetectorRef.detectChanges();
    }

    // Notify consumers about changes
    this.optionSelected.emit(new TsSelectionListChange(this, selection.option.value));
  }

  /**
   * Deselect an item
   *
   * @param option - The option to select
   */
  public deselectItem(option: TsChipEvent): void {
    // Find the key of the selection in the selectedOptions array
    const options = (this.selectionListFormControl.value || [])
      .filter(opt => !this.valueComparator(opt, option.chip.value));

    // Update the form control
    this.selectionListFormControl.setValue(options);

    // If the only chip was removed, re-focus the input
    // istanbul ignore else
    if (options.length === 0) {
      this.focusInput();
    }

    // HACK: For some reason, triggering change detection works in the selection method above, but not here. Same issue seems present in
    // TsOptionComponent where `setActiveStyles` works by calling the CDR but `setInactiveStyles` required a timeout.
    Promise.resolve().then(() => {
      // Update the panel position in case the removal of a chip causes the select height to change
      if (this.trigger.overlayRef) {
        this.trigger.overlayRef.updatePosition();
      }
    });

    // Notify consumers about changes
    this.optionDeselected.emit(new TsSelectionListChange(this, option));
  }

  /**
   * Function for tracking for-loops changes
   *
   * @param index - The item index
   * @returns The unique ID
   */
  public trackByFn(index): number {
    return index;
  }

  /**
   * Set up a key manager to listen to keyboard events on the overlay panel
   */
  private initKeyManager(): void {
    // We need to initialize with wrapping turned on
    this.keyManager = new ActiveDescendantKeyManager<TsOptionComponent>(this.options)
      .withTypeAhead()
      .withVerticalOrientation()
      .withHorizontalOrientation('ltr')
      .withWrap();

  }

  /**
   * Emit a change event to set the model value
   */
  private propagateChanges(): void {
    const valueToEmit = this.selectionListFormControl.value;
    this.value = valueToEmit;
    this.valueChange.emit(valueToEmit);
    this.onChange(valueToEmit);
    this.selectionChange.emit(new TsSelectionListChange(this, valueToEmit));
    this.changeDetectorRef.markForCheck();
  }

  /**
   * Call FormControl updateValueAndValidity function to ensure value and valid status get updated.
   */
  private updateValueAndValidity(): void {
    // istanbul ignore else
    if (this.ngControl && this.ngControl.control) {
      this.ngControl.control.updateValueAndValidity();
    }
  }

  /**
   * Reset input
   */
  private resetAutocompleteQuery(): void {
    // istanbul ignore else
    if (!this.keyManager) {
      this.initKeyManager();
    }
    // Deselect the option from the key manager
    this.keyManager.updateActiveItem(-1);
    this.inputElement.nativeElement.value = '';
  }

  /**
   * Scroll the active option into view
   */
  private scrollActiveOptionIntoView(): void {
    const ctrlValue = this.selectionListFormControl.value;
    if (ctrlValue.length === 0) {
      return;
    }
    const allOptions = this.trigger.selectionListPanel.options.toArray().map(o => o.value);
    const index = allOptions.findIndex(o => this.valueComparator(o, ctrlValue[ctrlValue.length - 1]));
    this.trigger.selectionListPanel.scrollTop = getOptionScrollPosition(
      index,
      this.trigger.itemHeight,
      this.trigger.selectionListPanel.scrollTop,
      SELECTION_LIST_PANEL_MAX_HEIGHT,
    );
    this.trigger.selectionListPanel.keyManager.setActiveItem(index);
  }
}

result-matching ""

    No results matching ""