UNPKG

@zeix/ui-element

Version:

UIElement - minimal reactive framework based on Web Components

459 lines (429 loc) 11.8 kB
import { type Signal, effect, enqueue, isSignal, isState, UNSET, } from '@zeix/cause-effect' import { isFunction } from '@zeix/cause-effect/src/util' import { type ComponentProps, type Component, RESET, type Cleanup, } from '../component' import { DEV_MODE, isString, elementName, log, LOG_ERROR, valueString } from '../core/util' /* === Types === */ type SignalLike<P extends ComponentProps, E extends Element, T> = | keyof P | Signal<NonNullable<T>> | ((element: E) => T | null | undefined) type UpdateOperation = 'a' | 'c' | 'h' | 'p' | 's' | 't' type ElementUpdater<E extends Element, T> = { op: UpdateOperation read: (element: E) => T | null update: (element: E, value: T) => string delete?: (element: E) => string 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 } /* === Internal === */ const resolveSignalLike = /*#__PURE__*/ < P extends ComponentProps, E extends Element, T extends {}, >( s: SignalLike<P, E, T>, host: Component<P>, target: E, ): T => isString(s) ? (host.getSignal(s).get() as unknown as T) : isSignal(s) ? s.get() : isFunction<T>(s) ? s(target) : RESET const isSafeURL = /*#__PURE__*/ (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 (_error) { return false } } return true } const safeSetAttribute = /*#__PURE__*/ ( 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 === */ /** * Effect for setting properties of a target element according to a given SignalLike * * @since 0.9.0 * @param {SignalLike<T>} s - state bound to the element property * @param {ElementUpdater} updater - updater object containing key, read, update, and delete methods */ const updateElement = <P extends ComponentProps, E extends Element, T extends {}>( s: SignalLike<P, E, T>, updater: ElementUpdater<E, T>, ) => (host: Component<P>, target: E): Cleanup => { const { op, read, update } = updater const fallback = read(target) const ops: Record<string, string> = { a: 'attribute ', c: 'class ', h: 'inner HTML', p: 'property ', s: 'style property ', t: 'text content', } let name: string = '' // If not yet set, set signal value to value read from DOM if (isString(s) && isString(fallback) && host[s] === RESET) host.attributeChangedCallback(s, null, fallback) const ok = (verb: string) => () => { if (DEV_MODE && host.debug) log( target, `${verb} ${ops[op] + name} of ${elementName(target)} in ${elementName(host)}`, ) updater.resolve?.(target) } const err = (verb: string) => (error: unknown) => { log( error, `Failed to ${verb} ${ops[op] + name} of ${elementName(target)} in ${elementName(host)}`, LOG_ERROR, ) updater.reject?.(error) } // Update the element's DOM state according to the signal value return effect(() => { let value = RESET try { value = resolveSignalLike(s, host, target) } catch (error) { log( error, `Failed to resolve value of ${valueString(s)} for ${ops[op] + name} of ${elementName(target)} in ${elementName(host)}`, LOG_ERROR, ) return } if (value === RESET) value = fallback else if (value === UNSET) value = updater.delete ? null : fallback // Nil path => delete the attribute or style property of the element if (updater.delete && value === null) { enqueue(() => { name = updater.delete!(target) return true }, [target, op]) .then(ok('Deleted')) .catch(err('delete')) // Ok path => update the element } else if (value != null) { const current = read(target) if (Object.is(value, current)) return enqueue(() => { name = update(target, value) return true }, [target, op]) .then(ok('Updated')) .catch(err('update')) } }) } /** * Effect for inserting or removing elements according to a given SignalLike * * @since 0.12.1 * @param {SignalLike<P, E, number>} s - state bound to the number of elements to insert (positive) or remove (negative) * @param {ElementInserter<E>} inserter - inserter object containing position, insert, and remove methods */ const insertOrRemoveElement = <P extends ComponentProps, E extends Element>( s: SignalLike<P, E, number>, inserter?: ElementInserter<E>, ) => (host: Component<P>, target: E) => { const ok = (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(s) ? s : isString(s) ? host.getSignal(s) : undefined if (isState<number>(signal)) signal.set(0) } } const err = (verb: string) => (error: unknown) => { log( error, `Failed to ${verb} element in ${elementName(target)} in ${elementName(host)}`, LOG_ERROR, ) inserter?.reject?.(error) } return effect(() => { let diff = 0 try { diff = resolveSignalLike(s, host, target) } catch (error) { log( error, `Failed to resolve value of ${valueString(s)} for insertion or deletion in ${elementName(target)} in ${elementName(host)}`, LOG_ERROR, ) return } if (diff === RESET) diff = 0 if (diff > 0) { // Positive diff => insert element if (!inserter) throw new TypeError(`No inserter provided`) enqueue(() => { for (let i = 0; i < diff; i++) { const element = inserter.create(target) if (!element) continue target.insertAdjacentElement( inserter.position ?? 'beforeend', element, ) } return true }, [target, 'i']) .then(ok('Inserted')) .catch(err('insert')) } else if (diff < 0) { // Negative diff => remove element enqueue(() => { if ( inserter && (inserter.position === 'afterbegin' || inserter.position === 'beforeend') ) { for (let i = 0; i > diff; i--) { if (inserter.position === 'afterbegin') target.firstElementChild?.remove() else target.lastElementChild?.remove() } } else { target.remove() } return true }, [target, 'r']) .then(ok('Removed')) .catch(err('remove')) } }) } /** * Set text content of an element * * @since 0.8.0 * @param {SignalLike<string>} s - state bound to the text content */ const setText = <P extends ComponentProps, E extends Element>( s: SignalLike<P, E, string>, ) => updateElement(s, { op: 't', read: (el: E): string | null => el.textContent, update: (el: E, value: string): string => { Array.from(el.childNodes) .filter(node => node.nodeType !== Node.COMMENT_NODE) .forEach(node => node.remove()) el.append(document.createTextNode(value)) return '' }, }) /** * Set property of an element * * @since 0.8.0 * @param {string} key - name of property to be set * @param {SignalLike<E[K]>} s - state bound to the property value */ const setProperty = < P extends ComponentProps, E extends Element, K extends keyof E, >( key: K, s: SignalLike<P, E, E[K]> = key as SignalLike<P, E, E[K]>, ) => updateElement(s, { op: 'p', read: (el: E) => (key in el ? el[key] : UNSET), update: (el: E, value: E[K]): string => { el[key] = value return String(key) }, }) /** * Set attribute of an element * * @since 0.8.0 * @param {string} name - name of attribute to be set * @param {SignalLike<string>} s - state bound to the attribute value */ const setAttribute = <P extends ComponentProps, E extends Element>( name: string, s: SignalLike<P, E, string> = name, ) => updateElement(s, { op: 'a', read: (el: E): string | null => el.getAttribute(name), update: (el: E, value: string): string => { safeSetAttribute(el, name, value) return name }, delete: (el: E): string => { el.removeAttribute(name) return name }, }) /** * Toggle a boolan attribute of an element * * @since 0.8.0 * @param {string} name - name of attribute to be toggled * @param {SignalLike<boolean>} s - state bound to the attribute existence */ const toggleAttribute = <P extends ComponentProps, E extends Element>( name: string, s: SignalLike<P, E, boolean> = name, ) => updateElement(s, { op: 'a', read: (el: E): boolean => el.hasAttribute(name), update: (el: E, value: boolean): string => { el.toggleAttribute(name, value) return name }, }) /** * Toggle a classList token of an element * * @since 0.8.0 * @param {string} token - class token to be toggled * @param {SignalLike<boolean>} s - state bound to the class existence */ const toggleClass = <P extends ComponentProps, E extends Element>( token: string, s: SignalLike<P, E, boolean> = token, ) => updateElement(s, { op: 'c', read: (el: E): boolean => el.classList.contains(token), update: (el: E, value: boolean): string => { el.classList.toggle(token, value) return token }, }) /** * Set a style property of an element * * @since 0.8.0 * @param {string} prop - name of style property to be set * @param {SignalLike<string>} s - state bound to the style property value */ const setStyle = < P extends ComponentProps, E extends HTMLElement | SVGElement | MathMLElement, >( prop: string, s: SignalLike<P, E, string> = prop, ) => updateElement(s, { op: 's', read: (el: E): string | null => el.style.getPropertyValue(prop), update: (el: E, value: string): string => { el.style.setProperty(prop, value) return prop }, delete: (el: E): string => { el.style.removeProperty(prop) return prop }, }) /** * Set inner HTML of an element * * @since 0.11.0 * @param {SignalLike<string>} s - state bound to the inner HTML * @param {'open' | 'closed'} [attachShadow] - whether to attach a shadow root to the element, expects mode 'open' or 'closed' * @param {boolean} [allowScripts] - whether to allow executable script tags in the HTML content, defaults to false */ const dangerouslySetInnerHTML = <P extends ComponentProps, E extends Element>( s: SignalLike<P, E, string>, attachShadow?: 'open' | 'closed', allowScripts?: boolean, ) => updateElement(s, { op: 'h', read: (el: E): string => (el.shadowRoot || !attachShadow ? el : null)?.innerHTML ?? '', update: (el: E, html: string): string => { if (!html) { if (el.shadowRoot) el.shadowRoot.innerHTML = '<slot></slot>' return '' } if (attachShadow && !el.shadowRoot) el.attachShadow({ mode: attachShadow }) 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' }, }) /* === Exported Types === */ export { type SignalLike, type UpdateOperation, type ElementUpdater, type ElementInserter, updateElement, insertOrRemoveElement, setText, setProperty, setAttribute, toggleAttribute, toggleClass, setStyle, dangerouslySetInnerHTML, }