libs/ui/select/src/lib/select/select.component.ts
A component to create a select menu
OnInit
AfterContentInit
OnChanges
OnDestroy
TsFormFieldControl
<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>
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 |
Properties |
|
Methods |
|
Inputs |
Outputs |
Accessors |
constructor(viewportRuler: ViewportRuler, changeDetectorRef: ChangeDetectorRef, ngZone: NgZone, documentService: TsDocumentService, elementRef: ElementRef, ngControl: NgControl)
|
|||||||||||||||||||||
Parameters :
|
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 |
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 |
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 |
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 Needed for TsFormFieldComponent. |
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 :
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 :
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 :
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 :
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 :
Returns :
void
|
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()
|
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 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 |
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
Returns :
TsOptionComponent | []
|
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
Parameters :
Returns :
void
|
delimiter | ||||||
getdelimiter()
|
||||||
setdelimiter(value: string)
|
||||||
Define the delimiter used in the list of selected options
Parameters :
Returns :
void
|
hint | ||||
gethint()
|
||||
sethint(value)
|
||||
Define a hint for the input
Parameters :
Returns :
void
|
id | ||||||
getid()
|
||||||
setid(value: string)
|
||||||
Define an ID for the component
Parameters :
Returns :
void
|
isRequired | ||||||
getisRequired()
|
||||||
setisRequired(value: boolean)
|
||||||
Define if the control is required
Parameters :
Returns :
void
|
label | ||||
getlabel()
|
||||
setlabel(value)
|
||||
Define the label text
Parameters :
Returns :
void
|
placeholder | ||||
getplaceholder()
|
||||
setplaceholder(value)
|
||||
Placeholder to be shown if no value has been selected
Parameters :
Returns :
void
|
tabIndex | ||||
gettabIndex()
|
||||
settabIndex(value)
|
||||
Define the tab index for the component
Parameters :
Returns :
void
|
value | ||||
getvalue()
|
||||
setvalue(newValue)
|
||||
Value of the select control
Parameters :
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;
}
}
}