chromium/chrome/test/data/webui/cr_elements/cr_radio_group_test.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 'chrome://resources/cr_elements/cr_radio_group/cr_radio_group.js';
import 'chrome://resources/cr_elements/cr_radio_button/cr_radio_button.js';

import type {CrRadioButtonElement} from 'chrome://resources/cr_elements/cr_radio_button/cr_radio_button.js';
import type {CrRadioGroupElement} from 'chrome://resources/cr_elements/cr_radio_group/cr_radio_group.js';
import {getTrustedHTML} from 'chrome://resources/js/static_types.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {assertDeepEquals, assertEquals, assertFalse, assertNotReached, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {pressAndReleaseKeyOn} from 'chrome://webui-test/keyboard_mock_interactions.js';
import {eventToPromise, microtasksFinished} from 'chrome://webui-test/test_util.js';

suite('cr-radio-group', () => {
  let radioGroup: CrRadioGroupElement;

  setup(() => {
    document.body.innerHTML = getTrustedHTML`
       <div id="parent">
          <cr-radio-group>
            <cr-radio-button name="1"></cr-radio-button>
            <cr-radio-button name="2"><input></input></cr-radio-button>
            <cr-radio-button name="3"><a></a></cr-radio-button>
          </cr-radio-group>
        </div>`;
    radioGroup = document.body.querySelector('cr-radio-group')!;
    return microtasksFinished();
  });

  function checkLength(length: number, selector: string) {
    assertEquals(length, radioGroup.querySelectorAll(selector).length);
  }

  function verifyNoneSelectedOneFocusable(name: string) {
    const uncheckedRows =
        Array.from(radioGroup.querySelectorAll<CrRadioButtonElement>(
            `cr-radio-button:not([checked])`));
    assertEquals(3, uncheckedRows.length);

    const focusableRow = uncheckedRows.filter(
        radioButton =>
            radioButton.name === name && radioButton.$.button.tabIndex === 0);
    assertEquals(1, focusableRow.length);

    const unfocusableRows = uncheckedRows.filter(
        radioButton => radioButton.$.button.tabIndex === -1);
    assertEquals(2, unfocusableRows.length);
  }

  function checkNoneFocusable() {
    const allRows = Array.from(radioGroup.querySelectorAll(`cr-radio-button`));
    assertEquals(3, allRows.length);

    const unfocusableRows =
        allRows.filter(radioButton => radioButton.$.button.tabIndex === -1);
    assertEquals(3, unfocusableRows.length);
  }

  function press(key: string, target?: Element) {
    pressAndReleaseKeyOn(
        target || radioGroup.querySelector('[name="1"]')!, -1, [], key);
  }

  async function checkPressed(
      keys: string[], initialSelection: string, expectedSelected: string) {
    for (const key of keys) {
      radioGroup.selected = initialSelection;
      await microtasksFinished();
      press(key);
      await microtasksFinished();
      checkSelected(expectedSelected);
    }
  }

  function checkSelected(name: string) {
    assertEquals(name, `${radioGroup.selected}`);

    const selectedRows =
        Array.from(radioGroup.querySelectorAll<CrRadioButtonElement>(
            `cr-radio-button[name="${name}"][checked]`));
    const focusableRows =
        selectedRows.filter(radioButton => radioButton.$.button.tabIndex === 0);
    assertEquals(1, focusableRows.length);

    const unselectedRows =
        Array.from(radioGroup.querySelectorAll<CrRadioButtonElement>(
            `cr-radio-button:not([name="${name}"]):not([checked])`));
    const filteredUnselected = unselectedRows.filter(
        radioButton => radioButton.$.button.tabIndex === -1);
    assertEquals(2, filteredUnselected.length);
  }

  test('selected-changed bubbles', () => {
    const whenFired = eventToPromise('selected-changed', radioGroup);
    radioGroup.selected = '1';
    return whenFired;
  });

  test('key events don\'t propagate to parents', async () => {
    const parent = document.body.querySelector<HTMLElement>('#parent');
    assertTrue(!!parent);

    // When the key was handled, the event should not propagate. Not using
    // eventToPromise on purpose, as Mocha fails to capture the error if it
    // happens in a Promise that is not awaited.
    const listener = () => {
      assertNotReached('Event should not have bubbled to parent.');
    };
    parent.addEventListener('keydown', listener, {once: true});
    await checkPressed(['ArrowRight'], '1', '2');
    parent.removeEventListener('keydown', listener);

    // When the key was not handled, the event should propagate.
    const whenBackspace = eventToPromise('keydown', parent);
    await checkPressed(['Backspace'], '1', '1');
    await whenBackspace;
  });

  test('key events when initially nothing checked', async () => {
    press('Enter');
    await microtasksFinished();
    checkSelected('1');

    radioGroup.selected = '';
    await microtasksFinished();
    verifyNoneSelectedOneFocusable('1');

    press(' ');
    await microtasksFinished();
    checkSelected('1');

    radioGroup.selected = '';
    await microtasksFinished();
    verifyNoneSelectedOneFocusable('1');

    press('ArrowRight');
    await microtasksFinished();
    checkSelected('2');
  });

  test('key events when an item is checked', async () => {
    await checkPressed(['End'], '1', '3');
    await checkPressed(['Home'], '3', '1');
    // Check for decrement.
    await checkPressed(['Home', 'PageUp', 'ArrowUp', 'ArrowLeft'], '2', '1');
    // No change when reached first selected.
    await checkPressed(['Home'], '1', '1');
    // Wraps when decrementing when first selected.
    await checkPressed(['PageUp', 'ArrowUp', 'ArrowLeft'], '1', '3');
    // Check for increment.
    await checkPressed(
        ['End', 'ArrowRight', 'PageDown', 'ArrowDown'], '2', '3');
    // No change when reached last selected.
    await checkPressed(['End'], '3', '3');
    // Wraps when incrementing when last selected.
    await checkPressed(['ArrowRight', 'PageDown', 'ArrowDown'], '3', '1');
  });

  test('mouse event', async () => {
    assertEquals(undefined, radioGroup.selected);
    radioGroup.querySelector<CrRadioButtonElement>('[name="2"]')!.click();
    await microtasksFinished();
    checkSelected('2');
  });

  test('key events skip over disabled radios', async () => {
    verifyNoneSelectedOneFocusable('1');
    radioGroup.querySelector<CrRadioButtonElement>('[name="2"]')!.disabled =
        true;
    await microtasksFinished();
    press('PageDown');
    await microtasksFinished();
    checkSelected('3');
  });

  test('disabled makes radios not focusable', async () => {
    radioGroup.selected = '1';
    await microtasksFinished();
    checkSelected('1');

    radioGroup.disabled = true;
    await microtasksFinished();
    checkNoneFocusable();

    radioGroup.disabled = false;
    await microtasksFinished();
    checkSelected('1');

    const firstRadio =
        radioGroup.querySelector<CrRadioButtonElement>('[name="1"]')!;
    firstRadio.disabled = true;
    await microtasksFinished();
    assertEquals(-1, firstRadio.$.button.tabIndex);

    const secondRadio =
        radioGroup.querySelector<CrRadioButtonElement>('[name="2"]')!;
    assertEquals(0, secondRadio.$.button.tabIndex);
    firstRadio.disabled = false;
    await microtasksFinished();
    checkSelected('1');

    radioGroup.selected = '';
    await microtasksFinished();
    verifyNoneSelectedOneFocusable('1');

    firstRadio.disabled = true;
    await microtasksFinished();
    verifyNoneSelectedOneFocusable('2');
  });

  test('when group is disabled, button aria-disabled is updated', async () => {
    assertEquals('false', radioGroup.getAttribute('aria-disabled'));
    assertFalse(radioGroup.disabled);
    checkLength(3, '[aria-disabled="false"]');
    radioGroup.disabled = true;
    await radioGroup.updateComplete;
    assertEquals('true', radioGroup.getAttribute('aria-disabled'));
    await microtasksFinished();
    checkLength(3, '[aria-disabled="true"]');

    radioGroup.disabled = false;
    await radioGroup.updateComplete;
    assertEquals('false', radioGroup.getAttribute('aria-disabled'));
    await microtasksFinished();
    checkLength(3, '[aria-disabled="false"]');

    // Check that if a button already disabled, it will remain disabled after
    // group is re-enabled.
    const firstRadio =
        radioGroup.querySelector<CrRadioButtonElement>('[name="1"]')!;
    firstRadio.disabled = true;
    await microtasksFinished();
    checkLength(2, '[aria-disabled="false"]');
    checkLength(1, '[aria-disabled="true"][disabled][name="1"]');

    radioGroup.disabled = true;
    await microtasksFinished();
    checkLength(3, '[aria-disabled="true"]');
    checkLength(1, '[aria-disabled="true"][disabled][name="1"]');

    radioGroup.disabled = false;
    await microtasksFinished();
    checkLength(2, '[aria-disabled="false"]');
    checkLength(1, '[aria-disabled="true"][disabled][name="1"]');
  });

  test('radios name change updates selection and tabindex', async () => {
    radioGroup.selected = '1';
    await microtasksFinished();
    checkSelected('1');
    const firstRadio =
        radioGroup.querySelector<CrRadioButtonElement>('[name="1"]')!;
    firstRadio.name = 'A';
    await microtasksFinished();
    assertEquals(0, firstRadio.$.button.tabIndex);
    assertFalse(firstRadio.checked);
    verifyNoneSelectedOneFocusable('A');
    radioGroup.querySelector<CrRadioButtonElement>('[name="2"]')!.name = '1';
    await microtasksFinished();
    checkSelected('1');
  });

  test('radios with links', async () => {
    const a = radioGroup.querySelector('a');
    assertTrue(!!a);
    assertEquals(-1, a!.tabIndex);
    verifyNoneSelectedOneFocusable('1');
    press('Enter', a!);
    await radioGroup.updateComplete;
    press(' ', a!);
    await radioGroup.updateComplete;
    a!.click();
    await radioGroup.updateComplete;
    verifyNoneSelectedOneFocusable('1');

    radioGroup.querySelector<CrRadioButtonElement>('[name="1"]')!.click();
    await microtasksFinished();
    checkSelected('1');
    press('Enter', a!);
    await radioGroup.updateComplete;
    press(' ', a!);
    await radioGroup.updateComplete;
    a!.click();
    await microtasksFinished();
    checkSelected('1');

    radioGroup.querySelector<CrRadioButtonElement>('[name="3"]')!.click();
    await microtasksFinished();
    checkSelected('3');
    assertEquals(0, a!.tabIndex);
  });

  test('radios with input', async () => {
    const input = radioGroup.querySelector('input');
    assertTrue(!!input);
    verifyNoneSelectedOneFocusable('1');
    press('Enter', input!);
    press(' ', input!);
    await radioGroup.updateComplete;
    verifyNoneSelectedOneFocusable('1');

    input!.click();
    await microtasksFinished();
    checkSelected('2');

    radioGroup.querySelector<CrRadioButtonElement>('[name="1"]')!.click();
    await microtasksFinished();
    press('Enter', input!);
    press(' ', input!);
    await microtasksFinished();
    checkSelected('1');

    input!.click();
    await microtasksFinished();
    checkSelected('2');
  });

  test(
      'select the radio that has focus when space or enter pressed',
      async () => {
        verifyNoneSelectedOneFocusable('1');
        press(
            'Enter',
            radioGroup.querySelector<CrRadioButtonElement>('[name="3"]')!);
        await microtasksFinished();
        checkSelected('3');
        press(
            ' ', radioGroup.querySelector<CrRadioButtonElement>('[name="2"]')!);
        await microtasksFinished();
        checkSelected('2');
      });

  // Test that when a 2 way binding to a Polymer parent is updated, the
  // radio buttons UI state has already been updated.
  test('TwoWayBindingWithPolymerParent', async () => {
    class TestElement extends PolymerElement {
      static get is() {
        return 'test-element';
      }

      static get template() {
        return html`
          <cr-radio-group
              selected="{{parentSelected}}"
              on-selected-changed="onSelectedChanged">
            <cr-radio-button name="one">Option 1</cr-radio-button>
            <cr-radio-button name="two">Option 2</cr-radio-button>
          </cr-radio-group>`;
      }

      static get properties() {
        return {
          parentSelected: String,
        };
      }

      parentSelected: string = 'one';
      changes: string[] = [];

      onSelectedChanged(e: CustomEvent<{value: string}>) {
        const buttons = this.shadowRoot!.querySelectorAll('cr-radio-button');
        assertEquals(2, buttons.length);
        // Verify that the buttons' checked state is already up to date.
        const isOne = e.detail.value === 'one';
        assertEquals(isOne, buttons[0]!.checked);
        assertEquals(!isOne, buttons[1]!.checked);
        this.changes.push(e.detail.value);
      }
    }

    customElements.define(TestElement.is, TestElement);

    document.body.innerHTML = window.trustedTypes!.emptyHTML;
    const element = document.createElement('test-element') as TestElement;
    document.body.appendChild(element);
    await microtasksFinished();

    const radioGroup = element.shadowRoot!.querySelector('cr-radio-group');
    assertTrue(!!radioGroup);
    assertEquals('one', radioGroup.selected);
    assertEquals('one', element.parentSelected);

    const radioButtons =
        element.shadowRoot!.querySelectorAll('cr-radio-button');
    assertEquals(2, radioButtons.length);
    radioButtons[1]!.click();
    await microtasksFinished();
    assertEquals('two', radioGroup.selected);
    assertEquals('two', element.parentSelected);

    assertDeepEquals(['one', 'two'], element.changes);
  });
});