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

/**
 * @fileoverview 'cr-dialog' is a component for showing a modal dialog. If the
 * dialog is closed via close(), a 'close' event is fired. If the dialog is
 * canceled via cancel(), a 'cancel' event is fired followed by a 'close' event.
 *
 * Additionally clients can get a reference to the internal native <dialog> via
 * calling getNative() and inspecting the |returnValue| property inside
 * the 'close' event listener to determine whether it was canceled or just
 * closed, where a truthy value means success, and a falsy value means it was
 * canceled.
 *
 * Note that <cr-dialog> wrapper itself always has 0x0 dimensions, and
 * specifying width/height on <cr-dialog> directly will have no effect on the
 * internal native <dialog>. Instead use cr-dialog::part(dialog) to specify
 * width/height (as well as other available mixins to style other parts of the
 * dialog contents).
 */
import '../cr_icon_button/cr_icon_button.js';

import {assert} from '//resources/js/assert.js';
import {CrLitElement} from '//resources/lit/v3_0/lit.rollup.js';

import {CrContainerShadowMixinLit} from '../cr_container_shadow_mixin_lit.js';
import type {CrInputElement} from '../cr_input/cr_input.js';
import {CrScrollObserverMixinLit} from '../cr_scroll_observer_mixin_lit.js';

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

const CrDialogElementBase =
    CrContainerShadowMixinLit(CrScrollObserverMixinLit(CrLitElement));

export interface CrDialogElement {
  $: {
    dialog: HTMLDialogElement,
  };
}

export class CrDialogElement extends CrDialogElementBase {
  static get is() {
    return 'cr-dialog';
  }

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

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

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

      /**
       * Alt-text for the dialog close button.
       */
      closeText: {type: String},

      /**
       * True if the dialog should remain open on 'popstate' events. This is
       * used for navigable dialogs that have their separate navigation handling
       * code.
       */
      ignorePopstate: {type: Boolean},

      /**
       * True if the dialog should ignore 'Enter' keypresses.
       */
      ignoreEnterKey: {type: Boolean},

      /**
       * True if the dialog should consume 'keydown' events. If ignoreEnterKey
       * is true, 'Enter' key won't be consumed.
       */
      consumeKeydownEvent: {type: Boolean},

      /**
       * True if the dialog should not be able to be cancelled, which will
       * prevent 'Escape' key presses from closing the dialog.
       */
      noCancel: {type: Boolean},

      // True if dialog should show the 'X' close button.
      showCloseButton: {type: Boolean},

      showOnAttach: {type: Boolean},

      /**
       * Text for the aria description.
       */
      ariaDescriptionText: {type: String},
    };
  }

  closeText?: string;
  consumeKeydownEvent: boolean = false;
  ignoreEnterKey: boolean = false;
  ignorePopstate: boolean = false;
  noCancel: boolean = false;
  open: boolean = false;
  showCloseButton: boolean = false;
  showOnAttach: boolean = false;
  ariaDescriptionText?: string;

  private mutationObserver_: MutationObserver|null = null;
  private boundKeydown_: ((e: KeyboardEvent) => void)|null = null;

  override firstUpdated() {
    // If the active history entry changes (i.e. user clicks back button),
    // all open dialogs should be cancelled.
    window.addEventListener('popstate', () => {
      if (!this.ignorePopstate && this.$.dialog.open) {
        this.cancel();
      }
    });

    if (!this.ignoreEnterKey) {
      this.addEventListener('keypress', this.onKeypress_.bind(this));
    }
    this.addEventListener('pointerdown', e => this.onPointerdown_(e));
  }

  override connectedCallback() {
    super.connectedCallback();
    const mutationObserverCallback = () => {
      if (this.$.dialog.open) {
        this.enableScrollObservation(true);
        this.addKeydownListener_();
      } else {
        this.enableScrollObservation(false);
        this.removeKeydownListener_();
      }
    };

    this.mutationObserver_ = new MutationObserver(mutationObserverCallback);

    this.mutationObserver_.observe(this.$.dialog, {
      attributes: true,
      attributeFilter: ['open'],
    });

    // In some cases dialog already has the 'open' attribute by this point.
    mutationObserverCallback();
    if (this.showOnAttach) {
      this.showModal();
    }
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    this.removeKeydownListener_();
    if (this.mutationObserver_) {
      this.mutationObserver_.disconnect();
      this.mutationObserver_ = null;
    }
  }

  private addKeydownListener_() {
    if (!this.consumeKeydownEvent) {
      return;
    }

    this.boundKeydown_ = this.boundKeydown_ || this.onKeydown_.bind(this);

    this.addEventListener('keydown', this.boundKeydown_);

    // Sometimes <body> is key event's target and in that case the event
    // will bypass cr-dialog. We should consume those events too in order to
    // behave modally. This prevents accidentally triggering keyboard commands.
    document.body.addEventListener('keydown', this.boundKeydown_);
  }

  private removeKeydownListener_() {
    if (!this.boundKeydown_) {
      return;
    }

    this.removeEventListener('keydown', this.boundKeydown_);
    document.body.removeEventListener('keydown', this.boundKeydown_);
    this.boundKeydown_ = null;
  }

  async showModal() {
    if (this.showOnAttach) {
      const element = this.querySelector('[autofocus]');
      if (element && element instanceof CrLitElement && !element.shadowRoot) {
        // Force initial render, so that any inner elements with [autofocus] are
        // picked up by the browser.
        element.ensureInitialRender();
      }
    }

    this.$.dialog.showModal();
    assert(this.$.dialog.open);
    this.open = true;
    await this.updateComplete;
    this.fire('cr-dialog-open');
  }

  cancel() {
    this.fire('cancel');
    this.$.dialog.close();
    assert(!this.$.dialog.open);
    this.open = false;
  }

  close() {
    this.$.dialog.close('success');
    assert(!this.$.dialog.open);
    this.open = false;
  }

  /**
   * Set the title of the dialog for a11y reader.
   * @param title Title of the dialog.
   */
  setTitleAriaLabel(title: string) {
    this.$.dialog.removeAttribute('aria-labelledby');
    this.$.dialog.setAttribute('aria-label', title);
  }

  protected onCloseKeypress_(e: Event) {
    // Because the dialog may have a default Enter key handler, prevent
    // keypress events from bubbling up from this element.
    e.stopPropagation();
  }

  protected onNativeDialogClose_(e: Event) {
    // Ignore any 'close' events not fired directly by the <dialog> element.
    if (e.target !== this.getNative()) {
      return;
    }

    // Catch and re-fire the 'close' event such that it bubbles across Shadow
    // DOM v1.
    this.fire('close');
  }

  protected async onNativeDialogCancel_(e: Event) {
    // Ignore any 'cancel' events not fired directly by the <dialog> element.
    if (e.target !== this.getNative()) {
      return;
    }

    if (this.noCancel) {
      e.preventDefault();
      return;
    }

    // When the dialog is dismissed using the 'Esc' key, need to manually update
    // the |open| property (since close() is not called).
    this.open = false;

    await this.updateComplete;

    // Catch and re-fire the native 'cancel' event such that it bubbles across
    // Shadow DOM v1.
    this.fire('cancel');
  }

  /**
   * Expose the inner native <dialog> for some rare cases where it needs to be
   * directly accessed (for example to programmatically setheight/width, which
   * would not work on the wrapper).
   */
  getNative(): HTMLDialogElement {
    return this.$.dialog;
  }

  private onKeypress_(e: KeyboardEvent) {
    if (e.key !== 'Enter') {
      return;
    }

    // Accept Enter keys from either the dialog itself, or a child cr-input,
    // considering that the event may have been retargeted, for example if the
    // cr-input is nested inside another element. Also exclude inputs of type
    // 'search', since hitting 'Enter' on a search field most likely intends to
    // trigger searching.
    const accept = e.target === this ||
        e.composedPath().some(
            el => (el as HTMLElement).tagName === 'CR-INPUT' &&
                (el as CrInputElement).type !== 'search');
    if (!accept) {
      return;
    }
    const actionButton = this.querySelector<HTMLElement>(
        '.action-button:not([disabled]):not([hidden])');
    if (actionButton) {
      actionButton.click();
      e.preventDefault();
    }
  }

  private onKeydown_(e: KeyboardEvent) {
    assert(this.consumeKeydownEvent);

    if (!this.getNative().open) {
      return;
    }

    if (this.ignoreEnterKey && e.key === 'Enter') {
      return;
    }

    // Stop propagation to behave modally.
    e.stopPropagation();
  }

  private onPointerdown_(e: PointerEvent) {
    // Only show pulse animation if user left-clicked outside of the dialog
    // contents.
    if (e.button !== 0 ||
        (e.composedPath()[0]! as HTMLElement).tagName !== 'DIALOG') {
      return;
    }

    this.$.dialog.animate(
        [
          {transform: 'scale(1)', offset: 0},
          {transform: 'scale(1.02)', offset: 0.4},
          {transform: 'scale(1.02)', offset: 0.6},
          {transform: 'scale(1)', offset: 1},
        ],
        {
          duration: 180,
          easing: 'ease-in-out',
          iterations: 1,
        });

    // Prevent any text from being selected within the dialog when clicking in
    // the backdrop area.
    e.preventDefault();
  }

  override focus() {
    const titleContainer =
        this.shadowRoot!.querySelector<HTMLElement>('.title-container');
    assert(titleContainer);
    titleContainer.focus();
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'cr-dialog': CrDialogElement;
  }
}

customElements.define(CrDialogElement.is, CrDialogElement);