@joist/element
Version:
Intelligently apply styles to WebComponents
158 lines (131 loc) • 4.29 kB
text/typescript
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);
}
}
}
}
}
}