rvx
Version:
A signal based rendering library
140 lines (122 loc) • 3.74 kB
text/typescript
import { ENV } from "../core/env.js";
import { capture, TeardownHook, uncapture } from "../core/lifecycle.js";
import { $, Signal, watchUpdates } from "../core/signals.js";
import { Content } from "../core/types.js";
import { render } from "../core/view.js";
export type StartTrigger = "on-connect" | "manual";
export type DisposeTrigger = "on-disconnect" | "manual";
export interface RvxElementOptions {
/**
* Shadow root options to use or false to attach content to the element directly.
*
* By default and when `true`, an open shadow root is attached immediately.
*/
shadow?: boolean | ShadowRootInit;
/**
* When to render this element's content.
*
* + `on-connect` - Default. Render when this element is connected.
* + `manual` - Render only when `.start()` is called.
*/
start?: StartTrigger;
/**
* When to dispose this element's content.
*
* + `on-disconnect` - Default. Dispose when this element is disconnected or when `.dispose()` is called.
* + `manual` - Dispose only when `.dispose()` is called.
*/
dispose?: DisposeTrigger;
}
const moduleEnv = ENV.current;
export abstract class RvxElement extends moduleEnv.HTMLElement {
static observedAttributes?: string[];
#signals = new Map<string, Signal<string | null>>();
#startTrigger: StartTrigger;
#disposeTrigger: DisposeTrigger;
#shadow?: ShadowRoot;
#dispose?: TeardownHook;
constructor(options?: RvxElementOptions) {
super();
this.#startTrigger = options?.start ?? "on-connect";
this.#disposeTrigger = options?.dispose ?? "on-disconnect";
const shadowInit = (options?.shadow === true ? undefined : options?.shadow) ?? { mode: "open" };
if (shadowInit !== false) {
this.#shadow = this.attachShadow(shadowInit);
}
}
/**
* Called to render the content of this element.
*
* @returns The content to attach to this element or the shadow root if it exists.
*/
abstract render(): Content;
/**
* Get a signal that reflects an attribute value.
*
* + `null` represents a missing attribute.
* + This signal is only updated if the name is part of the static `observedAttributes` array.
* + Updating the signal value will also update or remove the attribute.
* + This signal will be kept alive until neither this element nor the signal is referenced anymore.
*
* @param name The attribute name.
* @returns The signal.
*/
reflect(name: string): Signal<string | null> {
let signal = this.#signals.get(name);
if (signal === undefined) {
signal = $(this.getAttribute(name));
this.#signals.set(name, signal);
uncapture(() => {
watchUpdates(signal!, value => {
if (value === null) {
this.removeAttribute(name);
} else {
this.setAttribute(name, value);
}
});
});
}
return signal;
}
/**
* Manually initialize this element.
*
* This has no effect if the element is already initialized.
*/
start(): void {
if (this.#dispose === undefined) {
this.#dispose = capture(() => {
ENV.inject(moduleEnv, () => {
const parent = this.#shadow ?? this;
parent.innerHTML = "";
render(this.render()).appendTo(parent);
});
});
}
}
/**
* Manually dispose this element.
*
* This will leave rendered content as is.
*/
dispose(): void {
this.#dispose?.();
this.#dispose = undefined;
}
connectedCallback(): void {
if (this.#startTrigger === "on-connect") {
this.start();
}
}
disconnectedCallback(): void {
if (this.#disposeTrigger === "on-disconnect") {
this.dispose();
}
}
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
const signal = this.#signals.get(name);
if (signal !== undefined) {
signal.value = newValue;
}
}
}