"Add to Scene" menu and Media Library dialog (#78)

This PR adds a new "Add to Scene" dropdown menu for adding elements to
the current scene, and a new Media Library dialog for importing and/or
adding Storyteller Engine assets (characters, scenes, animations, etc.)
associated with the user's Storyteller.ai account.

The scope of this PR is limited to the UI — actual asset importing
behavior, and adding elements other than primitive shapes to the current
scene, will be implemented in follow-up PRs.

### Technical Details

In order to facilitate these changes, a new "overlay" abstraction has
been added to `@storyteller/studio-ui` (loosely modeled after
[`@angular/cdk/overlay`](https://material.angular.io/cdk/overlay/overview))
for managing DOM elements which should render in a self-contained
stacking context "above" the rest of the application.

Two new base UI components &mdash; `<sts-menu>` and `<sts-dialog>`
&mdash; have also been added to make use of this overlay system, but
it's worth noting that these new components have not yet been
exhaustively tested or thoroughly fleshed out, so they may require
significant changes in order to accommodate future use cases or address
unforeseen bugs.

### Testing

The Media Library makes some API calls which will run into CORS errors
in a normal browser. This can be bypassed by running Chrome with the
following command-line flags:
```
--disable-web-security --ignore-certificate-errors --user-data-dir=path/to/some/temporary/directory
```
Verify all of the following:

* The "Add to Scene" menu opens and closes as expected
* The primitive-shape menu items (Cube, Sphere, etc.) add the
  corresponding elements to the scene
* The "Media Library..." menu item correctly opens the Media Library
  dialog
* The Media Library's search bar and category filters work as expected
* A single asset can be selected by clicking on it
* Multiple assets can be selected by holding the "Ctrl" or "Cmd" key
* The selection can be cleared by clicking in an empty space in the
  results grid
* An "Add to Scene" button appears if (and only if) one or more assets
  are selected
* The Media Library dialog can be closed by:
  - Clicking the "X" in the upper right-hand corner
  - Pressing the "Esc" key
  - Clicking anywhere outside of the dialog boundaries

Additionally, ensure that the Media Library dialog handles all of the
following situations gracefully:
* User is not logged in to fakeyou.com
* User is logged in but does not have any engine-compatible assets
  uploaded (this can be tested by selecting any of the categories from the
  sidebar, since the database has not yet been migrated to use the new
  `media_file_class` field, so all of those categories should currently
  yield an empty list)
* User is logged in and there are engine-compatible assets to list (this
  can be tested by pressing the "Save" button in the main application
  toolbar to save the current scene to your account, and selecting the
  "Home" entry from the Media Library sidebar)
This commit is contained in:
Danny McGee
2024-03-15 13:30:06 -04:00
committed by GitHub
parent 17b0e14dc8
commit 03f87b2544
30 changed files with 2004 additions and 118 deletions
+1
View File
@@ -35,6 +35,7 @@
:host(.disabled:hover) {
background: #0003;
color: #FFF6;
cursor: default;
}
.icon {
+10 -4
View File
@@ -10,10 +10,12 @@ import "./icon.element";
export class ButtonElement extends LitElement {
static override styles = unsafeCSS(styles);
@property({ reflect: true })
override role = "button";
@property() icon?: string;
@property({ type: Boolean }) disabled = false;
override role = "button";
override tabIndex = 0;
protected override willUpdate(changes: PropertyValues<this>): void {
@@ -35,9 +37,13 @@ export class ButtonElement extends LitElement {
}
protected override render = () => html`
${this.icon
? html`<sts-icon icon=${this.icon} aria-hidden="true"></sts-icon>`
: nothing}
${this.icon ? html`
<sts-icon
part="icon"
icon=${this.icon}
aria-hidden="true"
></sts-icon>
` : nothing}
<slot></slot>
`
}
+191
View File
@@ -0,0 +1,191 @@
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"));
}
}
@@ -2,10 +2,12 @@
:host {
box-sizing: border-box;
display: block;
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
gap: 0.75rem;
background: #0004;
border: 1px solid #FFF2;
@@ -22,6 +24,10 @@
}
}
[part^="icon-"] {
color: #FFF6;
}
:host(:focus) {
outline: none;
border-color: #FFF4;
@@ -30,11 +36,16 @@
}
.input {
width: 100%;
flex: 1 0 0;
appearance: none;
background: transparent;
border: none;
font: inherit;
color: inherit;
outline: none !important;
&::placeholder {
font: inherit;
color: #FFF6;
}
}
+23 -6
View File
@@ -1,6 +1,7 @@
import { on } from "@storyteller/framework";
import { html, unsafeCSS } from "lit";
import { html, nothing, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { FormFieldBaseElement } from "./form-field-base.element";
@@ -14,8 +15,18 @@ export class TextFieldElement extends FormFieldBaseElement {
delegatesFocus: true,
}
@property({
attribute: "type",
reflect: true,
})
fieldType = "text";
@property() placeholder?: string;
@property() value = "";
@property({ attribute: "icon-pre" }) iconPre?: string;
@property({ attribute: "icon-post" }) iconPost?: string;
@on("keydown")
@on("keyup")
blockKeypressBubbling(event: KeyboardEvent): void {
@@ -24,18 +35,24 @@ export class TextFieldElement extends FormFieldBaseElement {
}
#onInput(event: InputEvent): void {
const updated = (event.target as HTMLInputElement).value;
console.log("updated:", updated);
this.dispatchEvent(new CustomEvent("value-change", { detail: updated }));
this.dispatchEvent(new CustomEvent("value-change", {
detail: (event.target as HTMLInputElement).value,
}));
}
protected override render = () => html`
${this.iconPre ? html`
<sts-icon part="icon-pre" .icon=${this.iconPre}></sts-icon>
` : nothing}
<input
class="input"
type="text"
.type=${this.fieldType}
placeholder=${ifDefined(this.placeholder)}
.value=${this.value}
@input=${this.#onInput}
/>
${this.iconPost ? html`
<sts-icon part="icon-post" .icon=${this.iconPost}></sts-icon>
` : nothing}
`;
}
+31 -2
View File
@@ -31,6 +31,21 @@ import fasCircleXmark from "@fortawesome/fontawesome-pro/svgs/solid/circle-xmark
import fasMinus from "@fortawesome/fontawesome-pro/svgs/solid/minus.svg?raw";
import fasListTree from "@fortawesome/fontawesome-pro/svgs/solid/list-tree.svg?raw";
import fasBars from "@fortawesome/fontawesome-pro/svgs/solid/bars.svg?raw";
import fasPlusLarge from "@fortawesome/fontawesome-pro/svgs/solid/plus-large.svg?raw";
import farFileImport from "@fortawesome/fontawesome-pro/svgs/regular/file-import.svg?raw";
import farPhotoFilmMusic from "@fortawesome/fontawesome-pro/svgs/regular/photo-film-music.svg?raw";
import fasChevronDown from "@fortawesome/fontawesome-pro/svgs/solid/chevron-down.svg?raw";
import farVolume from "@fortawesome/fontawesome-pro/svgs/regular/volume.svg?raw";
import farFilm from "@fortawesome/fontawesome-pro/svgs/regular/film.svg?raw";
import fasPersonRunning from "@fortawesome/fontawesome-pro/svgs/solid/person-running.svg?raw";
import farMountainSun from "@fortawesome/fontawesome-pro/svgs/regular/mountain-sun.svg?raw";
import farArrowRightToBracket from "@fortawesome/fontawesome-pro/svgs/regular/arrow-right-to-bracket.svg?raw";
import farMagnifyingGlass from "@fortawesome/fontawesome-pro/svgs/regular/magnifying-glass.svg?raw";
import fasHouse from "@fortawesome/fontawesome-pro/svgs/solid/house.svg?raw";
import customCylinder from "../assets/icons/cylinder.svg?raw";
import customSphere from "../assets/icons/sphere.svg?raw";
import customTorus from "../assets/icons/torus.svg?raw";
import styles from "./icon.element.scss?inline";
@@ -61,35 +76,49 @@ function renderSvg(iconId: string) {
"bone": () => farBone,
"bwd-fast": () => fasBwdFast,
"bwd-step": () => fasBwdStep,
"caret-right": () => fasCaretRight,
"check": () => fasCheck,
"chevron-down": () => fasChevronDown,
"circle-check": () => fasCircleCheck,
"circle-question": () => fasCircleQuestion,
"circle-xmark": () => fasCircleXmark,
"caret-right": () => fasCaretRight,
"crosshairs": () => farCrosshairs,
"cube": () => farCube,
"cylinder": () => customCylinder,
"figure": () => fasPersonRunning,
"file-import": () => farFileImport,
"film": () => farFilm,
"floppy-disk": () => fasFloppyDisk,
"fwd-fast": () => fasFwdFast,
"fwd-step": () => fasFwdStep,
"home": () => fasHouse,
"import": () => farArrowRightToBracket,
"key": () => fasKey,
"light": () => fasBrightness,
"list-tree": () => fasListTree,
"minus": () => fasMinus,
"globe": () => farGlobe,
"photo-film-music": () => farPhotoFilmMusic,
"play": () => fasPlay,
"plus-large": () => fasPlusLarge,
"question": () => fasQuestion,
"rotate": () => farRotate,
"scale": () => farScale,
"scene": () => farMountainSun,
"search": () => farMagnifyingGlass,
"skeleton": () => farSkeleton,
"sphere": () => customSphere,
"torus": () => customTorus,
"up-down-left-right": () => farUpDownLeftRight,
"video": () => farVideo,
"volume": () => farVolume,
"xmark": () => fasXmark,
_: () => null,
});
if (!svgSrc) return nothing;
const [, attrsString, body] = svgSrc.match(/<svg(.+?)>(.+?)<\/svg>/)!;
const [, attrsString, body] = svgSrc.match(/<svg(.+?)>(.+?)<\/svg>/s)!;
const attrs = Object.fromEntries(
Array.from(attrsString.matchAll(/([-:_a-zA-Z0-9]+)(?:=['"]?([^'"]*)['"]?)/g))
.map(([, attr, value]) => [attr, value])
+35
View File
@@ -0,0 +1,35 @@
:host {
display: flex;
flex-direction: column;
border-radius: 0.5rem;
padding: 0.5rem 0;
min-width: 220px;
background: #0006;
backdrop-filter: blur(16px);
overflow: hidden;
}
::slotted(sts-button) {
background: unset;
backdrop-filter: unset;
border-radius: unset;
}
::slotted(sts-button:hover) {
background: #0004;
// FIXME: https://github.com/w3c/csswg-drafts/issues/3896
&::part(icon) {
opacity: 0.75;
}
}
::slotted(hr) {
display: block;
width: 100%;
border: none;
height: 1px;
margin: 0.5rem 0;
background: #FFF1;
}
+213
View File
@@ -0,0 +1,213 @@
import { inject, on } from "@storyteller/framework";
import { LitElement, PropertyValues, css, html, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { createRef, ref, type Ref } from "lit/directives/ref.js";
import { styleMap } from "lit/directives/style-map.js";
import { v4 as uuid } from "uuid";
import {
OVERLAY_PROVIDER,
type OverlayHandle,
type OverlayProvider,
} from "./overlay-provider.element";
import panelStyles from "./menu.element.scss?inline";
/**
* A simple, WIP [menu widget](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/)
* implementation.
*
* @note
* This should _not_ be used for a `<select />` style combo-box widget, as it
* does not implement the correct ARIA roles/behavior for that use case.
*
* @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-app")
* class MyApp extends LitElement {
* menuTriggerRef = createRef<ButtonElement>();
*
* render = () => html`
* <sts-overlay-provider>
* <sts-button ${ref(this.menuTriggerRef)}>
* Open Menu
* </sts-button>
*
* <sts-menu
* autoClose
* .trigger=${this.menuTriggerRef}
* >
* <sts-button role="menuitem">Lorem</sts-button>
* <sts-button role="menuitem">Ipsum</sts-button>
* <sts-button role="menuitem">Dolor</sts-button>
* <sts-button role="menuitem">Sit Amet</sts-button>
* <hr />
* <sts-button role="menuitem">Consectetur</sts-button>
* <sts-button role="menuitem">Adipiscing Elit</sts-button>
* </sts-menu>
* </sts-overlay-provider>
* `
* }
* ```
*/
@customElement("sts-menu")
export class MenuElement extends LitElement {
static override styles = css`
:host {
visibility: hidden;
}
`;
/**
* A {@linkcode Ref} of the button that should open this menu when pressed.
*/
@property({ attribute: false })
trigger = createRef<HTMLElement>();
/** Close the menu when one of its menuitems is pressed */
@property({ type: Boolean })
autoClose = false;
@inject(OVERLAY_PROVIDER)
_overlay!: OverlayProvider;
#menuId = uuid();
#slotRef = createRef<HTMLSlotElement>();
#panelRef = createRef<MenuPanelElement>();
#handle?: WeakRef<OverlayHandle>;
show(): void {
const trigger = this.trigger.value;
if (!trigger) return;
trigger.classList.toggle("active", true);
trigger.ariaExpanded = "true";
const rect = trigger.getBoundingClientRect();
this.#handle = this._overlay.attach(this, html`
<sts-menu-panel
${ref(this.#panelRef)}
id=${this.#menuId}
class=${ifDefined(this.className)}
style=${styleMap({
position: "absolute",
top: `${rect.bottom + 4}px`,
left: `${rect.left}px`,
})}
?autoClose=${this.autoClose}
@hide=${this.hide}
>${
// Move the nodes assigned to the <sts-menu>'s slot into the
// <sts-menu-panel>'s slot
this.#slotRef.value?.assignedNodes()
}</sts-menu-panel>
`);
}
hide(): void {
const trigger = this.trigger.value;
if (trigger) {
trigger.classList.toggle("active", false);
trigger.ariaExpanded = "false";
}
if (this.#panelRef.value) {
// Move the nodes assigned to the <sts-menu-panel>'s slot back into the
// <sts-menu>'s slot
for (let node of this.#panelRef.value.getSlot()!.assignedNodes())
this.appendChild(node);
}
if (this.#handle)
this._overlay.detach(this.#handle);
this.#handle = undefined;
}
protected override update(changes: PropertyValues<this>): void {
super.update(changes);
if (this.#handle)
this._overlay.reRender(this.#handle);
}
protected override updated(changes: PropertyValues<this>): void {
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.setAttribute("aria-owns", this.#menuId);
trigger.ariaHasPopup = "true";
trigger.ariaExpanded = "false";
trigger.addEventListener("click", this.#onTriggerClick);
}
super.updated(changes);
}
override disconnectedCallback(): void {
this.trigger.value?.removeEventListener("click", this.#onTriggerClick);
super.disconnectedCallback();
}
#onTriggerClick = (event: Event): void => {
event.stopPropagation();
this.show();
}
protected override render = () => html`
<slot ${ref(this.#slotRef)}></slot>
`
}
// TODO: ARIA keyboard navigation: https://www.w3.org/WAI/ARIA/apg/patterns/menubar/
@customElement("sts-menu-panel")
export class MenuPanelElement extends LitElement {
static override styles = unsafeCSS(panelStyles);
@property({ reflect: true })
override role = "menu";
@property({ type: Boolean })
autoClose = false;
#slotRef = createRef<HTMLSlotElement>();
getSlot(): HTMLSlotElement | undefined { return this.#slotRef.value; }
@on("click")
onClick(event: PointerEvent): void {
event.stopPropagation();
if (
this.autoClose
&& event.target
&& event.target instanceof HTMLElement
&& event.target.role === "menuitem"
) {
this.dispatchEvent(new CustomEvent("hide"));
}
}
@on("window:click")
onWindowClick(event: PointerEvent): void {
if (!event.composedPath().includes(this))
this.dispatchEvent(new CustomEvent("hide"));
}
protected override render = () => html`
<slot ${ref(this.#slotRef)}></slot>
`
}
@@ -0,0 +1,4 @@
.overlay-stack {
isolation: isolate;
z-index: 99999;
}
@@ -0,0 +1,262 @@
import { UniqueToken, provide } from "@storyteller/framework";
import { LitElement, TemplateResult, html, render, unsafeCSS } from "lit";
import { customElement } from "lit/decorators.js";
import { Ref, createRef, ref } from "lit/directives/ref.js";
import { StyleInfo, styleMap } from "lit/directives/style-map.js";
import styles from "./overlay-provider.element.scss?inline";
export const OVERLAY_PROVIDER = UniqueToken.create<OverlayProvider>();
/**
* A low-level utility for rendering "overlay" elements to the DOM. Overlays are
* rendered in a separate [stacking context](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Understanding_z-index/Stacking_context)
* which sits "on top" of the rest of the DOM, which is useful for creating
* things like modals and pop-up menus.
*
* To use this utility, first ensure that your entire application is wrapped
* with an `<sts-overlay-provider>` element, then inject the
* {@linkcode OVERLAY_PROVIDER} token to access this interface.
*
* @example
* ```
* class MyCustomOverlay extends LitElement {
* \@inject(OVERLAY_PROVIDER)
* _overlay?: OverlayProvider;
*
* #handle?: WeakRef<OverlayHandle>;
*
* openOverlay(): void {
* this.#handle = this._overlay.attach(this, html`
* <div>
* <p>This is a custom overlay!</p>
* <sts-button \@click=${this.closeOverlay}>
* Close Overlay
* </sts-button>
* </div>
* `);
* }
*
* closeOverlay(): void {
* if (this.#handle)
* this._overlay.detach(this.#handle);
*
* this.#handle = undefined;
* }
*
* render = () => html`
* <sts-button \@click=${this.openOverlay}>
* Open Overlay
* </sts-button>
* `
* }
* ```
*/
export interface OverlayProvider {
/**
* Attach a template to the overlay stack.
*
* @param host
* This is the instance that will be the receiver (i.e. the `this` argument)
* for any event-handlers that are bound in the provided `template`.
*
* @param template
* The Lit template to render as an overlay, usually created with Lit's
* `html` tagged template function.
*
* @param options
* Additional options to customize the overlay. See
* {@linkcode AttachOptions} for details.
*/
attach(
host: LitElement,
template: TemplateResult,
options?: AttachOptions,
): WeakRef<OverlayHandle>;
/**
* Detach a currently-attached overlay from the DOM.
*
* After invoking this method, the provided `handle` will no longer be valid
* and should be discarded by the caller.
*/
detach(handle: WeakRef<OverlayHandle>): void;
/** Re-render a currently-attached overlay. */
reRender(handle: WeakRef<OverlayHandle>): void;
}
export interface AttachOptions {
/** Custom styles for the container that will wrap the overlay template. */
containerStyles?: Readonly<StyleInfo>;
}
/** An opaque handle to an attached overlay template. */
export interface OverlayHandle {
readonly id: number;
}
/**
* @internal
*
* A simple "handle" to an attached overlay instance. The `id` property tracks
* the current index of its associated parts in the `OverlayProviderElement`'s
* set of arrays.
*
* When entries are inserted, removed, or moved within the arrays, these handles
* must be updated with the correct indices by calling `_updateId`. This ensures
* that existing `OverlayHandle`s that have been passed out to consumers will
* remain valid for looking up their associated templates, overlay containers,
* etc.
*/
class OverlayHandleInternal implements OverlayHandle {
#id: number;
get id() { return this.#id; }
constructor (id: number) {
this.#id = id;
}
_updateId(id: number): void {
this.#id = id;
}
}
@customElement("sts-overlay-provider")
@provide(OVERLAY_PROVIDER)
export class OverlayProviderElement
extends LitElement
implements OverlayProvider
{
static override styles = unsafeCSS(styles);
/**
* Handles for each currently attached overlay instance. The `id` of these
* handles must always match their index within this array.
*/
#handles: OverlayHandleInternal[] = [];
/**
* Refs for the container elements of each currently attached overlay
* instance. The order and length of entries in this array must be kept in
* sync with their corresponding `#handles`.
*/
#containerRefs: Ref<HTMLElement>[] = [];
/**
* The Lit templates for the container elements of each currently attached
* overlay instance. The order and length of entries in this array must be
* kept in sync with their corresponding `#handles`.
*/
#containerTemplates: TemplateResult[] = [];
/**
* The Lit templates (and their hosts/event receivers) for each currently
* attached overlay instance. The order and length of entries in this array
* must be kept in sync with their corresponding `#handles`.
*/
#templates: [LitElement, TemplateResult][] = [];
attach(
host: LitElement,
template: TemplateResult,
options: AttachOptions = {},
): WeakRef<OverlayHandle> {
// Create and append a new handle for the overlay
const index = this.#handles.length;
const handle = new OverlayHandleInternal(index);
this.#handles.push(handle);
// Create and append the new container ref and template
const containerStyles = options.containerStyles ?? {};
this.#containerRefs.push(createRef());
this.#containerTemplates.push(html`
<div
${ref(this.#containerRefs[index])}
slot="overlay"
style=${styleMap({
position: "fixed",
inset: 0,
...containerStyles,
})}
></div>
`);
// Append the provided host and template
this.#templates.push([host, template]);
// (Re-)render the overlay stack and return the handle to the consumer
this.#updateLightDom();
return new WeakRef(handle);
}
detach(handle: WeakRef<OverlayHandle>): void {
// Find the index of the overlay to remove
const index = handle.deref()?.id;
if (index == null || index === -1) return;
// Remove the corresponding entry from each array
const [removedHandle] = this.#handles.splice(index, 1);
this.#containerRefs.splice(index, 1);
this.#containerTemplates.splice(index, 1);
this.#templates.splice(index, 1);
// Update the handle IDs to match their new indices
removedHandle._updateId(-1);
for (let i = index; i < this.#handles.length; ++i)
this.#handles[i]._updateId(i);
// Rerender the overlay stack
this.#updateLightDom();
}
// TODO: I'm not actually sure whether this serves a necessary function.
// Needs further testing.
reRender(handle: WeakRef<OverlayHandle>): void {
const index = handle.deref()?.id;
if (index == null || index === -1)
return;
const container = this.#containerRefs[index]?.value;
if (!container) return;
const tuple = this.#templates[index];
if (!tuple) return;
const [host, template] = tuple;
render(template, container, { host });
}
#updateLightDom(): void {
// First, render the set of container templates to this element's "light
// DOM" container (i.e. the publicly-accessible portion of the DOM which
// sits outside of the `#shadow-root`).
//
// Because each container template bears a `slot="overlay"` attribute,
// they should be automatically assigned to our shadow root's
// `slot[name="overlay"]`, which will position the containers in a
// separate stacking context above the other DOM nodes passed into the
// `<sts-overlay-provider>`.
//
// After this step (and _only_ after this step), the corresponding refs in
// the `#containerRefs` array should have valid, non-null `value`s.
render(this.#containerTemplates, this, { host: this });
for (let i = 0; i < this.#handles.length; ++i) {
// Render each overlay template into its corresponding container
const container = this.#containerRefs[i].value;
if (!container) throw new Error("Attempted to retrieve an invalid container ref!");
const [host, template] = this.#templates[i];
render(template, container, { host });
}
}
protected override render = () => html`
<slot></slot>
<div class="overlay-stack">
<slot name="overlay"></slot>
</div>
`;
}