shoelace/src/components/dialog/dialog.ts

348 wiersze
13 KiB
TypeScript
Czysty Zwykły widok Historia

2023-01-13 20:43:55 +00:00
import '../icon-button/icon-button';
import { animateTo, stopAnimations } from '../../internal/animate';
2021-09-29 12:40:26 +00:00
import { classMap } from 'lit/directives/class-map.js';
2023-01-13 20:43:55 +00:00
import { customElement, property, query } from 'lit/decorators.js';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry';
import { HasSlotController } from '../../internal/slot';
import { html } from 'lit';
2021-09-29 12:40:26 +00:00
import { ifDefined } from 'lit/directives/if-defined.js';
2023-01-13 20:43:55 +00:00
import { LocalizeController } from '../../utilities/localize';
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
2022-09-16 20:21:40 +00:00
import { waitForEvent } from '../../internal/event';
2023-01-13 20:43:55 +00:00
import { watch } from '../../internal/watch';
2022-03-24 12:01:09 +00:00
import Modal from '../../internal/modal';
2022-08-17 15:37:37 +00:00
import ShoelaceElement from '../../internal/shoelace-element';
2021-07-10 00:45:44 +00:00
import styles from './dialog.styles';
import type { CSSResultGroup } from 'lit';
2021-07-12 14:36:06 +00:00
2021-02-26 14:09:13 +00:00
/**
Enrich components `@summary` with description from docs (#962) * keep header styles with repositioned description text * `animated-image` move description to component * code style * `avatar` add summary from docs * `badge` add summary from docs * `breadcrumb` add summary from docs * `button` add summary from docs * lead sentence is now part of the header * `button-group` add summary from docs * `card` add summary from docs * `checkbox` add summary from docs * `color-picker` add summary from docs * `details` add summary from docs * `dialog` add summary from docs * `divider` add summary from docs * `drawer` add summary from docs * `dropdown` add summary from docs * `format-bytes` add summary from docs * `format-date` add summary from docs * `format-number` add summary from docs * `icon` add summary from docs * `icon-button` add summary from docs * `image-comparer` add summary from docs * `include` add summary from docs * `input` add summary from docs * `menu` add summary from docs * `menu-item` add summary from docs * `menu-label` add summary from docs * `popup` add summary from docs * `progressbar` add summary from docs * `progress-ring` add summary from docs * `radio` add summary from docs * `radio-button` add summary from docs * `range` add summary from docs * `rating` add summary from docs * `relative-time` add summary from docs * `select` add summary from docs * `skeleton` add summary from docs * `spinner` add summary from docs * `split-panel` add summary from docs * `switch` add summary from docs * `tab-group` add summary from docs * `tag` add summary from docs * `textarea` add summary from docs * `tooltip` add summary from docs * `visually-hidden` add summary from docs * `animation` add summary from docs * `breadcrumb-item` add summary from docs * `mutation-observer` add summary from docs * `radio-group` add summary from docs * `resize-observer` add summary from docs * `tab` add summary from docs * `tab-panel` add summary from docs * `tree` add summary from docs * `tree-item` add summary from docs * remove `title` for further usage of `Sl` classnames in docs * revert: use markdown parser for component summary
2022-10-21 13:56:35 +00:00
* @summary Dialogs, sometimes called "modals", appear above the page and require the user's immediate attention.
2023-01-12 15:26:25 +00:00
* @documentation https://shoelace.style/components/dialog
2021-02-26 14:09:13 +00:00
* @status stable
2023-01-12 15:26:25 +00:00
* @since 2.0
2021-02-26 14:09:13 +00:00
*
* @dependency sl-icon-button
*
2022-12-06 16:18:14 +00:00
* @slot - The dialog's main content.
2022-08-03 15:06:52 +00:00
* @slot label - The dialog's label. Alternatively, you can use the `label` attribute.
2021-06-25 20:25:46 +00:00
* @slot footer - The dialog's footer, usually one or more buttons representing various options.
2021-02-26 14:09:13 +00:00
*
2021-06-25 20:25:46 +00:00
* @event sl-show - Emitted when the dialog opens.
2021-09-27 21:58:14 +00:00
* @event sl-after-show - Emitted after the dialog opens and all animations are complete.
2021-06-25 20:25:46 +00:00
* @event sl-hide - Emitted when the dialog closes.
2021-09-27 21:58:14 +00:00
* @event sl-after-hide - Emitted after the dialog closes and all animations are complete.
2022-02-27 16:46:55 +00:00
* @event sl-initial-focus - Emitted when the dialog opens and is ready to receive focus. Calling
* `event.preventDefault()` will prevent focusing and allow you to set it on a different element, such as an input.
2022-02-10 15:34:22 +00:00
* @event {{ source: 'close-button' | 'keyboard' | 'overlay' }} sl-request-close - Emitted when the user attempts to
* close the dialog by clicking the close button, clicking the overlay, or pressing escape. Calling
* `event.preventDefault()` will keep the dialog open. Avoid using this unless closing the dialog will result in
* destructive behavior such as data loss.
*
2022-12-06 16:18:14 +00:00
* @csspart base - The component's base wrapper.
* @csspart overlay - The overlay that covers the screen behind the dialog.
* @csspart panel - The dialog's panel (where the dialog and its content are rendered).
* @csspart header - The dialog's header. This element wraps the title and header actions.
2022-11-29 16:17:15 +00:00
* @csspart header-actions - Optional actions to add to the header. Works best with `<sl-icon-button>`.
2022-12-06 16:18:14 +00:00
* @csspart title - The dialog's title.
* @csspart close-button - The close button, an `<sl-icon-button>`.
* @csspart close-button__base - The close button's exported `base` part.
* @csspart body - The dialog's body.
* @csspart footer - The dialog's footer.
*
2021-06-25 20:25:46 +00:00
* @cssproperty --width - The preferred width of the dialog. Note that the dialog will shrink to accommodate smaller screens.
* @cssproperty --header-spacing - The amount of padding to use for the header.
* @cssproperty --body-spacing - The amount of padding to use for the body.
* @cssproperty --footer-spacing - The amount of padding to use for the footer.
*
2021-06-25 20:25:46 +00:00
* @animation dialog.show - The animation to use when showing the dialog.
* @animation dialog.hide - The animation to use when hiding the dialog.
* @animation dialog.denyClose - The animation to use when a request to close the dialog is denied.
* @animation dialog.overlay.show - The animation to use when showing the dialog's overlay.
* @animation dialog.overlay.hide - The animation to use when hiding the dialog's overlay.
2021-02-26 14:09:13 +00:00
*/
2021-03-18 13:04:23 +00:00
@customElement('sl-dialog')
2022-08-17 15:37:37 +00:00
export default class SlDialog extends ShoelaceElement {
static styles: CSSResultGroup = styles;
2021-03-06 17:01:39 +00:00
private readonly hasSlotController = new HasSlotController(this, 'footer');
2022-01-24 22:27:24 +00:00
private readonly localize = new LocalizeController(this);
2021-02-26 14:09:13 +00:00
private modal: Modal;
private originalTrigger: HTMLElement | null;
2021-02-26 14:09:13 +00:00
2023-01-03 20:04:07 +00:00
@query('.dialog') dialog: HTMLElement;
@query('.dialog__panel') panel: HTMLElement;
@query('.dialog__overlay') overlay: HTMLElement;
2022-12-06 16:18:14 +00:00
/**
* Indicates whether or not the dialog is open. You can toggle this attribute to show and hide the dialog, or you can
* use the `show()` and `hide()` methods and this attribute will reflect the dialog's open state.
*/
2021-07-01 00:04:46 +00:00
@property({ type: Boolean, reflect: true }) open = false;
2021-02-26 14:09:13 +00:00
/**
* The dialog's label as displayed in the header. You should always include a relevant label even when using
2022-12-06 16:18:14 +00:00
* `no-header`, as it is required for proper accessibility. If you need to display HTML, use the `label` slot instead.
2021-02-26 14:09:13 +00:00
*/
2021-07-01 00:04:46 +00:00
@property({ reflect: true }) label = '';
2021-02-26 14:09:13 +00:00
/**
* Disables the header. This will also remove the default close button, so please ensure you provide an easy,
* accessible way for users to dismiss the dialog.
*/
2021-07-01 00:04:46 +00:00
@property({ attribute: 'no-header', type: Boolean, reflect: true }) noHeader = false;
2021-03-06 17:01:39 +00:00
connectedCallback() {
super.connectedCallback();
this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this);
this.modal = new Modal(this);
2021-02-26 14:09:13 +00:00
}
2021-06-02 12:47:55 +00:00
firstUpdated() {
this.dialog.hidden = !this.open;
2021-06-28 20:49:56 +00:00
if (this.open) {
2022-11-09 16:34:50 +00:00
this.addOpenListeners();
2021-06-28 20:49:56 +00:00
this.modal.activate();
lockBodyScrolling(this);
}
}
2021-03-06 17:01:39 +00:00
disconnectedCallback() {
super.disconnectedCallback();
2021-02-26 14:09:13 +00:00
unlockBodyScrolling(this);
}
2022-02-10 15:34:22 +00:00
private requestClose(source: 'close-button' | 'keyboard' | 'overlay') {
2022-09-16 20:21:40 +00:00
const slRequestClose = this.emit('sl-request-close', {
2022-02-10 15:34:22 +00:00
cancelable: true,
detail: { source }
});
2021-06-21 13:40:11 +00:00
if (slRequestClose.defaultPrevented) {
2022-06-09 22:14:38 +00:00
const animation = getAnimation(this, 'dialog.denyClose', { dir: this.localize.dir() });
animateTo(this.panel, animation.keyframes, animation.options);
2021-06-21 13:40:11 +00:00
return;
}
this.hide();
2021-02-26 14:09:13 +00:00
}
2023-01-03 20:04:07 +00:00
private addOpenListeners() {
2022-11-09 16:34:50 +00:00
document.addEventListener('keydown', this.handleDocumentKeyDown);
}
2023-01-03 20:04:07 +00:00
private removeOpenListeners() {
2022-11-09 16:34:50 +00:00
document.removeEventListener('keydown', this.handleDocumentKeyDown);
}
2023-01-03 20:04:07 +00:00
private handleDocumentKeyDown(event: KeyboardEvent) {
2022-11-09 16:34:50 +00:00
if (this.open && event.key === 'Escape') {
event.stopPropagation();
2022-02-10 15:34:22 +00:00
this.requestClose('keyboard');
2021-02-26 14:09:13 +00:00
}
}
2021-02-26 14:09:13 +00:00
2021-06-15 13:26:35 +00:00
@watch('open', { waitUntilFirstUpdate: true })
2021-05-27 20:29:10 +00:00
async handleOpenChange() {
if (this.open) {
// Show
2022-09-16 20:21:40 +00:00
this.emit('sl-show');
2022-11-09 16:34:50 +00:00
this.addOpenListeners();
2021-05-27 20:29:10 +00:00
this.originalTrigger = document.activeElement as HTMLElement;
this.modal.activate();
lockBodyScrolling(this);
2022-02-27 16:46:55 +00:00
// When the dialog is shown, Safari will attempt to set focus on whatever element has autofocus. This can cause
// the dialogs's animation to jitter (if it starts offscreen), so we'll temporarily remove the attribute, call
// `focus({ preventScroll: true })` ourselves, and add the attribute back afterwards.
//
// Related: https://github.com/shoelace-style/shoelace/issues/693
//
const autoFocusTarget = this.querySelector('[autofocus]');
if (autoFocusTarget) {
autoFocusTarget.removeAttribute('autofocus');
}
2021-05-27 20:29:10 +00:00
await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]);
this.dialog.hidden = false;
2022-02-27 16:46:55 +00:00
// Set initial focus
requestAnimationFrame(() => {
2022-09-16 20:21:40 +00:00
const slInitialFocus = this.emit('sl-initial-focus', { cancelable: true });
2022-02-27 16:46:55 +00:00
if (!slInitialFocus.defaultPrevented) {
// Set focus to the autofocus target and restore the attribute
if (autoFocusTarget) {
(autoFocusTarget as HTMLInputElement).focus({ preventScroll: true });
} else {
this.panel.focus({ preventScroll: true });
2022-02-21 02:18:26 +00:00
}
2022-02-27 16:46:55 +00:00
}
// Restore the autofocus attribute
if (autoFocusTarget) {
autoFocusTarget.setAttribute('autofocus', '');
}
});
2021-05-27 20:29:10 +00:00
2022-06-09 22:14:38 +00:00
const panelAnimation = getAnimation(this, 'dialog.show', { dir: this.localize.dir() });
const overlayAnimation = getAnimation(this, 'dialog.overlay.show', { dir: this.localize.dir() });
2021-05-27 20:29:10 +00:00
await Promise.all([
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options),
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options)
]);
2022-09-16 20:21:40 +00:00
this.emit('sl-after-show');
2021-05-27 20:29:10 +00:00
} else {
// Hide
2022-09-16 20:21:40 +00:00
this.emit('sl-hide');
2022-11-09 16:34:50 +00:00
this.removeOpenListeners();
2021-05-27 20:29:10 +00:00
this.modal.deactivate();
await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]);
2022-06-09 22:14:38 +00:00
const panelAnimation = getAnimation(this, 'dialog.hide', { dir: this.localize.dir() });
const overlayAnimation = getAnimation(this, 'dialog.overlay.hide', { dir: this.localize.dir() });
2022-08-23 14:55:49 +00:00
// Animate the overlay and the panel at the same time. Because animation durations might be different, we need to
// hide each one individually when the animation finishes, otherwise the first one that finishes will reappear
// unexpectedly. We'll unhide them after all animations have completed.
2021-05-27 20:29:10 +00:00
await Promise.all([
2022-08-23 14:55:49 +00:00
animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options).then(() => {
this.overlay.hidden = true;
}),
animateTo(this.panel, panelAnimation.keyframes, panelAnimation.options).then(() => {
this.panel.hidden = true;
})
2021-05-27 20:29:10 +00:00
]);
2022-08-23 14:55:49 +00:00
2021-05-27 20:29:10 +00:00
this.dialog.hidden = true;
2022-08-23 14:55:49 +00:00
// Now that the dialog is hidden, restore the overlay and panel for next time
this.overlay.hidden = false;
this.panel.hidden = false;
2021-05-27 20:29:10 +00:00
unlockBodyScrolling(this);
// Restore focus to the original trigger
const trigger = this.originalTrigger;
if (typeof trigger?.focus === 'function') {
setTimeout(() => trigger.focus());
2021-05-27 20:29:10 +00:00
}
2022-09-16 20:21:40 +00:00
this.emit('sl-after-hide');
2021-05-27 20:29:10 +00:00
}
2021-03-06 20:09:12 +00:00
}
2023-01-03 20:04:07 +00:00
/** Shows the dialog. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the dialog */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
2021-02-26 14:09:13 +00:00
render() {
return html`
<div
part="base"
class=${classMap({
dialog: true,
'dialog--open': this.open,
'dialog--has-footer': this.hasSlotController.test('footer')
2021-02-26 14:09:13 +00:00
})}
>
2022-02-10 15:34:22 +00:00
<div part="overlay" class="dialog__overlay" @click=${() => this.requestClose('overlay')} tabindex="-1"></div>
2021-02-26 14:09:13 +00:00
<div
part="panel"
class="dialog__panel"
role="dialog"
aria-modal="true"
aria-hidden=${this.open ? 'false' : 'true'}
2021-03-15 11:56:15 +00:00
aria-label=${ifDefined(this.noHeader ? this.label : undefined)}
2021-12-30 17:14:39 +00:00
aria-labelledby=${ifDefined(!this.noHeader ? 'title' : undefined)}
2021-02-26 14:09:13 +00:00
tabindex="0"
>
${!this.noHeader
? html`
<header part="header" class="dialog__header">
2022-01-24 22:27:24 +00:00
<h2 part="title" class="dialog__title" id="title">
<slot name="label"> ${this.label.length > 0 ? this.label : String.fromCharCode(65279)} </slot>
2022-01-24 22:27:24 +00:00
</h2>
2022-11-29 16:17:15 +00:00
<div part="header-actions" class="dialog__header-actions">
<slot name="header-actions"></slot>
<sl-icon-button
part="close-button"
exportparts="base:close-button__base"
class="dialog__close"
name="x-lg"
label=${this.localize.term('close')}
library="system"
@click="${() => this.requestClose('close-button')}"
></sl-icon-button>
</div>
2021-02-26 14:09:13 +00:00
</header>
`
: ''}
2022-12-02 22:03:59 +00:00
<slot part="body" class="dialog__body"></slot>
2021-02-26 14:09:13 +00:00
<footer part="footer" class="dialog__footer">
<slot name="footer"></slot>
2021-02-26 14:09:13 +00:00
</footer>
</div>
</div>
`;
}
}
setDefaultAnimation('dialog.show', {
keyframes: [
2022-12-01 20:38:59 +00:00
{ opacity: 0, scale: 0.8 },
{ opacity: 1, scale: 1 }
],
2021-05-26 11:31:42 +00:00
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('dialog.hide', {
keyframes: [
2022-12-01 20:38:59 +00:00
{ opacity: 1, scale: 1 },
{ opacity: 0, scale: 0.8 }
],
2021-05-26 11:31:42 +00:00
options: { duration: 250, easing: 'ease' }
});
2021-06-21 13:40:11 +00:00
setDefaultAnimation('dialog.denyClose', {
2022-12-01 20:38:59 +00:00
keyframes: [{ scale: 1 }, { scale: 1.02 }, { scale: 1 }],
2021-06-21 13:40:11 +00:00
options: { duration: 250 }
});
setDefaultAnimation('dialog.overlay.show', {
keyframes: [{ opacity: 0 }, { opacity: 1 }],
2021-05-26 11:31:42 +00:00
options: { duration: 250 }
});
setDefaultAnimation('dialog.overlay.hide', {
keyframes: [{ opacity: 1 }, { opacity: 0 }],
2021-05-26 11:31:42 +00:00
options: { duration: 250 }
});
2021-03-12 14:09:08 +00:00
declare global {
interface HTMLElementTagNameMap {
'sl-dialog': SlDialog;
}
}