UNPKG

@material/web

Version:
371 lines 13.3 kB
/** * @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 { requestUpdateOnAriaChange } from '../../internal/aria/delegate.js'; import { redispatchEvent } from '../../internal/controller/events.js'; import { DIALOG_DEFAULT_CLOSE_ANIMATION, DIALOG_DEFAULT_OPEN_ANIMATION } from './animations.js'; /** * A dialog component. * * @fires open Dispatched when the dialog is opening before any animations. * @fires opened Dispatched when the dialog has opened after any animations. * @fires close Dispatched when the dialog is closing before any animations. * @fires closed Dispatched when the dialog has closed after any animations. * @fires cancel Dispatched when the dialog has been canceled by clicking on the * scrim or pressing Escape. */ export class Dialog extends LitElement { /** * 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(); /** * 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 = ''; /** * 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; 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 `autocomplete` 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; 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, }; 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} .returnValue=${this.returnValue || 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> </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; } 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(); } async animateDialog(animation) { 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) { animations.push(element.animate(...animateArgs)); } } await Promise.all(animations.map(animation => animation.finished)); } 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; }); } } (() => { requestUpdateOnAriaChange(Dialog); })(); /** @nocollapse */ Dialog.shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }; __decorate([ property({ type: Boolean }) ], Dialog.prototype, "open", null); __decorate([ property({ attribute: false }) ], Dialog.prototype, "returnValue", void 0); __decorate([ property() ], Dialog.prototype, "type", 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([ state() ], Dialog.prototype, "hasHeadline", void 0); __decorate([ state() ], Dialog.prototype, "hasActions", void 0); __decorate([ state() ], Dialog.prototype, "hasIcon", void 0); //# sourceMappingURL=dialog.js.map