UNPKG

@heartml/reciprocate

Version:

Helper utility for adding signal-based reactivity and attribute/property reflection to custom elements

194 lines (165 loc) 5.59 kB
// @ts-check // https://stackoverflow.com/a/67243723 const kebabize = (str) => str.replace(/[A-Z0-9]+(?![a-z])|[A-Z0-9]/g, ($, ofs) => (ofs ? "-" : "") + $.toLowerCase()) class ReciprocalProperty { /** * * @param {HTMLElement} element - element to connect * @param {string} name - property name * @param {(value: any) => any} signalFunction - function to call to create a signal * @param {() => any} effectFunction - function to call to establish an effect */ constructor(element, name, signalFunction, effectFunction) { this.element = element this.name = name this.type = this.determineType() const attributes = Object.fromEntries( // @ts-ignore element.constructor.observedAttributes.map((item) => [item, item]) ) this.attribute = attributes[name.toLowerCase()] if (!this.attribute) { this.attribute = attributes[kebabize(name)] if (!this.attribute) { console.warn(element) throw `Unable to determine attribute name based on ${name}` } } const signal = signalFunction(element[name]) this.signal = signal this.setupReflection(effectFunction) Object.defineProperty(element, name, { get() { return signal.get() }, set(value) { signal.set(value) }, enumerable: true, configurable: false, }) } determineType() { const value = this.element[this.name] if (Array.isArray(value) || typeof value === "object") { return "object" } return typeof value } /** * Sets up the signal subscription so when the property value changes, the attribute reflects a * string value (or the attribute is removed for null/false) */ setupReflection(effectFunction) { if (!this.reflects) { this.reflects = true this._signalling = true effectFunction(() => { const value = this.signal.get() if (this._signalling) return this._inCallback = true if (Array.isArray(value) || (value !== null && typeof value === "object")) { this.element.setAttribute(this.attribute, JSON.stringify(value)) } else if (value == null || value === false) { this.element.removeAttribute(this.attribute) } else if (value === true) { this.element.setAttribute(this.attribute, "") } else { this.element.setAttribute(this.attribute, value) } this._inCallback = false }) this._signalling = false } } /** * Parses a string attribute value and attempts to set the signal value accordingly * * @param {string} value - the attribute value */ convertFromString(value) { if (this._inCallback) return this._signalling = true if (this.type === "boolean") { this.signal.set(!!(value === "" ? true : value)) } else if (this.type === "number") { this.signal.set(Number(value == null ? null : value)) } else if (this.type === "object") { try { this.signal.set(value ? JSON.parse(value) : new (this.signal.get().constructor)()) } catch (ex) { console.warn(`${ex.message} for ${this.element.localName}[${this.attribute}]`) this.signal.set(new (this.signal.get().constructor)()) } } else { this.signal.set(value) } this._signalling = false } } class RunEffects { effectStorage = [] /** * @param {HTMLElement} element - element to connect * @param {(fn: () => void) => any} effectFunction - function to call to establish an effect */ constructor(element, effectFunction) { this.element = element this.effectFunction = effectFunction } /** * Executes effect callbacks using the previously provided effect function, saving them for * later disposal purposes * * @param {(() => void)[]} fx - one or more effect callbacks */ run(...fx) { for (const fn of fx) { this.effectStorage.push(this.effectFunction(fn)) } } /** * Loop through previous effect return values and clear the effect storage * * @param {(fn: any) => void} callback - use the effect's return value argument to dispose */ stop(callback) { this.effectStorage.forEach(callback) this.effectStorage = [] } /** * Converts value for the provided attribute name to the corresponding element property * * @param {string} name * @param {string} newValue */ setProp(name, newValue) { /** @type {Record<string, ReciprocalProperty>} */ const attrProps = this.element["_reciprocalProperties"] if (attrProps) attrProps[name]?.convertFromString(newValue) } } /** * @param {HTMLElement} element - element to connect * @param {(value: any) => any} signalFunction - function to call to create a signal * @param {() => any} effectFunction - function to call to establish an effect * @returns {RunEffects} manage the execution and disposal of one or more effects */ function reciprocate(element, signalFunction, effectFunction) { Object.defineProperty(element, "_reciprocalProperties", { value: {}, }) Object.keys(element).forEach((key) => { const reciprocalProperty = new ReciprocalProperty(element, key, signalFunction, effectFunction) Object.defineProperty(element, `${key}Signal`, { get: () => { return reciprocalProperty.signal }, }) // @ts-ignore element._reciprocalProperties[reciprocalProperty.attribute] = reciprocalProperty }) return new RunEffects(element, effectFunction) } export { ReciprocalProperty, RunEffects, reciprocate }