src/app/shared/components/dual-slider/dual-slider.component.ts
Component containing a button that when clicked will show a slider popover.
OnDestroy
OnChanges
changeDetection | ChangeDetectionStrategy.OnPush |
selector | ccf-dual-slider |
styleUrls | ./dual-slider.component.scss |
templateUrl | ./dual-slider.component.html |
Properties |
Methods |
Inputs |
Outputs |
HostListeners |
Accessors |
constructor(overlay: Overlay, element: ElementRef
|
||||||||||||||||
Creates an instance of dual slider component.
Parameters :
|
label | |
Type : string
|
|
Which criteria the slider is selecting for. |
selection | |
Type : number[]
|
|
The current range selected. |
valueRange | |
Type : number[]
|
|
The lower and upper range of the slider. |
selectionChange | |
Type : EventEmitter
|
|
Emits the new selection range when a change is made to it. |
document:touchstart | ||||||
Arguments : '$event.target'
|
||||||
document:touchstart(target: HTMLElement)
|
||||||
Listens to document click, mouse movement, and touch event. Closes the slider popover when such an event occurs outside the button or popover.
Parameters :
|
closeSliderPopover | ||||||||
closeSliderPopover(target: HTMLElement)
|
||||||||
Decorators :
@HostListener('document:click', ['$event.target'])
|
||||||||
Listens to document click, mouse movement, and touch event. Closes the slider popover when such an event occurs outside the button or popover.
Parameters :
Returns :
void
|
onKeyHigh | ||||||||
onKeyHigh(event: KeyboardEvent)
|
||||||||
Updates the slider's high pointer value when Enter key is pressed.
Parameters :
Returns :
void
|
onKeyLow | ||||||||
onKeyLow(event: KeyboardEvent)
|
||||||||
Updates the slider's low pointer value when Enter key is pressed.
Parameters :
Returns :
void
|
optionsChanged |
optionsChanged()
|
Updates the slider options, and the slider values if necessary.
Returns :
void
|
sliderValueChanged |
sliderValueChanged()
|
Handler for updates to the slider values. Emits the updated selection value array.
Returns :
void
|
toggleSliderPopover |
toggleSliderPopover()
|
Toggles the visibility of the slider popover.
Returns :
void
|
contentsVisible |
Type : string
|
Default value : 'invisible'
|
Determines if slider contents are visible (used for fade-in effect). |
highValue |
Type : number
|
Value bound to the slider's high pointer value. |
isSliderOpen |
Default value : false
|
Determines whether slider popover is shown. |
lowValue |
Type : number
|
Value bound to the slider's low pointer value. |
options |
Type : Options
|
Slider options. |
popoverElement |
Type : ElementRef<HTMLElement>
|
Decorators :
@ViewChild('popover', {read: ElementRef, static: false})
|
Reference to the popover element. This is undefined until the slider popover is initialized. |
popoverPortal |
Type : CdkPortal
|
Decorators :
@ViewChild(CdkPortal, {static: true})
|
Reference to the template for the slider popover. |
rangeLabel |
getrangeLabel()
|
Computes the current age range for display in the button.
Returns :
string
|
import { ConnectedPosition, Overlay, OverlayRef } from '@angular/cdk/overlay';
import { CdkPortal } from '@angular/cdk/portal';
import {
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
HostListener,
Input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
ViewChild,
} from '@angular/core';
import { Options } from '@angular-slider/ngx-slider';
import { GoogleAnalyticsService } from 'ngx-google-analytics';
/**
* Component containing a button that when clicked will show a slider popover.
*/
@Component({
selector: 'ccf-dual-slider',
templateUrl: './dual-slider.component.html',
styleUrls: ['./dual-slider.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DualSliderComponent implements OnDestroy, OnChanges {
/**
* Reference to the template for the slider popover.
*/
@ViewChild(CdkPortal, { static: true }) popoverPortal!: CdkPortal;
/**
* Reference to the popover element.
* This is undefined until the slider popover is initialized.
*/
@ViewChild('popover', { read: ElementRef, static: false }) popoverElement!: ElementRef<HTMLElement>;
/**
* Which criteria the slider is selecting for.
*/
@Input() label!: string;
/**
* The lower and upper range of the slider.
*/
@Input() valueRange!: number[];
/**
* The current range selected.
*/
@Input() selection!: number[];
/**
* Emits the new selection range when a change is made to it.
*/
@Output() readonly selectionChange = new EventEmitter<number[]>();
/**
* Determines whether slider popover is shown.
*/
isSliderOpen = false;
/**
* Slider options.
*/
options!: Options;
/**
* Value bound to the slider's low pointer value.
*/
lowValue!: number;
/**
* Value bound to the slider's high pointer value.
*/
highValue!: number;
/**
* Determines if slider contents are visible (used for fade-in effect).
*/
contentsVisible = 'invisible';
/**
* Computes the current age range for display in the button.
*/
get rangeLabel(): string {
const { lowValue, highValue } = this;
if (lowValue === highValue) {
return `${lowValue}`;
}
return `${lowValue}-${highValue}`;
}
/**
* Reference to the slider popover overlay.
*/
private readonly overlayRef: OverlayRef;
/**
* Determines whether slider popover has been created and initialized.
*/
private isSliderInitialized = false;
/**
* Creates an instance of dual slider component.
*
* @param overlay The overlay service used to create the slider popover.
* @param element A reference to the component's element. Used during event handling.
* @param ga Analytics service
*/
constructor(
overlay: Overlay,
private readonly element: ElementRef<HTMLElement>,
private readonly ga: GoogleAnalyticsService,
) {
const position: ConnectedPosition = { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top' };
const positionStrategy = overlay.position().flexibleConnectedTo(element).withPositions([position]);
this.overlayRef = overlay.create({
panelClass: 'slider-pane',
positionStrategy,
});
}
/**
* Updates slider options (with optionsChanged) and selection when changes detected.
*
* @param changes Changes that have been made to the slider properties.
*/
ngOnChanges(changes: SimpleChanges): void {
if (changes['valueRange']) {
this.optionsChanged();
}
if (changes['selection']) {
// Detect when selection is changed and update low/high value.
this.lowValue = Math.min(...this.selection);
this.highValue = Math.max(...this.selection);
}
}
/**
* Updates the slider options, and the slider values if necessary.
*/
optionsChanged(): void {
this.options = {
floor: this.valueRange ? this.valueRange[0] : 0,
ceil: this.valueRange ? this.valueRange[1] : 0,
step: 1,
hideLimitLabels: true,
hidePointerLabels: true,
};
this.lowValue = this.options.floor ?? 0;
this.highValue = this.options.ceil ?? 0;
}
/**
* Angular's OnDestroy hook.
* Cleans up the overlay.
*/
ngOnDestroy(): void {
this.overlayRef.dispose();
}
/**
* Listens to document click, mouse movement, and touch event.
* Closes the slider popover when such an event occurs outside the button or popover.
*
* @param target The element on which the event was fired.
*/
@HostListener('document:click', ['$event.target'])
@HostListener('document:touchstart', ['$event.target'])
closeSliderPopover(target: HTMLElement): void {
const { element, isSliderOpen, popoverElement } = this;
const isEventOutside =
!isSliderOpen || element.nativeElement.contains(target) || popoverElement?.nativeElement?.contains?.(target);
if (isEventOutside) {
return;
}
this.overlayRef.detach();
this.isSliderInitialized = false;
this.isSliderOpen = false;
this.contentsVisible = 'invisible';
}
/**
* Toggles the visibility of the slider popover.
*/
toggleSliderPopover(): void {
const { isSliderOpen, isSliderInitialized } = this;
if (isSliderInitialized) {
this.overlayRef.detach();
this.isSliderInitialized = false;
} else if (!isSliderInitialized && !isSliderOpen) {
this.initializeSliderPopover();
}
this.contentsVisible = this.contentsVisible === 'visible' ? 'invisible' : 'visible';
this.isSliderOpen = !isSliderOpen;
}
/**
* Handler for updates to the slider values.
* Emits the updated selection value array.
*/
sliderValueChanged(): void {
const { lowValue, highValue } = this;
this.selection = [lowValue, highValue];
this.ga.event('slider_range_change', 'dual_slider', `${this.label}:${lowValue}:${highValue}`);
this.selectionChange.emit(this.selection);
}
/**
* Creates and initializes the slider popover.
*/
private initializeSliderPopover(): void {
const { overlayRef, popoverPortal } = this;
overlayRef.attach(popoverPortal);
overlayRef.updatePosition();
this.isSliderInitialized = true;
}
/**
* Updates the slider's low pointer value when Enter key is pressed.
*
* @param event Event passed into the component
*/
onKeyLow(event: KeyboardEvent): void {
const newValue = Number((event.target as HTMLInputElement).value);
if (event.key === 'Enter') {
if (newValue >= Number(this.options.floor) && newValue <= Number(this.options.ceil)) {
this.lowValue = newValue;
}
(event.target as HTMLInputElement).value = String(this.lowValue);
(event.target as HTMLInputElement).blur();
this.sliderValueChanged();
}
}
/**
* Updates the slider's high pointer value when Enter key is pressed.
*
* @param event Event passed into the component
*/
onKeyHigh(event: KeyboardEvent): void {
const newValue = Number((event.target as HTMLInputElement).value);
if (event.key === 'Enter') {
if (newValue >= Number(this.options.floor) && newValue <= Number(this.options.ceil)) {
this.highValue = newValue;
}
(event.target as HTMLInputElement).value = String(this.highValue);
(event.target as HTMLInputElement).blur();
this.sliderValueChanged();
}
}
}
<div class="ccf-slider wrapper">
<div class="container">
<div *cdk-portal class="ccf-slider detached" #popover>
<div class="label min fade-in {{ contentsVisible }}">
<div class="label floor">{{ options.floor }}></div>
<input class="input-low" type="text" value="{{ lowValue }}" (keyup)="onKeyLow($event)" />
</div>
<ngx-slider
class="slider fade-in {{ contentsVisible }}"
[options]="options"
[(value)]="lowValue"
[(highValue)]="highValue"
(userChangeEnd)="sliderValueChanged()"
>
</ngx-slider>
<div class="label max fade-in {{ contentsVisible }}">
<div class="label ceil">{{ options.ceil }}</div>
<input class="input-high" type="text" value="{{ highValue }}" (keyup)="onKeyHigh($event)" />
</div>
</div>
<mat-form-field
class="slider-form-field"
[class.highlight]="isSliderOpen"
(click)="toggleSliderPopover()"
subscriptSizing="dynamic"
>
<div class="slider-labels">
<span class="name-label">{{ label }}</span>
<span class="range-label">{{ rangeLabel }}</span>
</div>
<mat-select></mat-select>
</mat-form-field>
</div>
</div>
./dual-slider.component.scss
@use 'sass:math';
.slider-form-field {
width: 100%;
height: 3rem;
::ng-deep .mat-mdc-text-field-wrapper {
padding-left: 0.25rem;
padding-right: 0.25rem;
height: calc(3rem - 1px);
.mat-mdc-form-field-flex {
.mat-mdc-form-field-infix {
font-size: 0.875rem;
border: none;
.slider-labels {
height: 19.25px;
display: flex;
flex-direction: column;
.name-label {
height: 100%;
}
.range-label {
font-weight: bold;
}
}
mat-select {
font-size: 1rem;
font-weight: bold;
.mat-mdc-select-arrow-wrapper {
position: relative;
bottom: 0.25rem;
right: 0.25rem;
}
}
}
}
.mdc-line-ripple::before {
border-bottom-width: 2px;
}
}
}
::ng-deep .ccf-slider.wrapper {
.mat-select-arrow-wrapper {
transform: translatey(-1.5em);
}
}
// Styles for the popover slider
// NOTE: This must NOT be nested inside the wrapper/container!
@keyframes slideInHorizontalSlider {
from {
width: 0;
}
to {
width: 20em;
}
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.visible {
animation: fadeIn 0.3s;
transition-delay: 0.275s;
animation-delay: 0.275s;
}
.invisible {
opacity: 0;
}
::ng-deep .slider-pane {
position: absolute !important;
top: -1px;
}
.ccf-slider.detached {
animation: slideInHorizontalSlider 0.3s;
animation-fill-mode: forwards;
box-shadow: 0.2rem 0.2rem 1rem 0rem #0000003e;
display: flex;
justify-content: center;
align-items: center;
width: 0rem;
height: 4.375rem;
padding: 0.75rem; // NOTE: Use padding instead of margin!
.slider ::ng-deep {
visibility: hidden;
margin-top: 0.9375rem;
margin-bottom: 0.9375rem;
.ngx-slider-bar {
opacity: 0.2;
height: 0.15rem;
}
.ngx-slider-selection {
opacity: 1;
}
.ngx-slider-pointer {
$pointer-size: 1rem;
width: $pointer-size;
height: $pointer-size;
top: 0.095rem - math.div($pointer-size, 2);
&:after {
display: none;
}
}
}
.label {
display: flex;
flex-direction: column;
width: 2rem;
&.min {
margin-right: 1rem;
}
&.max {
margin-left: 1rem;
align-items: flex-end;
.ceil,
input {
text-align: right;
}
}
.floor,
.ceil {
font-size: 0.875rem;
}
input {
border: none;
width: 1.75rem;
font-size: 1rem;
font-weight: bold;
padding: 0;
}
}
}