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!
* ` : 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()}
>${unsafeStatic(this.tag!)}>
`, {
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();
}
}