import type { Action, ActionResult } from './types' export type WickedParameters = Record export type WickedParametersFromAttributesCallback = ( node: Element, ) => Parameters type PossibleAttributeValueKeys = keyof { [Key in keyof T]: Key extends string ? T[Key] extends string | null | undefined ? string | undefined : never : never } export type WickedParametersFromAttributes = | PossibleAttributeValueKeys[] | { [attributeName: string]: PossibleAttributeValueKeys } | WickedParametersFromAttributesCallback | null export type WickedConfigItem = | [ selector: string, action: WickedAction, attributes?: WickedParametersFromAttributes, ] export type WickedAction = ( node: Element, parameters: Parameters, ) => WickedActionResult | void | undefined | null export interface WickedActionResult { update?: (parameters: Parameters) => void destroy?: () => void } export function withOptions< Options extends Record, Parameters = WickedParameters, Action extends WickedAction = WickedAction, >(action: Action, options: Parameters): Action { return function withOptions$(node, parameters) { const { update, destroy } = action(node, { ...options, ...parameters }) || {} return { update(parameters) { update?.({ ...options, ...parameters }) }, destroy, } } as Action } export type LazyLoader = () => Promise< WickedAction | { default: WickedAction } > export function lazy( load: LazyLoader, ): WickedAction { let action: WickedAction | undefined let pending: Promise> | undefined return function lazy$(node, parameters) { if (action) return action(node, parameters) let result: WickedActionResult | void | undefined | null let alive = true if (!pending) { pending = load().then( (module) => (action = typeof module === 'function' ? module : module.default), ) } pending.then((action) => { if (alive) { result = action(node, parameters) } }) return { update(newParameters) { parameters = newParameters result?.update?.(newParameters) }, destroy() { alive = false result?.destroy?.() }, } } } interface Registration { _: void | undefined | null | WickedActionResult e: Element // s: string a: WickedParametersFromAttributesCallback f: string[] | null } // Inpsired by https://github.com/WebReflection/wicked-elements export function wicked( node: Element, initialConfig: WickedConfigItem[], ): ActionResult { const live = new Map() let config: [ selector: string, action: WickedAction, attributes: WickedParametersFromAttributesCallback, attributeFilter: string[] | null, ][] = [] const mo = new MutationObserver(handleChanges) // listen for added/removed nodes mo.observe(node, { childList: true, subtree: true }) update(initialConfig) return { update, destroy } function handleChanges(records: MutationRecord[]) { const changed = new Set() for (const record of records) { if (record.type == 'attributes') { live.get(node)?.forEach((registration) => { if (!registration.f || registration.f.includes(record.attributeName as string)) { changed.add(registration) } }) } else { record.removedNodes.forEach((node) => { if ('querySelectorAll' in node) { unregister(node as Element) } }) record.addedNodes.forEach((node) => { if ('querySelectorAll' in node) { register(node as Element) } }) } } changed.forEach(({ _, e, a }) => _?.update?.(a(e))) } function register(root: Element) { for (const item of config) { if (root.matches(item[0])) { add(root, item) } root.querySelectorAll(item[0]).forEach((element) => add(element, item)) } } function unregister(root: Element) { remove(root) root.querySelectorAll('*').forEach(remove) } function remove(element: Element) { const registrations = live.get(element) if (registrations) { live.delete(element) registrations.forEach(({ _ }) => _?.destroy?.()) } } function add( element: Element, [, action, attributes, attributeFilter]: [ selector: string, action: WickedAction, attributes: WickedParametersFromAttributesCallback, attributeFilter: string[] | null, ], ): void { let registrations = live.get(element) if (!registrations) { live.set(element, (registrations = [])) } registrations.push({ _: action(element, attributes(element)), e: element, // s: selector, a: attributes, f: attributeFilter, }) const attributesFilter = registrations.reduce( (combined: Set | undefined, { f: filter }) => { if (!filter) return undefined if (combined) { filter.forEach((attribute) => combined.add(attribute)) } return combined }, new Set(), ) let options: MutationObserverInit = { attributes: true, attributeFilter: attributesFilter && [...attributesFilter], } // ensure we keep listening for added/removed nodes if (element === node) { options = { ...options, childList: true, subtree: true, } } mo.observe(element, options) } function clear() { for (const element of live.keys()) { remove(element) } } function update(initialConfig: WickedConfigItem[]) { clear() config = initialConfig.map(([selector, action, attributes = []]) => { const attributeHandler = createAttributesHandler(attributes) const attributeFilter = typeof attributes === 'function' ? null : Array.isArray(attributes) ? attributes : attributes && Object.keys(attributes) return [selector, action, attributeHandler, attributeFilter] }) register(node) } function destroy() { clear() config.length = 0 mo.disconnect() } } function createAttributesHandler( attributes?: WickedParametersFromAttributes | undefined | null, ): WickedParametersFromAttributesCallback { if (typeof attributes === 'function') { return attributes } if (Array.isArray(attributes)) { return (node) => Object.fromEntries( attributes.map((attribute) => [attribute, node.getAttribute(attribute) ?? undefined]), ) } if (attributes) { return (node) => Object.fromEntries( Object.entries(attributes).map(([attribute, parameter]) => [ parameter, node.getAttribute(attribute) ?? undefined, ]), ) } return (node) => Object.fromEntries(Array.from(node.attributes, (attr) => [attr.name, attr.value])) }