@heartml/reciprocate
Version:
Helper utility for adding signal-based reactivity and attribute/property reflection to custom elements
194 lines (165 loc) • 5.59 kB
JavaScript
// @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 }