UNPKG

@joist/element

Version:

Intelligently apply styles to WebComponents

158 lines (131 loc) 4.29 kB
import { type AttrMetadata, metadataStore } from "./metadata.js"; import type { ShadowResult } from "./result.js"; export interface ElementOpts { tagName?: string; shadowDom?: ShadowResult[]; shadowDomOpts?: ShadowRootInit; } interface ElementConstructor { new (...args: any[]): HTMLElement; } export function element<T extends ElementConstructor>(opts?: ElementOpts) { return function elementDecorator(Base: T, ctx: ClassDecoratorContext<T>): T { const meta = metadataStore.read(ctx.metadata); ctx.addInitializer(function () { if (opts?.tagName) { if (!customElements.get(opts.tagName)) { customElements.define(opts.tagName, this); } } }); const def = { [Base.name]: class extends Base { static observedAttributes: string[] = Array.from(meta.attrs.keys()); #abortController: AbortController | null = null; constructor(...args: any[]) { super(...args); if (opts?.shadowDom) { if (!this.shadowRoot) { this.attachShadow(opts.shadowDomOpts ?? { mode: "open" }); } for (const res of opts.shadowDom) { res.apply(this); } } for (const cb of meta.onReady) { cb.call(this); } } attributeChangedCallback( name: string, oldValue: string, newValue: string, ) { const attr = meta.attrs.get(name); const cbs = meta.attrChanges.get(name); if (attr) { if (oldValue !== newValue) { const ogValue = attr.getPropValue.call(this); if (newValue === "") { // treat as boolean attr.setPropValue.call(this, true); } else if (typeof ogValue === "number") { // treat as number attr.setPropValue.call(this, Number(newValue)); } else { // treat as string attr.setPropValue.call(this, newValue); } } if (cbs) { for (const cb of cbs) { cb.call(this, name, oldValue, newValue); } } if (attr.observe) { if (super.attributeChangedCallback) { super.attributeChangedCallback(name, oldValue, newValue); } } } } connectedCallback() { if (this.isConnected) { for (const { event, cb, selector } of meta.listeners) { const root = selector(this); if (root) { this.#abortController = new AbortController(); root.addEventListener(event, cb.bind(this), { signal: this.#abortController.signal, }); } else { throw new Error(`could not add listener to ${root}`); } } reflectAttributeValues(this, meta.attrs); if (super.connectedCallback) { super.connectedCallback(); } } } disconnectedCallback(): void { if (this.#abortController) { this.#abortController.abort(); this.#abortController = null; } if (super.disconnectedCallback) { super.disconnectedCallback(); } } }, }; return def[Base.name]; }; } function reflectAttributeValues<T extends HTMLElement>( el: T, attrs: AttrMetadata, ) { for (const [attrName, { getPropValue, reflect }] of attrs) { if (reflect) { const value = getPropValue.call(el); // reflect values back to attributes if (value !== null && value !== undefined && value !== "") { if (typeof value === "boolean") { if (value === true) { // set boolean attribute if (!el.hasAttribute(attrName)) { el.setAttribute(attrName, ""); } } } else { // set key/value attribute const strValue = String(value); if (el.getAttribute(attrName) !== strValue) { el.setAttribute(attrName, strValue); } } } } } }