@zeix/ui-element
Version:
UIElement - minimal reactive framework based on Web Components
355 lines (310 loc) • 11.3 kB
text/typescript
import {
type Signal, type ComputedCallbacks,
UNSET, isSignal, isComputedCallbacks, toSignal, isState, isComputed, computed
} from "@zeix/cause-effect"
import { isFunction } from "./core/util"
import { DEV_MODE, elementName, log, LOG_ERROR, LOG_WARN, typeString, valueString } from "./core/log"
import { type UI, ui } from "./core/ui"
import { type UnknownContext, useContext } from "./core/context"
/* === Types === */
export type ComponentSignals = { [key: string]: {} }
export type AttributeParser<T, S extends ComponentSignals> = (
value: string | null,
host: UIElement<S>,
old?: string | null
) => T
export type StateUpdater<T> = (v: T) => T
export type Root<S extends ComponentSignals> = ShadowRoot | UIElement<S>
export type SignalInitializer<T, S extends ComponentSignals> = T
| AttributeParser<T, S>
| ComputedCallbacks<NonNullable<T>, []>
/* === Constants === */
export const RESET: any = Symbol() // explicitly marked as any so it can be used as signal value of any type
/* === Internal Functions === */
/**
* Check if a value is an attribute parser
*
* @since 0.10.1
* @param {unknown} value - value to check
* @returns {boolean} - true if value is an attribute parser, false otherwise
*/
const isAttributeParser = <T, S extends ComponentSignals>(value: unknown): value is AttributeParser<T, S> =>
isFunction(value) && !!value.length
/**
* Check if a value is a state updater
*
* @since 0.11.0
* @param {unknown} value - value to check
* @returns {boolean} - true if value is a state updater, false otherwise
*/
const isStateUpdater = <T>(value: unknown): value is StateUpdater<T> =>
isFunction(value) && !!value.length
/**
* Unwrap a signal or function to its value
*
* @since 0.10.1
* @param {T | (() => T) | Signal<T>} v
* @returns {T} - value of the signal or function
*/
const unwrap = <T extends {}>(v: T | (() => T) | Signal<T>): T =>
isFunction<T>(v) ? unwrap(v()) : isSignal<T>(v) ? unwrap(v.get()) : v
/* === Exported Functions === */
/**
* Parse according to states
*
* @since 0.8.4
* @param {UIElement} host - host UIElement
* @param {string} key - key for attribute parser or initial value from states
* @param {string | null} value - attribute value
* @param {string | null} [old=undefined] - old attribute value
* @returns {T | undefined}
*/
export const parse = <T, S extends ComponentSignals>(
host: UIElement<S>,
key: string,
value: string | null,
old?: string | null
): T | undefined => {
const parser = host.init[key] as SignalInitializer<T, S>
return isAttributeParser<T, S>(parser)
? parser(value, host, old)
: value as T ?? undefined
}
/* === Exported Class === */
/**
* Base class for reactive custom elements
*
* @since 0.1.0
* @class UIElement
* @extends HTMLElement
* @type {UIElement}
*/
export class UIElement<S extends ComponentSignals = {}> extends HTMLElement {
static registry: CustomElementRegistry = customElements
static readonly localName: string
static observedAttributes: string[]
static consumedContexts: UnknownContext[]
static providedContexts: UnknownContext[]
/**
* Define a custom element in the custom element registry
*
* @since 0.5.0
*/
static define(name: string = this.localName): typeof UIElement {
try {
this.registry.define(name, this)
if (DEV_MODE) log(name, 'Registered custom element')
} catch (error) {
log(error, `Failed to register custom element ${name}`, LOG_ERROR)
}
return this
}
/**
* @since 0.11.0
* @property {{ [K in keyof S]: SignalInitializer<S[K], S> }} init - object of signal initializers (initial values, attribute parsers or computed callbacks)
*/
init: { [K in keyof S]: SignalInitializer<S[K], S> } = {} as { [K in keyof S]: SignalInitializer<S[K], S> }
/**
* @since 0.9.0
* @property {S} signals - object of publicly exposed signals bound to the custom element
*/
signals: {[K in keyof S]: Signal<S[K]> } = {} as { [K in keyof S]: Signal<S[K]> }
/**
* @since 0.10.1
* @property {(() => void)[]} cleanup - array of functions to remove bound event listeners and perform other cleanup operations
*/
cleanup: (() => void)[] = []
/**
* @ property {ElementInternals | undefined} internals - native internal properties of the custom element
* /
internals: ElementInternals | undefined
/**
* @since 0.8.1
* @property {UI<UIElement>} self - UI object for this element
*/
self: UI<UIElement, S> = ui<UIElement, S>(this)
/**
* @since 0.8.3
*/
get root(): Root<S> {
return this.shadowRoot || this
}
/**
* @since 0.9.0
*/
debug: boolean = false
/**
* Native callback function when an observed attribute of the custom element changes
*
* @since 0.1.0
* @param {string} name - 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(
name: string,
old: string | null,
value: string | null
): void {
if (value === old || isComputed(this.signals[name])) return // unchanged or controlled
const parsed = parse(this, name, value, old)
if (DEV_MODE && this.debug)
log(value, `Attribute "${name}" of ${elementName(this)} changed from ${valueString(old)} to ${valueString(value)}, parsed as <${typeString(parsed)}> ${valueString(parsed)}`)
this.set(name, parsed ?? RESET)
}
/**
* Native callback function when the custom element is first connected to the document
*
* Used for context providers and consumers
* If your component uses context, you must call `super.connectedCallback()`
*
* @since 0.7.0
*/
connectedCallback(): void {
if (DEV_MODE) {
this.debug = this.hasAttribute('debug')
if (this.debug) log(this, 'Connected')
}
for (const [key, init] of Object.entries((this.init))) {
// Only handle keys that are not part of the observedAttributes array to prevent double initialization
if ((this.constructor as typeof UIElement).observedAttributes?.includes(key)) continue
const result = isAttributeParser(init)
? init(this.getAttribute(key), this)
: isComputedCallbacks<{}>(init)
? computed(init)
: init
this.set(key, result ?? RESET, false)
}
useContext(this)
}
/**
* Native callback function when the custom element is disconnected from the document
*/
disconnectedCallback(): void {
this.cleanup.forEach(off => off())
this.cleanup = []
if (DEV_MODE && this.debug)
log(this, 'Disconnected')
}
/**
* Native callback function when the custom element is adopted into a new document
*/
adoptedCallback(): void {
if (DEV_MODE && this.debug)
log(this, 'Adopted')
}
/**
* Check whether a state is set
*
* @since 0.2.0
* @param {string} key - state to be checked
* @returns {boolean} `true` if this element has state with the given key; `false` otherwise
*/
has(key: string): boolean {
return key in this.signals
}
/**
* Get the current value of a state
*
* @since 0.2.0
* @param {K} key - state to get value from
* @returns {S[K]} current value of state; undefined if state does not exist
*/
get<K extends keyof S | string>(key: K): S[K] {
const value = unwrap(this.signals[key])
if (DEV_MODE && this.debug)
log(value, `Get current value of Signal ${valueString(key)} in ${elementName(this)}`)
return value
}
/**
* Create a state or update its value and return its current value
*
* @since 0.2.0
* @param {K} key - state to set value to
* @param {S[K] | ComputedCallbacks<S[K], []> | Signal<S[K]> | StateUpdater<S[K]>} value - initial or new value; may be a function (gets old value as parameter) to be evaluated when value is retrieved
* @param {boolean} [update=true] - if `true` (default), the state is updated; if `false`, do nothing if state already exists
*/
set<K extends keyof S | string>(
key: K,
value: S[K] | ComputedCallbacks<S[K], []> | Signal<S[K]> | StateUpdater<S[K]>,
update: boolean = true
): void {
// Error and early return if value is null or undefined
if (null == value) {
log(value, `Attempt to set State ${valueString(key)} to null or undefined in ${elementName(this)}`, LOG_ERROR)
return
}
let op: string;
const s = this.signals[key]
const old = s?.get()
// State does not exist => create new state
if (!(key in this.signals)) {
if (isStateUpdater<S[K]>(value)) {
log(value, `Cannot use updater function to create a Computed in ${elementName(this)}`, LOG_ERROR)
return
}
if (DEV_MODE && this.debug) op = 'Create Signal of type'
this.signals[key] = toSignal(value)
// State already exists => update existing state
} else if (update || old === UNSET || old === RESET) {
if (isComputedCallbacks<S[K]>(value)) {
log(value, `Cannot use computed callbacks to update Signal ${valueString(key)} in ${elementName(this)}`, LOG_ERROR)
return
}
// Value is a Signal => replace state with new signal
if (isSignal(value)) {
if (DEV_MODE && this.debug) op = 'Replace'
this.signals[key] = value
if (isState(s)) s.set(UNSET) // clear previous state so watchers re-subscribe to new signal
// Value is not a Signal => set existing state to new value
} else {
if (isState<S[K]>(s)) {
if (DEV_MODE && this.debug) op = 'Update State of type'
s.set(isStateUpdater<S[K]>(value) ? value(old) : value)
} else {
log(value, `Computed ${valueString(key)} in ${elementName(this)} cannot be set`, LOG_WARN)
return
}
}
// Do nothing if state already exists and update is false
} else return
if (DEV_MODE && this.debug)
log(value, `${op!} ${typeString(value)} ${valueString(key)} in ${elementName(this)}`)
}
/**
* Delete a state, also removing all effects dependent on the state
*
* @since 0.4.0
* @param {string} key - state to be deleted
* @returns {boolean} `true` if the state existed and was deleted; `false` if ignored
*/
delete(key: string): boolean {
if (DEV_MODE && this.debug)
log(key, `Delete Signal ${valueString(key)} from ${elementName(this)}`)
return delete this.signals[key]
}
/**
* Get array of first sub-element matching a given selector within the custom element
*
* @since 0.8.1
* @param {string} selector - selector to match sub-element
* @returns {UI<Element>[]} - array of zero or one UI objects of matching sub-element
*/
first<E extends Element = HTMLElement>(selector: string): UI<E, S> {
let element = this.root.querySelector<E>(selector)
if (this.shadowRoot && !element) element = this.querySelector(selector)
return ui(this, element ? [element] : [])
}
/**
* Get array of all sub-elements matching a given selector within the custom element
*
* @since 0.8.1
* @param {string} selector - selector to match sub-elements
* @returns {UI<Element>} - array of UI object of matching sub-elements
*/
all<E extends Element = HTMLElement>(selector: string): UI<E, S> {
let elements = this.root.querySelectorAll<E>(selector)
if (this.shadowRoot && !elements.length) elements = this.querySelectorAll(selector)
return ui(this, Array.from(elements))
}
}