From 701ba0476598248294f17a62ebd90998cec48965 Mon Sep 17 00:00:00 2001
From: Danny McGee
Date: Thu, 21 Mar 2024 22:38:33 -0400
Subject: [PATCH] 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.
---
framework/src/index.ts | 1 +
framework/src/lib/deref.spec.ts | 38 ++
framework/src/lib/deref.ts | 16 +
studio-ui/src/index.ts | 2 +-
studio-ui/src/lib/dialog.element.ts | 191 ---------
.../src/lib/dialog/dialog-base.element.scss | 41 ++
.../src/lib/dialog/dialog-base.element.ts | 118 ++++++
.../src/lib/dialog/dialog-header.element.scss | 44 ++
.../src/lib/dialog/dialog-header.element.ts | 100 +++++
studio-ui/src/lib/dialog/dialog.element.ts | 399 ++++++++++++++++++
studio-ui/src/lib/dialog/index.ts | 7 +
studio-ui/src/lib/overlay-provider.element.ts | 15 +-
.../src/lib/media-library-dialog.element.scss | 70 ---
.../src/lib/media-library-dialog.element.ts | 53 ++-
tsconfig.base.json | 2 +-
utility/src/index.ts | 1 +
utility/src/lib/array.ts | 20 +
17 files changed, 835 insertions(+), 283 deletions(-)
create mode 100644 framework/src/lib/deref.spec.ts
create mode 100644 framework/src/lib/deref.ts
delete mode 100644 studio-ui/src/lib/dialog.element.ts
create mode 100644 studio-ui/src/lib/dialog/dialog-base.element.scss
create mode 100644 studio-ui/src/lib/dialog/dialog-base.element.ts
create mode 100644 studio-ui/src/lib/dialog/dialog-header.element.scss
create mode 100644 studio-ui/src/lib/dialog/dialog-header.element.ts
create mode 100644 studio-ui/src/lib/dialog/dialog.element.ts
create mode 100644 studio-ui/src/lib/dialog/index.ts
create mode 100644 utility/src/lib/array.ts
diff --git a/framework/src/index.ts b/framework/src/index.ts
index 3842106..a65b2ec 100644
--- a/framework/src/index.ts
+++ b/framework/src/index.ts
@@ -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";
diff --git a/framework/src/lib/deref.spec.ts b/framework/src/lib/deref.spec.ts
new file mode 100644
index 0000000..90b7655
--- /dev/null
+++ b/framework/src/lib/deref.spec.ts
@@ -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`
`, 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`", () => {
+ 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);
+ });
+});
diff --git a/framework/src/lib/deref.ts b/framework/src/lib/deref.ts
new file mode 100644
index 0000000..ba10bb7
--- /dev/null
+++ b/framework/src/lib/deref.ts
@@ -0,0 +1,16 @@
+import { Ref } from "lit/directives/ref.js";
+
+export function deref(refOrValue: Ref | T | null | undefined): T | undefined {
+ if (isRef(refOrValue))
+ return refOrValue.value;
+
+ return (refOrValue ?? undefined);
+}
+
+function isRef(refOrValue: Ref | T): refOrValue is Ref {
+ if (!refOrValue || typeof refOrValue !== "object")
+ return false;
+
+ const keys = Object.keys(refOrValue);
+ return keys.length === 1 && keys[0] === "value"
+}
diff --git a/studio-ui/src/index.ts b/studio-ui/src/index.ts
index b306d48..b75f273 100644
--- a/studio-ui/src/index.ts
+++ b/studio-ui/src/index.ts
@@ -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";
diff --git a/studio-ui/src/lib/dialog.element.ts b/studio-ui/src/lib/dialog.element.ts
deleted file mode 100644
index 43326c8..0000000
--- a/studio-ui/src/lib/dialog.element.ts
+++ /dev/null
@@ -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`
- *
- * My Dialog
- * this.dispatchEvent(new CustomEvent("close"))}
- * >
- *
- *
- * `
- * }
- * ```
- * ### 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 {
- /**
- * 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();
-
- @inject(OVERLAY_PROVIDER)
- _overlay!: OverlayProvider;
-
- #overlayHandle?: WeakRef;
- #dialogRef = createRef();
-
- 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): 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"));
- }
-}
diff --git a/studio-ui/src/lib/dialog/dialog-base.element.scss b/studio-ui/src/lib/dialog/dialog-base.element.scss
new file mode 100644
index 0000000..05a4edd
--- /dev/null
+++ b/studio-ui/src/lib/dialog/dialog-base.element.scss
@@ -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;
+ }
+}
diff --git a/studio-ui/src/lib/dialog/dialog-base.element.ts b/studio-ui/src/lib/dialog/dialog-base.element.ts
new file mode 100644
index 0000000..7d9e7a5
--- /dev/null
+++ b/studio-ui/src/lib/dialog/dialog-base.element.ts
@@ -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`
+
+
+ ${this.heading}
+
+
+
+
+
+
+ `;
+ }
+}
diff --git a/studio-ui/src/lib/dialog/dialog-header.element.scss b/studio-ui/src/lib/dialog/dialog-header.element.scss
new file mode 100644
index 0000000..dcb827a
--- /dev/null
+++ b/studio-ui/src/lib/dialog/dialog-header.element.scss
@@ -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;
+ }
+}
diff --git a/studio-ui/src/lib/dialog/dialog-header.element.ts b/studio-ui/src/lib/dialog/dialog-header.element.ts
new file mode 100644
index 0000000..68160b1
--- /dev/null
+++ b/studio-ui/src/lib/dialog/dialog-header.element.ts
@@ -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`
+
+ ${this.icon ? html`
+
+ ` : nothing}
+
+
+ Dialog
+
+
+
+
+
+
+ ${/*
+ 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}
+
+
+ `;
+}
diff --git a/studio-ui/src/lib/dialog/dialog.element.ts b/studio-ui/src/lib/dialog/dialog.element.ts
new file mode 100644
index 0000000..07d0d74
--- /dev/null
+++ b/studio-ui/src/lib/dialog/dialog.element.ts
@@ -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`
+ *
+ * My Dialog
+ *
+ *
+ * `
+ * }
+ * ```
+ * ### 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 {
+ /**
+ * 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}.
+ *
+ *
+ *
+ *
+ *
+ * `
+ * }
+ *
+ * \@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;
+
+ /**
+ * 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 = {
+ 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`
+ 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);
+ }
+
+ 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();
+ }
+}
diff --git a/studio-ui/src/lib/dialog/index.ts b/studio-ui/src/lib/dialog/index.ts
new file mode 100644
index 0000000..8cb5302
--- /dev/null
+++ b/studio-ui/src/lib/dialog/index.ts
@@ -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";
diff --git a/studio-ui/src/lib/overlay-provider.element.ts b/studio-ui/src/lib/overlay-provider.element.ts
index 29069c2..9a4a66e 100644
--- a/studio-ui/src/lib/overlay-provider.element.ts
+++ b/studio-ui/src/lib/overlay-provider.element.ts
@@ -89,6 +89,8 @@ export interface OverlayProvider {
export interface AttachOptions {
/** Custom styles for the container that will wrap the overlay template. */
containerStyles?: Readonly;
+ /** 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();
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`
`);
diff --git a/studio-web/src/lib/media-library-dialog.element.scss b/studio-web/src/lib/media-library-dialog.element.scss
index 3de4a13..ce711b8 100644
--- a/studio-web/src/lib/media-library-dialog.element.scss
+++ b/studio-web/src/lib/media-library-dialog.element.scss
@@ -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;
diff --git a/studio-web/src/lib/media-library-dialog.element.ts b/studio-web/src/lib/media-library-dialog.element.ts
index 78e1347..958fa35 100644
--- a/studio-web/src/lib/media-library-dialog.element.ts
+++ b/studio-web/src/lib/media-library-dialog.element.ts
@@ -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`
+
+ Import Asset
+
+
+ Lorem ipsum dolor sit amet
+ `;
+ }
+
protected override render = (): TemplateResult => html`
-
-
-
-
Media Library
-
- ${/*
- 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}
- this.dispatchEvent(new CustomEvent("close"))}
- >
-
+
+ ${this.heading}
+
+
+
+
`;
}
diff --git a/tsconfig.base.json b/tsconfig.base.json
index a6c768a..abe4e99 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -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"],
diff --git a/utility/src/index.ts b/utility/src/index.ts
index e35da3a..f153bb0 100644
--- a/utility/src/index.ts
+++ b/utility/src/index.ts
@@ -1,3 +1,4 @@
+export * from "./lib/array";
export * from "./lib/assert";
export * from "./lib/exists";
export * from "./lib/match";
diff --git a/utility/src/lib/array.ts b/utility/src/lib/array.ts
new file mode 100644
index 0000000..ea10ca0
--- /dev/null
+++ b/utility/src/lib/array.ts
@@ -0,0 +1,20 @@
+export type ArrayOrSingle = T | T[];
+
+export function asArray(value: ArrayOrSingle | Iterable): T[] {
+ if (Array.isArray(value))
+ return value;
+
+ if (isIterable(value))
+ return Array.from(value);
+
+ return [value];
+}
+
+export function isIterable(value: unknown): value is Iterable {
+ return (
+ value != null
+ && typeof value === "object"
+ && Symbol.iterator in value
+ && typeof value[Symbol.iterator] === "function"
+ )
+}