chromium/chrome/test/data/webui/cr_elements/cr_input_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.

// clang-format off
import 'chrome://resources/cr_elements/cr_input/cr_input.js';

import type {CrInputElement} from 'chrome://resources/cr_elements/cr_input/cr_input.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {assertDeepEquals, assertEquals, assertFalse, assertNotEquals, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {eventToPromise, isChildVisible} from 'chrome://webui-test/test_util.js';
import {getTrustedHTML} from 'chrome://resources/js/static_types.js';
// clang-format on

suite('cr-input', function() {
  let crInput: CrInputElement;
  let input: HTMLInputElement;

  setup(function() {
    return regenerateNewInput();
  });

  async function regenerateNewInput(): Promise<void> {
    document.body.innerHTML = window.trustedTypes!.emptyHTML;
    crInput = document.createElement('cr-input');
    document.body.appendChild(crInput);
    input = crInput.inputElement;
    await crInput.updateComplete;
  }

  test('AttributesCorrectlySupported', async () => {
    // [externalName, internalName, defaultValue, testValue]
    type AttributeData = [
      keyof CrInputElement, keyof HTMLInputElement, boolean | number | string,
      boolean | number | string
    ];
    const attributesToTest: AttributeData[] = [
      ['autofocus', 'autofocus', false, true],
      ['disabled', 'disabled', false, true],
      ['max', 'max', '', '100'],
      ['min', 'min', '', '1'],
      ['maxlength', 'maxLength', -1, 5],
      ['minlength', 'minLength', -1, 5],
      ['pattern', 'pattern', '', '[a-z]+'],
      ['readonly', 'readOnly', false, true],
      ['required', 'required', false, true],
      ['type', 'type', 'text', 'password'],
      ['inputmode', 'inputMode', '', 'none'],
    ];

    for (const [externalName, internalName, defaultValue, testValue] of
             attributesToTest) {
      await regenerateNewInput();
      assertEquals(defaultValue, input[internalName]);
      crInput.setAttribute(externalName, testValue.toString());
      await crInput.updateComplete;
      assertEquals(testValue, input[internalName]);
    }
  });

  test('UnsupportedInputTabindex', async () => {
    let errorThrown = false;
    try {
      crInput.inputTabindex = 2;
      await crInput.updateComplete;
    } catch (e) {
      errorThrown = true;
    }
    assertTrue(errorThrown);
  });

  test('UnsupportedTypeThrows', async () => {
    let errorThrown = false;
    try {
      crInput.type = 'checkbox';
      await crInput.updateComplete;
    } catch (e) {
      errorThrown = true;
    }
    assertTrue(errorThrown);
  });

  test('inputTabindexCorrectlyBound', async () => {
    assertEquals(0, input['tabIndex']);
    crInput.setAttribute('input-tabindex', '-1');
    await crInput.updateComplete;
    assertEquals(-1, input.tabIndex);
  });

  test('placeholderCorrectlyBound', async () => {
    assertFalse(input.hasAttribute('placeholder'));

    crInput.placeholder = '';
    await crInput.updateComplete;
    assertTrue(input.hasAttribute('placeholder'));

    crInput.placeholder = 'hello';
    await crInput.updateComplete;
    assertEquals('hello', input.getAttribute('placeholder'));

    crInput.placeholder = null;
    await crInput.updateComplete;
    assertFalse(input.hasAttribute('placeholder'));
  });

  test('labelHiddenWhenEmpty', async () => {
    const label = crInput.$.label;
    assertEquals('none', getComputedStyle(crInput.$.label).display);
    crInput.label = 'foobar';
    await crInput.updateComplete;
    assertEquals('block', getComputedStyle(crInput.$.label).display);
    assertEquals('foobar', label.textContent!.trim());
  });

  test('valueSetCorrectly', async () => {
    crInput.value = 'hello';
    await crInput.updateComplete;
    assertEquals(crInput.value, input.value);

    // |value| is copied when typing triggers inputEvent.
    input.value = 'hello sir';
    input.dispatchEvent(new InputEvent('input'));
    await crInput.updateComplete;
    assertEquals(crInput.value, input.value);
  });

  test('focusState', async () => {
    assertFalse(crInput.hasAttribute('focused_'));

    const underline = crInput.$.underline;
    const label = crInput.$.label;
    const originalLabelColor = getComputedStyle(label).color;

    async function waitForTransitions(): Promise<TransitionEvent[]> {
      const events: TransitionEvent[] = [];
      let e = await eventToPromise('transitionend', underline);
      events.push(e);
      e = await eventToPromise('transitionend', underline);
      events.push(e);
      return events;
    }

    assertEquals('0', getComputedStyle(underline).opacity);
    assertEquals(0, underline.offsetWidth);

    let whenTransitionsEnd = waitForTransitions();

    input.focus();
    await crInput.updateComplete;
    assertTrue(crInput.hasAttribute('focused_'));
    assertEquals(originalLabelColor, getComputedStyle(label).color);
    let events = await whenTransitionsEnd;
    // Ensure transitions finished in the expected order.
    assertEquals(2, events.length);
    assertEquals('opacity', events[0]!.propertyName);
    assertEquals('width', events[1]!.propertyName);

    assertEquals('1', getComputedStyle(underline).opacity);
    assertNotEquals(0, underline.offsetWidth);

    whenTransitionsEnd = waitForTransitions();
    input.blur();
    events = await whenTransitionsEnd;
    // Ensure transitions finished in the expected order.
    assertEquals(2, events.length);
    assertEquals('opacity', events[0]!.propertyName);
    assertEquals('width', events[1]!.propertyName);

    assertFalse(crInput.hasAttribute('focused_'));
    assertEquals('0', getComputedStyle(underline).opacity);
    assertEquals(0, underline.offsetWidth);
  });

  test('invalidState', async () => {
    crInput.errorMessage = 'error';
    await crInput.updateComplete;
    const errorLabel = crInput.$.error;
    const underline = crInput.$.underline;
    const label = crInput.$.label;
    const originalLabelColor = getComputedStyle(label).color;
    const originalLineColor = getComputedStyle(underline).borderBottomColor;

    assertEquals('', errorLabel.textContent);
    assertFalse(errorLabel.hasAttribute('role'));
    assertEquals('0', getComputedStyle(underline).opacity);
    assertEquals(0, underline.offsetWidth);
    assertEquals('hidden', getComputedStyle(errorLabel).visibility);

    const whenTransitionEnd = eventToPromise('transitionend', underline);

    crInput.invalid = true;
    await crInput.updateComplete;
    assertTrue(crInput.hasAttribute('invalid'));
    assertEquals('alert', errorLabel.getAttribute('role'));
    assertEquals(crInput.errorMessage, errorLabel.textContent);
    assertEquals('visible', getComputedStyle(errorLabel).visibility);
    assertTrue(originalLabelColor !== getComputedStyle(label).color);
    assertTrue(
        originalLineColor !== getComputedStyle(underline).borderBottomColor);

    await whenTransitionEnd;
    assertEquals('1', getComputedStyle(underline).opacity);
    assertTrue(0 !== underline.offsetWidth);
  });

  test('validation', async () => {
    crInput.value = 'FOO';
    crInput.autoValidate = true;
    await crInput.updateComplete;
    assertFalse(crInput.hasAttribute('required'));
    assertFalse(crInput.invalid);

    // Note that even with |autoValidate|, crInput.invalid only updates after
    // |value| is changed.
    crInput.setAttribute('required', '');
    await crInput.updateComplete;
    assertFalse(crInput.invalid);

    crInput.value = '';
    await crInput.updateComplete;
    assertTrue(crInput.invalid);
    crInput.value = 'BAR';
    await crInput.updateComplete;
    assertFalse(crInput.invalid);

    const testPattern = '[a-z]+';
    crInput.setAttribute('pattern', testPattern);
    crInput.value = 'FOO';
    await crInput.updateComplete;
    assertTrue(crInput.invalid);
    crInput.value = 'foo';
    await crInput.updateComplete;
    assertFalse(crInput.invalid);

    // Without |autoValidate|, crInput.invalid should not change even if input
    // value is not valid.
    crInput.autoValidate = false;
    crInput.value = 'ALL CAPS';
    await crInput.updateComplete;
    assertFalse(crInput.invalid);
    assertFalse(input.checkValidity());
    crInput.value = '';
    await crInput.updateComplete;
    assertFalse(crInput.invalid);
    assertFalse(input.checkValidity());
  });

  test('numberValidation', async () => {
    crInput.type = 'number';
    crInput.value = '50';
    crInput.autoValidate = true;
    await crInput.updateComplete;
    assertFalse(crInput.invalid);

    // Note that even with |autoValidate|, crInput.invalid only updates after
    // |value| is changed.
    const testMin = 1;
    const testMax = 100;
    crInput.setAttribute('min', testMin.toString());
    crInput.setAttribute('max', testMax.toString());
    crInput.value = '200';
    await crInput.updateComplete;
    assertTrue(crInput.invalid);
    crInput.value = '20';
    await crInput.updateComplete;
    assertFalse(crInput.invalid);
    crInput.value = '-2';
    await crInput.updateComplete;
    assertTrue(crInput.invalid);
    crInput.value = '40';
    await crInput.updateComplete;
    assertFalse(crInput.invalid);

    // Without |autoValidate|, crInput.invalid should not change even if input
    // value is not valid.
    crInput.autoValidate = false;
    crInput.value = '200';
    await crInput.updateComplete;
    assertFalse(crInput.invalid);
    assertFalse(input.checkValidity());
    crInput.value = '-2';
    await crInput.updateComplete;
    assertFalse(crInput.invalid);
    assertFalse(input.checkValidity());
  });

  test('ariaDescriptionsCorrect', async () => {
    assertEquals(crInput.inputElement.getAttribute('aria-description'), null);

    const ariaDescription = 'description';
    crInput.ariaDescription = ariaDescription;
    await crInput.updateComplete;
    assertEquals(
        crInput.inputElement.getAttribute('aria-description'), ariaDescription);

    crInput.ariaDescription = null;
    await crInput.updateComplete;
    assertEquals(crInput.inputElement.getAttribute('aria-description'), null);
  });

  test('ariaLabelsCorrect', function() {
    assertFalse(!!crInput.inputElement.getAttribute('aria-label'));

    /**
     * This function assumes attributes are passed in priority order.
     */
    function testAriaLabel(attributes: string[]) {
      document.body.innerHTML = window.trustedTypes!.emptyHTML;
      crInput = document.createElement('cr-input');
      attributes.forEach(attribute => {
        // Using their name as the value out of convenience.
        crInput.setAttribute(attribute, attribute);
      });
      document.body.appendChild(crInput);
      // Assuming first attribute takes priority.
      assertEquals(
          attributes[0], crInput.inputElement.getAttribute('aria-label'));
    }

    testAriaLabel(['aria-label', 'label', 'placeholder']);
    testAriaLabel(['label', 'placeholder']);
    testAriaLabel(['placeholder']);
  });

  test('select', async () => {
    crInput.value = '0123456789';
    await crInput.updateComplete;
    assertFalse(input.matches(':focus'));
    crInput.select();
    assertTrue(input.matches(':focus'));
    assertEquals('0123456789', window.getSelection()!.toString());

    await regenerateNewInput();
    crInput.value = '0123456789';
    await crInput.updateComplete;
    assertFalse(input.matches(':focus'));
    crInput.select(2, 6);
    assertTrue(input.matches(':focus'));
    assertEquals('2345', window.getSelection()!.toString());

    await regenerateNewInput();
    crInput.value = '';
    await crInput.updateComplete;
    assertFalse(input.matches(':focus'));
    crInput.select();
    assertTrue(input.matches(':focus'));
    assertEquals('', window.getSelection()!.toString());
  });

  test('slots', function() {
    document.body.innerHTML = getTrustedHTML`
      <cr-input>
        <div slot="inline-prefix" id="inline-prefix">One</div>
        <div slot="suffix" id="suffix">Two</div>
        <div slot="inline-suffix" id="inline-suffix">Three</div>
      </cr-input>
    `;
    crInput = document.querySelector('cr-input')!;
    assertTrue(isChildVisible(crInput, '#inline-prefix', true));
    assertTrue(isChildVisible(crInput, '#suffix', true));
    assertTrue(isChildVisible(crInput, '#inline-suffix', true));
  });

  // Test that 2-way bindings with Polymer parent elements are updated
  // when validate() is called and before the change event fires
  test('TwoWayBindingWithPolymerParent', async () => {
    class TestElement extends PolymerElement {
      static get is() {
        return 'test-element';
      }

      static get template() {
        return html`
          <cr-input value="{{parentValue}}" invalid="{{parentInvalid}}"
              required on-invalid-changed="onInvalidChanged"
              on-value-changed="onValueChanged"
              on-change="onChange">
          </cr-input>`;
      }

      static get properties() {
        return {
          parentValue: String,
          parentInvalid: Boolean,
        };
      }

      parentValue: string = 'hello';
      parentInvalid: boolean = false;
      private events_: string[] = [];
      private expectedValue_: string = 'hello';

      onInvalidChanged(e: CustomEvent<{value: boolean}>) {
        assertEquals(this.expectedValue_ === '', e.detail.value);
        this.events_.push(e.type);
      }

      onChange(e: CustomEvent<{sourceEvent: InputEvent}>) {
        // Ensure the 2 way binding has already updated.
        assertEquals(this.expectedValue_, this.parentValue);
        assertEquals(this.expectedValue_, e.detail.sourceEvent.detail);
        this.events_.push(e.type);
      }

      onValueChanged(e: CustomEvent<{value: string}>) {
        assertEquals(this.expectedValue_, e.detail.value);
        this.events_.push(e.type);
      }

      validateEvents(expectedEvents: string[]) {
        assertDeepEquals(expectedEvents, this.events_);
        this.events_ = [];
      }

      setExpectedValue(value: string) {
        this.expectedValue_ = value;
      }
    }

    customElements.define(TestElement.is, TestElement);

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

    const input = element.shadowRoot!.querySelector('cr-input');
    assertTrue(!!input);
    await input.updateComplete;
    // Initialization events
    element.validateEvents(['invalid-changed', 'value-changed']);

    function simulateUserInput(inputValue: string): Promise<void> {
      element.setExpectedValue(inputValue);
      assertTrue(!!input);
      input.inputElement.value = inputValue;
      input.inputElement.dispatchEvent(
          new CustomEvent('input', {composed: true, bubbles: true}));
      input.inputElement.dispatchEvent(
          new CustomEvent('change', {detail: inputValue}));
      return eventToPromise('change', input);
    }

    // Clear the input. This makes it invalid since |required| is set.
    await simulateUserInput('');
    let valid = input.validate();
    assertFalse(valid);
    // 2 way binding should have updated since validate() was called.
    assertTrue(element.parentInvalid);
    element.validateEvents(['value-changed', 'change', 'invalid-changed']);

    // Reset to a valid input value.
    await simulateUserInput('test');
    valid = input.validate();
    assertTrue(valid);
    // 2 way binding should have updated since validate() was called.
    assertFalse(element.parentInvalid);
    element.validateEvents(['value-changed', 'change', 'invalid-changed']);
  });
});