UNPKG

@joist/element

Version:

Intelligently apply styles to WebComponents

151 lines (123 loc) 4.37 kB
import { define } from "./define.js"; import type { DefineOpts } from "./define.js"; import { type AttrMetadata, metadataStore } from "./metadata.js"; import type { ShadowResult } from "./result.js"; export interface ElementOpts extends Partial<DefineOpts> { 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) { define({ tagName: opts.tagName, dependsOn: opts.dependsOn }, 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 sourceValue = attr.access.get.call(this); let value: string | number | boolean = newValue; if (typeof sourceValue === "boolean") { // treat as boolean value = newValue !== null; } else if (typeof sourceValue === "number") { // treat as number value = Number(newValue); } attr.access.set.call(this, value); } 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.#abortController) { this.#abortController = new AbortController(); for (const { event, cb, selector } of meta.listeners) { const root = selector(this); if (root) { 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] as T; }; } function reflectAttributeValues<T extends HTMLElement>(el: T, attrs: AttrMetadata) { for (const [attrName, { access, reflect }] of attrs) { if (reflect) { const value = access.get.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 if (!el.hasAttribute(attrName)) { // only set parent attribute if it doesn't exist // set key/value attribute const strValue = String(value); if (el.getAttribute(attrName) !== strValue) { el.setAttribute(attrName, strValue); } } } } } }