WebComponent(spec) builds a custom-element base class from a spec: shadow root or declarative shadow DOM, template, shared stylesheet (adopted, or a <style> once per root for light DOM), reflected props with two-way attribute sync, a per-connection AbortSignal, host event wiring, and typed element refs. Types derive exact observedAttributes, prop value types, ref element types from selectors, and reject prop names that collide with the base class or mixin members. Tooling: strict tsconfig, type-aware oxlint, oxfmt, and Vitest browser mode (Chromium) with type tests.
254 lines
8.7 KiB
TypeScript
254 lines
8.7 KiB
TypeScript
import { attributeName, type AnyPropDef } from './props.ts';
|
|
import type { Spec, ValidateSpec, WebComponentConstructor } from './types.ts';
|
|
|
|
interface ResolvedProp {
|
|
key: string;
|
|
attribute: string;
|
|
def: AnyPropDef;
|
|
}
|
|
|
|
/**
|
|
* Mixin factory: builds a custom-element base class from a {@link Spec}.
|
|
*
|
|
* The generated class owns the boilerplate — shadow root (or declarative
|
|
* shadow DOM), template, styles, `observedAttributes`, a two-way reflected
|
|
* accessor per prop, a per-connection `AbortSignal`, host event wiring, and
|
|
* typed element refs. A subclass overrides `onConnect` / `onDisconnect` /
|
|
* `onPropChange` / `handleEvent` as needed.
|
|
*
|
|
* Don't declare class fields with the same name as a prop in a subclass — a
|
|
* field is defined on the instance and shadows the reflected accessor.
|
|
*/
|
|
// The mixin is necessarily one closure around the generated class.
|
|
// oxlint-disable-next-line max-lines-per-function
|
|
export function WebComponent<const S extends Spec>(
|
|
spec: S & ValidateSpec<S>,
|
|
): WebComponentConstructor<S> {
|
|
const Base = spec.base ?? HTMLElement;
|
|
const resolved: ResolvedProp[] = Object.entries(spec.props ?? {}).map(([key, def]) => ({
|
|
key,
|
|
attribute: attributeName(key, def, spec.dataPrefix),
|
|
def,
|
|
}));
|
|
const byAttribute = new Map(resolved.map((prop) => [prop.attribute, prop]));
|
|
const element: [name: string, selector: string][] = Object.entries(spec.element ?? {});
|
|
const elements: [name: string, selector: string][] = Object.entries(spec.elements ?? {});
|
|
const events = spec.events ?? [];
|
|
|
|
// Parsed lazily, once per class, so defining the class needs no DOM work.
|
|
let template: HTMLTemplateElement | undefined;
|
|
function getTemplate(): HTMLTemplateElement | undefined {
|
|
if (spec.template === undefined || typeof spec.template !== 'string') return spec.template;
|
|
if (!template) {
|
|
template = document.createElement('template');
|
|
template.innerHTML = spec.template;
|
|
}
|
|
return template;
|
|
}
|
|
|
|
let sheet: CSSStyleSheet | undefined;
|
|
function getSheet(css: string): CSSStyleSheet {
|
|
if (!sheet) {
|
|
sheet = new CSSStyleSheet();
|
|
sheet.replaceSync(css);
|
|
}
|
|
return sheet;
|
|
}
|
|
|
|
// Light-DOM styles are added once per root (document or enclosing shadow
|
|
// root) for this class, not once per element.
|
|
const styledRoots = new WeakSet<Node>();
|
|
|
|
class WebComponentElement extends Base {
|
|
static readonly observedAttributes = resolved.map((prop) => prop.attribute);
|
|
static readonly attributes = Object.fromEntries(
|
|
resolved.map((prop) => [prop.key, prop.attribute]),
|
|
);
|
|
|
|
static {
|
|
for (const prop of resolved) {
|
|
Object.defineProperty(this.prototype, prop.key, {
|
|
configurable: true,
|
|
enumerable: true,
|
|
get(this: WebComponentElement) {
|
|
return this.#values.get(prop.key);
|
|
},
|
|
set(this: WebComponentElement, value: unknown) {
|
|
this.#write(prop, value);
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
readonly internals: ElementInternals;
|
|
readonly #root: ShadowRoot | null;
|
|
#values = new Map<string, unknown>(resolved.map((prop) => [prop.key, prop.def.default]));
|
|
#controller = new AbortController();
|
|
#refs?: object;
|
|
#upgraded = false;
|
|
#rendered = false;
|
|
|
|
// The attribute currently being written by a prop setter, so the
|
|
// attributeChangedCallback it triggers doesn't decode the value straight
|
|
// back. This is the only place a prop/attribute cycle could form.
|
|
#syncing: string | null = null;
|
|
|
|
// TypeScript requires a mixin constructor to take `...args: any[]`.
|
|
// oxlint-disable-next-line no-explicit-any, no-unsafe-argument
|
|
constructor(...args: any[]) {
|
|
// oxlint-disable-next-line no-unsafe-argument
|
|
super(...args);
|
|
this.internals = this.attachInternals();
|
|
|
|
// A declarative shadow root (open or closed) was attached by the parser
|
|
// before upgrade: use it as-is and skip `shadow` / `template`.
|
|
let root = this.internals.shadowRoot;
|
|
if (!root && spec.shadow) {
|
|
root = this.attachShadow({ mode: spec.shadow });
|
|
const content = getTemplate()?.content;
|
|
if (content) root.append(content.cloneNode(true));
|
|
}
|
|
this.#root = root;
|
|
|
|
if (root && spec.css !== undefined)
|
|
root.adoptedStyleSheets = [...root.adoptedStyleSheets, getSheet(spec.css)];
|
|
}
|
|
|
|
get signal(): AbortSignal {
|
|
return this.#controller.signal;
|
|
}
|
|
|
|
get root(): ShadowRoot | HTMLElement {
|
|
return this.#root ?? this;
|
|
}
|
|
|
|
get refs(): object {
|
|
return (this.#refs ??= this.#buildRefs());
|
|
}
|
|
|
|
connectedCallback(): void {
|
|
if (this.#controller.signal.aborted) this.#controller = new AbortController();
|
|
if (!this.#upgraded) {
|
|
this.#upgraded = true;
|
|
this.#upgradeProperties();
|
|
}
|
|
if (!this.#root) this.#renderLightDom();
|
|
for (const type of events) {
|
|
this.addEventListener(type, this, { signal: this.signal });
|
|
}
|
|
this.onConnect();
|
|
}
|
|
|
|
disconnectedCallback(): void {
|
|
this.#controller.abort();
|
|
this.onDisconnect();
|
|
}
|
|
|
|
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
|
|
if (oldValue === newValue || name === this.#syncing) return;
|
|
const prop = byAttribute.get(name);
|
|
if (!prop) return;
|
|
const value: unknown = newValue === null ? prop.def.default : prop.def.decode(newValue, this);
|
|
this.#store(prop, value);
|
|
}
|
|
|
|
handleEvent(_event: Event): void {
|
|
// Overridden by subclasses that list `events`.
|
|
}
|
|
|
|
onConnect(): void {
|
|
// Overridden by subclasses.
|
|
}
|
|
|
|
onDisconnect(): void {
|
|
// Overridden by subclasses.
|
|
}
|
|
|
|
onPropChange(_change: unknown): void {
|
|
// Overridden by subclasses.
|
|
}
|
|
|
|
// Property → value + attribute.
|
|
#write(prop: ResolvedProp, value: unknown): void {
|
|
if (!this.#store(prop, value)) return;
|
|
const raw = prop.def.encode(value, this);
|
|
if (this.getAttribute(prop.attribute) === raw) return;
|
|
this.#syncing = prop.attribute;
|
|
try {
|
|
if (raw === null) this.removeAttribute(prop.attribute);
|
|
else this.setAttribute(prop.attribute, raw);
|
|
} finally {
|
|
this.#syncing = null;
|
|
}
|
|
}
|
|
|
|
// Records a value and notifies; returns whether it actually changed.
|
|
#store(prop: ResolvedProp, value: unknown): boolean {
|
|
const oldValue = this.#values.get(prop.key);
|
|
if (Object.is(oldValue, value)) return false;
|
|
this.#values.set(prop.key, value);
|
|
this.onPropChange({ name: prop.key, value, oldValue });
|
|
return true;
|
|
}
|
|
|
|
// A property set on the element before its class was defined is an own
|
|
// data property shadowing the accessor; move it through the accessor.
|
|
// Done on first connect rather than in the constructor, where subclass
|
|
// fields (and so `onPropChange`) aren't initialized yet.
|
|
#upgradeProperties(): void {
|
|
for (const prop of resolved) {
|
|
if (!Object.hasOwn(this, prop.key)) continue;
|
|
const value: unknown = Reflect.get(this, prop.key);
|
|
Reflect.deleteProperty(this, prop.key);
|
|
this.#write(prop, value);
|
|
}
|
|
}
|
|
|
|
#renderLightDom(): void {
|
|
const content = getTemplate()?.content;
|
|
if (content && !this.#rendered) {
|
|
this.#rendered = true;
|
|
this.append(content.cloneNode(true));
|
|
}
|
|
|
|
const { css } = spec;
|
|
if (css === undefined) return;
|
|
const rootNode = this.getRootNode();
|
|
if (styledRoots.has(rootNode)) return;
|
|
styledRoots.add(rootNode);
|
|
if (rootNode instanceof ShadowRoot) {
|
|
rootNode.adoptedStyleSheets = [...rootNode.adoptedStyleSheets, getSheet(css)];
|
|
} else {
|
|
const style = document.createElement('style');
|
|
style.dataset['wcMixin'] = this.localName;
|
|
style.textContent = css;
|
|
document.head.append(style);
|
|
}
|
|
}
|
|
|
|
#buildRefs(): object {
|
|
// Getter return types are annotated: `PropertyDescriptor.get` returns
|
|
// `any`, which the query methods would otherwise infer their result from.
|
|
const refs = {};
|
|
for (const [name, selector] of element) {
|
|
Object.defineProperty(refs, name, {
|
|
enumerable: true,
|
|
get: (): Element | null => this.root.querySelector(selector),
|
|
});
|
|
}
|
|
for (const [name, selector] of elements) {
|
|
Object.defineProperty(refs, name, {
|
|
enumerable: true,
|
|
get: (): Element[] => [...this.root.querySelectorAll(selector)],
|
|
});
|
|
}
|
|
return refs;
|
|
}
|
|
}
|
|
|
|
// The runtime class can't express the spec-derived accessors and members;
|
|
// this is the one place the generated type is attached.
|
|
// oxlint-disable-next-line no-unsafe-type-assertion
|
|
return WebComponentElement as unknown as WebComponentConstructor<S>;
|
|
}
|