UNPKG

hooktml

Version:

A reactive HTML component library with hooks-based lifecycle management

147 lines (126 loc) 5 kB
import { isFunction, isHTMLElement, isHTMLElementArray, isNil, isNonEmptyArray, isNonEmptyObject, isNotNil, isSignal, isEmptyArray } from '../utils/type-guards.js' import { useEffect } from '../core/hookContext.js' import { tryCatch } from '../utils/try-catch.js' import { logger } from '../utils/logger.js' /** * Hook for setting HTML attributes on an element or array of elements * @param {HTMLElement|HTMLElement[]|null|undefined} elementOrElements - The element(s) to set attributes on (or null/undefined) * @param {Record<string, string|null|{value: string|null, subscribe: Function}|Function>} attrMap - Object mapping attribute names to string values, null to remove, signals, or functions * @returns {Function} Cleanup function that removes all applied attributes */ export const useAttributes = (elementOrElements, attrMap, deps = []) => { if (isNil(elementOrElements)) { logger.info('[HookTML] useAttributes called with null/undefined element, skipping attribute setting') return () => { } // Return no-op cleanup function } // Handle empty arrays gracefully if (isEmptyArray(elementOrElements)) { logger.info('[HookTML] useAttributes called with empty array, skipping attribute setting') return () => { } // Return no-op cleanup function } const elements = isHTMLElementArray(elementOrElements) ? elementOrElements : [elementOrElements] if (elements.some(element => !isHTMLElement(element))) { throw new Error('[HookTML] useAttributes requires HTMLElement(s) as first argument') } if (!isNonEmptyObject(attrMap)) { throw new Error('[HookTML] useAttributes requires a non-empty object mapping attribute names to values') } const implicitDeps = Object.values(attrMap).filter(isSignal) const allDeps = implicitDeps.concat(deps) const modifiedAttributesPerElement = new WeakMap() const evaluateCondition = (condition, element, index) => { if (isFunction(condition)) { return condition(element, index) } else if (isSignal(condition)) { return condition.value } else { return condition } } const applyAttributes = () => { elements.forEach((element, index) => { let modifiedAttributes = modifiedAttributesPerElement.get(element) if (!modifiedAttributes) { modifiedAttributes = new Map() modifiedAttributesPerElement.set(element, modifiedAttributes) } Object.entries(attrMap).forEach(([attrName, valueOrSignal]) => { if (!modifiedAttributes.has(attrName)) { modifiedAttributes.set(attrName, element.hasAttribute(attrName) ? element.getAttribute(attrName) : null ) } const value = evaluateCondition(valueOrSignal, element, index) if (isNil(value)) { element.removeAttribute(attrName) } else { element.setAttribute(attrName, value) } }) }) } // Apply attributes immediately applyAttributes() // Set up reactive updates if any signals were provided if (isNonEmptyArray(allDeps)) { tryCatch({ fn: () => { useEffect(() => { applyAttributes() }, allDeps) }, onError: (error) => { logger.error('Error in useAttributes:', error) // Handle case where useEffect is called outside component/directive context // Set up manual signal subscriptions as fallback // Since we've already filtered with isSignal, we know these have a subscribe method const unsubscribes = implicitDeps.map(signal => { return isSignal(signal) ? signal.subscribe(() => applyAttributes()) : null }).filter(isNotNil) // Add cleanup for manual subscriptions to each element's modifiedAttributes for proper teardown elements.forEach(element => { const modifiedAttributes = modifiedAttributesPerElement.get(element) if (modifiedAttributes) { const originalCleanup = modifiedAttributes.get('__cleanup') modifiedAttributes.set('__cleanup', () => { unsubscribes.forEach(unsub => unsub()) if (isFunction(originalCleanup)) originalCleanup() }) } }) } }) } // Return cleanup function return () => { elements.forEach(element => { const modifiedAttributes = modifiedAttributesPerElement.get(element) if (modifiedAttributes) { // Run any stored cleanup function first const cleanup = modifiedAttributes.get('__cleanup') if (isFunction(cleanup)) cleanup() // Restore original attribute values modifiedAttributes.forEach((originalValue, attrName) => { if (attrName === '__cleanup') return if (isNil(originalValue)) { element.removeAttribute(attrName) } else { element.setAttribute(attrName, originalValue) } }) modifiedAttributes.clear() } }) } }