@zeix/ui-element
Version:
UIElement - minimal reactive framework based on Web Components
447 lines (414 loc) • 12.8 kB
text/typescript
import { type Signal, effect, enqueue, isComputed, isSignal, isState, UNSET } from '@zeix/cause-effect'
import { isFunction, isString } from '../core/util'
import { type ComponentSignals, parse, UIElement, RESET } from '../ui-element'
import { elementName, log, LOG_ERROR, valueString } from '../core/log'
/* === Types === */
type ValueProvider<T> = <E extends Element>(target: E, index: number) =>
T
type SignalLike<T> = PropertyKey
| Signal<NonNullable<T>>
| ValueProvider<T>
type ElementUpdater<E extends Element, T> = {
op: string,
read: (element: E) => T | null,
update: (element: E, value: T) => string,
delete?: (element: E) => string,
}
type NodeInserter<S extends ComponentSignals> = {
type: string,
where: InsertPosition,
create: (host: UIElement<S>) => Node | undefined,
}
/* === Internal === */
const ops: Record<string, string> = {
a: 'attribute ',
c: 'class ',
h: 'inner HTML',
p: 'property ',
s: 'style property ',
t: 'text content',
}
const resolveSignalLike = <T, S extends ComponentSignals, E extends Element>(
s: SignalLike<T>,
host: UIElement<S>,
target: E,
index: number
): T => isString(s) ? host.get(s)
: isSignal(s) ? s.get()
: isFunction(s) ? s(target, index)
: 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)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} 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 = <
E extends Element,
T extends {},
S extends ComponentSignals = {},
K extends keyof S = never
>(
s: K | SignalLike<T>,
updater: ElementUpdater<E, S[K] | T>
) => (host: UIElement<S>, target: E, index: number): void => {
const { op, read, update } = updater
const fallback = read(target)
// If not yet set, set signal value to value read from DOM
if (isString(s) && !isComputed(host.signals[s as K])) {
const value = isString(fallback)
? parse(host, s, fallback)
: fallback
if (null != value) host.set(s as K, value as S[K], false)
}
const err = (error: unknown, verb: string, prop: string = 'element') =>
log(error, `Failed to ${verb} ${prop} ${elementName(target)} in ${elementName(host)}`, LOG_ERROR)
// Update the element's DOM state according to the signal value
effect(() => {
let value = RESET
try {
value = resolveSignalLike(s, host, target, index)
} catch (error) {
err(error, 'update')
return
}
if (value === RESET) value = fallback as T
if (value === UNSET) value = updater.delete ? null : fallback
// Nil path => delete the attribute or style property of the element
if (updater.delete && value === null) {
let name: string = ''
enqueue(() => {
name = updater.delete!(target)
return true
}, [target, op]).then(() => {
log(target, `Deleted ${ops[op] + name} of ${elementName(target)} in ${elementName(host)}`)
}).catch((error) => {
err(error, 'delete', `${ops[op] + name} of`)
})
// Ok path => update the element
} else if (value != null) {
const current = read(target)
if (Object.is(value, current)) return
let name: string = ''
enqueue(() => {
name = update(target, value)
return true
}, [target, op]).then(() => {
log(target, `Updated ${ops[op] + name} of ${elementName(target)} in ${elementName(host)}`)
}).catch((error) => {
err(error, 'update', `${ops[op] + name} of`)
})
}
})
}
/**
* Effect to insert a node relative to an element according to a given SignalLike
*/
const insertNode = <
E extends Element,
S extends ComponentSignals = {},
K extends keyof S = never
>(
s: K | SignalLike<boolean>,
{ type, where, create }: NodeInserter<S>
) => (host: UIElement<S>, target: E, index: number): void => {
const methods: Record<InsertPosition, keyof Element> = {
beforebegin: 'before',
afterbegin: 'prepend',
beforeend: 'append',
afterend: 'after'
}
if (!isFunction(target[methods[where]])) {
log(`Invalid insertPosition ${valueString(where)} for ${elementName(host)}:`, LOG_ERROR)
return
}
const err = (error: unknown) =>
log(error, `Failed to insert ${type} into ${elementName(host)}:`, LOG_ERROR)
effect(() => {
let really = false
try {
really = resolveSignalLike(s, host, target, index)
} catch (error) {
err(error)
return
}
if (!really) return
enqueue(() => {
const node = create(host)
if (!node) return
(target[methods[where]] as (...nodes: Node[]) => void)(node)
}, [target, 'i']).then(() => {
const maybeSignal = isString(s) ? host.signals[s] : s
if (isState<boolean>(maybeSignal)) maybeSignal.set(false)
log(target, `Inserted ${type} into ${elementName(host)}`)
}).catch((error) => {
err(error)
})
})
}
/**
* Set text content of an element
*
* @since 0.8.0
* @param {SignalLike<string>} s - state bound to the text content
*/
const setText = <E extends Element>(
s: SignalLike<string>
) => updateElement(s, {
op: 't',
read: (el: E) => el.textContent,
update: (el: E, value: 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 = <E extends Element, K extends keyof E>(
key: K,
s: SignalLike<E[K]> = key
) => updateElement(s, {
op: 'p',
read: (el: E) => key in el ? el[key] : UNSET,
update: (el: E, value: E[K]) => {
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 = <E extends Element>(
name: string,
s: SignalLike<string> = name
) => updateElement(s, {
op: 'a',
read: (el: E) => el.getAttribute(name),
update: (el: E, value: string) => {
safeSetAttribute(el, name, value)
return name
},
delete: (el: E) => {
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 = <E extends Element>(
name: string,
s: SignalLike<boolean> = name
) => updateElement(s, {
op: 'a',
read: (el: E) => el.hasAttribute(name),
update: (el: E, value: boolean) => {
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 = <E extends Element>(
token: string,
s: SignalLike<boolean> = token
) => updateElement(s, {
op: 'c',
read: (el: E) => el.classList.contains(token),
update: (el: E, value: boolean) => {
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 = <E extends (HTMLElement | SVGElement | MathMLElement)>(
prop: string,
s: SignalLike<string> = prop
) => updateElement(s, {
op: 's',
read: (el: E) => el.style.getPropertyValue(prop),
update: (el: E, value: string) => {
el.style.setProperty(prop, value)
return prop
},
delete: (el: E) => {
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 = <E extends Element>(
s: SignalLike<string>,
attachShadow?: 'open' | 'closed',
allowScripts?: boolean,
) => updateElement(s, {
op: 'h',
read: (el: E) => (el.shadowRoot || !attachShadow ? el : null)?.innerHTML ?? '',
update: (el: E, html: 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'
}
})
/**
* Insert template content next to or inside an element
*
* @since 0.11.0
* @param {HTMLTemplateElement} template - template element to clone or import from
* @param {SignalLike<boolean>} s - insert if SignalLike evalutes to true, otherwise ignore
* @param {InsertPosition} where - position to insert the template relative to the target element ('beforebegin', 'afterbegin', 'beforeend', 'afterend')
*/
const insertTemplate = <S extends ComponentSignals>(
template: HTMLTemplateElement,
s: SignalLike<boolean>,
where: InsertPosition = 'beforeend'
) => insertNode(s, {
type: 'template content',
where,
create: (host: UIElement<S>) => {
if (!(template instanceof HTMLTemplateElement)) {
log(`Invalid template to insert into ${elementName(host)}:`, LOG_ERROR)
return
}
const clone = host.shadowRoot
? document.importNode(template.content, true)
: template.content.cloneNode(true)
return clone
}
})
/**
* Create an element with a given tag name and optionally set its attributes
*
* @since 0.11.0
* @param {string} tag - tag name of the element to create
* @param {SignalLike<boolean>} s - insert if SignalLike evalutes to true, otherwise ignore
* @param {InsertPosition} [where] - position to insert the template relative to the target element ('beforebegin', 'afterbegin', 'beforeend', 'afterend')
* @param {Record<string, string>} [attributes] - attributes to set on the element
* @param {string} [text] - text content to set on the element
*/
const createElement = (
tag: string,
s: SignalLike<boolean>,
where: InsertPosition = 'beforeend',
attributes: Record<string, string> = {},
text?: string,
) => insertNode(s, {
type: 'new element',
where,
create: () => {
const child = document.createElement(tag)
for (const [key, value] of Object.entries(attributes))
safeSetAttribute(child, key, value)
if (text)
child.textContent = text
return child
}
})
/**
* Remove an element from the DOM
*
* @since 0.9.0
* @param {SignalLike<string>} s - state bound to the element removal
*/
const removeElement = <E extends Element, S extends ComponentSignals>(
s: SignalLike<boolean>
) => (host: UIElement<S>, target: E, index: number): void => {
const err = (error: unknown) =>
log(error, `Failed to delete ${elementName(target)} from ${elementName(host)}:`, LOG_ERROR)
effect(() => {
let really = false
try {
really = resolveSignalLike(s, host, target, index)
} catch (error) {
err(error)
return
}
if (!really) return
enqueue(() => {
target.remove()
return true
}, [target, 'r']).then(() => {
log(target, `Deleted ${elementName(target)} into ${elementName(host)}`)
}).catch((error) => {
err(error)
})
})
}
/* === Exported Types === */
export {
type ValueProvider, type SignalLike, type ElementUpdater, type NodeInserter,
updateElement, insertNode,
setText, setProperty, setAttribute, toggleAttribute, toggleClass, setStyle,
insertTemplate, createElement, removeElement, dangerouslySetInnerHTML
}