import { deref, inject } from "@storyteller/framework"; import { type ArrayOrSingle, asArray, exists } from "@storyteller/utility"; import { LitElement, TemplateResult, type PropertyValues, css } from "lit"; import { html, unsafeStatic } from "lit/static-html.js"; import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { StyleInfo } from "lit/directives/style-map.js"; import { Ref } from "lit/directives/ref.js"; import { OVERLAY_PROVIDER, type OverlayHandle, type OverlayProvider, } from "../overlay-provider.element"; import { type DialogBaseElement } from "./dialog-base.element"; /** * A simple, WIP [dialog widget](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) * implementation. * * @warning * This component should be considered experimental! Its API is still a work-in- * progress, potential use cases have not been thoroughly explored, and it may * not behave as expected in all circumstances. * * @example * ``` * \@customElement("my-dialog") * class MyDialogElement extends DialogBaseElement { * render = () => html` * * My Dialog * *
*
*

Hello, world!

*
*
* ` * } * ``` * ### Trigger-based implementation * ``` * \@customElement("my-app") * class MyApp extends LitElement { * dialogTriggerRef = createRef(); * * render = () => html` * * * Open Dialog * * * * * ` * } * ``` * ### Manual-open implementation * ``` * \@customElement("my-app") * class MyApp extends LitElement { * dialogRef = createRef(); * * render = () => html` * * { * this.dialogRef.value?.open(); * }} * > * Open Dialog * * * * * ` * } * ``` */ @customElement("sts-dialog") export class DialogElement extends LitElement { static override styles = css` :host { display: none; } `; /** * Tag-name of a custom element inheriting from {@linkcode DialogBaseElement}` */ @property() tag?: string; /** * A custom Lit template to render. Use this when you want to render some * quick-and-dirty custom dialog content without extending the {@linkcode DialogBaseElement} * class, or when you need to dynamically set the properties or slot content * of your custom dialog element. * * @example * * ### Quick custom content * ``` * \@customElement("my-app") * class MyApp extends LitElement { * dialogTriggerRef = createRef(); * * render = () => html` * * * Open Dialog * * * Hey folks here's my dialog, hope you like it!

* `] as const} * >
*
* ` * } * ``` * * @example * * ### More complex custom content * ``` * \@customElement("my-app") * class MyApp extends LitElement { * dialogTriggerRef = createRef(); * * get dialogTemplate() { * return html` * * My Dialog * * *
*

Custom sidebar

*
    *
  • Lorem ipsum
  • *
  • Dolor
  • *
  • Sit amet
  • *
*
* *

These paragraphs will appear in the main content area.

*

Lorem ipsum dolor sit amet.

*

Consectetur adipiscing elit.

* *
* * Confirm * * * Cancel * *
* ` * } * * render = () => html` * * * Open Dialog * * * * * ` * } * ``` * * @example * ### Dynamic custom dialog class * ``` * import styles from "./my-counter-dialog.element.scss?inline"; * * \@customElement("my-counter-dialog") * class CounterDialogElement extends DialogBaseElement { * static override styles = css` * ${DialogBaseElement.styles} * ${unsafeCSS(styles)} * `; * * \@property({ type: Number }) * count = 0; * * render = () => html` * * Counter Dialog * * *
* *

The count is ${this.count}.

* *
*
* *
* this.dispatchEvent(new CustomEvent("increment"))} * > * ++ * *
* ` * } * * \@customElement("my-app") * class MyApp extends LitElement { * dialogTriggerRef = createRef(); * dialogRef = createRef(); * * \@state() count = 0; * * onIncrement(): void { * this.count++; * } * * onClose(): void { * this.dialogRef.value?.close(); * } * * get dialogTemplate() { * return html` * * ${this.count === 69 ? html` *

(Nice.)

* ` : nothing} *
* ` * } * * render = () => html` * * * Open Dialog * * * * * ` * } * ``` * * @see {@linkcode skipWrapper} * @see {@linkcode DialogBaseElement} */ @property({ attribute: false }) template?: [LitElement, TemplateResult]; /** * Use this in conjunction with the {@linkcode template} property to render * a custom dialog element inheriting from {@linkcode DialogBaseElement} with * dynamic property binding and/or slotted content. * * This avoids wrapping your custom dialog template in a redundant * `` tag, which is important for UX and * accessibility when rendering a custom component that already implements * the [dialog widget](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) * spec. */ @property({ type: Boolean }) skipWrapper = false; /** * If this property is `true`, the dialog will not be immediately closed when * its backdrop is clicked. */ @property({ type: Boolean }) ignoreBackdropClick = false; /** * If this property is `true`, the dialog backdrop will be completely * transparent and allow the user to interact with background elements while * the dialog is still present. */ @property({ type: Boolean }) nonBlocking = false; /** * ARIA role to pass to the custom element defined by `tag`. * * Has no effect if a custom {@linkcode template} is provided with * {@linkcode skipWrapper} enabled. * */ @property() dialogRole?: "dialog" | "alert"; /** * @optional * Ref(s) of the HTML element(s) which should open this dialog when clicked. */ @property({ attribute: false }) trigger: ArrayOrSingle | HTMLElement> = []; @inject(OVERLAY_PROVIDER) _overlay!: OverlayProvider; #overlayHandle?: WeakRef; open(): void { if (!this.tag && !this.template) return; const containerStyles: StyleInfo = { display: "flex", alignItems: "center", justifyContent: "center", }; if (this.nonBlocking) { containerStyles["pointer-events"] = "none"; } else { containerStyles["background"] = "#1A1A2766"; } const onBackdropClick = this.onBackdropClick; if (this.template) { const [host, template] = this.template; this.#overlayHandle = this._overlay.attach( host, this.skipWrapper ? template : html` this.close()} > ${template} `, { containerStyles, onBackdropClick, }, ); } else { this.#overlayHandle = this._overlay.attach(this, html` <${unsafeStatic(this.tag!)} role=${ifDefined(this.dialogRole)} @close=${() => this.close()} > `, { containerStyles, onBackdropClick, }); } } close(): void { if (this.#overlayHandle) this._overlay.detach(this.#overlayHandle); } protected override updated(changes: PropertyValues): void { if (changes.has("tag") && changes.get("tag") != null) throw new Error(`DialogElement's "tag" attribute should not be changed after rendering!`); if (changes.has("trigger")) { const prevTrigger = changes.get("trigger"); if (prevTrigger) for (let trigger of asArray(prevTrigger).map(deref).filter(exists)) trigger.removeEventListener("click", this.onTriggerClick); for (let trigger of asArray(this.trigger).map(deref).filter(exists)) trigger.addEventListener("click", this.onTriggerClick); } if (changes.has("template")) { if (this.#overlayHandle && this.template) this._overlay.reRender(this.#overlayHandle, this.template); } super.updated(changes); } override disconnectedCallback(): void { for (let trigger of asArray(this.trigger).map(deref).filter(exists)) trigger.removeEventListener("click", this.onTriggerClick); super.disconnectedCallback(); } onTriggerClick = (event: Event): void => { event.stopPropagation(); this.open(); } onBackdropClick = (): void => { if (!this.ignoreBackdropClick && !this.nonBlocking) this.close(); } }