chromium/ash/webui/common/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.

// Forked from
// ui/webui/resources/cr_elements/cr_radio_group/cr_radio_group.ts

import '../cr_radio_button/cr_radio_button.js';
import '../cr_shared_vars.css.js';

import {assert} from '//resources/js/assert.js';
import {EventTracker} from '//resources/js/event_tracker.js';
import {PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

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

import {getTemplate} 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 PolymerElement {
  static get is() {
    return 'cr-radio-group';
  }

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

  static get properties() {
    return {
      disabled: {
        type: Boolean,
        value: false,
        reflectToAttribute: true,
        observer: 'update_',
      },

      selected: {
        type: String,
        notify: true,
        observer: 'update_',
      },

      selectableElements: {
        type: String,
        value: 'cr-radio-button, cr-card-radio-button, controlled-radio-button',
      },

      nestedSelectable: {
        type: Boolean,
        value: false,
        observer: 'populate_',
      },

      selectableRegExp_: {
        value: Object,
        computed: 'computeSelectableRegExp_(selectableElements)',
      },
    };
  }

  disabled: boolean;
  selected: string;
  selectableElements: string;
  nestedSelectable: boolean;
  private selectableRegExp_: 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 ready() {
    super.ready();
    this.addEventListener(
        'keydown', e => this.onKeyDown_(/** @type {!KeyboardEvent} */ (e)));
    this.addEventListener('click', this.onClick_.bind(this));

    if (!this.hasAttribute('role')) {
      this.setAttribute('role', 'radiogroup');
    }
    this.setAttribute('aria-disabled', 'false');
  }

  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 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 computeSelectableRegExp_(): RegExp {
    const tags = this.selectableElements.split(', ').join('|');
    return new RegExp(`^(${tags})$`, 'i');
  }

  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 nodes =
        this.shadowRoot!.querySelector('slot')!.assignedNodes({flatten: true});
    this.buttons_ = Array.from(nodes).flatMap(node => {
      if (node.nodeType !== Node.ELEMENT_NODE) {
        return [];
      }
      const el = node as HTMLElement;

      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}`);
    });
    this.setAttribute('aria-disabled', `${this.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);