@zeix/ui-element
Version:
UIElement - minimal reactive framework based on Web Components
459 lines (429 loc) • 11.8 kB
text/typescript
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,
}