chromium/chrome/browser/resources/ash/settings/controls/v2/settings_dropdown_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-dropdown-v2' is an element displaying a list of options. Works with
 * or without a pref object.
 *
 * - Usage: without pref
 *   - `value` must be provided and `pref` must not be used.
 *
 *   <settings-dropdown-v2
 *       options="[[dropdownOptions_]]"
 *       value="[[value]]"
 *       on-change="onDropdownChange_">
 *   <settings-dropdown-v2>
 *
 * - Usage: with pref
 *   - `pref` must be provided and `value` must not be used.
 *
 *   <settings-dropdown-v2
 *       options="[[dropdownOptions_]]"
 *       pref="[[prefs.foo.bar]]"
 *       on-change="onDropdownChange_">
 *   <settings-dropdown-v2>
 */

import 'chrome://resources/ash/common/cr_elements/cr_shared_vars.css.js';
import 'chrome://resources/ash/common/cr_elements/cros_color_overrides.css.js';
import 'chrome://resources/ash/common/cr_elements/md_select.css.js';
import 'chrome://resources/ash/common/cr_elements/policy/cr_policy_pref_indicator.js';

import {microTask, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {assertExists} from '../../assert_extras.js';

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

/**
 * - `label` is shown in the UI.
 * - `value` is the underlying value for the option.
 * - `hidden` specifies whether to hide this option in the UI.
 */
export interface DropdownOption {
  label: string;
  value: number|string;
  hidden?: boolean;
}

export type DropdownOptionList = DropdownOption[];

export interface SettingsDropdownV2Element {
  $: {
    select: HTMLSelectElement,
  };
}

const SettingsDropdownV2ElementBase = PrefControlMixinInternal(PolymerElement);

export class SettingsDropdownV2Element extends SettingsDropdownV2ElementBase {
  static get is() {
    return 'settings-dropdown-v2' as const;
  }

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

  static get properties() {
    return {
      /**
       * List of options for the dropdown.
       */
      options: {
        type: Array,
        value: () => {
          return [];
        },
      },

      /**
       * Note: This property should not be set via downward data-binding from
       * parent elements if `pref` is defined.
       * Represents the value of the dropdown. When `pref` is specified, the
       * pref object's value is synced to this property. When `pref` is not
       * specified, this property can be updated via downward data-binding.
       */
      value: {
        type: String,
        value: undefined,
      },

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

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

  static get observers() {
    return [
      'syncPrefChangeToValue_(pref.*)',
      'setSelectedOption_(options, value)',
    ];
  }

  options: DropdownOptionList;
  override validPrefTypes: chrome.settingsPrivate.PrefType[] = [
    chrome.settingsPrivate.PrefType.NUMBER,
    chrome.settingsPrivate.PrefType.STRING,
  ];
  value?: DropdownOption['value'];

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

  /**
   * Event handler for when a menu item is selected by user action. Dispatches a
   * `change` event containing the selected value.
   */
  private onChange_(): void {
    const optionIndex = this.findMatchingOptionIndex_(this.$.select.value);
    const selectedOption = this.options[optionIndex];
    assertExists(selectedOption);
    this.value = selectedOption.value;

    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 the `options` list or `value`.
   * Programmatically sets the <select>'s value via the index of the selected
   * option. An index of -1 means that no option is selected and the <select>
   * should appear blank.
   */
  private setSelectedOption_(): void {
    const optionIndex = this.findMatchingOptionIndex_(this.value);

    // Wait for the dom-repeat to populate the <select> options before setting
    // the value.
    microTask.run(() => {
      this.$.select.selectedIndex = optionIndex;
    });
  }

  /**
   * Returns the index of an option with the same value as `value`. Returns -1
   * if no options match.
   */
  private findMatchingOptionIndex_(value: DropdownOption['value']|
                                   undefined): number {
    if (value === undefined) {
      return -1;
    }

    return this.options.findIndex((option) => {
      return option.value.toString() === value.toString();
    });
  }

  /**
   * Determines if the internal select element should be disabled. It should be
   * disabled if there are no menu items.
   */
  private isSelectDisabled_(): boolean {
    return this.disabled || this.options.length === 0;
  }

  /**
   * Manually remove the aria-label attribute from the host node since it is
   * applied to the internal select. `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 select. `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 {
    [SettingsDropdownV2Element.is]: SettingsDropdownV2Element;
  }
}

customElements.define(SettingsDropdownV2Element.is, SettingsDropdownV2Element);