File

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

Description

The event object that is emitted when the select value has changed

Index

Properties

Constructor

constructor(source: TsSelectComponent, value: T)
Parameters :
Name Type Optional
source TsSelectComponent No
value T No

Properties

Public source
Type : TsSelectComponent
Public value
Type : T
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 ""