chromium/third_party/material_web_components/components-chromium/node_modules/@material/web/dialog/internal/dialog.js

/**
 * @license
 * Copyright 2023 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import { __decorate } from "tslib";
import '../../divider/divider.js';
import { html, isServer, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { mixinDelegatesAria } from '../../internal/aria/delegate.js';
import { redispatchEvent } from '../../internal/events/redispatch-event.js';
import { DIALOG_DEFAULT_CLOSE_ANIMATION, DIALOG_DEFAULT_OPEN_ANIMATION, } from './animations.js';
// Separate variable needed for closure.
const dialogBaseClass = mixinDelegatesAria(LitElement);
/**
 * A dialog component.
 *
 * @fires open {Event} Dispatched when the dialog is opening before any animations.
 * @fires opened {Event} Dispatched when the dialog has opened after any animations.
 * @fires close {Event} Dispatched when the dialog is closing before any animations.
 * @fires closed {Event} Dispatched when the dialog has closed after any animations.
 * @fires cancel {Event} Dispatched when the dialog has been canceled by clicking
 * on the scrim or pressing Escape.
 */
export class Dialog extends dialogBaseClass {
    // We do not use `delegatesFocus: true` due to a Chromium bug with
    // selecting text.
    // See https://bugs.chromium.org/p/chromium/issues/detail?id=950357
    /**
     * Opens the dialog when set to `true` and closes it when set to `false`.
     */
    get open() {
        return this.isOpen;
    }
    set open(open) {
        if (open === this.isOpen) {
            return;
        }
        this.isOpen = open;
        if (open) {
            this.setAttribute('open', '');
            this.show();
        }
        else {
            this.removeAttribute('open');
            this.close();
        }
    }
    constructor() {
        super();
        /**
         * Skips the opening and closing animations.
         */
        this.quick = false;
        /**
         * Gets or sets the dialog's return value, usually to indicate which button
         * a user pressed to close it.
         *
         * https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/returnValue
         */
        this.returnValue = '';
        /**
         * Disables focus trapping, which by default keeps keyboard Tab navigation
         * within the dialog.
         *
         * When disabled, after focusing the last element of a dialog, pressing Tab
         * again will release focus from the window back to the browser (such as the
         * URL bar).
         *
         * Focus trapping is recommended for accessibility, and should not typically
         * be disabled. Only turn this off if the use case of a dialog is more
         * accessible without focus trapping.
         */
        this.noFocusTrap = false;
        /**
         * Gets the opening animation for a dialog. Set to a new function to customize
         * the animation.
         */
        this.getOpenAnimation = () => DIALOG_DEFAULT_OPEN_ANIMATION;
        /**
         * Gets the closing animation for a dialog. Set to a new function to customize
         * the animation.
         */
        this.getCloseAnimation = () => DIALOG_DEFAULT_CLOSE_ANIMATION;
        this.isOpen = false;
        this.isOpening = false;
        this.isConnectedPromise = this.getIsConnectedPromise();
        this.isAtScrollTop = false;
        this.isAtScrollBottom = false;
        this.nextClickIsFromContent = false;
        // Dialogs should not be SSR'd while open, so we can just use runtime checks.
        this.hasHeadline = false;
        this.hasActions = false;
        this.hasIcon = false;
        // See https://bugs.chromium.org/p/chromium/issues/detail?id=1512224
        // Chrome v120 has a bug where escape keys do not trigger cancels. If we get
        // a dialog "close" event that is triggered without a "cancel" after an escape
        // keydown, then we need to manually trigger our closing logic.
        //
        // This bug occurs when pressing escape to close a dialog without first
        // interacting with the dialog's content.
        //
        // Cleanup tracking:
        // https://github.com/material-components/material-web/issues/5330
        // This can be removed when full CloseWatcher support added and the above bug
        // in Chromium is fixed to fire 'cancel' with one escape press and close with
        // multiple.
        this.escapePressedWithoutCancel = false;
        // This TreeWalker is used to walk through a dialog's children to find
        // focusable elements. TreeWalker is faster than `querySelectorAll('*')`.
        // We check for isServer because there isn't a "document" during an SSR
        // run.
        this.treewalker = isServer
            ? null
            : document.createTreeWalker(this, NodeFilter.SHOW_ELEMENT);
        if (!isServer) {
            this.addEventListener('submit', this.handleSubmit);
        }
    }
    /**
     * Opens the dialog and fires a cancelable `open` event. After a dialog's
     * animation, an `opened` event is fired.
     *
     * Add an `autofocus` attribute to a child of the dialog that should
     * receive focus after opening.
     *
     * @return A Promise that resolves after the animation is finished and the
     *     `opened` event was fired.
     */
    async show() {
        this.isOpening = true;
        // Dialogs can be opened before being attached to the DOM, so we need to
        // wait until we're connected before calling `showModal()`.
        await this.isConnectedPromise;
        await this.updateComplete;
        const dialog = this.dialog;
        // Check if already opened or if `dialog.close()` was called while awaiting.
        if (dialog.open || !this.isOpening) {
            this.isOpening = false;
            return;
        }
        const preventOpen = !this.dispatchEvent(new Event('open', { cancelable: true }));
        if (preventOpen) {
            this.open = false;
            this.isOpening = false;
            return;
        }
        // All Material dialogs are modal.
        dialog.showModal();
        this.open = true;
        // Reset scroll position if re-opening a dialog with the same content.
        if (this.scroller) {
            this.scroller.scrollTop = 0;
        }
        // Native modal dialogs ignore autofocus and instead force focus to the
        // first focusable child. Override this behavior if there is a child with
        // an autofocus attribute.
        this.querySelector('[autofocus]')?.focus();
        await this.animateDialog(this.getOpenAnimation());
        this.dispatchEvent(new Event('opened'));
        this.isOpening = false;
    }
    /**
     * Closes the dialog and fires a cancelable `close` event. After a dialog's
     * animation, a `closed` event is fired.
     *
     * @param returnValue A return value usually indicating which button was used
     *     to close a dialog. If a dialog is canceled by clicking the scrim or
     *     pressing Escape, it will not change the return value after closing.
     * @return A Promise that resolves after the animation is finished and the
     *     `closed` event was fired.
     */
    async close(returnValue = this.returnValue) {
        this.isOpening = false;
        if (!this.isConnected) {
            // Disconnected dialogs do not fire close events or animate.
            this.open = false;
            return;
        }
        await this.updateComplete;
        const dialog = this.dialog;
        // Check if already closed or if `dialog.show()` was called while awaiting.
        if (!dialog.open || this.isOpening) {
            this.open = false;
            return;
        }
        const prevReturnValue = this.returnValue;
        this.returnValue = returnValue;
        const preventClose = !this.dispatchEvent(new Event('close', { cancelable: true }));
        if (preventClose) {
            this.returnValue = prevReturnValue;
            return;
        }
        await this.animateDialog(this.getCloseAnimation());
        dialog.close(returnValue);
        this.open = false;
        this.dispatchEvent(new Event('closed'));
    }
    connectedCallback() {
        super.connectedCallback();
        this.isConnectedPromiseResolve();
    }
    disconnectedCallback() {
        super.disconnectedCallback();
        this.isConnectedPromise = this.getIsConnectedPromise();
    }
    render() {
        const scrollable = this.open && !(this.isAtScrollTop && this.isAtScrollBottom);
        const classes = {
            'has-headline': this.hasHeadline,
            'has-actions': this.hasActions,
            'has-icon': this.hasIcon,
            'scrollable': scrollable,
            'show-top-divider': scrollable && !this.isAtScrollTop,
            'show-bottom-divider': scrollable && !this.isAtScrollBottom,
        };
        // The focus trap sentinels are only added after the dialog opens, since
        // dialog.showModal() will try to autofocus them, even with tabindex="-1".
        const showFocusTrap = this.open && !this.noFocusTrap;
        const focusTrap = html `
      <div
        class="focus-trap"
        tabindex="0"
        aria-hidden="true"
        @focus=${this.handleFocusTrapFocus}></div>
    `;
        const { ariaLabel } = this;
        return html `
      <div class="scrim"></div>
      <dialog
        class=${classMap(classes)}
        aria-label=${ariaLabel || nothing}
        aria-labelledby=${this.hasHeadline ? 'headline' : nothing}
        role=${this.type === 'alert' ? 'alertdialog' : nothing}
        @cancel=${this.handleCancel}
        @click=${this.handleDialogClick}
        @close=${this.handleClose}
        @keydown=${this.handleKeydown}
        .returnValue=${this.returnValue || nothing}>
        ${showFocusTrap ? focusTrap : nothing}
        <div class="container" @click=${this.handleContentClick}>
          <div class="headline">
            <div class="icon" aria-hidden="true">
              <slot name="icon" @slotchange=${this.handleIconChange}></slot>
            </div>
            <h2 id="headline" aria-hidden=${!this.hasHeadline || nothing}>
              <slot
                name="headline"
                @slotchange=${this.handleHeadlineChange}></slot>
            </h2>
            <md-divider></md-divider>
          </div>
          <div class="scroller">
            <div class="content">
              <div class="top anchor"></div>
              <slot name="content"></slot>
              <div class="bottom anchor"></div>
            </div>
          </div>
          <div class="actions">
            <md-divider></md-divider>
            <slot name="actions" @slotchange=${this.handleActionsChange}></slot>
          </div>
        </div>
        ${showFocusTrap ? focusTrap : nothing}
      </dialog>
    `;
    }
    firstUpdated() {
        this.intersectionObserver = new IntersectionObserver((entries) => {
            for (const entry of entries) {
                this.handleAnchorIntersection(entry);
            }
        }, { root: this.scroller });
        this.intersectionObserver.observe(this.topAnchor);
        this.intersectionObserver.observe(this.bottomAnchor);
    }
    handleDialogClick() {
        if (this.nextClickIsFromContent) {
            // Avoid doing a layout calculation below if we know the click came from
            // content.
            this.nextClickIsFromContent = false;
            return;
        }
        // Click originated on the backdrop. Native `<dialog>`s will not cancel,
        // but Material dialogs do.
        const preventDefault = !this.dispatchEvent(new Event('cancel', { cancelable: true }));
        if (preventDefault) {
            return;
        }
        this.close();
    }
    handleContentClick() {
        this.nextClickIsFromContent = true;
    }
    handleSubmit(event) {
        const form = event.target;
        const { submitter } = event;
        if (form.method !== 'dialog' || !submitter) {
            return;
        }
        // Close reason is the submitter's value attribute, or the dialog's
        // `returnValue` if there is no attribute.
        this.close(submitter.getAttribute('value') ?? this.returnValue);
    }
    handleCancel(event) {
        if (event.target !== this.dialog) {
            // Ignore any cancel events dispatched by content.
            return;
        }
        this.escapePressedWithoutCancel = false;
        const preventDefault = !redispatchEvent(this, event);
        // We always prevent default on the original dialog event since we'll
        // animate closing it before it actually closes.
        event.preventDefault();
        if (preventDefault) {
            return;
        }
        this.close();
    }
    handleClose() {
        if (!this.escapePressedWithoutCancel) {
            return;
        }
        this.escapePressedWithoutCancel = false;
        this.dialog?.dispatchEvent(new Event('cancel', { cancelable: true }));
    }
    handleKeydown(event) {
        if (event.key !== 'Escape') {
            return;
        }
        // An escape key was pressed. If a "close" event fires next without a
        // "cancel" event first, then we know we're in the Chrome v120 bug.
        this.escapePressedWithoutCancel = true;
        // Wait a full task for the cancel/close event listeners to fire, then
        // reset the flag.
        setTimeout(() => {
            this.escapePressedWithoutCancel = false;
        });
    }
    async animateDialog(animation) {
        // Always cancel the previous animations. Animations can include `fill`
        // modes that need to be cleared when `quick` is toggled. If not, content
        // that faded out will remain hidden when a `quick` dialog re-opens after
        // previously opening and closing without `quick`.
        this.cancelAnimations?.abort();
        this.cancelAnimations = new AbortController();
        if (this.quick) {
            return;
        }
        const { dialog, scrim, container, headline, content, actions } = this;
        if (!dialog || !scrim || !container || !headline || !content || !actions) {
            return;
        }
        const { container: containerAnimate, dialog: dialogAnimate, scrim: scrimAnimate, headline: headlineAnimate, content: contentAnimate, actions: actionsAnimate, } = animation;
        const elementAndAnimation = [
            [dialog, dialogAnimate ?? []],
            [scrim, scrimAnimate ?? []],
            [container, containerAnimate ?? []],
            [headline, headlineAnimate ?? []],
            [content, contentAnimate ?? []],
            [actions, actionsAnimate ?? []],
        ];
        const animations = [];
        for (const [element, animation] of elementAndAnimation) {
            for (const animateArgs of animation) {
                const animation = element.animate(...animateArgs);
                this.cancelAnimations.signal.addEventListener('abort', () => {
                    animation.cancel();
                });
                animations.push(animation);
            }
        }
        await Promise.all(animations.map((animation) => animation.finished.catch(() => {
            // Ignore intentional AbortErrors when calling `animation.cancel()`.
        })));
    }
    handleHeadlineChange(event) {
        const slot = event.target;
        this.hasHeadline = slot.assignedElements().length > 0;
    }
    handleActionsChange(event) {
        const slot = event.target;
        this.hasActions = slot.assignedElements().length > 0;
    }
    handleIconChange(event) {
        const slot = event.target;
        this.hasIcon = slot.assignedElements().length > 0;
    }
    handleAnchorIntersection(entry) {
        const { target, isIntersecting } = entry;
        if (target === this.topAnchor) {
            this.isAtScrollTop = isIntersecting;
        }
        if (target === this.bottomAnchor) {
            this.isAtScrollBottom = isIntersecting;
        }
    }
    getIsConnectedPromise() {
        return new Promise((resolve) => {
            this.isConnectedPromiseResolve = resolve;
        });
    }
    handleFocusTrapFocus(event) {
        const [firstFocusableChild, lastFocusableChild] = this.getFirstAndLastFocusableChildren();
        if (!firstFocusableChild || !lastFocusableChild) {
            // When a dialog does not have focusable children, the dialog itself
            // receives focus.
            this.dialog?.focus();
            return;
        }
        // To determine which child to focus, we need to know which focus trap
        // received focus...
        const isFirstFocusTrap = event.target === this.firstFocusTrap;
        const isLastFocusTrap = !isFirstFocusTrap;
        // ...and where the focus came from (what was previously focused).
        const focusCameFromFirstChild = event.relatedTarget === firstFocusableChild;
        const focusCameFromLastChild = event.relatedTarget === lastFocusableChild;
        // Although this is a focus trap, focus can come from outside the trap.
        // This can happen when elements are programmatically `focus()`'d. It also
        // happens when focus leaves and returns to the window, such as clicking on
        // the browser's URL bar and pressing Tab, or switching focus between
        // iframes.
        const focusCameFromOutsideDialog = !focusCameFromFirstChild && !focusCameFromLastChild;
        // Focus the dialog's first child when we reach the end of the dialog and
        // focus is moving forward. Or, when focus is moving forwards into the
        // dialog from outside of the window.
        const shouldFocusFirstChild = (isLastFocusTrap && focusCameFromLastChild) ||
            (isFirstFocusTrap && focusCameFromOutsideDialog);
        if (shouldFocusFirstChild) {
            firstFocusableChild.focus();
            return;
        }
        // Focus the dialog's last child when we reach the beginning of the dialog
        // and focus is moving backward. Or, when focus is moving backwards into the
        // dialog from outside of the window.
        const shouldFocusLastChild = (isFirstFocusTrap && focusCameFromFirstChild) ||
            (isLastFocusTrap && focusCameFromOutsideDialog);
        if (shouldFocusLastChild) {
            lastFocusableChild.focus();
            return;
        }
        // The booleans above are verbose for readability, but code executation
        // won't actually reach here.
    }
    getFirstAndLastFocusableChildren() {
        if (!this.treewalker) {
            return [null, null];
        }
        let firstFocusableChild = null;
        let lastFocusableChild = null;
        // Reset the current node back to the root host element.
        this.treewalker.currentNode = this.treewalker.root;
        while (this.treewalker.nextNode()) {
            // Cast as Element since the TreeWalker filter only accepts Elements.
            const nextChild = this.treewalker.currentNode;
            if (!isFocusable(nextChild)) {
                continue;
            }
            if (!firstFocusableChild) {
                firstFocusableChild = nextChild;
            }
            lastFocusableChild = nextChild;
        }
        // We set lastFocusableChild immediately after finding a
        // firstFocusableChild, which means the pair is either both null or both
        // non-null. Cast since TypeScript does not recognize this.
        return [firstFocusableChild, lastFocusableChild];
    }
}
__decorate([
    property({ type: Boolean })
], Dialog.prototype, "open", null);
__decorate([
    property({ type: Boolean })
], Dialog.prototype, "quick", void 0);
__decorate([
    property({ attribute: false })
], Dialog.prototype, "returnValue", void 0);
__decorate([
    property()
], Dialog.prototype, "type", void 0);
__decorate([
    property({ type: Boolean, attribute: 'no-focus-trap' })
], Dialog.prototype, "noFocusTrap", void 0);
__decorate([
    query('dialog')
], Dialog.prototype, "dialog", void 0);
__decorate([
    query('.scrim')
], Dialog.prototype, "scrim", void 0);
__decorate([
    query('.container')
], Dialog.prototype, "container", void 0);
__decorate([
    query('.headline')
], Dialog.prototype, "headline", void 0);
__decorate([
    query('.content')
], Dialog.prototype, "content", void 0);
__decorate([
    query('.actions')
], Dialog.prototype, "actions", void 0);
__decorate([
    state()
], Dialog.prototype, "isAtScrollTop", void 0);
__decorate([
    state()
], Dialog.prototype, "isAtScrollBottom", void 0);
__decorate([
    query('.scroller')
], Dialog.prototype, "scroller", void 0);
__decorate([
    query('.top.anchor')
], Dialog.prototype, "topAnchor", void 0);
__decorate([
    query('.bottom.anchor')
], Dialog.prototype, "bottomAnchor", void 0);
__decorate([
    query('.focus-trap')
], Dialog.prototype, "firstFocusTrap", void 0);
__decorate([
    state()
], Dialog.prototype, "hasHeadline", void 0);
__decorate([
    state()
], Dialog.prototype, "hasActions", void 0);
__decorate([
    state()
], Dialog.prototype, "hasIcon", void 0);
function isFocusable(element) {
    // Check if the element is a known built-in focusable element:
    // - <a> and <area> with `href` attributes.
    // - Form controls that are not disabled.
    // - `contenteditable` elements.
    // - Anything with a non-negative `tabindex`.
    const knownFocusableElements = ':is(button,input,select,textarea,object,:is(a,area)[href],[tabindex],[contenteditable=true])';
    const notDisabled = ':not(:disabled,[disabled])';
    const notNegativeTabIndex = ':not([tabindex^="-"])';
    if (element.matches(knownFocusableElements + notDisabled + notNegativeTabIndex)) {
        return true;
    }
    const isCustomElement = element.localName.includes('-');
    if (!isCustomElement) {
        return false;
    }
    // If a custom element does not have a tabindex, it may still be focusable
    // if it delegates focus with a shadow root. We also need to check again if
    // the custom element is a disabled form control.
    if (!element.matches(notDisabled)) {
        return false;
    }
    return element.shadowRoot?.delegatesFocus ?? false;
}
//# sourceMappingURL=dialog.js.map