File

libs/ui/select/src/lib/select/select.component.ts

Description

A component to create a select menu

Implements

OnInit AfterContentInit OnChanges OnDestroy TsFormFieldControl

Example

<ts-select
[allowMultiple]="true"
[compareWith]="myCompareFn"
delimiter=","
[hideRequiredMarker]="true"
hint="My hint!"
id="my-id"
[isDisabled]="true"
[isFilterable]="true"
[isRequired]="true"
label="My label!"
placeholder="My placeholder!"
[showProgress]="true"
[showRefineSearchMessage]="true"
[showRefresh]="true"
[sortComparator]="myComparator"
tabIndex="-1"
theme="primary"
[totalHiddenResults]="1278"
[validateOnChange]="true"
value="My value!"
(closed)="panelWasClosed($event)"
(duplicateSelection)="duplicateWasSelected($event)"
(opened)="panelWasOpened($event)"
(optionDeselected)="optionWasDeselected($event)"
(optionSelected)="optionWasSelected($event)"
(optionsRefreshRequested)="refreshWasSelected()"
(queryChange)="searchQueryChanged($event)"
(selectionChange)="aSelectionWasChanged($event)"
(valueChange)="theValueWasChanged($event)"
></ts-select>

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

Metadata

changeDetection ChangeDetectionStrategy.OnPush
encapsulation ViewEncapsulation.None
exportAs tsSelect
host {
}
providers { provide: TsFormFieldControl, useExisting: TsSelectComponent, } { provide: TS_OPTION_PARENT_COMPONENT, useExisting: TsSelectComponent, } { provide: MAT_CHECKBOX_DEFAULT_OPTIONS, useValue: { clickAction: }, }
selector ts-select
styleUrls ./select.component.scss
templateUrl ./select.component.html

Index

Properties
Methods
Inputs
Outputs
Accessors

Constructor

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

Inputs

allowMultiple
Default value : false

Define if multiple selections are allowed

compareWith

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.

Learn more about compareWith in the Angular docs: https://angular.io/api/forms/SelectControlValueAccessor#customizing-option-selection

delimiter
Type : string

Define the delimiter used in the list of selected options

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

isFilterable
Default value : false

Define if the select is filterable

isRequired
Type : boolean

Define if the control is required

label

Define the label text

noValidationOrHint
Default value : false

Define whether a validation or a hint needed.

placeholder

Placeholder to be shown if no value has been selected

showProgress
Default value : false

Define if the component should currently be showing a progress spinner

showRefineSearchMessage
Default value : false

Define if the component should expose a message telling the user to refine their search

showRefresh
Default value : false

Define if the select should show an option to trigger a refresh (by emitting an event)

sortComparator
Type : TsSelectSortComparatorFunction | undefined

Function used to sort the values in a select in multiple mode

Follows the same logic as Array.prototype.sort.

See TsSelectSortComparatorFunction

tabIndex

Define the tab index for the component

theme
Type : TsStyleThemeTypes
Default value : 'primary'

Define the component theme

totalHiddenResults
Type : undefined | number

Define the total number of records

validateOnChange
Default value : false

Define if validation messages should be shown immediately or on blur

value

Value of the select control

Outputs

closed
Type : EventEmitter<void>

Event for when the panel is closed

duplicateSelection
Type : EventEmitter<string>

Event for when a duplicate selection is made

opened
Type : EventEmitter<void>

Event for when the panel is opened

optionDeselected
Type : EventEmitter<TsSelectChange>

Event for when an option is removed

optionSelected
Type : EventEmitter<TsSelectChange>

Event for when an option is selected

optionsRefreshRequested
Type : EventEmitter<void>

Event for when the user requests a refresh of the available options

queryChange
Type : EventEmitter<string>

Event for when the query has changed, used by filterable select

selectionChange
Type : EventEmitter<TsSelectChange>

Event for when the selections change

valueChange
Type : EventEmitter<string | []>

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 focus
focus()

Focus the correct element

When in standard select mode we should focus the select itself.

Returns : void
Public handleKeydown
handleKeydown(event: KeyboardEvent)

Handles all keydown events on the select

Parameters :
Name Type Optional Description
event KeyboardEvent No
  • The KeyboardEvent
Returns : void
Public onAttached
onAttached()

Callback that is invoked when the overlay panel has been attached

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: unknown) => 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 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 toggle
toggle()

Toggles the overlay panel open or closed.

Returns : void
Public toggleAllOptions
toggleAllOptions()

Toggle the selection all options

If any are selected, it will unselect all & vice-versa.

Returns : void
Public writeValue
writeValue(value: unknown)

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 unknown No
  • New value to be written to the model
Returns : void

Properties

Protected _id
Type : string
Default value : this.uid
Public Readonly componentName
Type : string
Default value : 'TsSelectComponent'

Give the component an explicit name

Public customTrigger
Type : TsSelectTriggerComponent | undefined
Decorators :
@ContentChild(TsSelectTriggerComponent)

Access the user-supplied override of the trigger element

Public flexGap
Type : string
Default value : TS_SPACING.small[0]

Define the flex layout gap

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

Access to the actual HTML element

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

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

Implemented as part of TsFormFieldControl.

Public labelElement
Type : ElementRef
Decorators :
@ViewChild('labelElement')

Access the label element

Public ngControl
Type : NgControl
Decorators :
@Self()
@Optional()
Public offsetY
Type : number
Default value : 0

The y-offset of the overlay panel in relation to the trigger's top start corner. This must be adjusted to align the selected option text over the trigger text. when the panel opens. This will be changed based on the y-position of the selected option.

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 groups of options

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 Readonly optionSelectionChanges
Type : Observable<TsOptionSelectionChange>
Default value : defer(() => merge<TsOptionSelectionChange>(...this.options.map(option => option.selectionChange)))

Combined stream of all of the child options' change events

Public overlayDir
Type : CdkConnectedOverlay
Decorators :
@ViewChild(CdkConnectedOverlay)

Access the overlay pane containing the options

Public panel
Type : ElementRef
Decorators :
@ViewChild('panel')

Access the panel containing the select options

Public panelDoneAnimatingStream
Default value : new Subject<string>()

Emits when the panel element is finished transforming in.

Public panelOpen
Default value : false

Whether or not the overlay panel is open

Public positions
Type : []
Default value : [ { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'top', }, { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'bottom', }, ]

This position config ensures that the top "start" corner of the overlay is aligned with with the top "start" of the origin by default (overlapping the trigger completely).

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 selectionModel
Type : SelectionModel<TsOptionComponent>

Manage selections

Public selfReference
Default value : this
Public Readonly stateChanges
Type : Subject<void>
Default value : new Subject<void>()
Public transformOrigin
Type : string
Default value : 'top'

The value of the select panel's transform-origin property

Public trigger
Type : ElementRef
Decorators :
@ViewChild('trigger')

Access the trigger that opens the select

Public triggerFontSize
Type : number
Default value : 0

The cached font-size of the trigger element

Public triggerRect
Type : ClientRect | undefined

The last measured value for the trigger's client bounding rect

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

Define the default component ID

Public viewportMarginSpacing
Default value : DEFAULT_VIEWPORT_MARGIN

Margin between select panel edge and viewport edge

Accessors

allOptionsSelected
getallOptionsSelected()

Whether all options are selected

Returns : boolean
empty
getempty()

Whether the select has a value

Returns : boolean
focused
getfocused()

Whether the input has focus

Returns : boolean
someOptionsSelected
getsomeOptionsSelected()

Whether at least 1 option is selected, but not all options

Returns : boolean
shouldLabelFloat
getshouldLabelFloat()

Determine if the label should float

Returns : boolean
selectTriggerValue
getselectTriggerValue()

The value displayed in the select trigger

Returns : string
selected
getselected()

The currently selected option or options

compareWith
getcompareWith()
setcompareWith(fn)

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.

Learn more about compareWith in the Angular docs: https://angular.io/api/forms/SelectControlValueAccessor#customizing-option-selection

Parameters :
Name Optional
fn No
Returns : void
delimiter
getdelimiter()
setdelimiter(value: string)

Define the delimiter used in the list of selected options

Parameters :
Name Type Optional
value string 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
label
getlabel()
setlabel(value)

Define the label text

Parameters :
Name Optional
value No
Returns : void
placeholder
getplaceholder()
setplaceholder(value)

Placeholder to be shown if no value has been selected

Parameters :
Name Optional
value No
Returns : void
tabIndex
gettabIndex()
settabIndex(value)

Define the tab index for the component

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

Value of the select control

Parameters :
Name Optional
newValue No
Returns : void
import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
import { SelectionModel } from '@angular/cdk/collections';
import {
  CdkConnectedOverlay,
  ViewportRuler,
} from '@angular/cdk/overlay';
import {
  AfterContentInit,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ContentChild,
  ContentChildren,
  ElementRef,
  EventEmitter,
  Input,
  isDevMode,
  NgZone,
  OnChanges,
  OnDestroy,
  OnInit,
  Optional,
  Output,
  QueryList,
  Self,
  SimpleChanges,
  ViewChild,
  ViewEncapsulation,
} from '@angular/core';
import { NgControl } from '@angular/forms';
import { MAT_CHECKBOX_DEFAULT_OPTIONS } from '@angular/material/checkbox';
import {
  BehaviorSubject,
  defer,
  merge,
  Observable,
  Subject,
} from 'rxjs';
import {
  startWith,
  take,
  takeUntil,
} from 'rxjs/operators';

import {
  coerceArray,
  coerceNumberProperty,
  hasRequiredControl,
  inputHasChanged,
  isString,
  isUndefined,
  KEYS,
  TsDocumentService,
  untilComponentDestroyed,
} from '@terminus/fe-utilities';
import { TsFormFieldControl } from '@terminus/ui-form-field';
import {
  allOptionsAreSelected,
  countGroupLabelsBeforeOption,
  getOptionScrollPosition,
  someOptionsAreSelected,
  toggleAllOptions,
  TS_OPTION_PARENT_COMPONENT,
  TsOptgroupComponent,
  TsOptionComponent,
  TsOptionSelectionChange,
} from '@terminus/ui-option';
import { TS_SPACING } from '@terminus/ui-spacing';
import { TsStyleThemeTypes } from '@terminus/ui-utilities';

import { tsSelectAnimations } from '../select-animations';
import { TsSelectTriggerComponent } from '../trigger/select-trigger.component';


/**
 * The following style constants are necessary to save here in order to properly calculate the alignment of the selected option over the
 * trigger element.
 */

// The max height of the select's overlay panel
export const SELECT_PANEL_MAX_HEIGHT = 256;

// The panel's padding on the x-axis
export const SELECT_PANEL_PADDING_X = 16;
const SELECT_ITEM_HEIGHT = 3;

// The panel's x axis padding if it is indented (e.g. there is an option group)
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
export const SELECT_PANEL_INDENT_PADDING_X = SELECT_PANEL_PADDING_X * 2;

// The height of the select items in `em` units
export const SELECT_ITEM_HEIGHT_EM = SELECT_ITEM_HEIGHT;

/**
 * Distance between the panel edge and the option text in multi-selection mode.
 *
 * Calculated as:
 * (SELECT_PANEL_PADDING_X * 1.5) + 20 = 44
 * The padding is multiplied by 1.5 because the checkbox's margin is half the padding.
 * The checkbox width is 16px.
 */
export const SELECT_MULTIPLE_PANEL_PADDING_X = 0;

/**
 * The select panel will only "fit" inside the viewport if it is positioned at this value or more away from the viewport boundary
 */
export const SELECT_PANEL_VIEWPORT_PADDING = 8;

const DEFAULT_DELIMITER = ',';

/**
 * Expose the formatter function type
 */
export type TsSelectFormatFn = (v: unknown) => string;

/**
 * Used to sort selected options.
 *
 * Function used to sort the values in a select in multiple mode. Follows the same logic as `Array.prototype.sort`.
 */
export type TsSelectSortComparatorFunction = (
  a: TsOptionComponent,
  b: TsOptionComponent,
  options: TsOptionComponent[],
) => number;

/**
 * Comparison function to specify which option is displayed
 */
export type TsSelectOptionCompareWith = (o1: unknown, o2: unknown) => boolean;

/**
 * The default compare with function used when the consumer does not define one
 *
 * @param o1
 * @param o2
 */
export const DEFAULT_COMPARE_WITH: TsSelectOptionCompareWith = (o1: unknown, o2: unknown) => o1 === o2;

/**
 * The select panel will only "fit" inside the viewport if it is positioned at this value or more away from the viewport boundary
 */
export const TS_SELECT_PANEL_VIEWPORT_PADDING = 8;

/**
 * The event object that is emitted when the select value has changed
 */
export class TsSelectChange<T = string | string[]> {
  constructor(
    // Reference to the select that emitted the change event
    // eslint-disable-next-line deprecation/deprecation
    public source: TsSelectComponent,
    // The current value
    public value: T,
  ) {}
}

/**
 * Interface requirements for a selected option
 */
export interface TsSelectOption {
  isDisabled?: boolean;
  children?: TsSelectOption[];
}

// Unique ID for each instance
let nextUniqueId = 0;
const DEFAULT_VIEWPORT_MARGIN = 100;


/**
 * A component to create a select menu
 *
 * @deprecated Please use `TsSelectionListComponent`
 *
 * @example
 * <ts-select
 *              [allowMultiple]="true"
 *              [compareWith]="myCompareFn"
 *              delimiter=","
 *              [hideRequiredMarker]="true"
 *              hint="My hint!"
 *              id="my-id"
 *              [isDisabled]="true"
 *              [isFilterable]="true"
 *              [isRequired]="true"
 *              label="My label!"
 *              placeholder="My placeholder!"
 *              [showProgress]="true"
 *              [showRefineSearchMessage]="true"
 *              [showRefresh]="true"
 *              [sortComparator]="myComparator"
 *              tabIndex="-1"
 *              theme="primary"
 *              [totalHiddenResults]="1278"
 *              [validateOnChange]="true"
 *              value="My value!"
 *              (closed)="panelWasClosed($event)"
 *              (duplicateSelection)="duplicateWasSelected($event)"
 *              (opened)="panelWasOpened($event)"
 *              (optionDeselected)="optionWasDeselected($event)"
 *              (optionSelected)="optionWasSelected($event)"
 *              (optionsRefreshRequested)="refreshWasSelected()"
 *              (queryChange)="searchQueryChanged($event)"
 *              (selectionChange)="aSelectionWasChanged($event)"
 *              (valueChange)="theValueWasChanged($event)"
 * ></ts-select>
 *
 * <example-url>https://getterminus.github.io/ui-demos-release/components/select</example-url>
 */
@Component({
  selector: 'ts-select',
  templateUrl: './select.component.html',
  styleUrls: ['./select.component.scss'],
  host: {
    'class': 'ts-select',
    '[class.ts-select--required]': 'isRequired',
    '[class.ts-select--disabled]': 'isDisabled',
    '[attr.aria-owns]': 'panelOpen ? optionIds : null',
    '[attr.aria-required]': 'isRequired.toString()',
    '[attr.aria-multiselectable]': 'allowMultiple',
    '[attr.tabindex]': 'tabIndex',
    '(keydown)': 'handleKeydown($event)',
  },
  animations: [
    tsSelectAnimations.transformPanel,
  ],
  providers: [
    {
      provide: TsFormFieldControl,
      // eslint-disable-next-line deprecation/deprecation
      useExisting: TsSelectComponent,
    },
    {
      provide: TS_OPTION_PARENT_COMPONENT,
      // eslint-disable-next-line deprecation/deprecation
      useExisting: TsSelectComponent,
    },
    // Since we handle all option selection/deselection functionality we tell the underlying MatCheckbox to do nothing on click.
    {
      provide: MAT_CHECKBOX_DEFAULT_OPTIONS,
      useValue: { clickAction: 'noop' },
    },
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  exportAs: 'tsSelect',
})
export class TsSelectComponent implements
  OnInit,
  AfterContentInit,
  OnChanges,
  OnDestroy,
  TsFormFieldControl<unknown> {

  /**
   * Give the component an explicit name
   */
  public readonly componentName = 'TsSelectComponent';

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

  /**
   * Define the flex layout gap
   */
  public flexGap: string = TS_SPACING.small[0];

  /**
   * Subject used to alert the parent {@link FormFieldComponent} 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 y-offset of the overlay panel in relation to the trigger's top start corner.
   * This must be adjusted to align the selected option text over the trigger text.
   * when the panel opens. This will be changed based on the y-position of the selected option.
   */
  public offsetY = 0;

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

  /**
   * Combined stream of all of the child options' change events
   */
  public readonly optionSelectionChanges: Observable<TsOptionSelectionChange> =
    // eslint-disable-next-line deprecation/deprecation
    defer(() => merge<TsOptionSelectionChange>(...this.options.map(option => option.selectionChange)));

  /**
   * Emits when the panel element is finished transforming in.
   */
  public panelDoneAnimatingStream = new Subject<string>();

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

  /**
   * This position config ensures that the top "start" corner of the overlay
   * is aligned with with the top "start" of the origin by default (overlapping
   * the trigger completely).
   */
  public positions = [
    {
      originX: 'start',
      originY: 'top',
      overlayX: 'start',
      overlayY: 'top',
    },
    {
      originX: 'start',
      originY: 'bottom',
      overlayX: 'start',
      overlayY: 'bottom',
    },
  ];

  /**
   * The scroll position of the overlay panel, calculated to center the selected option.
   */
  private scrollTop = 0;

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

  /**
   * Manage selections
   *
   */
  public selectionModel!: SelectionModel<TsOptionComponent>;

  // 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>();

  /**
   * The value of the select panel's transform-origin property
   */
  public transformOrigin = 'top';

  /**
   * The cached font-size of the trigger element
   */
  public triggerFontSize = 0;

  /**
   * The last measured value for the trigger's client bounding rect
   */
  public triggerRect: ClientRect | undefined;

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

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

  /**
   * Margin between select panel edge and viewport edge
   */
  public viewportMarginSpacing = DEFAULT_VIEWPORT_MARGIN;

  /**
   * Whether all options are selected
   */
  public get allOptionsSelected(): boolean {
    return allOptionsAreSelected(this.options);
  }

  /**
   * Whether the select has a value
   */
  public get empty(): boolean {
    return this.selectionModel && this.selectionModel.isEmpty();
  }

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

  /**
   * Calculates the amount of items in the select. This includes options and group labels.
   */
  private get itemCount(): number {
    return this.options.length + this.optionGroups.length;
  }

  /**
   * Calculates the height of the options
   *
   * Only called if at least one option exists
   */
  private get itemHeight(): number {
    // Try to use the 2nd option in case the first option is blank or a filter etc. Fall back to the first item if needed.
    const options = this.options.toArray();
    const option = options[1] || options[0];
    return option && option.elementRef.nativeElement.offsetHeight;
  }

  /**
   * Whether at least 1 option is selected, but not all options
   */
  public get someOptionsSelected(): boolean {
    return someOptionsAreSelected(this.options);
  }

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

  /**
   * The value displayed in the select trigger
   */
  public get selectTriggerValue(): string {
    if (this.allowMultiple) {
      const selectedOptions = this.selectionModel.selected.map(option => option.viewValue);
      return selectedOptions.join(`${this.delimiter} `);
    }
    return this.selectionModel.selected[0].viewValue;
  }

  /**
   * The currently selected option or options
   */
  public get selected(): TsOptionComponent | TsOptionComponent[] {
    return this.allowMultiple ? this.selectionModel.selected : this.selectionModel.selected[0];
  }

  /**
   * Access the user-supplied override of the trigger element
   */
  // eslint-disable-next-line deprecation/deprecation
  @ContentChild(TsSelectTriggerComponent)
  // eslint-disable-next-line deprecation/deprecation
  public customTrigger: TsSelectTriggerComponent | undefined;

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

  /**
   * Access the label element
   */
  @ViewChild('labelElement')
  public labelElement!: ElementRef;

  /**
   * Access the trigger that opens the select
   */
  @ViewChild('trigger')
  public trigger!: ElementRef;

  /**
   * Access the overlay pane containing the options
   */
  @ViewChild(CdkConnectedOverlay)
  public overlayDir!: CdkConnectedOverlay;

  /**
   * Access the panel containing the select options
   */
  @ViewChild('panel')
  public panel!: ElementRef;

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

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

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

  /**
   * 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.
   *
   * Learn more about `compareWith` in the Angular docs:
   * https://angular.io/api/forms/SelectControlValueAccessor#customizing-option-selection
   *
   * @param fn
   */
  @Input()
  public set compareWith(fn: TsSelectOptionCompareWith) {
    if (typeof fn !== 'function' && isDevMode()) {
      // eslint-disable-next-line no-console
      console.warn(`TsSelectComponent: "compareWith" must be a function. Falling back to the default.`);
      this._compareWith = DEFAULT_COMPARE_WITH;
    }

    this._compareWith = fn;

    // A different comparator means the selection could change so we need to reinitialize any selections
    if (this.selectionModel) {
      this.initializeSelection();
    }
  }
  public get compareWith(): TsSelectOptionCompareWith {
    return this._compareWith;
  }
  private _compareWith: TsSelectOptionCompareWith = DEFAULT_COMPARE_WITH;

  /**
   * Define the delimiter used in the list of selected options
   *
   * @param value
   */
  @Input()
  public set delimiter(value: string) {
    this._delimiter = isString(value) ? value : DEFAULT_DELIMITER;
  }
  public get delimiter(): string {
    return this._delimiter;
  }
  private _delimiter: string = DEFAULT_DELIMITER;

  /**
   * 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 select is filterable
   */
  @Input()
  public isFilterable = 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 the label text
   *
   * @param value
   */
  @Input()
  public set label(value: string | undefined) {
    this._label = value;
  }
  public get label(): string | undefined {
    return this._label;
  }
  private _label: string | undefined;


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

  /**
   * Placeholder to be shown if no value has been selected
   *
   * @param value
   */
  @Input()
  public set placeholder(value: string | undefined) {
    this._placeholder = value;
    this.stateChanges.next();
  }
  public get placeholder(): string | undefined {
    return this._placeholder;
  }
  private _placeholder: string | undefined;

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

  /**
   * Define if the component should expose a message telling the user to refine their search
   */
  @Input()
  public showRefineSearchMessage = false;

  /**
   * Define if the select should show an option to trigger a refresh (by emitting an event)
   */
  @Input()
  public showRefresh = false;

  /**
   * Function used to sort the values in a select in multiple mode
   *
   * Follows the same logic as `Array.prototype.sort`.
   *
   * See {@link TsSelectSortComparatorFunction}
   */
  @Input()
  public sortComparator: TsSelectSortComparatorFunction | undefined;

  /**
   * Define the tab index for the component
   *
   * @param value
   */
  @Input()
  public set tabIndex(value: string | number) {
    this._tabIndex = coerceNumberProperty(value);
  }
  public get tabIndex(): string | number {
    return this._tabIndex;
  }
  private _tabIndex: string | number = 0;

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

  /**
   * Define the total number of records
   */
  @Input()
  public totalHiddenResults: undefined | number;

  /**
   * 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: unknown) {
    if (newValue !== this._value) {
      this._value = newValue;
    }
  }
  public get value(): unknown {
    return this._value;
  }
  private _value: unknown;

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

  /**
   * Event for when a duplicate selection is made
   */
  @Output()
  public readonly duplicateSelection: EventEmitter<string> = new EventEmitter();

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

  /**
   * Event for when an option is removed
   */
  @Output()
  public readonly optionDeselected: EventEmitter<TsSelectChange> = new EventEmitter();

  /**
   * Event for when an option is selected
   */
  @Output()
  public readonly optionSelected: EventEmitter<TsSelectChange> = new EventEmitter();

  /**
   * Event for when the user requests a refresh of the available options
   */
  @Output()
  public readonly optionsRefreshRequested: EventEmitter<void> = new EventEmitter();

  /**
   * Event for when the query has changed, used by filterable select
   */
  @Output()
  public readonly queryChange: EventEmitter<string> = new EventEmitter();

  /**
   * Event for when the selections change
   */
  @Output()
  public readonly selectionChange: EventEmitter<TsSelectChange> = new 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 {@link TsFormFieldComponent}.
   */
  @Output()
  public readonly valueChange: EventEmitter<string | string[]> = new EventEmitter<string | string[]>();


  constructor(
    private viewportRuler: ViewportRuler,
    private changeDetectorRef: ChangeDetectorRef,
    private ngZone: NgZone,
    private documentService: TsDocumentService,
    private 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;
    }
  }

  /**
   * Trigger change detection when the underlying form changes
   */
  public ngOnInit(): void {
    // TODO: re-initialize the selection model if this.allowMultiple changes (rather than throw error like material)
    this.selectionModel = new SelectionModel<TsOptionComponent>(this.allowMultiple);

    // 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.
    // istanbul ignore else
    // eslint-disable-next-line dot-notation
    if (this.ngControl && this.ngControl['form']) {
      // 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) {
              this.setSelectionByValue(newValue);
            }
          });
      }
    }
  }

  /**
   * Initialize the key manager and set up change listeners
   */
  public ngAfterContentInit(): void {
    this.initKeyManager();

    // NOTE: Known bug: This event will come through twice for each selection.
    // NOTE: Selection model is created during OnInit so it cannot be null here
    this.selectionModel.changed.pipe(
      untilComponentDestroyed(this),
    ).subscribe(event => {
      event.added.forEach(option => {
        option.select();
        this.optionSelected.emit(new TsSelectChange(this, option.value));
      });

      event.removed.forEach(option => {
        option.deselect();
        this.optionDeselected.emit(new TsSelectChange(this, option.value));
      });
    });

    // If the array changes, reset options
    this.options.changes.pipe(
      // eslint-disable-next-line deprecation/deprecation
      startWith<void, null>(null),
      untilComponentDestroyed(this),
    ).subscribe(() => {
      this.resetOptions();
      this.initializeSelection();
    });
  }

  /**
   * Trigger updates when the label is dynamically changed
   *
   * @param changes
   */
  public ngOnChanges(changes: SimpleChanges): void {
    // Let the parent FormField know that it should update the ouline gap for the new label
    // istanbul ignore else
    if ((!!(inputHasChanged(changes, 'label')) && !changes.label.firstChange)) {
      // Trigger change detection first so that the FormField will be working with the latest version
      this.changeDetectorRef.detectChanges();
      this.labelChanges.next();
    }
  }

  /**
   * Cleanup
   */
  public ngOnDestroy(): void {
    this.stateChanges.complete();
  }

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

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

  /**
   * Toggles the overlay panel open or closed.
   */
  public toggle(): void {
    // istanbul ignore else
    if (!this.isDisabled) {
      this.panelOpen ? this.close() : this.open();
    }
  }

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

    this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
    // Note: The computed font-size will be a string pixel value (e.g. "16px").
    // `parseInt` ignores the trailing 'px' and converts this to a number.
    this.triggerFontSize = parseInt(getComputedStyle(this.trigger.nativeElement)['font-size'], 10);

    this.panelOpen = true;
    this.keyManager.withHorizontalOrientation(null);
    this.highlightCorrectOption();
    this.changeDetectorRef.markForCheck();

    // Set the font size on the panel element once it exists.
    this.ngZone.onStable.asObservable().pipe(take(1)).subscribe(() => {
      // istanbul ignore else
      if (this.triggerFontSize && this.overlayDir.overlayRef && this.overlayDir.overlayRef.overlayElement) {
        this.overlayDir.overlayRef.overlayElement.style.fontSize = `${this.triggerFontSize}px`;
      }

      this.options.first.elementRef.nativeElement.getBoundingClientRect();
      this.calculateOverlayPosition();
    });

    // Alert the consumer
    this.opened.emit();
  }

  /**
   * Close the overlay panel
   */
  public close(): void {
    if (this.panelOpen) {
      this.panelOpen = false;
      this.keyManager.withHorizontalOrientation('ltr');
      this.changeDetectorRef.markForCheck();
      this.onTouched();
      this.updateValueAndValidity();
      // Alert the consumer
      this.closed.emit();
    }
  }

  /**
   * Callback that is invoked when the overlay panel has been attached
   */
  public onAttached(): void {
    this.overlayDir.positionChange.pipe(take(1)).subscribe(() => {
      this.changeDetectorRef.detectChanges();
      this.setPanelScrollTop(this.scrollTop);
    });
  }

  /**
   * Handles all keydown events on the select
   *
   * @param event - The KeyboardEvent
   */
  public handleKeydown(event: KeyboardEvent): void {
    if (this.isDisabled) {
      return;
    }

    this.panelOpen ? this.handleOpenKeydown(event) : this.handleClosedKeydown(event);
  }

  /**
   * Handle keyboard events when the select panel is closed
   *
   * @param event - The KeyboardEvent
   */
  private handleClosedKeydown(event: KeyboardEvent): void {
    const keyCode = event.code;
    const arrowKeys = [KEYS.DOWN_ARROW.code, KEYS.UP_ARROW.code, KEYS.LEFT_ARROW.code, KEYS.RIGHT_ARROW.code];
    const isArrowKey = arrowKeys.indexOf(keyCode) >= 0;
    const isOpenKey = keyCode === KEYS.ENTER.code || keyCode === KEYS.SPACE.code;

    // Open the select on ALT + arrow key to match the native <select>
    if (isOpenKey || ((this.allowMultiple || event.altKey) && isArrowKey)) {
      // Prevent the page from scrolling down when space is pressed
      event.preventDefault();
      this.open();
    } else if (!this.allowMultiple) {
      this.keyManager.onKeydown(event);
    }
  }

  /**
   * Handle keyboard events when the select panel is open
   *
   * @param event - The KeyboardEvent
   */
  // eslint-disable-next-line complexity
  private handleOpenKeydown(event: KeyboardEvent): void {
    const keyCode = event.code;
    const isArrowKey = keyCode === KEYS.DOWN_ARROW.code || keyCode === KEYS.UP_ARROW.code;
    const manager = this.keyManager;
    const target: HTMLElement = event.target as HTMLElement;
    const isFilter = this.isFilterable && target.tagName.toLowerCase() === 'input';

    if (keyCode === KEYS.HOME.code || keyCode === KEYS.END.code) {
      // Focus the first/last item with HOME/END respectively
      event.preventDefault();
      keyCode === KEYS.HOME.code ? manager.setFirstItemActive() : manager.setLastItemActive();
    } else if (isArrowKey && event.altKey) {
      // Close the select on ALT+ARROW to match the native <select>
      event.preventDefault();
      this.close();
    } else if ((keyCode === KEYS.ENTER.code || (keyCode === KEYS.SPACE.code && !isFilter)) && manager.activeItem) {
      // Select the active item with SPACE or ENTER
      event.preventDefault();
      manager.activeItem.selectViaInteraction();
    } else if (this.allowMultiple && keyCode === KEYS.A.code && event.ctrlKey) {
      // Select all with CTRL+A
      event.preventDefault();
      const hasDeselectedOptions = this.options.some(opt => !opt.isDisabled && !opt.selected);

      this.options.forEach(option => {
        // istanbul ignore else
        if (!option.isDisabled) {
          hasDeselectedOptions ? option.select() : option.deselect();
        }
      });
    } else {
      const shouldSelect = this.allowMultiple && isArrowKey && event.shiftKey;

      if (isArrowKey && event.shiftKey) {
        if (keyCode === KEYS.DOWN_ARROW.code) {
          manager.setNextItemActive();
        } else {
          manager.setPreviousItemActive();
        }
      } else {
        manager.onKeydown(event);
      }
      if (shouldSelect && manager.activeItem) {
        manager.activeItem.selectViaInteraction();
      }
    }
  }

  /**
   * Drops current option subscriptions and IDs and resets from scratch
   */
  private resetOptions(): void {
    this.optionSelectionChanges.pipe(
      takeUntil(this.options.changes),
      untilComponentDestroyed(this),
    ).subscribe(event => {
      this.onSelect(event.source, event.isUserInput);

      // istanbul ignore else
      if (event.isUserInput && !this.allowMultiple && this.panelOpen) {
        this.close();
        this.focus();
      }
    });

    // Listen to changes in the internal state of the options and react accordingly.
    // Handles cases like the labels of the selected options changing.
    // eslint-disable-next-line deprecation/deprecation
    merge(...this.options.map(option => option.stateChanges))
      .pipe(untilComponentDestroyed(this))
      .subscribe(() => {
        this.changeDetectorRef.markForCheck();
        this.stateChanges.next();
      });

    this.setOptionIds();
  }

  /**
   * Handle the selection when an option is clicked
   *
   * @param option - The selected option
   * @param isUserInput - Whether this selection happened from a user's click
   */
  private onSelect(option: TsOptionComponent, isUserInput: boolean): void {
    const wasSelected = this.selectionModel.isSelected(option);

    // If not in multiple selection mode, clear any existing selection first
    if (option.value == null && !this.allowMultiple) {
      option.deselect();
      this.selectionModel.clear();
      this.propagateChanges(option.value);
    } else {
      option.selected ? this.selectionModel.select(option) : this.selectionModel.deselect(option);

      // istanbul ignore else
      if (isUserInput) {
        this.keyManager.setActiveItem(option);
      }

      // istanbul ignore else
      if (this.allowMultiple) {
        this.sortValues();

        if (isUserInput) {
          // In case the user selected the option with their mouse, we
          // want to restore focus back to the trigger, in order to
          // prevent the select keyboard controls from clashing with
          // the ones from `TsOptionComponent`.
          this.focus();
        }
      }
    }

    // Only propagate if the selected option is not already in the selectionModel
    if (wasSelected !== this.selectionModel.isSelected(option)) {
      this.propagateChanges();
    }

    this.stateChanges.next();
  }

  /**
   * Records option IDs to pass to the aria-owns property
   */
  private setOptionIds(): void {
    this.optionIds = this.options.map(option => option.id).join(' ');
  }

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


    this.keyManager.tabOut.pipe(
      untilComponentDestroyed(this),
    ).subscribe(() => {
      // Restore focus to the trigger before closing. Ensures that the focus
      // position won't be lost if the user got focus into the overlay.
      this.focus();
      this.close();
    });

    this.keyManager.change.pipe(untilComponentDestroyed(this)).subscribe(() => {
      if (this.panelOpen && this.panel) {
        this.scrollActiveOptionIntoView();
      } else if (!this.panelOpen && !this.allowMultiple && this.keyManager.activeItem) {
        this.keyManager.activeItem.selectViaInteraction();
      }
    });
  }

  /**
   * Focus the correct element
   *
   * When in standard select mode we should focus the select itself.
   */
  public focus(): void {
    this.elementRef.nativeElement.focus();
  }

  /**
   * Sort the selected values in the selectedModel based on their order in the panel
   */
  private sortValues(): void {
    // istanbul ignore else
    if (this.allowMultiple) {
      const options = this.options.toArray();

      this.selectionModel
        .sort((a, b) => {
          if (this.sortComparator) {
            return this.sortComparator(a, b, options);
          }
          return options.indexOf(a) - options.indexOf(b);
        });

      this.stateChanges.next();
    }
  }

  /**
   * Emit a change event to set the model value
   *
   * @param fallbackValue - A fallback value to use when no selection exists
   */
  private propagateChanges(fallbackValue?: unknown): void {
    let valueToEmit: string | string[];

    if (this.allowMultiple) {
      valueToEmit = (this.selected as TsOptionComponent[]).map(option => option.value);
    } else {
      valueToEmit = this.selected ? (this.selected as TsOptionComponent).value : fallbackValue;
    }

    this.value = valueToEmit;
    this.valueChange.emit(valueToEmit);
    this.onChange(valueToEmit);
    this.selectionChange.emit(new TsSelectChange(this, valueToEmit));
    this.changeDetectorRef.markForCheck();
  }

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

  /**
   * 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: unknown): 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: unknown) => 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();
  }

  /**
   * Initialize any existing selections into the selectionModel
   */
  private initializeSelection(): void {
    // Defer setting the value in order to avoid the "Expression
    // has changed after it was checked" errors from Angular.
    Promise.resolve().then(() => {
      this.setSelectionByValue(this.ngControl ? this.ngControl.value : this.value);
    });
  }

  /**
   * Sets the selected option based on a value.
   * If no option can be found with the designated value, the select trigger is cleared.
   *
   * @param value - The value to use to look up options
   */
  private setSelectionByValue(value: string | string[]): void {
    if (this.allowMultiple && value) {
      value = coerceArray(value);
      this.selectionModel.clear();
      value.forEach((currentValue: string) => this.selectOptionByValue(currentValue));
      this.sortValues();
    } else {
      this.selectionModel.clear();
      const correspondingOption = this.selectOptionByValue(value);

      // Shift focus to the active item. Note that we shouldn't do this in multiple
      // mode, because we don't know what option the user interacted with last.
      if (correspondingOption) {
        this.keyManager.setActiveItem(correspondingOption);
      }
    }

    this.changeDetectorRef.markForCheck();
  }

  /**
   * Find and select an option based on its value
   *
   * @param value - The value to use when searching for a matching option
   * @returns Option that has the corresponding value
   */
  private selectOptionByValue(value: string | string[]): TsOptionComponent | undefined {
    const correspondingOption = this.options.find((option: TsOptionComponent) => {
      try {
        // Treat null as a special reset value.
        return option.value != null && this.compareWith(option.value,  value);
      } catch (error) {
        // istanbul ignore else
        if (isDevMode()) {
          // Notify developers of errors in their comparator.
          // eslint-disable-next-line no-console
          console.warn(error);
        }
        return false;
      }
    });

    if (correspondingOption) {
      this.selectionModel.select(correspondingOption);
    }

    return correspondingOption;
  }

  /**
   * Scroll the active option into view
   */
  private scrollActiveOptionIntoView(): void {
    const activeOptionIndex: number = this.keyManager.activeItemIndex || 0;
    const labelCount: number = countGroupLabelsBeforeOption(activeOptionIndex, this.options, this.optionGroups);
    const total = getOptionScrollPosition(
      activeOptionIndex + labelCount,
      this.itemHeight,
      this.getPanelScrollTop(),
      SELECT_PANEL_MAX_HEIGHT,
    );

    this.setPanelScrollTop(total);
  }

  /**
   * Calculate the scroll position and x- and y- offsets of the overlay panel
   */
  private calculateOverlayPosition(): void {
    const itemHeight = this.itemHeight;
    const items = this.itemCount;
    const panelHeight = Math.min(items * itemHeight, SELECT_PANEL_MAX_HEIGHT);
    const scrollContainerHeight = items * itemHeight;

    // The farthest the panel can be scrolled before it hits the bottom
    const maxScroll = scrollContainerHeight - panelHeight;

    // If no value is selected we open the popup to the first item.
    // NOTE: Since we are checking the `empty` value first, we know that the selection model is not empty
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    let selectedOptionOffset = this.empty ? 0 : this.getOptionIndex(this.selectionModel.selected[0])!;

    // Make sure we take into account optgroups also
    selectedOptionOffset += countGroupLabelsBeforeOption(selectedOptionOffset, this.options, this.optionGroups);

    // We must maintain a scroll buffer so the selected option will be scrolled to the
    // center of the overlay panel rather than the top.
    // eslint-disable-next-line @typescript-eslint/no-magic-numbers
    const scrollBuffer = panelHeight / 2;
    this.scrollTop = this.calculateOverlayScroll(selectedOptionOffset, scrollBuffer, maxScroll);
    this.offsetY = this.calculateOverlayOffsetY(selectedOptionOffset, scrollBuffer, maxScroll);

    this.checkOverlayWithinViewport(maxScroll);
  }

  /**
   * Calculate the scroll position of the select's overlay panel
   *
   * This attempts to center the selected option in the panel. If the option is too high or too low in the panel to be scrolled to the
   * center, it clamps the scroll position to the min or max scroll positions respectively.
   *
   * @param selectedIndex - The index of the item to scroll to
   * @param scrollBuffer - The amount to buffer the scroll
   * @param maxScroll - The maximum amount the panel can scroll
   */
  private calculateOverlayScroll(selectedIndex: number, scrollBuffer: number, maxScroll: number): number {
    const itemHeight = this.itemHeight;
    const optionOffsetFromScrollTop = itemHeight * selectedIndex;
    // eslint-disable-next-line @typescript-eslint/no-magic-numbers
    const halfOptionHeight = itemHeight / 2;

    // Starts at the optionOffsetFromScrollTop, which scrolls the option to the top of the scroll container, then subtracts the scroll
    // buffer to scroll the option down to the center of the overlay panel. Half the option height must be re-added to the scrollTop so the
    // option is centered based on its middle, not its top edge.
    const optimalScrollPosition = optionOffsetFromScrollTop - scrollBuffer + halfOptionHeight;
    return Math.min(Math.max(0, optimalScrollPosition), maxScroll);
  }

  /**
   * Calculates the y-offset of the select's overlay panel in relation to the top start corner of the trigger.
   * It has to be adjusted in order for the selected option to be aligned over the trigger when the panel opens.
   *
   * @param selectedIndex - The index of the selected item
   * @param scrollBuffer - The number of pixels to buffer the scroll by
   * @param maxScroll - The farthest the panel can scroll
   * @returns The overlay's Y offset
   */
  private calculateOverlayOffsetY(selectedIndex: number, scrollBuffer: number, maxScroll: number): number {
    // NOTE: scrollBuffer is half of the panel height - which is really half of SELECT_PANEL_MAX_HEIGHT (when many options exist)
    // NOTE: maxScroll is the height of all options minus the height of the panel
    const itemHeight = this.itemHeight;
    // eslint-disable-next-line @typescript-eslint/no-magic-numbers
    const optionHeightAdjustment = (itemHeight - (this.triggerRect ? this.triggerRect.height : 0)) / 2;
    const maxOptionsDisplayed = Math.floor(SELECT_PANEL_MAX_HEIGHT / itemHeight);

    // scrollbuffer - options
    let optionOffsetFromPanelTop: number;

    if (this.scrollTop === 0) {
      optionOffsetFromPanelTop = selectedIndex * itemHeight;
    } else if (this.scrollTop === maxScroll) {
      const firstDisplayedIndex = this.itemCount - maxOptionsDisplayed;
      const selectedDisplayIndex = selectedIndex - firstDisplayedIndex;

      // The first item is partially out of the viewport. Therefore we need to calculate what
      // portion of it is shown in the viewport and account for it in our offset.
      const partialItemHeight = itemHeight - (((this.itemCount * itemHeight) - SELECT_PANEL_MAX_HEIGHT) % itemHeight);

      // Because the panel height is longer than the height of the options alone,
      // there is always extra padding at the top or bottom of the panel. When
      // scrolled to the very bottom, this padding is at the top of the panel and
      // must be added to the offset.
      optionOffsetFromPanelTop = (selectedDisplayIndex * itemHeight) + partialItemHeight;
    } else {
      // If the option was scrolled to the middle of the panel using a scroll buffer,
      // its offset will be the scroll buffer minus the half height that was added to
      // center it.
      // eslint-disable-next-line @typescript-eslint/no-magic-numbers
      optionOffsetFromPanelTop = scrollBuffer - (itemHeight / 2);
    }

    // The final offset is the option's offset from the top, adjusted for the height difference,
    // multiplied by -1 to ensure that the overlay moves in the correct direction up the page.
    // The value is rounded to prevent some browsers from blurring the content.
    return Math.round((optionOffsetFromPanelTop * -1) - optionHeightAdjustment);
  }

  /**
   * Check that the attempted overlay position will fit within the viewport.
   *
   * If it will not fit, tries to adjust the scroll position and the associated y-offset so the panel can open fully on-screen.
   * If it still won't fit, sets the offset back to 0 to allow the fallback position to take over.
   *
   * @param maxScroll - The maximum amount to allow the panel to scroll
   */
  private checkOverlayWithinViewport(maxScroll: number): void {
    const itemHeight = this.itemHeight;
    const viewportSize = this.viewportRuler.getViewportSize();
    // Space between top of trigger and top of viewport
    const topSpaceAvailable = this.triggerRect ? (this.triggerRect.top - SELECT_PANEL_VIEWPORT_PADDING) : 0;
    // Viewport height - trigger bottom - viewport padding
    const bottomSpaceAvailable = viewportSize.height - (this.triggerRect ? this.triggerRect.bottom : 0) - SELECT_PANEL_VIEWPORT_PADDING;
    const panelHeightTop = Math.abs(this.offsetY);
    // 256 when maxed out
    const totalPanelHeight = Math.min(this.itemCount * itemHeight, SELECT_PANEL_MAX_HEIGHT);
    // total panel - offsetY - trigger height
    const panelHeightBottom = totalPanelHeight - panelHeightTop - (this.triggerRect ? this.triggerRect.height : 0);

    if (panelHeightBottom > bottomSpaceAvailable) {
      this.adjustPanelUp(panelHeightBottom, bottomSpaceAvailable);
    } else if (panelHeightTop > topSpaceAvailable) {
      this.adjustPanelDown(panelHeightTop, topSpaceAvailable, maxScroll);
    } else {
      this.transformOrigin = this.getOriginBasedOnOption();
    }
  }

  /**
   * Adjust the overlay panel up to fit in the viewport
   *
   * @param panelHeightBottom - The height of the panel bottom
   * @param bottomSpaceAvailable - The amount of available space at the bottom
   */
  private adjustPanelUp(panelHeightBottom: number, bottomSpaceAvailable: number): void {
    // Browsers ignore fractional scroll offsets, so we need to round.
    const distanceBelowViewport = Math.round(panelHeightBottom - bottomSpaceAvailable);

    // Scrolls the panel up by the distance it was extending past the boundary, then
    // adjusts the offset by that amount to move the panel up into the viewport.
    this.scrollTop -= distanceBelowViewport;
    // Don't allow the offset to be set below 0
    this.offsetY = (this.offsetY - distanceBelowViewport) < 0 ? 0 : this.offsetY - distanceBelowViewport;
    this.transformOrigin = this.getOriginBasedOnOption();

    // If the panel is scrolled to the very top, it won't be able to fit the panel
    // by scrolling, so set the offset to 0 to allow the fallback position to take effect.
    // istanbul ignore else
    if (this.scrollTop <= 0) {
      this.scrollTop = 0;
      this.offsetY = 0;
      this.transformOrigin = `50% bottom 0px`;
    }
  }

  /**
   * Adjusts the overlay panel down to fit in the viewport
   *
   * @param panelHeightTop - The height of the panel top
   * @param topSpaceAvailable - The amount of available space at the top
   * @param maxScroll - The maximum amount the panel can be scrolled
   */
  private adjustPanelDown(panelHeightTop: number, topSpaceAvailable: number, maxScroll: number) {
    // Browsers ignore fractional scroll offsets, so we need to round.
    const distanceAboveViewport = Math.round(panelHeightTop - topSpaceAvailable);

    // Scrolls the panel down by the distance it was extending past the boundary, then
    // adjusts the offset by that amount to move the panel down into the viewport.
    this.scrollTop += distanceAboveViewport;
    // Don't allow the offset to be set below 0
    this.offsetY = (this.offsetY + distanceAboveViewport) < 0 ? 0 : this.offsetY + distanceAboveViewport;
    this.transformOrigin = this.getOriginBasedOnOption();

    // If the panel is scrolled to the very bottom, it won't be able to fit the
    // panel by scrolling, so set the offset to 0 to allow the fallback position
    // to take effect.
    // istanbul ignore else
    if (this.scrollTop >= maxScroll) {
      this.scrollTop = maxScroll;
      this.offsetY = 0;
      this.transformOrigin = `50% top 0px`;
      return;
    }
  }

  /**
   * Set the transform origin point based on the selected option
   *
   * @returns The transform origin CSS string
   */
  private getOriginBasedOnOption(): string {
    const itemHeight = this.itemHeight;
    /* eslint-disable @typescript-eslint/no-magic-numbers */
    const optionHeightAdjustment = (itemHeight - (this.triggerRect ? this.triggerRect.height : 0)) / 2;
    const originY = Math.abs(this.offsetY) - optionHeightAdjustment + (itemHeight / 2);
    /* eslint-enable @typescript-eslint/no-magic-numbers */
    return `50% ${originY}px 0px`;
  }

  /**
   * Get the index of the provided option in the option list
   *
   * @param option - The option whose index should be found
   * @returns The index of the option
   */
  private getOptionIndex(option: TsOptionComponent): number | undefined {
    return this.options.reduce((result: number | undefined, current: TsOptionComponent, index: number) => {
      // eslint-disable-next-line no-undefined
      const optionIndexIfCurrent = option === current ? index : undefined;
      return isUndefined(result) ? optionIndexIfCurrent : result;
      // eslint-disable-next-line no-undefined
    }, undefined);
  }

  /**
   * Highlight the selected item.
   *
   * If no option is selected, it will highlight the first item instead.
   */
  private highlightCorrectOption(): void {
    // istanbul ignore else
    if (this.keyManager) {
      if (this.empty) {
        this.keyManager.setFirstItemActive();
      } else {
        this.keyManager.setActiveItem(this.selectionModel.selected[0]);
      }
    }
  }

  /**
   * Toggle the selection all options
   *
   * If any are selected, it will unselect all & vice-versa.
   */
  public toggleAllOptions(): void {
    toggleAllOptions(this.options);
  }

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

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

  /**
   * Get the panel's scrollTop
   *
   * @returns The scrollTop number
   */
  private getPanelScrollTop(): number {
    return this.panel ? this.panel.nativeElement.scrollTop : 0;
  }

  /**
   * Set the panel's scrollTop
   *
   * This allows us to manually scroll to display options above or below the fold, as they are not actually being focused when active.
   *
   *
   * @param scrollTop - The number to set scrollTop to
   */
  private setPanelScrollTop(scrollTop: number): void {
    // istanbul ignore else
    if (this.panel) {
      this.panel.nativeElement.scrollTop = scrollTop;
    }
  }
}

result-matching ""

    No results matching ""