File

src/app/shared/components/dual-slider/dual-slider.component.ts

Description

Component containing a button that when clicked will show a slider popover.

Implements

OnDestroy OnChanges

Metadata

Index

Properties
Methods
Inputs
Outputs
HostListeners
Accessors

Constructor

constructor(overlay: Overlay, element: ElementRef, ga: GoogleAnalyticsService)

Creates an instance of dual slider component.

Parameters :
Name Type Optional Description
overlay Overlay No

The overlay service used to create the slider popover.

element ElementRef<HTMLElement> No

A reference to the component's element. Used during event handling.

ga GoogleAnalyticsService No

Analytics service

Inputs

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.

Outputs

selectionChange
Type : EventEmitter

Emits the new selection range when a change is made to it.

HostListeners

document:click
Arguments : '$event.target'
document:click(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 :
Name Optional Description
target No

The element on which the event was fired.

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 :
Name Optional Description
target No

The element on which the event was fired.

Methods

closeSliderPopover
closeSliderPopover(target: HTMLElement)
Decorators :
@HostListener('document:click', ['$event.target'])
@HostListener('document:touchstart', ['$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 :
Name Type Optional Description
target HTMLElement No

The element on which the event was fired.

Returns : void
onKeyHigh
onKeyHigh(event: KeyboardEvent)

Updates the slider's high pointer value when Enter key is pressed.

Parameters :
Name Type Optional Description
event KeyboardEvent No

Event passed into the component

Returns : void
onKeyLow
onKeyLow(event: KeyboardEvent)

Updates the slider's low pointer value when Enter key is pressed.

Parameters :
Name Type Optional Description
event KeyboardEvent No

Event passed into the component

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

Properties

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.

Accessors

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;
    }
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""