@material/web
Version:
Material web components
371 lines • 13.3 kB
JavaScript
/**
* @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}
=${this.handleCancel}
=${this.handleDialogClick}
.returnValue=${this.returnValue || nothing}
>
<div class="container"
=${this.handleContentClick}
>
<div class="headline">
<div class="icon" aria-hidden="true">
<slot name="icon" =${this.handleIconChange}></slot>
</div>
<h2 id="headline" aria-hidden=${!this.hasHeadline || nothing}>
<slot name="headline"
=${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"
=${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