@zeix/ui-element
Version:
UIElement - a HTML-first library for reactive Web Components
400 lines (368 loc) • 10.2 kB
text/typescript
import {
type Cleanup,
type Computed,
TYPE_COMPUTED,
UNSET,
type Watcher,
computed,
isFunction,
notify,
subscribe,
} from '@zeix/cause-effect'
import type {
Component,
ComponentProps,
ElementFromSelector,
SignalProducer,
} from '../component'
import { elementName, isCustomElement } from './util'
/* === Types === */
type EventType<K extends string> = K extends keyof HTMLElementEventMap
? HTMLElementEventMap[K]
: Event
type EventTransformerContext<
T extends {},
E extends Element,
C extends HTMLElement,
Evt extends Event,
> = {
event: Evt
host: C
target: E
value: T
}
type EventTransformer<
T extends {},
E extends Element,
C extends HTMLElement,
Evt extends Event,
> = (context: EventTransformerContext<T, E, C, Evt>) => T | void
type EventTransformers<
T extends {},
E extends Element,
C extends HTMLElement,
> = {
[K in keyof HTMLElementEventMap]?: EventTransformer<T, E, C, EventType<K>>
}
/* === Error Class === */
/**
* Error thrown when a circular dependency is detected in a selection signal
*/
class CircularMutationError extends Error {
constructor(message: string) {
super(message)
this.name = 'CircularMutationError'
}
}
/* === Internal === */
/**
* Extract attribute names from a CSS selector
* Handles various attribute selector formats: .class, #id, [attr], [attr=value], [attr^=value], etc.
*
* @param {string} selector - CSS selector to parse
* @returns {string[]} - Array of attribute names found in the selector
*/
const extractAttributes = (selector: string): string[] => {
const attributes = new Set<string>()
if (selector.includes('.')) attributes.add('class')
if (selector.includes('#')) attributes.add('id')
if (selector.includes('[')) {
const parts = selector.split('[')
for (let i = 1; i < parts.length; i++) {
const part = parts[i]
if (!part.includes(']')) continue
const attrName = part
.split('=')[0]
.trim()
.replace(/[^a-zA-Z0-9_-]/g, '')
if (attrName) attributes.add(attrName)
}
}
return [...attributes]
}
/**
* Compare two arrays of elements to determine if they contain the same elements
*
* @param {E[]} arr1 - First array of elements to compare
* @param {E[]} arr2 - Second array of elements to compare
* @returns {boolean} - True if arrays contain the same elements, false otherwise
*/
const areElementArraysEqual = <E extends Element>(
arr1: E[],
arr2: E[],
): boolean => {
if (arr1.length !== arr2.length) return false
const set1 = new Set(arr1)
for (const el of arr2) {
if (!set1.has(el)) return false
}
return true
}
/* === Exported Functions === */
/**
* Produce a computed signal from transformed event data
*
* @since 0.13.3
* @param {T | ((host: C) => T)} initialize - Initial value or initialize function
* @param {S} selector - CSS selector for the source element
* @param {EventTransformers<T, ElementFromSelector<S, E>, C>} events - Transformation functions for events
* @returns {(host: C) => Computed<T>} Signal producer for value from event
*/
const fromEvents =
<
T extends {},
E extends Element = HTMLElement,
C extends HTMLElement = HTMLElement,
S extends string = string,
>(
initialize: T | ((host: C) => T),
selector: S,
events: EventTransformers<T, ElementFromSelector<S, E>, C>,
): SignalProducer<T, C> =>
(host: C) => {
const watchers: Set<Watcher> = new Set()
let value: T = isFunction<T>(initialize)
? initialize(host)
: (initialize as T)
const eventMap = new Map<string, EventListener>()
let cleanup: Cleanup | undefined
const listen = () => {
for (const [type, transform] of Object.entries(events)) {
const listener = ((e: Event) => {
const target = e.target as Element
if (!target) return
const source = target.closest(
selector,
) as ElementFromSelector<S, E> | null
if (!source || !host.contains(source)) return
e.stopPropagation()
try {
const newValue = transform({
event: e as any,
host,
target: source,
value,
})
if (newValue == null) return
if (!Object.is(newValue, value)) {
value = newValue
if (watchers.size > 0) notify(watchers)
else if (cleanup) cleanup()
}
} catch (error) {
e.stopImmediatePropagation()
throw error
}
}) as EventListener
eventMap.set(type, listener)
host.addEventListener(type, listener)
}
cleanup = () => {
if (eventMap.size) {
for (const [type, listener] of eventMap) {
host.removeEventListener(type, listener)
}
eventMap.clear()
}
cleanup = undefined
}
}
return {
[Symbol.toStringTag]: TYPE_COMPUTED,
get(): T {
subscribe(watchers)
if (watchers.size && !eventMap.size) listen()
return value
},
}
}
/**
* Observe a DOM subtree with a mutation observer
*
* @since 0.12.2
* @param {ParentNode} parent - parent node
* @param {string} selector - selector for matching elements to observe
* @param {MutationCallback} callback - mutation callback
* @returns {MutationObserver} - the created mutation observer
*/
const observeSubtree = (
parent: ParentNode,
selector: string,
callback: MutationCallback,
): MutationObserver => {
const observer = new MutationObserver(callback)
const observerConfig: MutationObserverInit = {
childList: true,
subtree: true,
}
const observedAttributes = extractAttributes(selector)
if (observedAttributes.length) {
observerConfig.attributes = true
observerConfig.attributeFilter = observedAttributes
}
observer.observe(parent, observerConfig)
return observer
}
/**
* Produce a computed signal of an array of elements matching a selector
*
* @since 0.13.1
* @param {K} selector - CSS selector for descendant elements
* @returns {(host: C) => Computed<ElementFromSelector<S, E>[]>} Signal producer for descendant element collection from a selector
*/
const fromSelector =
<
E extends Element = HTMLElement,
C extends HTMLElement = HTMLElement,
S extends string = string,
>(
selector: S,
): SignalProducer<ElementFromSelector<S, E>[], C> =>
(host: C) => {
const watchers: Set<Watcher> = new Set()
const select = () =>
Array.from(
(host.shadowRoot ?? host).querySelectorAll<
ElementFromSelector<S, E>
>(selector),
)
let value: ElementFromSelector<S, E>[] = UNSET
let observer: MutationObserver | undefined
let mutationDepth = 0
const MAX_MUTATION_DEPTH = 2 // Consider a depth > 1 as circular
const observe = () => {
value = select()
observer = observeSubtree(host, selector, () => {
// If we have no watchers, just disconnect
if (!watchers.size) {
observer?.disconnect()
observer = undefined
return
}
mutationDepth++
if (mutationDepth > MAX_MUTATION_DEPTH) {
observer?.disconnect()
observer = undefined
mutationDepth = 0
throw new CircularMutationError(
'Circular mutation in element selection detected',
)
}
try {
const newElements = select()
if (!areElementArraysEqual(value, newElements)) {
value = newElements
notify(watchers)
}
} finally {
mutationDepth--
}
})
}
return {
[Symbol.toStringTag]: TYPE_COMPUTED,
get(): ElementFromSelector<S, E>[] {
subscribe(watchers)
if (!watchers.size) value = select()
else if (!observer) observe()
return value
},
}
}
/**
* Reduced properties of descendant elements
*
* @since 0.13.3
* @param {C} host - Host element for computed property
* @param {S} selector - CSS selector for descendant elements
* @param {(accumulator: T, currentElement: ElementFromSelector<S, E>, currentIndex: number, array: ElementFromSelector<S, E>[]) => T} reducer - Function to reduce values
* @param {T} initialValue - Initial value function for reduction
* @returns {Computed<T>} Computed signal of reduced values of descendant elements
*/
const reduced = <
T extends {},
E extends Element = HTMLElement,
C extends HTMLElement = HTMLElement,
S extends string = string,
>(
host: C,
selector: S,
reducer: (
accumulator: T,
currentElement: ElementFromSelector<S, E>,
currentIndex: number,
array: ElementFromSelector<S, E>[],
) => T,
initialValue: T,
): Computed<T> =>
computed(() =>
(
fromSelector<ElementFromSelector<S, E>>(selector)(host) as Computed<
ElementFromSelector<S, E>[]
>
)
.get()
.reduce(reducer, initialValue),
)
/**
* Read a signal property from a custom element safely after it's defined
*
* @since 0.13.1
* @param {Component<Q> | null} target - Taget descendant element
* @param {K} prop - Property name to get signal for
* @param {Q[K]} fallback - Fallback value to use until component is ready
* @returns {() => Q[K]} Function that returns signal value or fallback
*/
const read = <Q extends ComponentProps, K extends keyof Q & string>(
target: Component<Q> | null,
prop: K,
fallback: Q[K],
): (() => Q[K]) => {
if (!target) return () => fallback
if (!isCustomElement(target))
throw new TypeError(`Target element must be a custom element`)
const awaited = computed(async () => {
await customElements.whenDefined(target.localName)
return target.getSignal(prop)
})
return () => {
const value = awaited.get()
return value === UNSET ? fallback : (value.get() as Q[K])
}
}
/**
* Assert that an element contains an expected descendant element
*
* @since 0.13.3
* @param {HTMLElement} host - Host element
* @param {S} selector - Descendant element to check for
* @returns {ElementFromSelector<S, E>} First found descendant element
* @throws {Error} If the element does not contain the required descendant element
*/
const requireDescendant = <
S extends string = string,
E extends Element = HTMLElement,
>(
host: HTMLElement,
selector: S,
): ElementFromSelector<S, E> => {
const target = host.querySelector<ElementFromSelector<S, E>>(selector)
if (!target) {
throw new Error(
`Component ${elementName(host)} does not contain required <${selector}> element`,
)
}
return target
}
export {
type EventType,
type EventTransformer,
type EventTransformers,
type EventTransformerContext,
fromEvents,
fromSelector,
reduced,
read,
observeSubtree,
requireDescendant,
}