chromium/chrome/browser/resources/ash/settings/controls/v2/settings_slider_v2.ts

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

/**
 * @fileoverview
 * settings-slider-v2 is a component that displays a range of values. When
 * `ticks` is specified and `value` does not map exactly to a tick mark, the
 * slider interpolates to the nearest tick.
 *
 * - Usage: without pref
 *   - `value` must be specified and `pref` must not be used.
 *
 *   // With ticks
 *   <settings-slider-v2
 *       value="[[sliderValue_]]"
 *       ticks="[[sliderTicks_]]"
 *       on-change="onSliderChange_"
 *       min-label="$i18n{low}"
 *       max-label="$i18n{high}">
 *   <settings-slider-v2>
 *
 *   // With scale
 *   <settings-slider-v2
 *       value="[[sliderValue_]]"
 *       min="0"
 *       max="100"
 *       scale="100"
 *       on-change="onSliderChange_"
 *       min-label="$i18n{low}"
 *       max-label="$i18n{high}">
 *   <settings-slider-v2>
 *
 * - Usage: with pref
 *   - `pref` must be specified and `value` must not be used.
 *
 *   // With ticks
 *   <settings-slider-v2
 *       pref="[[prefs.foo.bar]]"
 *       ticks="[[sliderTicks_]]"
 *       on-change="onSliderChange_"
 *       min-label="$i18n{low}"
 *       max-label="$i18n{high}"
 *       hide-markers>
 *   <settings-slider-v2>
 *
 *   // With scale
 *   <settings-slider-v2
 *       pref="[[prefs.foo.bar]]"
 *       min="0"
 *       max="100"
 *       scale="100"
 *       on-change="onSliderChange_"
 *       min-label="$i18n{low}"
 *       max-label="$i18n{high}">
 *   <settings-slider-v2>
 */

import 'chrome://resources/ash/common/cr_elements/cr_shared_vars.css.js';
import 'chrome://resources/ash/common/cr_elements/cr_hidden_style.css.js';
import 'chrome://resources/ash/common/cr_elements/cr_slider/cr_slider.js';
import 'chrome://resources/ash/common/cr_elements/cros_color_overrides.css.js';

import {CrSliderElement, SliderTick} from 'chrome://resources/ash/common/cr_elements/cr_slider/cr_slider.js';
import {assert} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {PrefControlMixinInternal} from './pref_control_mixin_internal.js';
import {getTemplate} from './settings_slider_v2.html.js';

export interface SettingsSliderV2Element {
  $: {
    slider: CrSliderElement,
  };
}

const SettingsSliderV2ElementBase = PrefControlMixinInternal(PolymerElement);

export class SettingsSliderV2Element extends SettingsSliderV2ElementBase {
  static get is() {
    return 'settings-slider-v2' as const;
  }

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

  static get properties() {
    return {
      /**
       * The current value of the slider. It shouldn't be used or updated if
       * `pref` is specified.
       */
      value: Number,

      /**
       * Values corresponding to each tick. It should be empty if `scale` is
       * specified.
       */
      ticks: {
        type: Array,
        value: () => [],
      },

      /**
       * A scale factor used to support fractional values. For example, if
       * min=0, max=10, scale=10, the value ranges for any decimal number
       * between [0, 1]. If min=0, max=10, scale=1, the value ranges for any
       * decimal number between [0, 10].
       * This is not compatible with `ticks`, i.e. if `scale` is not 1 then
       * `ticks` must not be set.
       */
      scale: {
        type: Number,
        value: 1,
      },

      /**
       * The slider minimum value. If `ticks` is not used, this must be
       * specified.
       */
      min: Number,

      /**
       * The slider maximum value. If `ticks` is not used, this must be
       * specified.
       */
      max: Number,

      /**
       * Label for the min value of the slider that shows below the slider. Also
       * used as the A11y label for the min value.
       */
      minLabel: String,

      /**
       * Label for the max value of the slider that shows below the slider. Also
       * used as the A11y label for the max value.
       */
      maxLabel: String,

      /**
       * Whether or not to hide the min and max labels below the slider.
       * Defaults to false.
       */
      hideLabel: {
        type: Boolean,
        value: false,
      },

      /**
       * Whether or not to hide tick marks on the slider. Default to false.
       * Only compatible with `ticks`, and not compatible with `scale`.
       */
      hideMarkers: {
        type: Boolean,
        value: false,
      },

      /**
       * By default, the slider value will only be updated when the dragging
       * event is finished. Set this property to true to trigger a change event
       * any time the slider is dragged.
       */
      updateValueInstantly: {
        type: Boolean,
        value: false,
        observer: 'onSliderChanged_',
      },

      // A11y properties added since they are data-bound in HTML.
      ariaLabel: {
        type: String,
        reflectToAttribute: false,
        observer: 'onAriaLabelSet_',
      },

      ariaDescription: {
        type: String,
        reflectToAttribute: false,
        observer: 'onAriaDescriptionSet_',
      },

      loaded_: Boolean,
    };
  }

  static get observers() {
    return [
      'syncPrefChangeToValue_(pref.*)',
      'valueChanged_(value, ticks.*, loaded_)',
    ];
  }

  ticks: SliderTick[]|number[];
  scale: number;
  min: number;
  max: number;
  hideLabel: boolean;
  minLabel: string;
  maxLabel: string;
  hideMarkers: boolean;
  updateValueInstantly: boolean;
  override validPrefTypes = [chrome.settingsPrivate.PrefType.NUMBER];
  value: number;
  private loaded_: boolean;

  override connectedCallback(): void {
    super.connectedCallback();

    this.loaded_ = true;
  }

  override focus(): void {
    this.$.slider.focus();
  }

  private getTickValue_(tick: number|SliderTick): number {
    return typeof tick === 'object' ? tick.value : tick;
  }

  private getTickValueAtIndex_(index: number): number {
    return this.getTickValue_(this.ticks[index]);
  }

  /**
   * Dispatches a `user-action-setting-pref-change` event to update `pref.value`
   * property to the value corresponding to the knob position after a user
   * action. If `pref` is not defined, update the `value` instead.
   */
  private onSliderChanged_(): void {
    if (!this.loaded_) {
      return;
    }

    if (this.$.slider.dragging && !this.updateValueInstantly) {
      return;
    }

    const sliderValue = this.$.slider.value;
    if (this.ticks.length > 0) {
      this.value = this.getTickValueAtIndex_(sliderValue);
    } else {
      this.value = sliderValue / this.scale;
    }

    if (this.pref) {
      this.updatePrefValueFromUserAction(this.value);
    }

    this.dispatchEvent(new CustomEvent('change', {
      bubbles: true,
      composed: false,  // Event should not pass the shadow DOM boundary.
      detail: this.value,
    }));
  }

  /**
   * This observer watches changes to `pref` and syncs it to the `value`
   * property.
   */
  private syncPrefChangeToValue_(): void {
    if (this.pref) {
      this.value = this.pref.value;
    }
  }

  /**
   * This observer watches changes to `value` via downwards data-flow and
   * updates the slider accordingly. If the knob is in the middle of being
   * dragged, the slider value is forced back to the current position.
   */
  private valueChanged_(): void {
    if (!this.loaded_ || this.$.slider.dragging ||
        this.$.slider.updatingFromKey) {
      return;
    }

    // First update the slider settings if `ticks` was set.
    const numTicks = this.ticks.length;
    if (numTicks === 1) {
      this.disabled = true;
      return;
    }

    const currentValue = this.value;

    // The preference and slider values are continuous when `ticks` is empty.
    if (numTicks === 0) {
      this.$.slider.value = currentValue * this.scale;
      return;
    }

    assert(this.scale === 1, 'Scale has to be 1 if ticks is set.');
    // Limit the number of ticks to 10 to keep the slider from looking too busy.
    const MAX_TICKS = 10;
    this.$.slider.markerCount =
        (this.hideMarkers || numTicks > MAX_TICKS) ? 0 : numTicks;

    // Convert from the public `value` to the slider index (where the knob
    // should be positioned on the slider).
    const index =
        this.ticks
            .map(
                (tick: number|SliderTick) =>
                    Math.abs(this.getTickValue_(tick) - currentValue))
            .reduce(
                (acc, diff, index) => diff < acc.diff ? {index, diff} : acc,
                {index: -1, diff: Number.MAX_VALUE})
            .index;
    assert(index !== -1);
    if (this.$.slider.value !== index) {
      this.$.slider.value = index;
    }
    const tickValue = this.getTickValueAtIndex_(index);
    if (this.value !== tickValue) {
      this.value = tickValue;
    }
  }

  private getRoleDescription_(): string {
    if (this.minLabel && this.maxLabel) {
      return loadTimeData.getStringF(
          'settingsSliderRoleDescription', this.minLabel, this.maxLabel);
    }

    return '';
  }

  private getAriaDisabled_(): string {
    return this.disabled ? 'true' : 'false';
  }

  /**
   * Manually remove the aria-label attribute from the host node since it is
   * applied to the internal slider. `reflectToAttribute=false` does not resolve
   * this issue. This prevents the aria-label from being duplicated by
   * screen readers.
   */
  private onAriaLabelSet_(): void {
    const ariaLabel = this.getAttribute('aria-label');
    this.removeAttribute('aria-label');
    if (ariaLabel) {
      this.ariaLabel = ariaLabel;
    }
  }

  /**
   * Manually remove the aria-description attribute from the host node since it
   * is applied to the internal slider. `reflectToAttribute=false` does not
   * resolve this issue. This prevents the aria-description from being
   * duplicated by screen readers.
   */
  private onAriaDescriptionSet_(): void {
    const ariaDescription = this.getAttribute('aria-description');
    this.removeAttribute('aria-description');
    if (ariaDescription) {
      this.ariaDescription = ariaDescription;
    }
  }
}

declare global {
  interface HTMLElementTagNameMap {
    [SettingsSliderV2Element.is]: SettingsSliderV2Element;
  }
}

customElements.define(SettingsSliderV2Element.is, SettingsSliderV2Element);