@zeix/ui-element
Version:
UIElement - minimal reactive framework based on Web Components
290 lines (259 loc) • 7.96 kB
text/typescript
import {
type MaybeSignal,
type Signal,
UNSET,
isComputed,
isSignal,
isState,
toSignal,
} from '@zeix/cause-effect'
import { isFunction } from '@zeix/cause-effect/src/util'
import { DEV_MODE, elementName, log, typeString, valueString } from './core/util'
import { run } from './core/ui'
/* === Types === */
type ReservedWords =
| 'constructor'
| 'prototype'
| '__proto__'
| 'toString'
| 'valueOf'
| 'hasOwnProperty'
| 'isPrototypeOf'
| 'propertyIsEnumerable'
| 'toLocaleString'
type ValidPropertyKey<T> = T extends keyof HTMLElement | ReservedWords
? never
: T
type ComponentProps = { [K in string as ValidPropertyKey<K>]: {} }
type Component<P extends ComponentProps> = HTMLElement &
P & {
// Common Web Component lifecycle properties
adoptedCallback?(): void
attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null,
): void
connectedCallback(): void
disconnectedCallback(): void
// Custom element properties
debug?: boolean
shadowRoot: ShadowRoot | null
// Component-specific signal methods
getSignal(prop: keyof P): Signal<P[keyof P]>
setSignal(prop: keyof P, signal: Signal<P[keyof P]>): void
}
type AttributeParser<C extends HTMLElement, T extends {}> = (
host: C,
value: string | null,
old?: string | null,
) => T
type SignalProducer<C extends HTMLElement, T extends {}> = (
host: C,
) => MaybeSignal<T>
type MethodProducer<C extends HTMLElement> = (host: C) => void
type Initializer<C extends HTMLElement, T extends {}> =
| T
| AttributeParser<C, T>
| SignalProducer<C, T>
| MethodProducer<C>
type Cleanup = () => void
type FxFunction<P extends ComponentProps, E extends Element> = (
host: Component<P>,
element: E,
) => Cleanup | void
/* === Constants === */
// Special value explicitly marked as any so it can be used as signal value of any type
const RESET: any = Symbol()
// HTMLElement property names to check against
const HTML_ELEMENT_PROPS = new Set(
Object.getOwnPropertyNames(HTMLElement.prototype),
)
// Add additional reserved words
const RESERVED_WORDS = new Set([
'constructor',
'prototype',
'__proto__',
'toString',
'valueOf',
'hasOwnProperty',
'isPrototypeOf',
'propertyIsEnumerable',
'toLocaleString',
])
/* === Internal Functions === */
const isAttributeParser = <C extends HTMLElement, T extends {}>(
value: unknown,
): value is AttributeParser<C, T> => isFunction(value) && value.length >= 2
const validatePropertyName = (prop: string): boolean =>
!(HTML_ELEMENT_PROPS.has(prop) || RESERVED_WORDS.has(prop))
/* === Exported Function === */
/**
* Define a component with its states and setup function (connectedCallback)
*
* @since 0.12.0
* @param {string} name - name of the custom element
* @param {{ [K in keyof S]: Initializer<S[K], Component<P>> }} init - signals of the component
* @param {FxFunction<S>[]} setup - setup function to be called in connectedCallback(), may return cleanup function to be called in disconnectedCallback()
* @returns {typeof HTMLElement & P} - constructor function for the custom element
*/
const component = <P extends ComponentProps>(
name: string,
init: {
[K in keyof P]: Initializer<Component<P>, P[K]>
} = {} as {
[K in keyof P]: Initializer<Component<P>, P[K]>
},
setup: (host: Component<P>) => FxFunction<P, Component<P>>[],
): Component<P> => {
class CustomElement extends HTMLElement {
debug?: boolean
#signals: {
[K in keyof P]: Signal<P[keyof P]>
} = {} as {
[K in keyof P]: Signal<P[keyof P]>
}
#cleanup: Cleanup | undefined
static observedAttributes =
Object.entries(init)
?.filter(([, ini]) => isAttributeParser(ini))
.map(([prop]) => prop) ?? []
/**
* Constructor function for the custom element: initializes signals
*/
constructor() {
super()
for (const [prop, ini] of Object.entries(init)) {
if (ini == null) continue
const result = isAttributeParser<
Component<P>,
Signal<P[keyof P]>
>(ini)
? ini(this as unknown as Component<P>, null)
: isFunction<Component<P>>(ini)
? ini(this as unknown as Component<P>)
: ini
if (result != null) this.setSignal(prop, toSignal(result))
}
}
/**
* Native callback function when the custom element is first connected to the document
*/
connectedCallback() {
if (DEV_MODE) {
this.debug = this.hasAttribute('debug')
if (this.debug) log(this, 'Connected')
}
const fns = setup(this as unknown as Component<P>)
if (!Array.isArray(fns))
throw new TypeError(
`Expected array of functions as return value of setup function in ${elementName(this)}`,
)
this.#cleanup = run(
fns,
this as unknown as Component<P>,
this as unknown as Component<P>,
)
}
/**
* Native callback function when the custom element is disconnected from the document
*/
disconnectedCallback() {
if (isFunction(this.#cleanup)) this.#cleanup()
if (DEV_MODE && this.debug) log(this, 'Disconnected')
}
/**
* Native callback function when an observed attribute of the custom element changes
*
* @param {string} attr - name of the modified attribute
* @param {string | null} old - old value of the modified attribute
* @param {string | null} value - new value of the modified attribute
*/
attributeChangedCallback(
attr: string,
old: string | null,
value: string | null,
) {
if (value === old || isComputed(this.#signals[attr])) return // unchanged or controlled by computed
const parse = init[attr] as AttributeParser<
Component<P>,
P[keyof P]
>
if (!isAttributeParser(parse)) return
const parsed = parse(this as unknown as Component<P>, value, old)
if (DEV_MODE && this.debug)
log(
value,
`Attribute "${attr}" of ${elementName(this)} changed from ${valueString(old)} to ${valueString(value)}, parsed as <${typeString(parsed)}> ${valueString(parsed)}`,
)
;(this as unknown as P)[attr as keyof P] = parsed
}
/**
* Get the the signal for a given key
*
* @since 0.12.0
* @param {K} key - key to get signal for
* @returns {S[K]} current value of signal; undefined if state does not exist
*/
getSignal(key: keyof P): Signal<P[keyof P]> {
const signal = this.#signals[key]
if (DEV_MODE && this.debug)
log(
signal,
`Get ${typeString(signal)} "${String(key)}" in ${elementName(this)}`,
)
return signal
}
/**
* Set the signal for a given key
*
* @since 0.12.0
* @param {K} key - key to set signal for
* @param {Signal<P[keyof P]>} signal - signal to set value to
* @throws {TypeError} if key is not a valid property key
* @throws {TypeError} if signal is not a valid signal
* @returns {void}
*/
setSignal(key: keyof P, signal: Signal<P[keyof P]>): void {
if (!validatePropertyName(String(key)))
throw new TypeError(
`Invalid property name "${String(key)}". Property names must be valid JavaScript identifiers and not conflict with inherited HTMLElement properties.`,
)
if (!isSignal(signal))
throw new TypeError(
`Expected signal as value for property "${String(key)}" on ${elementName(this)}.`,
)
const prev = this.#signals[key]
const writable = isState(signal)
this.#signals[key] = signal
Object.defineProperty(this, key, {
get: signal.get,
set: writable ? signal.set : undefined,
enumerable: true,
configurable: writable,
})
if (prev && isState(prev)) prev.set(UNSET)
if (DEV_MODE && this.debug)
log(
signal,
`Set ${typeString(signal)} "${String(key)} in ${elementName(this)}`,
)
}
}
customElements.define(name, CustomElement)
return CustomElement as unknown as Component<P>
}
export {
type Component,
type ComponentProps,
type ValidPropertyKey,
type ReservedWords,
type Initializer,
type AttributeParser,
type SignalProducer,
type MethodProducer,
type Cleanup,
type FxFunction,
RESET,
component,
}