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:
Danny McGee
2024-03-21 22:38:33 -04:00
committed by GitHub
parent 67a12e42e2
commit 701ba04765
17 changed files with 835 additions and 283 deletions
+1
View File
@@ -1,4 +1,5 @@
export * from "./lib/decorators"; export * from "./lib/decorators";
export * from "./lib/deref";
export * from "./lib/di"; export * from "./lib/di";
export { default as bind, type Props as BindProps } from "./lib/directives/bind"; export { default as bind, type Props as BindProps } from "./lib/directives/bind";
export { default as drag, type Props as DragProps } from "./lib/directives/drag"; export { default as drag, type Props as DragProps } from "./lib/directives/drag";
+38
View File
@@ -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);
});
});
+16
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
export * from "./lib/button.element"; export * from "./lib/button.element";
export * from "./lib/dialog.element"; export * from "./lib/dialog";
export * from "./lib/icon.element"; export * from "./lib/icon.element";
export * from "./lib/menu.element"; export * from "./lib/menu.element";
export * from "./lib/overlay-provider.element"; export * from "./lib/overlay-provider.element";
-191
View File
@@ -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>
`;
}
+399
View File
@@ -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();
}
}
+7
View File
@@ -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";
+14 -1
View File
@@ -89,6 +89,8 @@ export interface OverlayProvider {
export interface AttachOptions { export interface AttachOptions {
/** Custom styles for the container that will wrap the overlay template. */ /** Custom styles for the container that will wrap the overlay template. */
containerStyles?: Readonly<StyleInfo>; 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. */ /** An opaque handle to an attached overlay template. */
@@ -167,8 +169,18 @@ export class OverlayProviderElement
this.#handles.push(handle); this.#handles.push(handle);
// Create and append the new container ref and template // Create and append the new container ref and template
const containerRef = createRef<HTMLElement>();
const containerStyles = options.containerStyles ?? {}; 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` this.#containerTemplates.push(html`
<div <div
${ref(this.#containerRefs[index])} ${ref(this.#containerRefs[index])}
@@ -178,6 +190,7 @@ export class OverlayProviderElement
inset: 0, inset: 0,
...containerStyles, ...containerStyles,
})} })}
@click=${onBackdropClick}
></div> ></div>
`); `);
@@ -1,78 +1,18 @@
@use "sass:math"; @use "sass:math";
*, *::before, *::after {
font-family: "Fira Sans", sans-serif;
box-sizing: border-box;
}
:host { :host {
display: flex;
flex-direction: column;
width: 1024px; width: 1024px;
height: 640px; height: 640px;
max-width: 80%; max-width: 80%;
max-height: 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 { .main {
display: flex;
flex: 1 0 0;
max-height: calc(80vh - 3rem); max-height: calc(80vh - 3rem);
} }
.sidebar { .sidebar {
flex: 0 0 256px; flex: 0 0 256px;
padding: 1rem;
background: #0004;
&__list { &__list {
list-style: none; list-style: none;
@@ -102,16 +42,7 @@ header {
} }
.content { .content {
display: flex;
flex-direction: column;
flex: 0 0 auto;
width: calc(100% - 256px); width: calc(100% - 256px);
gap: 1rem;
border: {
inline-end: 0.5rem solid #0004;
block-end: 0.5rem solid #0004;
}
padding: 1rem;
position: relative; position: relative;
&__ui { &__ui {
@@ -222,7 +153,6 @@ header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
aling-self: center;
.heading, p { .heading, p {
text-align: center; text-align: center;
@@ -1,4 +1,5 @@
import { bind, inject } from "@storyteller/framework"; import { bind, inject } from "@storyteller/framework";
import { ButtonElement } from "@storyteller/studio-ui/button";
import { DialogBaseElement } from "@storyteller/studio-ui/dialog"; import { DialogBaseElement } from "@storyteller/studio-ui/dialog";
import { import {
type MediaFile as IMediaFile, type MediaFile as IMediaFile,
@@ -16,8 +17,9 @@ import {
type PropertyValues, type PropertyValues,
TemplateResult, TemplateResult,
unsafeCSS, unsafeCSS,
css,
} from "lit"; } 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 { classMap } from "lit/directives/class-map.js";
import { ifDefined } from "lit/directives/if-defined.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") @customElement("sts-media-library-dialog")
export class MediaLibraryDialogElement extends DialogBaseElement { 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() _mediaFiles?: MediaFile[];
@state() _searchTerm = ""; @state() _searchTerm = "";
@@ -41,6 +48,9 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
@inject(StorytellerApi) @inject(StorytellerApi)
_api!: StorytellerApi; _api!: StorytellerApi;
@queryAll("[data-import-trigger]")
importTriggers!: ButtonElement[];
#dateFmt = new Intl.DateTimeFormat("en-us"); #dateFmt = new Intl.DateTimeFormat("en-us");
get #mediaFiles() { get #mediaFiles() {
@@ -136,25 +146,23 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
this._selected = []; 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` protected override render = (): TemplateResult => html`
<header part="header"> <sts-dialog-header icon="photo-film-music">
<div class="title"> ${this.heading}
<sts-icon icon="photo-film-music"></sts-icon> </sts-dialog-header>
<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>
<section class="main"> <section class="main">
<nav class="sidebar"> <nav class="sidebar" part="sidebar">
<ul class="sidebar__list"> <ul class="sidebar__list">
${MEDIA_FILE_CATEGORIES.map(cat => html` ${MEDIA_FILE_CATEGORIES.map(cat => html`
<li class="sidebar__listitem"> <li class="sidebar__listitem">
@@ -181,6 +189,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
<sts-button <sts-button
class="btn-secondary" class="btn-secondary"
icon="import" icon="import"
data-import-trigger
> >
Import... Import...
</sts-button> </sts-button>
@@ -211,6 +220,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
<sts-button <sts-button
class="btn-secondary" class="btn-secondary"
icon="import" icon="import"
data-import-trigger
> >
Import... Import...
</sts-button> </sts-button>
@@ -258,6 +268,11 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
` : nothing} ` : nothing}
</section> </section>
</section> </section>
<sts-dialog
.trigger=${this.importTriggers}
.template=${[this, this.importDialogTemplate] as const}
></sts-dialog>
`; `;
} }
+1 -1
View File
@@ -19,7 +19,7 @@
"@storyteller/studio": ["dist/studio"], "@storyteller/studio": ["dist/studio"],
"@storyteller/studio-ui": ["studio-ui/src/index.ts"], "@storyteller/studio-ui": ["studio-ui/src/index.ts"],
"@storyteller/studio-ui/button": ["studio-ui/src/lib/button.element.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/icon": ["studio-ui/src/lib/icon.element.ts"],
"@storyteller/studio-ui/menu": ["studio-ui/src/lib/menu.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"], "@storyteller/studio-ui/overlay-provider": ["studio-ui/src/lib/overlay-provider.element.ts"],
+1
View File
@@ -1,3 +1,4 @@
export * from "./lib/array";
export * from "./lib/assert"; export * from "./lib/assert";
export * from "./lib/exists"; export * from "./lib/exists";
export * from "./lib/match"; export * from "./lib/match";
+20
View File
@@ -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"
)
}