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"))} - * > - *
- *
- *

Hello, world!

- *
- * ` - * } - * ``` - * ### Trigger-based implementation - * ``` - * \@customElement("my-app") - * class MyApp extends LitElement { - * dialogTriggerRef = createRef(); - * - * render = () => html` - * - * - * Open Dialog - * - * - * - * - * ` - * } - * ``` - * ### Manual-open implementation - * ``` - * \@customElement("my-app") - * class MyApp extends LitElement { - * dialogRef = createRef(); - * - * render = () => html` - * - * { - * this.dialogRef.value?.open(); - * }} - * > - * Open Dialog - * - * - * - * - * ` - * } - * ``` - */ -@customElement("sts-dialog") -export class DialogElement extends LitElement { - /** - * 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()} - > - `, { - 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 + * + *
+ *
+ *

Hello, world!

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

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

Custom sidebar

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

These paragraphs will appear in the main content area.

+ *

Lorem ipsum dolor sit amet.

+ *

Consectetur adipiscing elit.

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

The count is ${this.count}.

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

(Nice.)

+ * ` : nothing} + *
+ * ` + * } + * + * render = () => html` + * + * + * Open Dialog + * + * + * + * + * ` + * } + * ``` + * + * @see {@linkcode skipWrapper} + * @see {@linkcode DialogBaseElement} + */ + @property({ attribute: false }) + template?: [LitElement, TemplateResult]; + + /** + * Use this in conjunction with the {@linkcode template} property to render + * a custom dialog element inheriting from {@linkcode DialogBaseElement} with + * dynamic property binding and/or slotted content. + * + * This avoids wrapping your custom dialog template in a redundant + * `` tag, which is important for UX and + * accessibility when rendering a custom component that already implements + * the [dialog widget](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) + * spec. + */ + @property({ type: Boolean }) + skipWrapper = false; + + /** + * If this property is `true`, the dialog will not be immediately closed when + * its backdrop is clicked. + */ + @property({ type: Boolean }) + ignoreBackdropClick = false; + + /** + * 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()} + > + `, { + 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" + ) +}