mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-09 00:09:55 +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,4 +1,5 @@
|
||||
export * from "./lib/decorators";
|
||||
export * from "./lib/deref";
|
||||
export * from "./lib/di";
|
||||
export { default as bind, type Props as BindProps } from "./lib/directives/bind";
|
||||
export { default as drag, type Props as DragProps } from "./lib/directives/drag";
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { html, render } from "lit";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import { deref } from "./deref";
|
||||
|
||||
describe("deref", () => {
|
||||
let elRef = createRef();
|
||||
|
||||
beforeEach(() => {
|
||||
elRef = createRef();
|
||||
render(html`<div ${ref(elRef)}></div>`, document.body);
|
||||
});
|
||||
|
||||
it("should return an HTML element when given an HTML element", () => {
|
||||
const el = elRef.value;
|
||||
expect(el).toBeDefined();
|
||||
|
||||
const derefed = deref(el);
|
||||
expect(derefed).toBe(el);
|
||||
});
|
||||
|
||||
it("should return an HTML element when given a `Ref<HTMLElement>`", () => {
|
||||
const el = elRef.value;
|
||||
const derefed = deref(elRef);
|
||||
|
||||
expect(derefed).toBe(el);
|
||||
});
|
||||
|
||||
it("should safely return `undefined` when given a nullish value", () => {
|
||||
const v1 = deref(null);
|
||||
expect(v1).toBe(undefined);
|
||||
|
||||
const v2 = deref(undefined);
|
||||
expect(v2).toBe(undefined);
|
||||
|
||||
const v3 = deref((() => {})());
|
||||
expect(v3).toBe(undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Ref } from "lit/directives/ref.js";
|
||||
|
||||
export function deref<T>(refOrValue: Ref<T> | T | null | undefined): T | undefined {
|
||||
if (isRef(refOrValue))
|
||||
return refOrValue.value;
|
||||
|
||||
return (refOrValue ?? undefined);
|
||||
}
|
||||
|
||||
function isRef<T>(refOrValue: Ref<T> | T): refOrValue is Ref<T> {
|
||||
if (!refOrValue || typeof refOrValue !== "object")
|
||||
return false;
|
||||
|
||||
const keys = Object.keys(refOrValue);
|
||||
return keys.length === 1 && keys[0] === "value"
|
||||
}
|
||||
@@ -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>
|
||||
`);
|
||||
|
||||
|
||||
@@ -1,78 +1,18 @@
|
||||
@use "sass:math";
|
||||
|
||||
*, *::before, *::after {
|
||||
font-family: "Fira Sans", sans-serif;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 1024px;
|
||||
height: 640px;
|
||||
max-width: 80%;
|
||||
max-height: 80%;
|
||||
|
||||
background: #0006;
|
||||
color: #FFFC;
|
||||
backdrop-filter: blur(32px);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
header {
|
||||
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;
|
||||
|
||||
sts-icon {
|
||||
color: #FFFC;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
h3 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
max-height: calc(80vh - 3rem);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex: 0 0 256px;
|
||||
padding: 1rem;
|
||||
background: #0004;
|
||||
|
||||
&__list {
|
||||
list-style: none;
|
||||
@@ -102,16 +42,7 @@ header {
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 auto;
|
||||
width: calc(100% - 256px);
|
||||
gap: 1rem;
|
||||
border: {
|
||||
inline-end: 0.5rem solid #0004;
|
||||
block-end: 0.5rem solid #0004;
|
||||
}
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
|
||||
&__ui {
|
||||
@@ -222,7 +153,6 @@ header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
aling-self: center;
|
||||
|
||||
.heading, p {
|
||||
text-align: center;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { bind, inject } from "@storyteller/framework";
|
||||
import { ButtonElement } from "@storyteller/studio-ui/button";
|
||||
import { DialogBaseElement } from "@storyteller/studio-ui/dialog";
|
||||
import {
|
||||
type MediaFile as IMediaFile,
|
||||
@@ -16,8 +17,9 @@ import {
|
||||
type PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
css,
|
||||
} from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { customElement, queryAll, state } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
@@ -29,7 +31,12 @@ import styles from "./media-library-dialog.element.scss?inline";
|
||||
|
||||
@customElement("sts-media-library-dialog")
|
||||
export class MediaLibraryDialogElement extends DialogBaseElement {
|
||||
static override styles = unsafeCSS(styles);
|
||||
static override styles = css`
|
||||
${DialogBaseElement.styles}
|
||||
${unsafeCSS(styles)}
|
||||
`;
|
||||
|
||||
override heading = "Media Library";
|
||||
|
||||
@state() _mediaFiles?: MediaFile[];
|
||||
@state() _searchTerm = "";
|
||||
@@ -41,6 +48,9 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
|
||||
@inject(StorytellerApi)
|
||||
_api!: StorytellerApi;
|
||||
|
||||
@queryAll("[data-import-trigger]")
|
||||
importTriggers!: ButtonElement[];
|
||||
|
||||
#dateFmt = new Intl.DateTimeFormat("en-us");
|
||||
|
||||
get #mediaFiles() {
|
||||
@@ -136,25 +146,23 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
|
||||
this._selected = [];
|
||||
}
|
||||
|
||||
get importDialogTemplate() {
|
||||
return html`
|
||||
<sts-dialog-header icon="import">
|
||||
Import Asset
|
||||
</sts-dialog-header>
|
||||
|
||||
<p>Lorem ipsum dolor sit amet</p>
|
||||
`;
|
||||
}
|
||||
|
||||
protected override render = (): TemplateResult => html`
|
||||
<header part="header">
|
||||
<div class="title">
|
||||
<sts-icon icon="photo-film-music"></sts-icon>
|
||||
<h3>Media Library</h3>
|
||||
</div>
|
||||
${/*
|
||||
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"
|
||||
icon="xmark"
|
||||
@click=${() => this.dispatchEvent(new CustomEvent("close"))}
|
||||
></sts-icon>
|
||||
</header>
|
||||
<sts-dialog-header icon="photo-film-music">
|
||||
${this.heading}
|
||||
</sts-dialog-header>
|
||||
|
||||
<section class="main">
|
||||
<nav class="sidebar">
|
||||
<nav class="sidebar" part="sidebar">
|
||||
<ul class="sidebar__list">
|
||||
${MEDIA_FILE_CATEGORIES.map(cat => html`
|
||||
<li class="sidebar__listitem">
|
||||
@@ -181,6 +189,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
|
||||
<sts-button
|
||||
class="btn-secondary"
|
||||
icon="import"
|
||||
data-import-trigger
|
||||
>
|
||||
Import...
|
||||
</sts-button>
|
||||
@@ -211,6 +220,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
|
||||
<sts-button
|
||||
class="btn-secondary"
|
||||
icon="import"
|
||||
data-import-trigger
|
||||
>
|
||||
Import...
|
||||
</sts-button>
|
||||
@@ -258,6 +268,11 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
|
||||
` : nothing}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<sts-dialog
|
||||
.trigger=${this.importTriggers}
|
||||
.template=${[this, this.importDialogTemplate] as const}
|
||||
></sts-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
"@storyteller/studio": ["dist/studio"],
|
||||
"@storyteller/studio-ui": ["studio-ui/src/index.ts"],
|
||||
"@storyteller/studio-ui/button": ["studio-ui/src/lib/button.element.ts"],
|
||||
"@storyteller/studio-ui/dialog": ["studio-ui/src/lib/dialog.element.ts"],
|
||||
"@storyteller/studio-ui/dialog": ["studio-ui/src/lib/dialog/index.ts"],
|
||||
"@storyteller/studio-ui/icon": ["studio-ui/src/lib/icon.element.ts"],
|
||||
"@storyteller/studio-ui/menu": ["studio-ui/src/lib/menu.element.ts"],
|
||||
"@storyteller/studio-ui/overlay-provider": ["studio-ui/src/lib/overlay-provider.element.ts"],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./lib/array";
|
||||
export * from "./lib/assert";
|
||||
export * from "./lib/exists";
|
||||
export * from "./lib/match";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export type ArrayOrSingle<T> = T | T[];
|
||||
|
||||
export function asArray<T>(value: ArrayOrSingle<T> | Iterable<T>): T[] {
|
||||
if (Array.isArray(value))
|
||||
return value;
|
||||
|
||||
if (isIterable<T>(value))
|
||||
return Array.from(value);
|
||||
|
||||
return [value];
|
||||
}
|
||||
|
||||
export function isIterable<T = unknown>(value: unknown): value is Iterable<T> {
|
||||
return (
|
||||
value != null
|
||||
&& typeof value === "object"
|
||||
&& Symbol.iterator in value
|
||||
&& typeof value[Symbol.iterator] === "function"
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user