mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-23 08:16:05 +00:00
Refactor Dialog abstraction (#90)
This PR overhauls the Dialog abstractions in `studio-ui` to be more flexible for a wider variety of use cases, and adds a placeholder "Import Asset" sub-dialog to the Media Library dialog.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
export * from "./lib/button.element";
|
||||
export * from "./lib/dialog.element";
|
||||
export * from "./lib/dialog";
|
||||
export * from "./lib/icon.element";
|
||||
export * from "./lib/menu.element";
|
||||
export * from "./lib/overlay-provider.element";
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import { inject, on } from "@storyteller/framework";
|
||||
import { LitElement, type PropertyValues } 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 { createRef, ref } from "lit/directives/ref.js";
|
||||
import {
|
||||
OVERLAY_PROVIDER,
|
||||
type OverlayHandle,
|
||||
type OverlayProvider,
|
||||
} from "./overlay-provider.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`
|
||||
* <header>
|
||||
* <h4>My Dialog</h4>
|
||||
* <sts-icon
|
||||
* icon="xmark"
|
||||
* \@click=${() => this.dispatchEvent(new CustomEvent("close"))}
|
||||
* ></sts-icon>
|
||||
* </header>
|
||||
* <section>
|
||||
* <p>Hello, world!</p>
|
||||
* </section>
|
||||
* `
|
||||
* }
|
||||
* ```
|
||||
* ### Trigger-based implementation
|
||||
* ```
|
||||
* \@customElement("my-app")
|
||||
* class MyApp extends LitElement {
|
||||
* dialogTriggerRef = createRef<ButtonElement>();
|
||||
*
|
||||
* render = () => html`
|
||||
* <sts-overlay-provider>
|
||||
* <sts-button ${ref(this.dialogTriggerRef)}>
|
||||
* Open Dialog
|
||||
* </sts-button>
|
||||
*
|
||||
* <sts-dialog
|
||||
* tag="my-dialog"
|
||||
* .trigger=${this.dialogTriggerRef}
|
||||
* ></sts-dialog>
|
||||
* </sts-overlay-provider>
|
||||
* `
|
||||
* }
|
||||
* ```
|
||||
* ### Manual-open implementation
|
||||
* ```
|
||||
* \@customElement("my-app")
|
||||
* class MyApp extends LitElement {
|
||||
* dialogRef = createRef<DialogElement>();
|
||||
*
|
||||
* render = () => html`
|
||||
* <sts-overlay-provider>
|
||||
* <sts-button
|
||||
* \@click=${() => {
|
||||
* this.dialogRef.value?.open();
|
||||
* }}
|
||||
* >
|
||||
* Open Dialog
|
||||
* </sts-button>
|
||||
*
|
||||
* <sts-dialog
|
||||
* ${ref(this.dialogRef)}
|
||||
* tag="my-dialog"
|
||||
* ></sts-dialog>
|
||||
* </sts-overlay-provider>
|
||||
* `
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@customElement("sts-dialog")
|
||||
export class DialogElement extends LitElement {
|
||||
/**
|
||||
* Tag-name of a custom element inheriting from {@linkcode DialogBaseElement}`
|
||||
*/
|
||||
@property() tag!: string;
|
||||
|
||||
/** ARIA role to pass to the custom element defined by `tag`. */
|
||||
@property() dialogRole?: "dialog" | "alert";
|
||||
|
||||
/**
|
||||
* @optional
|
||||
* Ref of the HTML element which should open the dialog when clicked.
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
trigger = createRef<HTMLElement>();
|
||||
|
||||
@inject(OVERLAY_PROVIDER)
|
||||
_overlay!: OverlayProvider;
|
||||
|
||||
#overlayHandle?: WeakRef<OverlayHandle>;
|
||||
#dialogRef = createRef<DialogBaseElement>();
|
||||
|
||||
open(): void {
|
||||
this.#overlayHandle = this._overlay.attach(this, html`
|
||||
<${unsafeStatic(this.tag)}
|
||||
${ref(this.#dialogRef)}
|
||||
role=${ifDefined(this.dialogRole)}
|
||||
@close=${() => this.close()}
|
||||
></${unsafeStatic(this.tag)}>
|
||||
`, {
|
||||
containerStyles: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.#overlayHandle)
|
||||
this._overlay.detach(this.#overlayHandle);
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
if (!this.tag)
|
||||
throw new Error("DialogElement requires a static `tag` attribute!");
|
||||
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
protected override updated(changes: PropertyValues<this>): 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?.value) {
|
||||
prevTrigger.value.removeEventListener("click", this.onTriggerClick);
|
||||
}
|
||||
|
||||
const trigger = this.trigger.value;
|
||||
if (!trigger) return;
|
||||
|
||||
trigger.addEventListener("click", this.onTriggerClick);
|
||||
}
|
||||
|
||||
super.updated(changes);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.trigger.value?.removeEventListener("click", this.onTriggerClick);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
onTriggerClick = (): void => {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
export class DialogBaseElement extends LitElement {
|
||||
@property({ reflect: true })
|
||||
override role = "dialog";
|
||||
|
||||
override tabIndex = -1;
|
||||
|
||||
override connectedCallback(): void {
|
||||
this.focus();
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
@on("window:click")
|
||||
onWindowClick(event: PointerEvent): void {
|
||||
if (!event.composedPath().includes(this))
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
}
|
||||
|
||||
@on("keydown")
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
if (/^(Escape|Shift)$/.test(event.key))
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.key === "Escape")
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
*, *::before, *::after {
|
||||
font-family: "Fira Sans", sans-serif;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
background: #0006;
|
||||
color: #FFFC;
|
||||
backdrop-filter: blur(32px);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 auto;
|
||||
gap: 1rem;
|
||||
border: {
|
||||
inline: 0.5rem solid #0004;
|
||||
block-end: 0.5rem solid #0004;
|
||||
}
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
[part="sidebar"]:not(:empty) {
|
||||
padding: 1rem;
|
||||
background: #0004;
|
||||
|
||||
+ .content {
|
||||
border-inline-start: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { on } from "@storyteller/framework";
|
||||
import { LitElement, TemplateResult, html, unsafeCSS } from "lit";
|
||||
import { customElement, property, queryAssignedElements } from "lit/decorators.js";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { type DialogHeaderElement } from "./dialog-header.element";
|
||||
|
||||
import styles from "./dialog-base.element.scss?inline";
|
||||
|
||||
/**
|
||||
* A base template for building custom dialogs. Can be extended via inheritance
|
||||
* or customized with [named slots](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot)
|
||||
* and [CSS parts](https://developer.mozilla.org/en-US/docs/Web/CSS/::part).
|
||||
*
|
||||
* @slot `dialog-header`
|
||||
*
|
||||
* Insert a custom header. Can alternatively be configured via the
|
||||
* {@linkcode heading} and {@linkcode icon} properties. See also
|
||||
* {@linkcode DialogHeaderElement}.
|
||||
*
|
||||
* @slot `sidebar`
|
||||
*
|
||||
* Insert a custom sidebar (empty by default).
|
||||
*
|
||||
* @slot `footer`
|
||||
*
|
||||
* Insert a custom footer (empty by default and not currently styled).
|
||||
*
|
||||
* @slot (default)
|
||||
*
|
||||
* Projected content without a named `slot` attribute will appear in the main
|
||||
* content area.
|
||||
*
|
||||
* @part `header`
|
||||
*
|
||||
* A CSS part for styling the default dialog header. Has no effect if a custom
|
||||
* `sts-dialog-header` or `[part="dialog-header"]` is inserted.
|
||||
*
|
||||
* @part `main`
|
||||
*
|
||||
* A CSS part for styling the wrapper around the `sidebar` and `content` areas.
|
||||
*
|
||||
* @part `content`
|
||||
*
|
||||
* A CSS part to style the wrapper around the main content area.
|
||||
*/
|
||||
@customElement("sts-dialog-base")
|
||||
export class DialogBaseElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
@property({ reflect: true })
|
||||
override role = "dialog";
|
||||
|
||||
@property({
|
||||
attribute: "aria-label",
|
||||
reflect: true,
|
||||
})
|
||||
heading?: string;
|
||||
|
||||
@property() icon?: string;
|
||||
|
||||
override ariaModal = "true";
|
||||
override tabIndex = -1;
|
||||
|
||||
@queryAssignedElements({ slot: "dialog-header" })
|
||||
dialogHeaders!: Element[];
|
||||
|
||||
override connectedCallback(): void {
|
||||
this.focus();
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
@on("keydown")
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
if (/^(Escape|Shift)$/.test(event.key))
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.key === "Escape")
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
}
|
||||
|
||||
onDialogHeaderChanged(): void {
|
||||
if (this.dialogHeaders.length && !this.ariaLabel) {
|
||||
const header = this.dialogHeaders[0];
|
||||
header.id = uuid();
|
||||
this.setAttribute("aria-labelledby", header.id);
|
||||
}
|
||||
else if (!this.dialogHeaders.length && this.hasAttribute("aria-labelledby")) {
|
||||
this.removeAttribute("aria-labelledby");
|
||||
}
|
||||
}
|
||||
|
||||
protected override render(): TemplateResult {
|
||||
return html`
|
||||
<slot
|
||||
name="dialog-header"
|
||||
@slotchange=${this.onDialogHeaderChanged}
|
||||
>
|
||||
<sts-dialog-header
|
||||
part="header"
|
||||
.icon=${this.icon}
|
||||
>
|
||||
${this.heading}
|
||||
</sts-dialog-header>
|
||||
</slot>
|
||||
|
||||
<section class="main" part="main">
|
||||
<slot name="sidebar" part="sidebar"></slot>
|
||||
|
||||
<section class="content" part="content">
|
||||
<slot></slot>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<slot name="footer"></slot>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
@use "sass:math";
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 3rem;
|
||||
padding: 0 {
|
||||
inline-start: 1rem;
|
||||
}
|
||||
background: #0006;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: math.div(12, 16) * 1rem;
|
||||
|
||||
&__icon {
|
||||
color: #FFFC;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&__heading {
|
||||
color: #FFFC;
|
||||
font: {
|
||||
weight: 500;
|
||||
size: math.div(15, 16) * 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
font-size: 1rem;
|
||||
color: #FFFC;
|
||||
opacity: 0.75;
|
||||
|
||||
&:hover {
|
||||
color: #FFFF;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { LitElement, html, nothing, unsafeCSS } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import styles from "./dialog-header.element.scss?inline";
|
||||
|
||||
/**
|
||||
* A configurable header for custom dialogs. This component should be preferred
|
||||
* over inserting a wholly custom `[part="dialog-header"]`.
|
||||
*
|
||||
* @slot (default)
|
||||
*
|
||||
* Projected content without a named `slot` attribute will appear as the
|
||||
* "heading", next to the icon. This should usually be simple text content
|
||||
* without any markup elements.
|
||||
*
|
||||
* @slot `extras`
|
||||
*
|
||||
* Use this slot to insert arbitrary content in between the title and close
|
||||
* button.
|
||||
*
|
||||
* @slot `close`
|
||||
*
|
||||
* Insert a custom close button, or an empty `[slot="close"]` to remove the
|
||||
* default close button.
|
||||
*
|
||||
* @part `title`
|
||||
*
|
||||
* A CSS part for styling the wrapper around the icon and heading.
|
||||
*
|
||||
* @part `icon`
|
||||
*
|
||||
* A CSS part for styling the icon.
|
||||
*
|
||||
* @part `heading`
|
||||
*
|
||||
* A CSS part for styling the wrapper around the slotted heading (default slot).
|
||||
*
|
||||
* @part `close`
|
||||
*
|
||||
* A CSS part for styling the default "close" button. Has no effect if a custom
|
||||
* `[part="close"]` is inserted.
|
||||
*/
|
||||
@customElement("sts-dialog-header")
|
||||
export class DialogHeaderElement extends LitElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
@property({ reflect: true })
|
||||
override id = uuid();
|
||||
|
||||
@property({ reflect: true })
|
||||
override slot = "dialog-header";
|
||||
|
||||
@property() icon?: string;
|
||||
|
||||
onClose(): void {
|
||||
this.dispatchEvent(new CustomEvent("close", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
protected override render = () => html`
|
||||
<div
|
||||
class="title"
|
||||
part="title"
|
||||
>
|
||||
${this.icon ? html`
|
||||
<sts-icon
|
||||
class="title__icon"
|
||||
part="icon"
|
||||
.icon=${this.icon}
|
||||
></sts-icon>
|
||||
` : nothing}
|
||||
|
||||
<span
|
||||
class="title__heading"
|
||||
part="heading"
|
||||
>
|
||||
<slot>Dialog</slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<slot name="extras"></slot>
|
||||
|
||||
<slot name="close">
|
||||
${/*
|
||||
Note: "Close" button is intentionally hidden from the a11y tree
|
||||
because because the Esc key is the ARIA-standard interaction for
|
||||
closing a dialog.
|
||||
*/ nothing}
|
||||
<sts-icon
|
||||
class="close"
|
||||
part="close"
|
||||
icon="xmark"
|
||||
@click=${this.onClose}
|
||||
></sts-icon>
|
||||
</slot>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { deref, inject } from "@storyteller/framework";
|
||||
import { type ArrayOrSingle, asArray, exists } from "@storyteller/utility";
|
||||
import { LitElement, TemplateResult, type PropertyValues } 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 { 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`
|
||||
* <sts-dialog-header>
|
||||
* My Dialog
|
||||
* </sts-dialog-header>
|
||||
* <section class="main">
|
||||
* <section class="content">
|
||||
* <p>Hello, world!</p>
|
||||
* </section>
|
||||
* </section>
|
||||
* `
|
||||
* }
|
||||
* ```
|
||||
* ### Trigger-based implementation
|
||||
* ```
|
||||
* \@customElement("my-app")
|
||||
* class MyApp extends LitElement {
|
||||
* dialogTriggerRef = createRef<ButtonElement>();
|
||||
*
|
||||
* render = () => html`
|
||||
* <sts-overlay-provider>
|
||||
* <sts-button ${ref(this.dialogTriggerRef)}>
|
||||
* Open Dialog
|
||||
* </sts-button>
|
||||
*
|
||||
* <sts-dialog
|
||||
* tag="my-dialog"
|
||||
* .trigger=${this.dialogTriggerRef}
|
||||
* ></sts-dialog>
|
||||
* </sts-overlay-provider>
|
||||
* `
|
||||
* }
|
||||
* ```
|
||||
* ### Manual-open implementation
|
||||
* ```
|
||||
* \@customElement("my-app")
|
||||
* class MyApp extends LitElement {
|
||||
* dialogRef = createRef<DialogElement>();
|
||||
*
|
||||
* render = () => html`
|
||||
* <sts-overlay-provider>
|
||||
* <sts-button
|
||||
* \@click=${() => {
|
||||
* this.dialogRef.value?.open();
|
||||
* }}
|
||||
* >
|
||||
* Open Dialog
|
||||
* </sts-button>
|
||||
*
|
||||
* <sts-dialog
|
||||
* ${ref(this.dialogRef)}
|
||||
* tag="my-dialog"
|
||||
* ></sts-dialog>
|
||||
* </sts-overlay-provider>
|
||||
* `
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@customElement("sts-dialog")
|
||||
export class DialogElement extends LitElement {
|
||||
/**
|
||||
* 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<ButtonElement>();
|
||||
*
|
||||
* render = () => html`
|
||||
* <sts-overlay-provider>
|
||||
* <sts-button ${ref(this.dialogTriggerRef)}>
|
||||
* Open Dialog
|
||||
* </sts-button>
|
||||
*
|
||||
* <sts-dialog
|
||||
* .trigger=${this.dialogTriggerRef}
|
||||
* .template=${[this, html`
|
||||
* <p>Hey folks here's my dialog, hope you like it!</p>
|
||||
* `] as const}
|
||||
* ></sts-dialog>
|
||||
* </sts-overlay-provider>
|
||||
* `
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ### More complex custom content
|
||||
* ```
|
||||
* \@customElement("my-app")
|
||||
* class MyApp extends LitElement {
|
||||
* dialogTriggerRef = createRef<ButtonElement>();
|
||||
*
|
||||
* get dialogTemplate() {
|
||||
* return html`
|
||||
* <sts-dialog-header icon="cube">
|
||||
* My Dialog
|
||||
* </sts-dialog-header>
|
||||
*
|
||||
* <div slot="sidebar">
|
||||
* <h4>Custom sidebar</h4>
|
||||
* <ul>
|
||||
* <li>Lorem ipsum</li>
|
||||
* <li>Dolor</li>
|
||||
* <li>Sit amet</li>
|
||||
* </ul>
|
||||
* </div>
|
||||
*
|
||||
* <p>These paragraphs will appear in the main content area.</p>
|
||||
* <p>Lorem ipsum dolor sit amet.</p>
|
||||
* <p>Consectetur adipiscing elit.</p>
|
||||
*
|
||||
* <footer
|
||||
* slot="footer"
|
||||
* style=${css`
|
||||
* display: flex;
|
||||
* justify-content: flex-end;
|
||||
* padding: 1em;
|
||||
* gap: 0.75em;
|
||||
* `}
|
||||
* >
|
||||
* <sts-button icon="check">
|
||||
* Confirm
|
||||
* </sts-button>
|
||||
* <sts-button icon="xmart">
|
||||
* Cancel
|
||||
* </sts-button>
|
||||
* </footer>
|
||||
* `
|
||||
* }
|
||||
*
|
||||
* render = () => html`
|
||||
* <sts-overlay-provider>
|
||||
* <sts-button ${ref(this.dialogTriggerRef)}>
|
||||
* Open Dialog
|
||||
* </sts-button>
|
||||
*
|
||||
* <sts-dialog
|
||||
* .trigger=${this.dialogTriggerRef}
|
||||
* .template=${[this, this.dialogTemplate] as const}
|
||||
* ></sts-dialog>
|
||||
* </sts-overlay-provider>
|
||||
* `
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @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`
|
||||
* <sts-dialog-header>
|
||||
* Counter Dialog
|
||||
* </sts-dialog-header>
|
||||
*
|
||||
* <section class="main">
|
||||
* <secion class="content">
|
||||
* <p>The count is ${this.count}.</p>
|
||||
* <slot></slot>
|
||||
* </secion>
|
||||
* </section>
|
||||
*
|
||||
* <footer class="footer">
|
||||
* <sts-button
|
||||
* \@click=${() => this.dispatchEvent(new CustomEvent("increment"))}
|
||||
* >
|
||||
* ++
|
||||
* </sts-button>
|
||||
* </footer>
|
||||
* `
|
||||
* }
|
||||
*
|
||||
* \@customElement("my-app")
|
||||
* class MyApp extends LitElement {
|
||||
* dialogTriggerRef = createRef<ButtonElement>();
|
||||
* dialogRef = createRef<DialogElement>();
|
||||
*
|
||||
* \@state() count = 0;
|
||||
*
|
||||
* onIncrement(): void {
|
||||
* this.count++;
|
||||
* }
|
||||
*
|
||||
* onClose(): void {
|
||||
* this.dialogRef.value?.close();
|
||||
* }
|
||||
*
|
||||
* get dialogTemplate() {
|
||||
* return html`
|
||||
* <my-counter-dialog
|
||||
* .count=${this.count}
|
||||
* \@increment=${this.onIncrement}
|
||||
* \@close=${this.onClose}
|
||||
* >
|
||||
* ${this.count === 69 ? html`
|
||||
* <p>(Nice.)</p>
|
||||
* ` : nothing}
|
||||
* </my-counter-dialog>
|
||||
* `
|
||||
* }
|
||||
*
|
||||
* render = () => html`
|
||||
* <sts-overlay-provider>
|
||||
* <sts-button ${ref(this.dialogTriggerRef)}>
|
||||
* Open Dialog
|
||||
* </sts-button>
|
||||
*
|
||||
* <sts-dialog
|
||||
* ${ref(this.dialogRef)}
|
||||
* .trigger=${this.dialogTriggerRef}
|
||||
* .template=${[this, this.dialogTemplate] as const}
|
||||
* skipWrapper
|
||||
* ></sts-dialog>
|
||||
* </sts-overlay-provider>
|
||||
* `
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @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
|
||||
* `<sts-dialog-base role="dialog">` 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;
|
||||
|
||||
/**
|
||||
* 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<Ref<HTMLElement> | HTMLElement> = [];
|
||||
|
||||
@inject(OVERLAY_PROVIDER)
|
||||
_overlay!: OverlayProvider;
|
||||
|
||||
#overlayHandle?: WeakRef<OverlayHandle>;
|
||||
|
||||
open(): void {
|
||||
if (!this.tag && !this.template)
|
||||
return;
|
||||
|
||||
const containerStyles = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#1A1A2766",
|
||||
};
|
||||
const onBackdropClick = this.onBackdropClick;
|
||||
|
||||
if (this.template) {
|
||||
const [host, template] = this.template;
|
||||
|
||||
this.#overlayHandle = this._overlay.attach(
|
||||
host,
|
||||
this.skipWrapper ? template : html`
|
||||
<sts-dialog-base
|
||||
role=${ifDefined(this.dialogRole)}
|
||||
@close=${() => this.close()}
|
||||
>
|
||||
${template}
|
||||
</sts-dialog-base>
|
||||
`,
|
||||
{
|
||||
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<this>): 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);
|
||||
}
|
||||
|
||||
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.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import "./dialog-base.element";
|
||||
import "./dialog-header.element";
|
||||
import "./dialog.element";
|
||||
|
||||
export * from "./dialog.element";
|
||||
export * from "./dialog-base.element";
|
||||
export * from "./dialog-header.element";
|
||||
@@ -89,6 +89,8 @@ export interface OverlayProvider {
|
||||
export interface AttachOptions {
|
||||
/** Custom styles for the container that will wrap the overlay template. */
|
||||
containerStyles?: Readonly<StyleInfo>;
|
||||
/** Handler to call when the overlay's backdrop is clicked. */
|
||||
onBackdropClick?: (event: PointerEvent) => any;
|
||||
}
|
||||
|
||||
/** An opaque handle to an attached overlay template. */
|
||||
@@ -167,8 +169,18 @@ export class OverlayProviderElement
|
||||
this.#handles.push(handle);
|
||||
|
||||
// Create and append the new container ref and template
|
||||
const containerRef = createRef<HTMLElement>();
|
||||
const containerStyles = options.containerStyles ?? {};
|
||||
this.#containerRefs.push(createRef());
|
||||
|
||||
const handleBackdropClick = options.onBackdropClick;
|
||||
const onBackdropClick = handleBackdropClick
|
||||
? (event: PointerEvent) => {
|
||||
if (event.target != null && event.target === containerRef.value)
|
||||
handleBackdropClick(event);
|
||||
}
|
||||
: () => {}
|
||||
|
||||
this.#containerRefs.push(containerRef);
|
||||
this.#containerTemplates.push(html`
|
||||
<div
|
||||
${ref(this.#containerRefs[index])}
|
||||
@@ -178,6 +190,7 @@ export class OverlayProviderElement
|
||||
inset: 0,
|
||||
...containerStyles,
|
||||
})}
|
||||
@click=${onBackdropClick}
|
||||
></div>
|
||||
`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user