UNPKG

hooktml

Version:

A reactive HTML component library with hooks-based lifecycle management

111 lines (94 loc) 3.6 kB
import { isHTMLElement, isHTMLElementArray, isNonEmptyArray, isNonEmptyObject, isSignal, isNil, isFunction, isEmptyArray } from '../utils/type-guards.js' import { useEffect } from '../core/hookContext.js' import { logger } from '../utils/logger.js' /** * Hook for conditionally applying CSS classes to an element or array of elements * @param {HTMLElement|HTMLElement[]|null|undefined} elementOrElements - The element(s) to apply classes to (or null/undefined) * @param {Record<string, boolean|{value: boolean, subscribe: Function}|Function>} classMap - Object mapping class names to boolean conditions, signals, or functions * @returns {Function} Cleanup function that removes all event listeners */ export const useClasses = (elementOrElements, classMap, deps = []) => { if (isNil(elementOrElements)) { logger.info('[HookTML] useClasses called with null/undefined element, skipping class application') return () => { } // Return no-op cleanup function } // Handle empty arrays gracefully if (isEmptyArray(elementOrElements)) { logger.info('[HookTML] useClasses called with empty array, skipping class application') return () => { } // Return no-op cleanup function } // Normalize input to array const elements = isHTMLElementArray(elementOrElements) ? elementOrElements : [elementOrElements] if (elements.some(element => !isHTMLElement(element))) { throw new Error('[HookTML] useClasses requires HTMLElement(s) as first argument') } if (!isNonEmptyObject(classMap)) { throw new Error('[HookTML] useClasses requires a non-empty object mapping class names to boolean conditions') } // Extract signals from classMap for effect dependencies (ignore functions) const implicitDeps = Object.values(classMap).filter(isSignal) const allDeps = implicitDeps.concat(deps); // Track which classes we've added per element for cleanup using WeakMap const addedClassesPerElement = new WeakMap() // Function to evaluate a condition for a specific element const evaluateCondition = (condition, element, index) => { if (isFunction(condition)) { return Boolean(condition(element, index)) } else if (isSignal(condition)) { return Boolean(condition.value) } else { return Boolean(condition) } } // Function to update classes based on current conditions const updateClasses = () => { elements.forEach((element, index) => { let addedClasses = addedClassesPerElement.get(element) if (!addedClasses) { addedClasses = new Set() addedClassesPerElement.set(element, addedClasses) } // Clear previously added classes first addedClasses.forEach(className => { element.classList.remove(className) }) addedClasses.clear() Object.entries(classMap).forEach(([className, condition]) => { const isActive = evaluateCondition(condition, element, index) if (isActive) { element.classList.add(className) addedClasses.add(className) } }) }) } // Initial application of classes updateClasses() // Set up reactive updates if any signals were provided if (isNonEmptyArray(allDeps)) { useEffect(() => { updateClasses() }, allDeps) } // Return cleanup function return () => { elements.forEach(element => { const addedClasses = addedClassesPerElement.get(element) if (addedClasses) { addedClasses.forEach(className => { element.classList.remove(className) }) addedClasses.clear() } }) } }