libs/ui/selection-list/src/lib/trigger/selection-list-trigger.directive.ts
A directive that adds selection-list trigger functionality to an input
ControlValueAccessor
OnDestroy
<input
[tsSelectionListTrigger]="myReferenceToSelectionListPanel"
[tsSelectionListDisabled]="false"
autocomplete="off"
[reopenAfterSelection]="false"
/>
Providers |
ControlValueAccessorProviderFactory<TsSelectionListTriggerDirective>(TsSelectionListTriggerDirective)
|
Selector | [tsSelectionListTrigger] |
Properties |
|
Methods |
|
Inputs |
Outputs |
Accessors |
constructor(elementRef: ElementRef
|
||||||||||||||||||||||||||||||
Parameters :
|
allowMultiple | |
Default value : false
|
|
Reflect the settings from the parent |
autocomplete | |
Default value : 'off'
|
|
The |
reopenAfterSelection | |
Type : boolean
|
|
Define if the panel should reopen after a selection is made |
tsSelectionListTrigger | |
Type : TsSelectionListPanelComponent
|
|
The panel to be attached to this trigger |
tsSelectionListTriggerDisabled | |
Type : boolean
|
|
Whether the trigger is disabled. When disabled, the element will act as a regular input and the user won't be able to open the panel. |
backdropClicked | |
Type : EventEmitter
|
|
Emit when the backdrop is clicked |
Public closePanel | ||||||||
closePanel(overrideReopenFlag)
|
||||||||
Close the panel
Parameters :
Returns :
void
|
Public handleFocus |
handleFocus()
|
Handle the focus event
Returns :
void
|
Public handleInput | ||||||||
handleInput(event: KeyboardEvent)
|
||||||||
Handle input into the trigger
Parameters :
Returns :
void
|
Public handleKeydown | ||||||||
handleKeydown(event: KeyboardEvent)
|
||||||||
Handle keydown events
Parameters :
Returns :
void
|
Public openPanel |
openPanel()
|
Open the panel
Returns :
void
|
Public registerOnChange | ||||||||
registerOnChange(fn: (value: string) => void)
|
||||||||
Register the onChange function NOTE: Implemented as part of ControlValueAccessor
Parameters :
Returns :
void
|
Public registerOnTouched | ||||||||
registerOnTouched(fn: () => void)
|
||||||||
Register the onTouched function NOTE: Implemented as part of ControlValueAccessor
Parameters :
Returns :
void
|
Public setDisabledState | ||||||||
setDisabledState(isDisabled: boolean)
|
||||||||
Set the disabled state NOTE: Implemented as part of ControlValueAccessor
Parameters :
Returns :
void
|
Public writeValue | ||||||||
writeValue(value: string)
|
||||||||
Function used to write the value by the model NOTE: Implemented as part of ControlValueAccessor NOTE: This method is called by the forms API to write to the view when programmatic changes from model to view are requested.
Parameters :
Returns :
void
|
Public elementRef |
Type : ElementRef<HTMLInputElement>
|
Public onChange |
Type : function
|
Default value : () => {...}
|
View -> model callback called when value changes |
Public onTouched |
Default value : () => {...}
|
View -> model callback called when the DOM has been touched |
Public Readonly optionSelections |
Type : Observable<TsOptionSelectionChange> | Observable<unknown>
|
Default value : defer(() => {
if (this.selectionListPanel && this.selectionListPanel.options) {
// TODO: Refactor deprecation
// eslint-disable-next-line deprecation/deprecation
return merge(...this.selectionListPanel.options.map(option => option.selectionChange));
}
// If there are any subscribers before `ngAfterViewInit`, the selection list will be undefined.
// Return a stream that we'll replace with the real one once everything is in place.
return this.ngZone.onStable
.asObservable()
// TODO: Refactor deprecation
// eslint-disable-next-line deprecation/deprecation
.pipe(take(1), switchMap(() => this.optionSelections));
})
|
Stream of option selections |
Public overlayRef |
Type : OverlayRef | null | undefined
|
Store a reference to the overlay |
Public Readonly uid |
Default value : `ts-selection-list-trigger-${nextUniqueId++}`
|
Define the default component ID |
activeOption |
getactiveOption()
|
The currently active option, coerced to TsOptionComponent type
Returns :
TsOptionComponent | null
|
itemHeight |
getitemHeight()
|
Calculates the height of the options Only called if at least one option exists
Returns :
number
|
panelClosingActions |
getpanelClosingActions()
|
A stream of actions that should close the panel, including when an option is selected, on blur, and when TAB is pressed. |
panelOpen |
getpanelOpen()
|
Whether or not the panel is open
Returns :
boolean
|
selectionListDisabled | ||||||
getselectionListDisabled()
|
||||||
setselectionListDisabled(value: boolean)
|
||||||
Whether the trigger is disabled. When disabled, the element will act as a regular input and the user won't be able to open the panel.
Parameters :
Returns :
void
|
reopenAfterSelection | ||||||
getreopenAfterSelection()
|
||||||
setreopenAfterSelection(value: boolean)
|
||||||
Define if the panel should reopen after a selection is made
Parameters :
Returns :
void
|
import {
FlexibleConnectedPositionStrategy,
Overlay,
OverlayConfig,
OverlayRef,
PositionStrategy,
ScrollStrategy,
ViewportRuler,
} from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import {
ChangeDetectorRef,
Directive,
ElementRef,
EventEmitter,
Host,
Inject,
InjectionToken,
Input,
isDevMode,
NgZone,
OnDestroy,
Optional,
Output,
ViewContainerRef,
} from '@angular/core';
import { ControlValueAccessor } from '@angular/forms';
import {
defer,
merge,
Observable,
of,
Subject,
Subscription,
} from 'rxjs';
import {
delay,
filter,
map,
switchMap,
take,
tap,
} from 'rxjs/operators';
import {
coerceBooleanProperty,
KEYS,
TsDocumentService,
untilComponentDestroyed,
} from '@terminus/fe-utilities';
import { TsFormFieldComponent } from '@terminus/ui-form-field';
import {
countGroupLabelsBeforeOption,
getOptionScrollPosition,
TsOptionComponent,
TsOptionSelectionChange,
} from '@terminus/ui-option';
import {
ControlValueAccessorProviderFactory,
TsUILibraryError,
} from '@terminus/ui-utilities';
import { TsSelectionListPanelComponent } from '../selection-list-panel/selection-list-panel.component';
// Injection token that determines the scroll handling while the panel is open
export const TS_SELECTION_LIST_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>('ts-selection-list-scroll-strategy');
/**
* @param overlay
*/
export const TS_SELECTION_LIST_SCROLL_STRATEGY_FACTORY =
(overlay: Overlay): () => ScrollStrategy => () => overlay.scrollStrategies.reposition();
export const TS_SELECTION_LIST_SCROLL_STRATEGY_FACTORY_PROVIDER = {
provide: TS_SELECTION_LIST_SCROLL_STRATEGY,
deps: [Overlay],
useFactory: TS_SELECTION_LIST_SCROLL_STRATEGY_FACTORY,
};
// The max height of the select's overlay panel
export const SELECTION_LIST_PANEL_MAX_HEIGHT = 256;
// Unique ID for each instance
let nextUniqueId = 0;
/**
* A directive that adds selection-list trigger functionality to an input
*
* @example
* <input
* [tsSelectionListTrigger]="myReferenceToSelectionListPanel"
* [tsSelectionListDisabled]="false"
* autocomplete="off"
* [reopenAfterSelection]="false"
* />
*/
@Directive({
selector: '[tsSelectionListTrigger]',
host: {
'class': 'ts-selection-list-trigger',
'[attr.autocomplete]': 'autocompleteAttribute',
'[attr.role]': 'selectionListDisabled ? null : "combobox"',
'[attr.aria-autocomplete]': 'selectionListDisabled ? null : "list"',
'[attr.aria-activedescendant]': 'activeOption?.id',
'[attr.aria-expanded]': 'selectionListDisabled ? null : panelOpen.toString()',
'[attr.aria-owns]': '(selectionListDisabled || !panelOpen) ? null : selectionListPanel?.id',
// Note: we use `focusin`, as opposed to `focus`, in order to open the panel
// a little earlier. This avoids issues where IE delays the focusing of the input.
'(blur)': 'onTouched()',
'(focusin)': 'handleFocus()',
'(click)': 'handleFocus()',
'(input)': 'handleInput($event)',
'(keydown)': 'handleKeydown($event)',
},
providers: [
ControlValueAccessorProviderFactory<TsSelectionListTriggerDirective>(TsSelectionListTriggerDirective),
],
exportAs: 'tsSelectionListTrigger',
})
export class TsSelectionListTriggerDirective<ValueType = string> implements ControlValueAccessor, OnDestroy {
/**
* Whether the panel can open the next time it is focused. Used to prevent a focused, closed panel from being reopened if
* the user switches to another browser tab and then comes back.
*/
private canOpenOnNextFocus = true;
/**
* Stream of keyboard events that can close the panel
*/
private readonly closeKeyEventStream = new Subject<void>();
/*
* Note: In some cases `openPanel` can end up being called after the component is destroyed. This flag is to ensure that we don't try to
* run change detection on a destroyed view.
*/
private componentDestroyed = false;
/**
* Store a reference to the document object
*/
private readonly document: Document;
/**
* Whether or not the label state is being overridden
*/
private manuallyFloatingLabel = false;
/**
* Stream of option selections
*/
public readonly optionSelections: Observable<TsOptionSelectionChange> | Observable<unknown> = defer(() => {
if (this.selectionListPanel && this.selectionListPanel.options) {
// TODO: Refactor deprecation
// eslint-disable-next-line deprecation/deprecation
return merge(...this.selectionListPanel.options.map(option => option.selectionChange));
}
// If there are any subscribers before `ngAfterViewInit`, the selection list will be undefined.
// Return a stream that we'll replace with the real one once everything is in place.
return this.ngZone.onStable
.asObservable()
// TODO: Refactor deprecation
// eslint-disable-next-line deprecation/deprecation
.pipe(take(1), switchMap(() => this.optionSelections));
});
/**
* Store whether the overlay is currently attached
*/
private overlayAttached = false;
/**
* Store a reference to the overlay
*/
public overlayRef: OverlayRef | null | undefined;
/**
* Old value of the native input
*
* NOTE: Used to work around issues with the `input` event on IE
*/
private previousValue: string | number | null | undefined;
/**
* Store a reference to the portal
*/
private portal: TemplatePortal | undefined;
/**
* Strategy that is used to position the panel
*/
private positionStrategy!: FlexibleConnectedPositionStrategy;
/**
* The defined scroll strategy
*/
private readonly scrollStrategy: () => ScrollStrategy;
/**
* Subscription to viewport size changes
*/
private viewportSubscription = Subscription.EMPTY;
/**
* Define the default component ID
*/
public readonly uid = `ts-selection-list-trigger-${nextUniqueId++}`;
/**
* The currently active option, coerced to TsOptionComponent type
*/
public get activeOption(): TsOptionComponent | null {
if (this.selectionListPanel && this.selectionListPanel.keyManager) {
return this.selectionListPanel.keyManager.activeItem;
}
return null;
}
/**
* Calculates the height of the options
*
* Only called if at least one option exists
*/
public 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.selectionListPanel.options.toArray();
const option = options[1] || options[0];
return option && option.elementRef.nativeElement.offsetHeight;
}
/**
* A stream of actions that should close the panel, including when an option is selected, on blur, and when TAB is pressed.
*/
public get panelClosingActions(): Observable<TsOptionSelectionChange | null> {
// eslint-disable-next-line deprecation/deprecation
return merge(
this.optionSelections,
this.selectionListPanel.keyManager.tabOut.pipe(filter(() => this.overlayAttached)),
this.closeKeyEventStream,
// eslint-disable-next-line deprecation/deprecation
this.overlayRef?.backdropClick() || of<string>(''),
)
.pipe(
// Normalize the output so we return a consistent type.
map(event => (event instanceof TsOptionSelectionChange ? event : null)),
);
}
/**
* Whether or not the panel is open
*/
public get panelOpen(): boolean {
return this.overlayAttached && this.selectionListPanel.showPanel;
}
/**
* Reflect the settings from the parent
*/
@Input()
public allowMultiple = false;
/**
* The `autocomplete` attribute to be set on the input element.
*/
// NOTE: Input has specific naming since it is accepting a standard HTML data attribute.
// eslint-disable-next-line @angular-eslint/no-input-rename
@Input('autocomplete')
public autocompleteAttribute = 'off';
/**
* Whether the trigger is disabled. When disabled, the element will act as a regular input and the user won't be able to open the panel.
*
* @param value
*/
// eslint-disable-next-line @angular-eslint/no-input-rename
@Input('tsSelectionListTriggerDisabled')
public set selectionListDisabled(value: boolean) {
this._selectionListDisabled = coerceBooleanProperty(value);
}
public get selectionListDisabled(): boolean {
return this._selectionListDisabled;
}
private _selectionListDisabled = false;
/**
* The panel to be attached to this trigger
*/
// Note: Renaming as prefixed name does not add clarity
@Input('tsSelectionListTrigger')
public selectionListPanel!: TsSelectionListPanelComponent;
/**
* Define if the panel should reopen after a selection is made
*
* @param value
*/
@Input()
public set reopenAfterSelection(value: boolean) {
this._reopenAfterSelection = value;
}
public get reopenAfterSelection(): boolean {
return this._reopenAfterSelection;
}
private _reopenAfterSelection = false;
/**
* Emit when the backdrop is clicked
*/
@Output()
public readonly backdropClicked = new EventEmitter<void>();
constructor(
public elementRef: ElementRef<HTMLInputElement>,
private overlay: Overlay,
private viewContainerRef: ViewContainerRef,
private ngZone: NgZone,
private changeDetectorRef: ChangeDetectorRef,
private documentService: TsDocumentService,
private viewportRuler: ViewportRuler,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@Inject(TS_SELECTION_LIST_SCROLL_STRATEGY) scrollStrategy: any,
@Optional() @Host() private formField: TsFormFieldComponent,
) {
// istanbul ignore else
if (typeof window !== 'undefined') {
ngZone.runOutsideAngular(() => {
window.addEventListener('blur', this.windowBlurHandler);
});
}
this.scrollStrategy = scrollStrategy;
this.document = this.documentService.document;
}
/**
* Clean up subscriptions and destroy the panel
*/
public ngOnDestroy(): void {
// istanbul ignore else
if (typeof window !== 'undefined') {
window.removeEventListener('blur', this.windowBlurHandler);
}
this.viewportSubscription.unsubscribe();
this.componentDestroyed = true;
this.destroyPanel();
this.closeKeyEventStream.complete();
}
/**
* Close the panel
*
* @param overrideReopenFlag - Whether the panel should reopen
*/
public closePanel(overrideReopenFlag = false): void {
this.resetLabel();
if (!this.overlayAttached) {
return;
}
// istanbul ignore else
if (this.panelOpen) {
// Only emit if the panel was visible.
this.selectionListPanel.closed.emit();
}
this.selectionListPanel.isOpen = this.overlayAttached = false;
// istanbul ignore else
if (this.overlayRef && this.overlayRef.hasAttached()) {
this.overlayRef.detach();
}
// Note that in some cases this can end up being called after the component is destroyed.
// Add a check to ensure that we don't try to run change detection on a destroyed view.
if (!this.componentDestroyed) {
// We need to trigger change detection manually, because `fromEvent` doesn't seem to do it at the proper time.
// This ensures that the label is reset when the user clicks outside.
this.changeDetectorRef.detectChanges();
}
const options = this.selectionListPanel.options.toArray();
// Only allow reopening when in multiple mode and when there are options, which could be selected
if (this.allowMultiple && this.reopenAfterSelection && !overrideReopenFlag && options.length) {
this.openPanel();
}
}
/**
* Handle the focus event
*/
public handleFocus(): void {
if (!this.canOpenOnNextFocus) {
this.canOpenOnNextFocus = true;
} else if (this.canOpen()) {
this.previousValue = this.elementRef.nativeElement.value;
this.attachOverlay();
this.floatLabel(true);
}
}
/**
* Handle input into the trigger
*
* @param event - The keyboard event
*/
public handleInput(event: KeyboardEvent): void {
const target = event.target as HTMLInputElement;
let value: number | string | null = target.value;
// Based on `NumberValueAccessor` from forms
if (target.type === 'number') {
value = value === '' ? null : parseFloat(value);
}
// If the input has a placeholder, IE will fire the `input` event on page load, focus and blur, in addition to when the user actually
// changed the value. To filter out all of the extra events, we save the value on focus and between `input` events, and we check
// whether it changed. See: https://connect.microsoft.com/IE/feedback/details/885747/
// istanbul ignore else
if (this.previousValue !== value && this.document.activeElement === event.target) {
this.previousValue = value;
this.onChange(value);
// istanbul ignore else
if (this.canOpen()) {
this.openPanel();
}
}
}
/**
* Handle keydown events
*
* @param event - The keyboard event
*/
public handleKeydown(event: KeyboardEvent): void {
const keyCode = event.code;
// Prevent the default action on all escape key presses. This is here primarily to bring IE in line with other browsers. By default,
// pressing escape on IE will cause it to revert the input value to the one that it had on focus, however it won't dispatch any events
// which means that the model value will be out of sync with the view.
if (keyCode === KEYS.ESCAPE.code) {
event.preventDefault();
}
if (this.activeOption && keyCode === KEYS.ENTER.code && this.panelOpen) {
this.activeOption.selectViaInteraction();
this.resetActiveItem();
event.preventDefault();
} else if (this.selectionListPanel) {
const prevActiveItem = this.selectionListPanel.keyManager.activeItem;
const isArrowKey = keyCode === KEYS.UP_ARROW.code || keyCode === KEYS.DOWN_ARROW.code;
if (this.panelOpen || keyCode === KEYS.TAB.code) {
this.selectionListPanel.keyManager.onKeydown(event);
} else if (isArrowKey && this.canOpen()) {
this.openPanel();
}
if (isArrowKey || this.selectionListPanel.keyManager.activeItem !== prevActiveItem) {
this.scrollToOption();
}
}
}
/**
* View -> model callback called when value changes
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public onChange: (value: any) => void = () => {};
/**
* View -> model callback called when the DOM has been touched
*/
public onTouched = () => {};
/**
* Open the panel
*/
public openPanel(): void {
this.attachOverlay();
this.floatLabel();
}
/**
* Register the onChange function
*
* NOTE: Implemented as part of ControlValueAccessor
*
* @param fn - The new onChange function
*/
public registerOnChange(fn: (value: string) => {}): void {
this.onChange = fn;
}
/**
* Register the onTouched function
*
* NOTE: Implemented as part of ControlValueAccessor
*
* @param fn - The new onTouched function
*/
public registerOnTouched(fn: () => {}) {
this.onTouched = fn;
}
/**
* Set the disabled state
*
* NOTE: Implemented as part of ControlValueAccessor
*
* @param isDisabled - Whether the element should be set to disabled
*/
public setDisabledState(isDisabled: boolean): void {
this.elementRef.nativeElement.disabled = isDisabled;
}
/**
* Function used to write the value by the model
*
* NOTE: Implemented as part of ControlValueAccessor
* NOTE: This method is called by the forms API to write to the view when programmatic changes from model to view are requested.
*
* @param value - The value to write
*/
public writeValue(value: string): void {}
/**
* Attach the overlay
*/
private attachOverlay(): void {
if (!this.selectionListPanel && isDevMode()) {
throw new TsUILibraryError(`TsSelectionListTriggerDirective: Attempting to open an undefined instance of 'ts-selection-list-panel'.`);
}
if (this.overlayRef) {
// Update the panel width in case anything has changed
this.overlayRef.updateSize({ width: this.getPanelWidth() });
} else {
this.portal = new TemplatePortal(this.selectionListPanel.template, this.viewContainerRef);
this.overlayRef = this.overlay.create(this.getOverlayConfig());
this.overlayRef.keydownEvents().pipe(untilComponentDestroyed(this)).subscribe(event => {
// Close when pressing ESCAPE or ALT + UP_ARROW, based on the a11y guidelines.
// See: https://www.w3.org/TR/wai-aria-practices-1.1/#textbox-keyboard-interaction
if (event.code === KEYS.ESCAPE.code || (event.code === KEYS.UP_ARROW.code && event.altKey)) {
this.resetActiveItem();
this.closeKeyEventStream.next();
}
});
this.viewportSubscription = this.viewportRuler.change().pipe(untilComponentDestroyed(this)).subscribe(() => {
if (this.panelOpen && this.overlayRef) {
this.overlayRef.updateSize({ width: this.getPanelWidth() });
}
});
}
// istanbul ignore else
if (this.overlayRef && !this.overlayRef.hasAttached()) {
this.overlayRef.attach(this.portal);
this.subscribeToClosingActions();
this.overlayRef.backdropClick().pipe(untilComponentDestroyed(this)).subscribe(() => {
this.backdropClicked.emit();
});
}
const wasOpen = this.panelOpen;
this.selectionListPanel.setVisibility();
this.selectionListPanel.isOpen = this.overlayAttached = true;
// We need to do an extra `panelOpen` check in here, because the panel won't be shown if there are no options.
// istanbul ignore else
if (this.panelOpen && wasOpen !== this.panelOpen) {
this.selectionListPanel.opened.emit();
}
}
/**
* Determine whether the panel can be opened
*/
private canOpen(): boolean {
const element = this.elementRef.nativeElement;
const isDisabled = coerceBooleanProperty(element.disabled)
|| coerceBooleanProperty(element.getAttribute('data-disabled'))
|| coerceBooleanProperty(this.selectionListDisabled);
const isReadOnly = coerceBooleanProperty(element.readOnly);
const allowsUserInput = coerceBooleanProperty(element.getAttribute('data-user-input'));
if (allowsUserInput) {
return !isReadOnly && !isDisabled;
}
return !isDisabled;
}
/**
* Clear any previous selected option and emit a selection change event for this option
*
* @param skip
*/
private clearPreviousSelectedOption(skip: TsOptionComponent): void {
this.selectionListPanel.options.forEach(option => {
// NOTE: Loose check (`!=`) needed for comparing classes
// istanbul ignore else
// eslint-disable-next-line eqeqeq
if (option != skip && option.selected) {
option.deselect();
}
});
}
/**
* Destroy the panel
*/
private destroyPanel(): void {
// istanbul ignore else
if (this.overlayRef) {
this.closePanel();
this.overlayRef.dispose();
this.overlayRef = null;
}
}
/**
* In 'auto' mode, the label will animate down as soon as focus is lost. This causes the value to jump when selecting an option with the
* mouse. This method manually floats the label until the panel can be closed.
*
* @param shouldAnimate - Whether the label should be animated when it is floated
*/
private floatLabel(shouldAnimate = false): void {
// istanbul ignore else
if (this.formField && this.formField.floatLabel === 'auto') {
if (shouldAnimate) {
this.formField.animateAndLockLabel();
} else {
this.formField.floatLabel = 'always';
}
this.manuallyFloatingLabel = true;
}
}
/**
* Return the connected element
*
* @returns The ElementRef
*/
private getConnectedElement(): ElementRef {
return this.formField ? this.formField.getConnectedOverlayOrigin() : this.elementRef;
}
/**
* Returns the width of the input element, so the panel width can match it
*/
private getHostWidth(): number {
return this.getConnectedElement().nativeElement.getBoundingClientRect().width;
}
/**
* Create a config for an overlay
*
* @returns The overlay config
*/
private getOverlayConfig(): OverlayConfig {
return new OverlayConfig({
backdropClass: 'ts-selection-list__backdrop',
direction: 'ltr',
hasBackdrop: true,
positionStrategy: this.getOverlayPositionStrategy(),
scrollStrategy: this.scrollStrategy(),
width: this.getPanelWidth(),
});
}
/**
* Get the overlay position strategy
*
* @returns The position strategy
*/
private getOverlayPositionStrategy(): PositionStrategy {
this.positionStrategy = this.overlay.position()
.flexibleConnectedTo(this.getConnectedElement())
.withFlexibleDimensions(false)
.withPush(false)
.withPositions([
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
},
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
},
]);
return this.positionStrategy;
}
/**
* Return the panel width
*
* @returns The width
*/
private getPanelWidth(): number | string {
return this.getHostWidth();
}
/**
* Resets the active item to -1 so arrow events will activate the correct options, or to 0 if the consumer opted into it
*/
private resetActiveItem(): void {
this.selectionListPanel.keyManager.setActiveItem(-1);
}
/**
* If the label has been manually elevated, return it to its normal state
*/
private resetLabel(): void {
// istanbul ignore else
if (this.manuallyFloatingLabel) {
this.formField.floatLabel = 'auto';
this.manuallyFloatingLabel = false;
}
}
/**
* This method closes the panel, and if a value is specified, also sets the associated control to that value.
* It will also mark the control as dirty if this interaction stemmed from the user.
*
* @param event - The event containing the option
*/
private setValueAndClose(event: TsOptionSelectionChange): void {
// istanbul ignore else
if (event && event.source) {
this.clearPreviousSelectedOption(event.source);
this.elementRef.nativeElement.focus();
this.selectionListPanel.emitSelectEvent(event.source);
}
this.closePanel();
}
/**
* This method listens to a stream of panel closing actions and resets the stream every time the option list changes
*
* @returns The subscription
*/
private subscribeToClosingActions(): Subscription {
const firstStable = this.ngZone.onStable.asObservable().pipe(take(1));
const optionChanges = this.selectionListPanel.options.changes.pipe(
// TODO: Refactor deprecation
// eslint-disable-next-line deprecation/deprecation
tap(() => this.positionStrategy.reapplyLastPosition()),
// Defer emitting to the stream until the next tick, because changing bindings in here will cause "changed after checked" errors.
delay(0),
);
// When the zone is stable initially, and when the option list changes...
// eslint-disable-next-line deprecation/deprecation
return merge(
firstStable,
optionChanges,
)
.pipe(
// Create a new stream of panelClosingActions, replacing any previous streams that were created, and flatten it so our stream only
// emits closing events...
// TODO: Refactor deprecation
// eslint-disable-next-line deprecation/deprecation
switchMap(() => {
// Focus the first option when options change
this.selectionListPanel.keyManager.setActiveItem(0);
this.selectionListPanel.setVisibility();
return this.panelClosingActions;
}),
// When the first closing event occurs...
take(1),
)
// Set the value, close the panel, and complete.
.subscribe((event: TsOptionSelectionChange | null) => {
// istanbul ignore else
if (event && event.source && event.source.value !== undefined) {
this.setValueAndClose(event);
} else {
this.closePanel();
}
});
}
/**
* Event handler for when the window is blurred.
*
* Needs to be an arrow function in order to preserve the context.
*/
private windowBlurHandler = (): void => {
// If the user blurred the window while the selection list is focused, it means that it'll be refocused when they come back. In this
// case we want to skip the first focus event, if the pane was closed, in order to avoid reopening it unintentionally.
this.canOpenOnNextFocus = this.document.activeElement !== this.elementRef.nativeElement || this.panelOpen;
};
/**
* Scroll to an option
*
* Given that we are not actually focusing active options, we must manually adjust scroll to reveal options below the fold. First, we find
* the offset of the option from the top of the panel. If that offset is below the fold, the new scrollTop will be the offset - the panel
* height + the option height, so the active option will be just visible at the bottom of the panel. If that offset is above the top of
* the visible panel, the new scrollTop will become the offset. If that offset is visible within the panel already, the scrollTop is not
* adjusted.
*/
private scrollToOption(): void {
const index: number = this.selectionListPanel.keyManager.activeItemIndex || 0;
const labelCount: number = countGroupLabelsBeforeOption(index, this.selectionListPanel.options, this.selectionListPanel.optionGroups);
if (index === 0 && labelCount === 1) {
// If we've got one group label before the option and we're at the top option,
// scroll the list to the top. This is better UX than scrolling the list to the
// top of the option, because it allows the user to read the top group's label.
this.selectionListPanel.scrollTop = 0;
} else {
this.selectionListPanel.scrollTop = getOptionScrollPosition(
index + labelCount,
this.itemHeight,
this.selectionListPanel.scrollTop,
SELECTION_LIST_PANEL_MAX_HEIGHT,
);
}
}
}