chromium/ash/webui/common/resources/cr_elements/cr_slider/cr_slider.ts

// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview 'cr-slider' is a slider component used to select a number from
 * a continuous or discrete range of numbers.
 *
 * Forked from ui/webui/resources/cr_elements/cr_slider/cr_slider.ts
 */
import '../cr_hidden_style.css.js';
import '../cr_shared_vars.css.js';

import {assert} from '//resources/js/assert.js';
import {EventTracker} from '//resources/js/event_tracker.js';
import {PaperRippleMixin} from '//resources/polymer/v3_0/paper-behaviors/paper-ripple-mixin.js';
import {Debouncer, microTask, PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './cr_slider.html.js';

/**
 * The |value| is the corresponding value that the current slider tick is
 * associated with. The string |label| is shown in the UI as the label for the
 * current slider value. The |ariaValue| number is used for aria-valuemin,
 * aria-valuemax, and aria-valuenow, and is optional. If missing, |value| will
 * be used instead.
 */
export interface SliderTick {
  value: number;
  label: string;
  ariaValue?: number;
}

function clamp(min: number, max: number, value: number): number {
  return Math.min(max, Math.max(min, value));
}

function getAriaValue(tick: SliderTick|number): number {
  if (Number.isFinite(tick as number)) {
    return tick as number;
  }

  const sliderTick = tick as SliderTick;
  return sliderTick.ariaValue !== undefined ? sliderTick.ariaValue! :
                                              sliderTick.value;
}

const CrSliderElementBase = PaperRippleMixin(PolymerElement);

/**
 * The following are the events emitted from cr-slider.
 *
 * cr-slider-value-changed: fired when updating slider via the UI.
 * dragging-changed: fired on pointer down and on pointer up.
 */

export interface CrSliderElement {
  $: {
    bar: HTMLElement,
    container: HTMLElement,
    knobAndLabel: HTMLElement,
    knob: HTMLElement,
  };
}

export class CrSliderElement extends CrSliderElementBase {
  static get is() {
    return 'cr-slider';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      disabled: {
        type: Boolean,
        value: false,
      },

      /**
       * Internal representation of disabled depending on |disabled| and
       * |ticks|.
       */
      disabled_: {
        type: Boolean,
        computed: 'computeDisabled_(disabled, ticks.*)',
        reflectToAttribute: true,
        observer: 'onDisabledChanged_',
      },

      dragging: {
        type: Boolean,
        value: false,
        notify: true,
      },

      updatingFromKey: {
        type: Boolean,
        value: false,
        notify: true,
      },

      /**
       * The amount the slider value increments by when pressing any of the keys
       * from `deltaKeyMap_`. Defaults to 1.
       */
      keyPressSliderIncrement: {
        type: Number,
        value: 1,
      },

      markerCount: {
        type: Number,
        value: 0,
      },

      max: {
        type: Number,
        value: 100,
      },

      min: {
        type: Number,
        value: 0,
      },

      /**
       * When set to false, the keybindings are not handled by this component,
       * for example when the owner of the component wants to set up its own
       * keybindings.
       */
      noKeybindings: {
        type: Boolean,
        value: false,
      },

      snaps: {
        type: Boolean,
        value: false,
      },

      /**
       * The data associated with each tick on the slider. Each element in the
       * array contains a value and the label corresponding to that value.
       */
      ticks: {
        type: Array,
        value: () => [],
      },

      value: Number,

      label_: {
        type: String,
        value: '',
      },

      showLabel_: {
        type: Boolean,
        value: false,
        reflectToAttribute: true,
      },

      isRtl_: {
        type: Boolean,
        value: false,
        reflectToAttribute: true,
      },

      /**
       * |transiting_| is set to true when bar is touched or clicked. This
       * triggers a single position transition effect to take place for the
       * knob, bar and label. When the transition is complete, |transiting_| is
       * set to false resulting in no transition effect during dragging, manual
       * value updates and keyboard events.
       */
      transiting_: {
        type: Boolean,
        value: false,
        reflectToAttribute: true,
      },
    };
  }

  static get observers() {
    return [
      'onTicksChanged_(ticks.*)',
      'updateUi_(ticks.*, value, min, max)',
      'onValueMinMaxChange_(value, min, max)',
      'buildDeltaKeyMap_(isRtl_, keyPressSliderIncrement)',
    ];
  }

  disabled: boolean;
  dragging: boolean;
  updatingFromKey: boolean;
  keyPressSliderIncrement: number;
  markerCount: number;
  max: number;
  min: number;
  noKeybindings: boolean;
  snaps: boolean;
  ticks: SliderTick[]|number[];
  value: number;

  private disabled_: boolean;
  private label_: string;
  private showLabel_: boolean;
  private isRtl_: boolean;
  private transiting_: boolean;

  private deltaKeyMap_: Map<string, number>|null = null;
  private draggingEventTracker_: EventTracker|null = null;
  private debouncer_: Debouncer;

  /* eslint-disable-next-line @typescript-eslint/naming-convention */
  override _rippleContainer: Element;

  override ready() {
    super.ready();
    this.setAttribute('role', 'slider');

    this.addEventListener('blur', this.hideRipple_);
    this.addEventListener('focus', this.showRipple_);
    this.addEventListener('keydown', this.onKeyDown_);
    this.addEventListener('keyup', this.onKeyUp_);
    this.addEventListener('pointerdown', this.onPointerDown_.bind(this));
  }

  override connectedCallback() {
    super.connectedCallback();
    this.isRtl_ = window.getComputedStyle(this)['direction'] === 'rtl';
    this.draggingEventTracker_ = new EventTracker();
  }

  private fire_(eventName: string, detail?: any) {
    this.dispatchEvent(
        new CustomEvent(eventName, {bubbles: true, composed: true, detail}));
  }

  private computeDisabled_(): boolean {
    return this.disabled || this.ticks.length === 1;
  }

  /**
   * When markers are displayed on the slider, they are evenly spaced across
   * the entire slider bar container and are rendered on top of the bar and
   * bar container. The location of the marks correspond to the discrete
   * values that the slider can have.
   * @return The array items have no type since this is used to
   *     create |markerCount| number of markers.
   */
  private getMarkers_<T>(): T[] {
    return new Array(Math.max(0, this.markerCount - 1));
  }

  private getMarkerClass_(index: number): string {
    const currentStep = (this.markerCount - 1) * this.getRatio();
    return index < currentStep ? 'active-marker' : 'inactive-marker';
  }

  /**
   * The ratio is a value from 0 to 1.0 corresponding to a location along the
   * slider bar where 0 is the minimum value and 1.0 is the maximum value.
   * This is a helper function used to calculate the bar width, knob location
   * and label location.
   */
  getRatio(): number {
    return (this.value - this.min) / (this.max - this.min);
  }

  /**
   * Removes all event listeners related to dragging, and cancels ripple.
   */
  private stopDragging_(pointerId: number) {
    this.draggingEventTracker_!.removeAll();
    this.releasePointerCapture(pointerId);
    this.dragging = false;
    this.hideRipple_();
  }

  private hideRipple_() {
    if (this.noink) {
      return;
    }

    this.getRipple().clear();
    this.showLabel_ = false;
  }

  private showRipple_() {
    if (this.noink) {
      return;
    }

    if (!this.getRipple().holdDown) {
      this.getRipple().showAndHoldDown();
    }
    this.showLabel_ = true;
  }

  private onDisabledChanged_() {
    this.setAttribute('tabindex', this.disabled_ ? '-1' : '0');
    this.blur();
  }

  private onKeyDown_(event: KeyboardEvent) {
    if (this.disabled_ || this.noKeybindings) {
      return;
    }

    if (event.metaKey || event.shiftKey || event.altKey || event.ctrlKey) {
      return;
    }

    let newValue: number|undefined;
    if (event.key === 'Home') {
      newValue = this.min;
    } else if (event.key === 'End') {
      newValue = this.max;
    } else if (this.deltaKeyMap_!.has(event.key)) {
      newValue = this.value + this.deltaKeyMap_!.get(event.key)!;
    }

    if (newValue === undefined) {
      return;
    }

    this.updatingFromKey = true;
    if (this.updateValue_(newValue)) {
      this.fire_('cr-slider-value-changed');
    }
    event.preventDefault();
    event.stopPropagation();
    this.showRipple_();
  }

  private onKeyUp_(event: KeyboardEvent) {
    if (event.key === 'Home' || event.key === 'End' ||
        this.deltaKeyMap_!.has(event.key)) {
      setTimeout(() => {
        this.updatingFromKey = false;
      });
    }
  }

  /**
   * When the left-mouse button is pressed, the knob location is updated and
   * dragging starts.
   */
  private onPointerDown_(event: PointerEvent) {
    if (this.disabled_ ||
        event.buttons !== 1 && event.pointerType === 'mouse') {
      return;
    }

    this.dragging = true;
    this.transiting_ = true;
    this.updateValueFromClientX_(event.clientX);
    this.showRipple_();

    this.setPointerCapture(event.pointerId);
    const stopDragging = this.stopDragging_.bind(this, event.pointerId);

    assert(!!this.draggingEventTracker_);
    this.draggingEventTracker_.add(this, 'pointermove', (e: PointerEvent) => {
      // Prevent unwanted text selection to occur while moving the pointer,
      // this is important.
      e.preventDefault();

      // If the left-button on the mouse is pressed by itself, then update.
      // Otherwise stop capturing the mouse events because the drag operation
      // is complete.
      if (e.buttons !== 1 && e.pointerType === 'mouse') {
        stopDragging();
        return;
      }
      this.updateValueFromClientX_(e.clientX);
    });
    this.draggingEventTracker_.add(this, 'pointercancel', stopDragging);
    this.draggingEventTracker_.add(this, 'pointerdown', stopDragging);
    this.draggingEventTracker_.add(this, 'pointerup', stopDragging);
    this.draggingEventTracker_.add(this, 'keydown', (e: KeyboardEvent) => {
      if (e.key === 'Escape' || e.key === 'Tab' || e.key === 'Home' ||
          e.key === 'End' || this.deltaKeyMap_!.has(e.key)) {
        stopDragging();
      }
    });
  }

  private onTicksChanged_() {
    if (this.ticks.length > 1) {
      this.snaps = true;
      this.max = this.ticks.length - 1;
      this.min = 0;
    }
    if (this.value !== undefined) {
      this.updateValue_(this.value);
    }
  }

  private onTransitionEnd_() {
    this.transiting_ = false;
  }

  private onValueMinMaxChange_() {
    this.debouncer_ = Debouncer.debounce(this.debouncer_, microTask, () => {
      if (this.value === undefined || this.min === undefined ||
          this.max === undefined) {
        return;
      }
      this.updateValue_(this.value);
    });
  }

  private updateUi_() {
    const percent = `${this.getRatio() * 100}%`;
    this.$.bar.style.width = percent;
    this.$.knobAndLabel.style.marginInlineStart = percent;

    const ticks = this.ticks;
    const value = this.value;
    if (ticks && ticks.length > 0 && Number.isInteger(value) && value >= 0 &&
        value < ticks.length) {
      const tick = ticks[this.value]!;
      this.label_ = Number.isFinite(tick) ? '' : (tick as SliderTick).label;
      const ariaValueNow = getAriaValue(tick);
      this.setAttribute('aria-valuetext', String(this.label_ || ariaValueNow));
      this.setAttribute('aria-valuenow', ariaValueNow.toString());
      this.setAttribute('aria-valuemin', getAriaValue(ticks[0]!).toString());
      this.setAttribute(
          'aria-valuemax', getAriaValue(ticks.slice(-1)[0]!).toString());
    } else {
      this.setAttribute(
          'aria-valuetext', value !== undefined ? value.toString() : '');
      this.setAttribute(
          'aria-valuenow', value !== undefined ? value.toString() : '');
      this.setAttribute('aria-valuemin', this.min.toString());
      this.setAttribute('aria-valuemax', this.max.toString());
    }
  }

  private updateValue_(value: number): boolean {
    this.$.container.hidden = false;
    if (this.snaps) {
      // Skip update if |value| has not passed the next value .8 units away.
      // The value will update as the drag approaches the next value.
      if (Math.abs(this.value - value) < .8) {
        return false;
      }
      value = Math.round(value);
    }
    value = clamp(this.min, this.max, value);
    if (this.value === value) {
      return false;
    }
    this.value = value;
    return true;
  }

  private updateValueFromClientX_(clientX: number) {
    const rect = this.$.container.getBoundingClientRect();
    let ratio = (clientX - rect.left) / rect.width;
    if (this.isRtl_) {
      ratio = 1 - ratio;
    }
    if (this.updateValue_(ratio * (this.max - this.min) + this.min)) {
      this.fire_('cr-slider-value-changed');
    }
  }

  private buildDeltaKeyMap_() {
    const increment = this.keyPressSliderIncrement;
    const decrement = -this.keyPressSliderIncrement;
    this.deltaKeyMap_ = new Map([
      ['ArrowDown', decrement],
      ['ArrowUp', increment],
      ['PageDown', decrement],
      ['PageUp', increment],
      ['ArrowLeft', this.isRtl_ ? increment : decrement],
      ['ArrowRight', this.isRtl_ ? decrement : increment],
    ]);
  }

  // Overridden from PaperRippleMixin
  /* eslint-disable-next-line @typescript-eslint/naming-convention */
  override _createRipple() {
    this._rippleContainer = this.$.knob;
    const ripple = super._createRipple();
    ripple.id = 'ink';
    ripple.setAttribute('recenters', '');
    ripple.classList.add('circle');
    return ripple;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'cr-slider': CrSliderElement;
  }
}

customElements.define(CrSliderElement.is, CrSliderElement);