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( spec: S & ValidateSpec, ): WebComponentConstructor { 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(); 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(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; }