chromium/chrome/test/data/webui/cr_elements/cr_dialog_test.ts

// Copyright 2016 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_dialog/cr_dialog.js';
import 'chrome://resources/cr_elements/cr_input/cr_input.js';
import 'chrome://resources/cr_elements/cr_textarea/cr_textarea.js';

import {CrLitElement, html} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import {getTrustedHTML} from 'chrome://resources/js/static_types.js';
import {getDeepActiveElement} from 'chrome://resources/js/util.js';
import type {CrDialogElement} from 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';
import type {CrInputElement} from 'chrome://resources/cr_elements/cr_input/cr_input.js';
import type {CrTextareaElement} from 'chrome://resources/cr_elements/cr_textarea/cr_textarea.js';
import {keyDownOn, keyEventOn} from 'chrome://webui-test/keyboard_mock_interactions.js';
import {assertEquals, assertFalse, assertNotEquals, assertNotReached, assertNull, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {eventToPromise, isVisible, microtasksFinished} from 'chrome://webui-test/test_util.js';

// clang-format on

suite('cr-dialog', function() {
  function pressEnter(element: HTMLElement) {
    keyEventOn(element, 'keypress', 13, [], 'Enter');
  }

  /**
   * Creates and shows two nested cr-dialogs.
   * @return An array of 2 dialogs. The first dialog
   *     is the outer dialog, and the second is the inner dialog.
   */
  function createAndShowNestedDialogs(): [CrDialogElement, CrDialogElement] {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog id="outer">
        <div slot="title">outer dialog title</div>
        <div slot="body">
          <cr-dialog id="inner">
            <div slot="title">inner dialog title</div>
            <div slot="body">body</div>
          </cr-dialog>
        </div>
      </cr-dialog>`;

    const outer = document.body.querySelector<CrDialogElement>('#outer');
    assertTrue(!!outer);
    const inner = document.body.querySelector<CrDialogElement>('#inner');
    assertTrue(!!inner);

    outer!.showModal();
    inner!.showModal();
    return [outer!, inner!];
  }

  setup(function() {
    document.body.innerHTML = window.trustedTypes!.emptyHTML;
    // Ensure svg, which is referred to by a relative URL, is loaded from
    // chrome://resources and not chrome://test
    const base = document.createElement('base');
    base.href = 'chrome://resources/cr_elements/';
    document.head.appendChild(base);
  });

  test('cr-dialog-open event fires when opened', function() {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog>
        <div slot="title">title</div>
        <div slot="body">body</div>
      </cr-dialog>`;

    const dialog = document.body.querySelector('cr-dialog')!;
    const whenFired = eventToPromise('cr-dialog-open', dialog);
    dialog.showModal();
    return whenFired;
  });

  test('close event bubbles', async function() {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog>
        <div slot="title">title</div>
        <div slot="body">body</div>
      </cr-dialog>`;

    const dialog = document.body.querySelector('cr-dialog')!;
    dialog.showModal();
    const whenFired = eventToPromise('close', dialog);
    dialog.close();
    await whenFired;
    assertEquals('success', dialog.getNative().returnValue);
  });

  // cr-dialog has to catch and re-fire 'close' events fired from it's native
  // <dialog> child to force them to bubble in Shadow DOM V1. Ensure that this
  // mechanism does not interfere with nested <cr-dialog> 'close' events.
  test(
      'close events not fired from <dialog> are not affected',
      async function() {
        const dialogs = createAndShowNestedDialogs();
        const outer = dialogs[0];
        const inner = dialogs[1];

        let whenFired = eventToPromise('close', window);
        inner.close();

        let e = await whenFired;
        // Check that the event's target is the inner dialog.
        assertEquals(inner, e.target);
        whenFired = eventToPromise('close', window);
        outer.close();
        e = await whenFired;
        // Check that the event's target is the outer dialog.
        assertEquals(outer, e.target);
      });

  test('cancel and close events bubbles when cancelled', async function() {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog>
        <div slot="title">title</div>
        <div slot="body">body</div>
      </cr-dialog>`;

    const dialog = document.body.querySelector('cr-dialog')!;
    dialog.showModal();
    const whenCancelFired = eventToPromise('cancel', dialog);
    const whenCloseFired = eventToPromise('close', dialog);
    dialog.cancel();
    await Promise.all([whenCancelFired, whenCloseFired]);
    assertEquals('', dialog.getNative().returnValue);
  });

  // cr-dialog has to catch and re-fire 'cancel' events fired from it's native
  // <dialog> child to force them to bubble in Shadow DOM V1. Ensure that this
  // mechanism does not interfere with nested <cr-dialog> 'cancel' events.
  test(
      'cancel events not fired from <dialog> are not affected',
      async function() {
        const dialogs = createAndShowNestedDialogs();
        const outer = dialogs[0];
        const inner = dialogs[1];

        let whenFired = eventToPromise('cancel', window);
        inner.cancel();

        let e = await whenFired;
        // Check that the event's target is the inner dialog.
        assertEquals(inner, e.target);
        whenFired = eventToPromise('cancel', window);
        outer.cancel();
        e = await whenFired;
        // Check that the event's target is the outer dialog.
        assertEquals(outer, e.target);
      });

  test('focuses title on show', function() {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog>
        <div slot="title">title</div>
        <div slot="body"><button>button</button></div>
      </cr-dialog>`;

    const dialog = document.body.querySelector('cr-dialog')!;
    const button = document.body.querySelector('button');

    assertNotEquals(dialog, document.activeElement);
    assertNotEquals(button, document.activeElement);

    dialog.showModal();

    assertEquals(dialog, document.activeElement);
    assertNotEquals(button, document.activeElement);
  });

  test('enter keys should trigger action buttons once', function() {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog show-close-button>
        <div slot="title">title</div>
        <div slot="body">
          <button class="action-button">button</button>
          <button id="other-button">other button</button>
        </div>
      </cr-dialog>`;

    const dialog = document.body.querySelector('cr-dialog')!;
    const actionButton =
        document.body.querySelector<HTMLElement>('.action-button')!;

    dialog.showModal();

    // MockInteractions triggers event listeners synchronously.
    let clickedCounter = 0;
    actionButton.addEventListener('click', function() {
      clickedCounter++;
    });

    function simulateEnterOnButton(button: HTMLElement) {
      pressEnter(button);
      // Also call manually click() since normally this is done by the browser.
      button.click();
    }

    // Enter key on the action button should only fire the click handler once.
    simulateEnterOnButton(actionButton);
    assertEquals(1, clickedCounter);

    // Enter keys on other buttons should be ignored.
    clickedCounter = 0;
    const otherButton =
        document.body.querySelector<HTMLElement>('#other-button');
    assertTrue(!!otherButton);
    simulateEnterOnButton(otherButton!);
    assertEquals(0, clickedCounter);

    // Enter keys on the close icon in the top-right corner should be ignored.
    const close = dialog.shadowRoot!.querySelector<HTMLElement>('#close');
    assertTrue(!!close);
    pressEnter(close);
    assertEquals(0, clickedCounter);
  });

  test('enter keys find the first non-hidden non-disabled button', function() {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog>
        <div slot="title">title</div>
        <div slot="body">
          <button id="hidden" class="action-button" hidden>hidden</button>
          <button class="action-button" disabled>disabled</button>
          <button class="action-button" disabled hidden>disabled hidden</button>
          <button id="active" class="action-button">active</button>
        </div>
      </cr-dialog>`;

    const dialog = document.body.querySelector('cr-dialog')!;
    const hiddenButton = document.body.querySelector<HTMLElement>('#hidden')!;
    const actionButton = document.body.querySelector<HTMLElement>('#active')!;
    dialog.showModal();

    // MockInteractions triggers event listeners synchronously.
    hiddenButton.addEventListener('click', function() {
      assertNotReached('Hidden button received a click.');
    });
    let clicked = false;
    actionButton.addEventListener('click', function() {
      clicked = true;
    });

    pressEnter(dialog);
    assertTrue(clicked);
  });

  test('enter keys from certain inputs only are processed', function() {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog>
        <div slot="title">title</div>
        <div slot="body">
          <foobar></foobar>
          <input type="checkbox">
          <input type="text">

          <cr-input type="search"></cr-input>
          <cr-input type="text"></cr-input>

          <div id="withShadow"></div>
          <button class="action-button">active</button>
        </div>
      </cr-dialog>`;

    const otherElement = document.body.querySelector<HTMLElement>('foobar')!;
    const inputCheckboxElement =
        document.body.querySelector<HTMLElement>('input[type="checkbox"]')!;
    const inputTextElement =
        document.body.querySelector<HTMLElement>('input[type="text"]')!;

    const crTextInputElement =
        document.body.querySelector<CrInputElement>('cr-input[type="text"]')!;
    const crSearchInputElement =
        document.body.querySelector<CrInputElement>('cr-input[type="search"]')!;

    // Attach a cr-input element nested within another element.
    const containerElement = document.body.querySelector('#withShadow')!;
    const shadow = containerElement.attachShadow({mode: 'open'});
    const crInputNested = document.createElement('cr-input');
    shadow.appendChild(crInputNested);

    const actionButton = document.body.querySelector('.action-button')!;

    // MockInteractions triggers event listeners synchronously.
    let clickedCounter = 0;
    actionButton.addEventListener('click', function() {
      clickedCounter++;
    });

    // Enter on anything other than cr-input should not be accepted.
    pressEnter(otherElement);
    assertEquals(0, clickedCounter);
    pressEnter(inputCheckboxElement);
    assertEquals(0, clickedCounter);
    pressEnter(inputTextElement);
    assertEquals(0, clickedCounter);

    // Enter on a cr-input with type "search" should not be accepted.
    pressEnter(crSearchInputElement);
    assertEquals(0, clickedCounter);

    // Enter on a cr-input with type "text" should be accepted.
    pressEnter(crTextInputElement);
    assertEquals(1, clickedCounter);

    // Enter on a nested <cr-input> should be accepted.
    pressEnter(crInputNested);
    assertEquals(2, clickedCounter);
  });

  test('focuses [autofocus] instead of title when present', function() {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog>
        <div slot="title">title</div>
        <div slot="body"><button autofocus>button</button></div>
      </cr-dialog>`;

    const dialog = document.body.querySelector('cr-dialog')!;
    const button = document.body.querySelector('button');

    assertNotEquals(dialog, document.activeElement);
    assertNotEquals(button, document.activeElement);

    dialog.showModal();

    assertNotEquals(dialog, document.activeElement);
    assertEquals(button, document.activeElement);
  });

  // Test that a cr-input[autofocus] is picked up by the browser when residing
  // within a <cr-dialog show-on-attach> which itself resides in a conditional
  // Lit template. Regression test for crbug/341327469.
  test('FocusesCrLitElementsWithAutofocus', async function() {
    class TestElement extends CrLitElement {
      static get is() {
        return 'test-element';
      }

      override render() {
        // clang-format off
        return html`
          ${this.showDialog ? html`
            <cr-dialog show-on-attach>
              <div slot="title">title</div>
              <div slot="body">
                <cr-input ?autofocus="${this.autofocusCrInput}"></cr-input>
                <cr-textarea ?autofocus="${this.autofocusCrTextarea}">
                </cr-textarea>
              </div>
            </cr-dialog>` : ''}`;
        // clang-format on
      }

      static override get properties() {
        return {
          showDialog: {type: Boolean},
          autofocusCrInput: {type: Boolean},
          autofocusCrTextarea: {type: Boolean},
        };
      }

      showDialog: boolean = false;
      autofocusCrInput: boolean = false;
      autofocusCrTextarea: boolean = false;
    }

    customElements.define(TestElement.is, TestElement);

    async function assertAutofocus(useTextarea: boolean) {
      document.body.innerHTML = window.trustedTypes!.emptyHTML;
      const element = document.createElement('test-element') as TestElement;
      useTextarea ? element.autofocusCrTextarea = true :
                    element.autofocusCrInput = true;
      const whenOpen = eventToPromise('cr-dialog-open', document.body);
      document.body.appendChild(element);
      element.showDialog = true;
      await whenOpen;

      const child = element.shadowRoot!.querySelector(
          useTextarea ? 'cr-textarea' : 'cr-input')!;
      assertEquals(
          useTextarea ? (child as CrTextareaElement).$.input :
                        (child as CrInputElement).inputElement,
          getDeepActiveElement());
    }

    await assertAutofocus(/*useTextarea=*/ false);
    await assertAutofocus(/*useTextarea=*/ true);
  });

  // Ensuring that intersectionObserver does not fire any callbacks before the
  // dialog has been opened.
  test('body scrollable border not added before modal shown', async function() {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog>
        <div slot="title">title</div>
        <div slot="body">body</div>
      </cr-dialog>`;

    const dialog = document.body.querySelector('cr-dialog')!;
    assertFalse(dialog.open);
    const bodyContainer = dialog.shadowRoot!.querySelector('.body-container');
    assertTrue(!!bodyContainer);
    const topShadow =
        dialog.shadowRoot!.querySelector('#cr-container-shadow-top');
    assertTrue(!!topShadow);
    const bottomShadow =
        dialog.shadowRoot!.querySelector('#cr-container-shadow-bottom');
    assertTrue(!!bottomShadow);

    await microtasksFinished();
    assertFalse(topShadow!.classList.contains('has-shadow'));
    assertFalse(bottomShadow!.classList.contains('has-shadow'));
  });

  test('dialog body scrollable border when appropriate', function(done) {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog>
        <div slot="title">title</div>
        <div slot="body">
          <div style="height: 100px">tall content</div>
        </div>
      </cr-dialog>`;

    const dialog = document.body.querySelector('cr-dialog')!;
    const bodyContainer =
        dialog.shadowRoot!.querySelector<HTMLElement>('.body-container');
    assertTrue(!!bodyContainer);
    const topShadow = dialog.shadowRoot!.querySelector<HTMLElement>(
        '#cr-container-shadow-top');
    assertTrue(!!topShadow);
    const bottomShadow = dialog.shadowRoot!.querySelector<HTMLElement>(
        '#cr-container-shadow-bottom');
    assertTrue(!!bottomShadow);

    dialog.showModal();  // Attach the dialog for the first time here.

    let observerCount = 0;

    function hasTransparentBorder(element: HTMLElement): boolean {
      const style = element.computedStyleMap().get('border-bottom-color') as
          CSSStyleValue;
      return style.toString() === 'rgba(0, 0, 0, 0)';
    }

    // Needs to setup the observer before attaching, since InteractionObserver
    // calls callback before MutationObserver does.
    const observer = new MutationObserver(function(changes) {
      // Only care about class mutations.
      if (changes[0]!.attributeName !== 'class') {
        return;
      }

      observerCount++;
      switch (observerCount) {
        case 1:  // Triggered when scrolled to bottom.
          assertTrue(hasTransparentBorder(bottomShadow!));
          assertFalse(hasTransparentBorder(topShadow!));
          bodyContainer!.scrollTop = 0;
          break;
        case 2:  // Triggered when scrolled back to top.
          assertFalse(hasTransparentBorder(bottomShadow));
          assertTrue(hasTransparentBorder(topShadow));
          bodyContainer!.scrollTop = 2;
          break;
        case 3:  // Triggered when finally scrolling to middle.
          assertFalse(hasTransparentBorder(bottomShadow!));
          assertFalse(hasTransparentBorder(topShadow!));
          observer.disconnect();
          done();
          break;
      }
    });
    observer.observe(bodyContainer!, {attributes: true});

    // Height is normally set via CSS, but mixin doesn't work with innerHTML.
    bodyContainer!.style.height = '60px';  // Element has "min-height: 60px".
    bodyContainer!.scrollTop = 100;
  });

  test(
      'dialog `open` attribute updated when Escape is pressed',
      async function() {
        document.body.innerHTML = getTrustedHTML`
          <cr-dialog>
            <div slot="title">title</div>
          </cr-dialog>`;

        const dialog = document.body.querySelector('cr-dialog')!;

        const whenOpen = eventToPromise('cr-dialog-open', dialog);
        dialog.showModal();
        await whenOpen;
        assertTrue(dialog.open);
        assertTrue(dialog.hasAttribute('open'));

        const whenCancel = eventToPromise('cancel', dialog);
        const e = new CustomEvent('cancel', {cancelable: true});
        dialog.getNative().dispatchEvent(e);
        await whenCancel;
        assertFalse(dialog.open);
        assertFalse(dialog.hasAttribute('open'));
      });

  test('dialog cannot be cancelled when `no-cancel` is set', function() {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog no-cancel>
        <div slot="title">title</div>
      </cr-dialog>`;

    const dialog = document.body.querySelector('cr-dialog')!;
    assertTrue(dialog.noCancel);
    dialog.showModal();

    assertNull(dialog.shadowRoot!.querySelector('#close'));

    // Hitting escape fires a 'cancel' event. Cancelling that event prevents the
    // dialog from closing.
    let e = new CustomEvent('cancel', {cancelable: true});
    dialog.getNative().dispatchEvent(e);
    assertTrue(e.defaultPrevented);

    dialog.noCancel = false;

    e = new CustomEvent('cancel', {cancelable: true});
    dialog.getNative().dispatchEvent(e);
    assertFalse(e.defaultPrevented);
  });

  test('dialog close button shown when showCloseButton is true', function() {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog show-close-button>
        <div slot="title">title</div>
      </cr-dialog>`;

    const dialog = document.body.querySelector('cr-dialog')!;
    assertTrue(dialog.showCloseButton);
    dialog.showModal();
    assertTrue(dialog.open);

    const close = dialog.shadowRoot!.querySelector<HTMLElement>('#close');
    assertTrue(!!close);
    assertTrue(isVisible(close));
    close.click();
    assertFalse(dialog.open);
  });

  test('dialog close button hidden when showCloseButton is false', function() {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog>
        <div slot="title">title</div>
      </cr-dialog>`;

    const dialog = document.body.querySelector('cr-dialog')!;
    dialog.showModal();

    assertNull(dialog.shadowRoot!.querySelector('#close'));
  });

  test(
      'keydown should be consumed when the property is true', async function() {
        document.body.innerHTML = getTrustedHTML`
      <cr-dialog consume-keydown-event>
        <div slot="title">title</div>
      </cr-dialog>`;

        const dialog = document.body.querySelector('cr-dialog')!;
        dialog.showModal();
        assertTrue(dialog.open);
        assertTrue(dialog.consumeKeydownEvent);

        function assertKeydownNotReached() {
          assertNotReached('keydown event was propagated');
        }
        document.addEventListener('keydown', assertKeydownNotReached);

        await microtasksFinished();
        keyDownOn(dialog, 65, [], 'a');
        keyDownOn(document.body, 65, [], 'a');
        document.removeEventListener('keydown', assertKeydownNotReached);
      });

  test(
      'keydown should be propagated when the property is false',
      async function() {
        document.body.innerHTML = getTrustedHTML`
      <cr-dialog>
        <div slot="title">title</div>
      </cr-dialog>`;

        const dialog = document.body.querySelector('cr-dialog')!;
        dialog.showModal();
        assertTrue(dialog.open);
        assertFalse(dialog.consumeKeydownEvent);

        let keydownCounter = 0;
        function assertKeydownCount() {
          keydownCounter++;
        }
        document.addEventListener('keydown', assertKeydownCount);

        await microtasksFinished();
        keyDownOn(dialog, 65, [], 'a');
        assertEquals(1, keydownCounter);
        document.removeEventListener('keydown', assertKeydownCount);
      });

  test('show-on-attach', () => {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog show-on-attach>
        <div slot="title">title</div>
      </cr-dialog>`;
    const dialog = document.body.querySelector('cr-dialog')!;
    assertTrue(dialog.showOnAttach);
    assertTrue(dialog.open);
  });

  test('close-text', async () => {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog close-text="foo" show-close-button>
        <div slot="title">title</div>
      </cr-dialog>`;
    const dialog = document.body.querySelector('cr-dialog')!;
    dialog.showModal();

    assertEquals('foo', dialog.closeText);
    const close = dialog.shadowRoot!.querySelector<HTMLElement>('#close');
    assertTrue(!!close);
    assertEquals('foo', close.ariaLabel);
    assertEquals('foo', close.getAttribute('aria-label'));

    dialog.closeText = undefined;
    await dialog.updateComplete;
    assertEquals(null, close.ariaLabel);
    assertFalse(close.hasAttribute('aria-label'));
  });

  // Test that when ignoreEnterKey is set, pressing "Enter" does not trigger the
  // action button.
  test('ignore-enter-key', () => {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog ignore-enter-key>
        <div slot="title">title</div>
        <div slot="body">
          <button class="action-button">button</button>
        </div>
      </cr-dialog>`;
    const dialog = document.body.querySelector('cr-dialog')!;
    dialog.showModal();

    assertTrue(dialog.ignoreEnterKey);

    // MockInteractions triggers event listeners synchronously.
    const actionButton =
        document.body.querySelector<HTMLElement>('.action-button');
    assertTrue(!!actionButton);

    let clickedCounter = 0;
    actionButton.addEventListener('click', function() {
      clickedCounter++;
    });
    pressEnter(dialog);

    assertEquals(0, clickedCounter);
  });

  test('close on popstate', function() {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog>
        <div slot="title">title</div>
      </cr-dialog>`;
    const dialog = document.body.querySelector('cr-dialog')!;
    assertFalse(dialog.ignorePopstate);
    dialog.showModal();
    assertTrue(dialog.open);

    window.dispatchEvent(new CustomEvent('popstate'));
    assertFalse(dialog.open);
  });

  test('ignore-pop-state', () => {
    document.body.innerHTML = getTrustedHTML`
      <cr-dialog ignore-popstate>
        <div slot="title">title</div>
      </cr-dialog>`;
    const dialog = document.body.querySelector('cr-dialog')!;
    assertTrue(dialog.ignorePopstate);

    dialog.showModal();
    assertTrue(dialog.open);

    window.dispatchEvent(new CustomEvent('popstate'));
    assertTrue(dialog.open);
  });
});