chromium/ui/webui/resources/cr_elements/cr_radio_group/cr_radio_group.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.

import '../cr_radio_button/cr_radio_button.js';

import {assert} from '//resources/js/assert.js';
import {EventTracker} from '//resources/js/event_tracker.js';
import type {PropertyValues} from '//resources/lit/v3_0/lit.rollup.js';
import {CrLitElement} from '//resources/lit/v3_0/lit.rollup.js';

import type {CrRadioButtonElement} from '../cr_radio_button/cr_radio_button.js';

import {getCss} from './cr_radio_group.css.js';
import {getHtml} from './cr_radio_group.html.js';

function isEnabled(radio: HTMLElement): boolean {
  return radio.matches(':not([disabled]):not([hidden])') &&
      radio.style.display !== 'none' && radio.style.visibility !== 'hidden';
}

export class CrRadioGroupElement extends CrLitElement {
  static get is() {
    return 'cr-radio-group';
  }

  static override get styles() {
    return getCss();
  }

  override render() {
    return getHtml.bind(this)();
  }

  static override get properties() {
    return {
      disabled: {
        type: Boolean,
        reflect: true,
      },

      selected: {
        type: String,
        notify: true,
      },

      selectableElements: {type: String},
      nestedSelectable: {type: Boolean},
      selectableRegExp_: {type: Object},
    };
  }

  disabled: boolean = false;
  selected?: string;
  selectableElements: string =
      'cr-radio-button, cr-card-radio-button, controlled-radio-button';
  nestedSelectable: boolean = false;
  private selectableRegExp_: RegExp = new RegExp('');

  private buttons_: CrRadioButtonElement[]|null = null;
  private buttonEventTracker_: EventTracker = new EventTracker();
  private deltaKeyMap_: Map<string, number>|null = null;
  private isRtl_: boolean = false;
  private populateBound_: (() => void)|null = null;

  override firstUpdated() {
    this.addEventListener('keydown', e => this.onKeyDown_(e));
    this.addEventListener('click', e => this.onClick_(e));

    if (!this.hasAttribute('role')) {
      this.setAttribute('role', 'radiogroup');
    }
  }

  override connectedCallback() {
    super.connectedCallback();
    this.isRtl_ = this.matches(':host-context([dir=rtl]) cr-radio-group');
    this.deltaKeyMap_ = new Map([
      ['ArrowDown', 1],
      ['ArrowLeft', this.isRtl_ ? 1 : -1],
      ['ArrowRight', this.isRtl_ ? -1 : 1],
      ['ArrowUp', -1],
      ['PageDown', 1],
      ['PageUp', -1],
    ]);

    this.populateBound_ = () => this.populate_();
    assert(this.populateBound_);
    this.shadowRoot!.querySelector('slot')!.addEventListener(
        'slotchange', this.populateBound_);

    this.populate_();
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    assert(this.populateBound_);
    this.shadowRoot!.querySelector('slot')!.removeEventListener(
        'slotchange', this.populateBound_);
    this.buttonEventTracker_.removeAll();
  }

  override willUpdate(changedProperties: PropertyValues<this>) {
    super.willUpdate(changedProperties);

    if (changedProperties.has('selectableElements')) {
      const tags = this.selectableElements.split(', ').join('|');
      this.selectableRegExp_ = new RegExp(`^(${tags})$`, 'i');
    }
  }

  override updated(changedProperties: PropertyValues<this>) {
    if (changedProperties.has('nestedSelectable')) {
      this.populate_();
    }

    if (changedProperties.has('disabled') ||
        changedProperties.has('selected')) {
      this.update_();
    }

    this.setAttribute('aria-disabled', `${this.disabled}`);

    // Clients of cr-radio-group generally expect that by the time
    // selected-changed or disabled-changed is fired, the state of the
    // buttons in the group (e.g. "checked", "disabled" properties) has been
    // updated accordingly. Since these events are fired in CrLitElement's
    // updated() method, call super.updated() only after all the button updates
    // performed in update_() are complete.
    super.updated(changedProperties);
  }

  override focus() {
    if (this.disabled || !this.buttons_) {
      return;
    }

    const radio =
        this.buttons_.find(radio => this.isButtonEnabledAndSelected_(radio));
    if (radio) {
      radio.focus();
    }
  }

  private onKeyDown_(event: KeyboardEvent) {
    if (this.disabled) {
      return;
    }

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

    const targetElement = event.target as CrRadioButtonElement;
    if (!this.buttons_ || !this.buttons_.includes(targetElement)) {
      return;
    }

    if (event.key === ' ' || event.key === 'Enter') {
      event.preventDefault();
      this.select_(targetElement);
      return;
    }

    const enabledRadios = this.buttons_.filter(isEnabled);
    if (enabledRadios.length === 0) {
      return;
    }

    assert(this.deltaKeyMap_);
    let selectedIndex;
    const max = enabledRadios.length - 1;
    if (event.key === 'Home') {
      selectedIndex = 0;
    } else if (event.key === 'End') {
      selectedIndex = max;
    } else if (this.deltaKeyMap_.has(event.key)) {
      const delta = this.deltaKeyMap_.get(event.key)!;
      // If nothing selected, start from the first radio then add |delta|.
      const lastSelection = enabledRadios.findIndex(radio => radio.checked);
      selectedIndex = Math.max(0, lastSelection) + delta;
      // Wrap the selection, if needed.
      if (selectedIndex > max) {
        selectedIndex = 0;
      } else if (selectedIndex < 0) {
        selectedIndex = max;
      }
    } else {
      return;
    }

    const radio = enabledRadios[selectedIndex]!;
    const name = `${radio.name}`;
    if (this.selected !== name) {
      event.preventDefault();
      event.stopPropagation();
      this.selected = name;
      radio.focus();
    }
  }

  private onClick_(event: Event) {
    const path = event.composedPath();
    if (path.some(target => /^a$/i.test((target as HTMLElement).tagName))) {
      return;
    }
    const target =
        path.find(
            n => this.selectableRegExp_.test((n as HTMLElement).tagName)) as
        CrRadioButtonElement;
    if (target && this.buttons_ && this.buttons_.includes(target)) {
      this.select_(target);
    }
  }

  private populate_() {
    const elements = this.shadowRoot!.querySelector('slot')!.assignedElements(
        {flatten: true});
    this.buttons_ = Array.from(elements).flatMap(el => {
      let result = [];
      if (el.matches(this.selectableElements)) {
        result.push(el);
      }

      if (this.nestedSelectable) {
        result = result.concat(
            Array.from(el.querySelectorAll(this.selectableElements)));
      }
      return result;
    }) as CrRadioButtonElement[];
    this.buttonEventTracker_.removeAll();
    this.buttons_!.forEach(el => {
      this.buttonEventTracker_!.add(
          el, 'disabled-changed', () => this.populate_());
      this.buttonEventTracker_!.add(el, 'name-changed', () => this.populate_());
    });
    this.update_();
  }

  private select_(button: CrRadioButtonElement) {
    if (!isEnabled(button)) {
      return;
    }

    const name = `${button.name}`;
    if (this.selected !== name) {
      this.selected = name;
    }
  }

  private isButtonEnabledAndSelected_(button: CrRadioButtonElement): boolean {
    return !this.disabled && button.checked && isEnabled(button);
  }

  private update_() {
    if (!this.buttons_) {
      return;
    }
    let noneMadeFocusable = true;
    this.buttons_.forEach(radio => {
      radio.checked =
          this.selected !== undefined && `${radio.name}` === `${this.selected}`;
      const disabled = this.disabled || !isEnabled(radio);
      const canBeFocused = radio.checked && !disabled;
      if (canBeFocused) {
        radio.focusable = true;
        noneMadeFocusable = false;
      } else {
        radio.focusable = false;
      }
      radio.setAttribute('aria-disabled', `${disabled}`);
    });
    if (noneMadeFocusable && !this.disabled) {
      const radio = this.buttons_.find(isEnabled);
      if (radio) {
        radio.focusable = true;
      }
    }
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'cr-radio-group': CrRadioGroupElement;
  }
}

customElements.define(CrRadioGroupElement.is, CrRadioGroupElement);