mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-18 07:35:19 +00:00
"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 — `<sts-menu>` and `<sts-dialog>` — 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:
@@ -35,6 +35,7 @@
|
||||
:host(.disabled:hover) {
|
||||
background: #0003;
|
||||
color: #FFF6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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])
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user