UNPKG

@zeix/ui-element

Version:

UIElement - a HTML-first library for reactive Web Components

760 lines (709 loc) 20.9 kB
import { type Cleanup, type Signal, UNSET, effect, enqueue, isFunction, isSignal, isState, toSignal, } from '@zeix/cause-effect' import { type Component, type ComponentProps, type Effect, RESET, } from '../component' import type { EventType } from '../core/dom' import { DEV_MODE, LOG_ERROR, elementName, hasMethod, isCustomElement, isDefinedObject, isString, log, valueString, } from '../core/util' /* === Types === */ type Reactive<T, P extends ComponentProps, E extends Element = HTMLElement> = | keyof P | Signal<NonNullable<T>> | ((element: E) => T | null | undefined) type Reactives<E extends Element, P extends ComponentProps> = { [K in keyof E]?: Reactive<E[K], P, E> } type UpdateOperation = 'a' | 'c' | 'd' | 'h' | 'm' | 'p' | 's' | 't' type ElementUpdater<E extends Element, T> = { op: UpdateOperation name?: string read: (element: E) => T | null update: (element: E, value: T) => void delete?: (element: E) => void resolve?: (element: E) => void reject?: (error: unknown) => void } type ElementInserter<E extends Element> = { position?: InsertPosition create: (parent: E) => Element | null resolve?: (parent: E) => void reject?: (error: unknown) => void } type DangerouslySetInnerHTMLOptions = { shadowRootMode?: ShadowRootMode allowScripts?: boolean } /* === Internal Constants === */ const RESOLVE_ERROR = Symbol('RESOLVE_ERROR') /* === Internal Functions === */ const resolveReactive = < T extends {}, P extends ComponentProps, E extends Element = Component<P>, >( reactive: Reactive<T, P, E>, host: Component<P>, target: E, context?: string, ): T | typeof RESOLVE_ERROR => { try { return isString(reactive) ? (host.getSignal(reactive).get() as unknown as T) : isSignal(reactive) ? reactive.get() : isFunction<T>(reactive) ? reactive(target) : RESET } catch (error) { if (context) { log( error, `Failed to resolve value of ${valueString(reactive)}${ context ? ` for ${context}` : '' } in ${elementName(target)}${ (host as unknown as E) !== target ? ` in ${elementName(host)}` : '' }`, LOG_ERROR, ) } return RESOLVE_ERROR } } const getOperationDescription = ( op: UpdateOperation, name: string = '', ): string => { const ops: Record<UpdateOperation, string> = { a: 'attribute ', c: 'class ', d: 'dataset ', h: 'inner HTML', m: 'method call ', p: 'property ', s: 'style property ', t: 'text content', } return ops[op] + name } const createOperationHandlers = <P extends ComponentProps, E extends Element>( host: Component<P>, target: E, operationDesc: string, resolve?: (target: E) => void, reject?: (error: unknown) => void, ) => { const ok = (verb: string) => () => { if (DEV_MODE && host.debug) { log( target, `${verb} ${operationDesc} of ${elementName(target)} in ${elementName(host)}`, ) } resolve?.(target) } const err = (verb: string) => (error: unknown) => { log( error, `Failed to ${verb} ${operationDesc} of ${elementName(target)} in ${elementName(host)}`, LOG_ERROR, ) reject?.(error) } return { ok, err } } const createDedupeSymbol = (operation: string, identifier?: string): symbol => { return Symbol(identifier ? `${operation}:${identifier}` : operation) } const withReactiveValue = <T, P extends ComponentProps, E extends Element>( reactive: Reactive<T, P, E>, host: Component<P>, target: E, context: string, handler: (value: T) => void, ): Cleanup => { return effect(() => { const value = resolveReactive(reactive, host, target, context) if (value === RESOLVE_ERROR) return handler(value as T) }) } const isSafeURL = (value: string): boolean => { if (/^(mailto|tel):/i.test(value)) return true if (value.includes('://')) { try { const url = new URL(value, window.location.origin) return ['http:', 'https:', 'ftp:'].includes(url.protocol) } catch { return false } } return true } const safeSetAttribute = ( element: Element, attr: string, value: string, ): void => { if (/^on/i.test(attr)) throw new Error(`Unsafe attribute: ${attr}`) value = String(value).trim() if (!isSafeURL(value)) throw new Error(`Unsafe URL for ${attr}: ${value}`) element.setAttribute(attr, value) } /* === Exported Functions === */ /** * Core effect function for updating element properties based on reactive values. * This function handles the lifecycle of reading, updating, and deleting element properties * while providing proper error handling and debugging support. * * @since 0.9.0 * @param {Reactive<T, P, E>} reactive - The reactive value that drives the element updates * @param {ElementUpdater<E, T>} updater - Configuration object defining how to read, update, and delete the element property * @returns {Effect<P, E>} Effect function that manages the element property updates */ const updateElement = <P extends ComponentProps, T extends {}, E extends Element = HTMLElement>( reactive: Reactive<T, P, E>, updater: ElementUpdater<E, T>, ): Effect<P, E> => (host, target): Cleanup => { const { op, name = '', read, update } = updater const fallback = read(target) const operationDesc = getOperationDescription(op, name) // If not yet set, set signal value to value read from DOM if ( isString(reactive) && isString(fallback) && host[reactive] === RESET ) { host.attributeChangedCallback(reactive, null, fallback) } const { ok, err } = createOperationHandlers( host, target, operationDesc, updater.resolve, updater.reject, ) return effect(() => { const updateSymbol = createDedupeSymbol(op, name) const value = resolveReactive(reactive, host, target, operationDesc) if (value === RESOLVE_ERROR) return const resolvedValue = value === RESET ? fallback : value === UNSET ? updater.delete ? null : fallback : value if (updater.delete && resolvedValue === null) { enqueue(() => { updater.delete!(target) return true }, updateSymbol) .then(ok('Deleted')) .catch(err('delete')) } else if (resolvedValue != null) { const current = read(target) if (Object.is(resolvedValue, current)) return enqueue(() => { update(target, resolvedValue) return true }, updateSymbol) .then(ok('Updated')) .catch(err('update')) } }) } /** * Effect for dynamically inserting or removing elements based on a reactive numeric value. * Positive values insert elements, negative values remove them. * * @since 0.12.1 * @param {Reactive<number, P, E>} reactive - Reactive value determining number of elements to insert (positive) or remove (negative) * @param {ElementInserter<E>} inserter - Configuration object defining how to create and position elements * @returns {Effect<P, E>} Effect function that manages element insertion and removal */ const insertOrRemoveElement = <P extends ComponentProps, E extends Element = HTMLElement>( reactive: Reactive<number, P, E>, inserter?: ElementInserter<E>, ): Effect<P, E> => (host, target) => { // Custom ok handler for insertOrRemoveElement const insertRemoveOk = (verb: string) => () => { if (DEV_MODE && host.debug) { log( target, `${verb} element in ${elementName(target)} in ${elementName(host)}`, ) } if (isFunction(inserter?.resolve)) { inserter.resolve(target) } else { const signal = isSignal(reactive) ? reactive : isString(reactive) ? host.getSignal(reactive) : undefined if (isState<number>(signal)) signal.set(0) } } const insertRemoveErr = (verb: string) => (error: unknown) => { log( error, `Failed to ${verb} element in ${elementName(target)} in ${elementName(host)}`, LOG_ERROR, ) inserter?.reject?.(error) } return effect(() => { const insertSymbol = createDedupeSymbol('i') const removeSymbol = createDedupeSymbol('r') const diff = resolveReactive( reactive, host, target, 'insertion or deletion', ) if (diff === RESOLVE_ERROR) return const resolvedDiff = diff === RESET ? 0 : diff if (resolvedDiff > 0) { // Positive diff => insert element if (!inserter) throw new TypeError(`No inserter provided`) enqueue(() => { for (let i = 0; i < resolvedDiff; i++) { const element = inserter.create(target) if (!element) continue target.insertAdjacentElement( inserter.position ?? 'beforeend', element, ) } return true }, insertSymbol) .then(insertRemoveOk('Inserted')) .catch(insertRemoveErr('insert')) } else if (resolvedDiff < 0) { // Negative diff => remove element enqueue(() => { if ( inserter && (inserter.position === 'afterbegin' || inserter.position === 'beforeend') ) { for (let i = 0; i > resolvedDiff; i--) { if (inserter.position === 'afterbegin') target.firstElementChild?.remove() else target.lastElementChild?.remove() } } else { target.remove() } return true }, removeSymbol) .then(insertRemoveOk('Removed')) .catch(insertRemoveErr('remove')) } }) } /** * Effect for setting the text content of an element. * Replaces all child nodes (except comments) with a single text node. * * @since 0.8.0 * @param {Reactive<string, P, E>} reactive - Reactive value bound to the text content * @returns {Effect<P, E>} Effect function that sets the text content of the element */ const setText = <P extends ComponentProps, E extends Element = HTMLElement>( reactive: Reactive<string, P, E>, ): Effect<P, E> => updateElement(reactive, { op: 't', read: el => el.textContent, update: (el, value) => { Array.from(el.childNodes) .filter(node => node.nodeType !== Node.COMMENT_NODE) .forEach(node => node.remove()) el.append(document.createTextNode(value)) }, }) /** * Effect for setting a property on an element. * Sets the specified property directly on the element object. * * @since 0.8.0 * @param {K} key - Name of the property to set * @param {Reactive<E[K], P, E>} reactive - Reactive value bound to the property value (defaults to property name) * @returns {Effect<P, E>} Effect function that sets the property on the element */ const setProperty = < P extends ComponentProps, K extends keyof E, E extends Element = HTMLElement, >( key: K, reactive: Reactive<E[K], P, E> = key as Reactive<E[K], P, E>, ): Effect<P, E> => updateElement(reactive, { op: 'p', name: String(key), read: el => (key in el ? el[key] : UNSET), update: (el, value) => { el[key] = value }, }) /** * Effect for controlling element visibility by setting the 'hidden' property. * When the reactive value is true, the element is shown; when false, it's hidden. * * @since 0.13.1 * @param {Reactive<boolean, P, E>} reactive - Reactive value bound to the visibility state * @returns {Effect<P, E>} Effect function that controls element visibility */ const show = <P extends ComponentProps, E extends HTMLElement = HTMLElement>( reactive: Reactive<boolean, P, E>, ): Effect<P, E> => updateElement(reactive, { op: 'p', name: 'hidden', read: el => !el.hidden, update: (el, value) => { el.hidden = !value }, }) /** * Effect for calling a method on an element. * * @since 0.13.3 * @param {K} methodName - Name of the method to call * @param {Reactive<boolean, P, E>} reactive - Reactive value bound to the method call * @param {unknown[]} args - Arguments to pass to the method * @returns Effect function that calls the method on the element */ const callMethod = < P extends ComponentProps, K extends keyof E, E extends HTMLElement = HTMLElement, >( methodName: K, reactive: Reactive<boolean, P, E>, args?: unknown[], ): Effect<P, E> => updateElement(reactive, { op: 'm', name: String(methodName), read: () => null, update: (el, value) => { if (value && hasMethod(el, methodName)) { if (args) el[methodName](...args) else el[methodName]() } }, }) /** * Effect for controlling element focus by calling the 'focus()' method. * If the reactive value is true, element will be focussed; when false, nothing happens. * * @since 0.13.3 * @param {Reactive<boolean, P, E>} reactive - Reactive value bound to the focus state * @returns {Effect<P, E>} Effect function that sets element focus */ const focus = <P extends ComponentProps, E extends HTMLElement = HTMLElement>( reactive: Reactive<boolean, P, E>, ): Effect<P, E> => updateElement(reactive, { op: 'm', name: 'focus', read: el => el === document.activeElement, update: (el, value) => { if (value && hasMethod(el, 'focus')) el.focus() }, }) /** * Effect for setting an attribute on an element. * Sets the specified attribute with security validation for unsafe values. * * @since 0.8.0 * @param {string} name - Name of the attribute to set * @param {Reactive<string, P, E>} reactive - Reactive value bound to the attribute value (defaults to attribute name) * @returns {Effect<P, E>} Effect function that sets the attribute on the element */ const setAttribute = < P extends ComponentProps, E extends Element = HTMLElement, >( name: string, reactive: Reactive<string, P, E> = name, ): Effect<P, E> => updateElement(reactive, { op: 'a', name, read: el => el.getAttribute(name), update: (el, value) => { safeSetAttribute(el, name, value) }, delete: el => { el.removeAttribute(name) }, }) /** * Effect for toggling a boolean attribute on an element. * When the reactive value is true, the attribute is present; when false, it's absent. * * @since 0.8.0 * @param {string} name - Name of the attribute to toggle * @param {Reactive<boolean, P, E>} reactive - Reactive value bound to the attribute presence (defaults to attribute name) * @returns {Effect<P, E>} Effect function that toggles the attribute on the element */ const toggleAttribute = < P extends ComponentProps, E extends Element = HTMLElement, >( name: string, reactive: Reactive<boolean, P, E> = name, ): Effect<P, E> => updateElement(reactive, { op: 'a', name, read: el => el.hasAttribute(name), update: (el, value) => { el.toggleAttribute(name, value) }, }) /** * Effect for toggling a CSS class token on an element. * When the reactive value is true, the class is added; when false, it's removed. * * @since 0.8.0 * @param {string} token - CSS class token to toggle * @param {Reactive<boolean, P, E>} reactive - Reactive value bound to the class presence (defaults to class name) * @returns {Effect<P, E>} Effect function that toggles the class on the element */ const toggleClass = <P extends ComponentProps, E extends Element = HTMLElement>( token: string, reactive: Reactive<boolean, P, E> = token, ): Effect<P, E> => updateElement(reactive, { op: 'c', name: token, read: el => el.classList.contains(token), update: (el, value) => { el.classList.toggle(token, value) }, }) /** * Effect for setting a CSS style property on an element. * Sets the specified style property with support for deletion via UNSET. * * @since 0.8.0 * @param {string} prop - Name of the CSS style property to set * @param {Reactive<string, P, E>} reactive - Reactive value bound to the style property value (defaults to property name) * @returns {Effect<P, E>} Effect function that sets the style property on the element */ const setStyle = < P extends ComponentProps, E extends HTMLElement | SVGElement | MathMLElement, >( prop: string, reactive: Reactive<string, P, E> = prop, ): Effect<P, E> => updateElement(reactive, { op: 's', name: prop, read: el => el.style.getPropertyValue(prop), update: (el, value) => { el.style.setProperty(prop, value) }, delete: el => { el.style.removeProperty(prop) }, }) /** * Effect for setting the inner HTML of an element with optional Shadow DOM support. * Provides security options for script execution and shadow root creation. * * @since 0.11.0 * @param {Reactive<string, P, E>} reactive - Reactive value bound to the inner HTML content * @param {DangerouslySetInnerHTMLOptions} options - Configuration options: shadowRootMode, allowScripts * @returns {Effect<P, E>} Effect function that sets the inner HTML of the element */ const dangerouslySetInnerHTML = < P extends ComponentProps, E extends Element = HTMLElement, >( reactive: Reactive<string, P, E>, options: DangerouslySetInnerHTMLOptions = {}, ): Effect<P, E> => updateElement(reactive, { op: 'h', read: el => (el.shadowRoot || !options.shadowRootMode ? el : null)?.innerHTML ?? '', update: (el, html) => { const { shadowRootMode, allowScripts } = options if (!html) { if (el.shadowRoot) el.shadowRoot.innerHTML = '<slot></slot>' return '' } if (shadowRootMode && !el.shadowRoot) el.attachShadow({ mode: shadowRootMode }) const target = el.shadowRoot || el target.innerHTML = html if (!allowScripts) return '' target.querySelectorAll('script').forEach(script => { const newScript = document.createElement('script') newScript.appendChild( document.createTextNode(script.textContent ?? ''), ) target.appendChild(newScript) script.remove() }) return ' with scripts' }, }) /** * Effect for attaching an event listener to an element. * Provides proper cleanup when the effect is disposed. * * @since 0.12.0 * @param {string} type - Event type * @param {(event: EventType<K>) => void} listener - Event listener function * @param {AddEventListenerOptions | boolean} options - Event listener options * @returns {Effect<ComponentProps, E>} Effect function that manages the event listener */ const on = <K extends keyof HTMLElementEventMap | string, E extends HTMLElement>( type: K, listener: (event: EventType<K>) => void, options: AddEventListenerOptions | boolean = false, ): Effect<ComponentProps, E> => (_, target): Cleanup => { if (!isFunction(listener)) throw new TypeError( `Invalid event listener provided for "${type} event on element ${elementName(target)}`, ) target.addEventListener(type, listener, options) return () => target.removeEventListener(type, listener) } /** * Effect for emitting custom events with reactive detail values. * Creates and dispatches CustomEvent instances with bubbling enabled by default. * * @since 0.13.3 * @param {string} type - Event type to emit * @param {Reactive<T, P, E>} reactive - Reactive value bound to the event detail * @returns {Effect<P, E>} Effect function that emits custom events */ const emitEvent = <T, P extends ComponentProps, E extends Element = HTMLElement>( type: string, reactive: Reactive<T, P, E>, ): Effect<P, E> => (host, target): Cleanup => withReactiveValue( reactive, host, target, `custom event "${type}" detail`, detail => { if (detail === RESET || detail === UNSET) return target.dispatchEvent( new CustomEvent(type, { detail, bubbles: true, }), ) }, ) /** * Effect for passing reactive values to a descendant UIElement component. * * @since 0.13.3 * @param {Reactives<Component<Q>, P>} reactives - Reactive values to pass * @returns {Effect<P, E>} Effect function that passes reactive values to the descendant component * @throws {TypeError} When the provided reactives are not an object or the target is not a UIElement component * @throws {Error} When passing signals failed for some other reason */ const pass = <P extends ComponentProps, Q extends ComponentProps>( reactives: Reactives<Component<Q>, P>, ): Effect<P, Component<Q>> => (host, target): Cleanup | void => { if (!isDefinedObject(reactives)) throw new TypeError(`Reactives must be an object of passed signals`) if (!isCustomElement(target)) throw new TypeError( `Target ${elementName(target)} is not a custom element`, ) customElements .whenDefined(target.localName) .then(() => { if (!hasMethod(target, 'setSignal')) throw new TypeError( `Target ${elementName(target)} is not a UIElement component`, ) for (const [prop, reactive] of Object.entries(reactives)) { target.setSignal( prop, isString(reactive) ? host.getSignal(reactive) : toSignal(reactive), ) } }) .catch(error => { throw new Error( `Failed to pass signals to ${elementName(target)}`, { cause: error }, ) }) } /* === Exports === */ export { type Reactive, type Reactives, type UpdateOperation, type ElementUpdater, type ElementInserter, type DangerouslySetInnerHTMLOptions, updateElement, insertOrRemoveElement, setText, setProperty, show, callMethod, focus, setAttribute, toggleAttribute, toggleClass, setStyle, dangerouslySetInnerHTML, on, emitEvent, pass, }