Created
April 14, 2025 05:49
-
-
Save hyrious/a052cbb8d1915307aaf757a68209a7cc to your computer and use it in GitHub Desktop.
Value Enhancer TSX
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { compute, isVal, setValue, subscribe, type ComputeGet, type ReadonlyVal } from 'value-enhancer'; | |
type Value<T> = T | ReadonlyVal<T>; | |
type ValueOrList<T> = Value<T> | ValueOrList<T>[]; | |
type ValueOrList2<T> = ValueOrList<T> | ValueOrList<ValueOrList<T>>; | |
type Element = HTMLElement | SVGElement; | |
type Attributes<T> = Partial<{ | |
[K in keyof T]: T[K] extends Function ? never : T[K] extends object ? Attributes<T[K]> : Value<number | T[K] | undefined | null>; | |
}>; | |
type Children = ValueOrList2<Element | string | ObserverNode | undefined>; | |
function hasVal(value: ValueOrList<unknown>): boolean { | |
if (isVal(value)) { | |
return true; | |
} | |
if (Array.isArray(value)) { | |
return value.some(v => hasVal(v)); | |
} | |
return false; | |
} | |
const _noop_ = () => void 0; | |
const _get_: ComputeGet = (v: unknown) => isVal(v) ? v.value : v; | |
const _str_ = (x: unknown): string => { | |
if (typeof x === 'string') return x; | |
if (x == null) return ""; | |
try { | |
return JSON.stringify(x, null, 2); | |
} catch { | |
// Insane case is not handled: x = { toString: () => { throw x } } | |
return x + ""; | |
} | |
}; | |
function resolve<T>(value: ValueOrList<T>, get: ComputeGet, cb: (val: T) => void): void { | |
if (isVal(value)) { | |
cb(get(value)); | |
return; | |
} | |
if (Array.isArray(value)) { | |
for (const v of value) { | |
resolve(v, get, cb); | |
} | |
return; | |
} | |
cb(value); | |
} | |
function getClassName(className: ValueOrList<string | undefined | false> | undefined, get: ComputeGet): string { | |
let result = ''; | |
resolve(className, get, v => { | |
if (v) { | |
if (result.length === 0) { | |
result = v; | |
} else { | |
result += ' ' + v; | |
} | |
} | |
}); | |
return result; | |
} | |
function getWindow(node: Node): Window & typeof globalThis { | |
return node.ownerDocument?.defaultView?.window || window; | |
} | |
function isSVGElement(dom: Element): dom is SVGElement { | |
return dom instanceof SVGElement || dom instanceof getWindow(dom).SVGElement; | |
} | |
function setClassName(dom: Element, className: string): void { | |
if (isSVGElement(dom)) { | |
dom.setAttribute('class', className); | |
} else { | |
dom.className = className; | |
} | |
} | |
function hyphenate(str: string): string { | |
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); | |
} | |
function convertCssValue(value: any): string { | |
return typeof value === 'number' ? value + 'px' : value; | |
} | |
function setOrRemoveAttribute(dom: Element, key: string, value: any): void { | |
if (value == null) { | |
dom.removeAttribute(key); | |
} else { | |
dom.setAttribute(hyphenate(key), String(value)); | |
} | |
} | |
class ObserverNode<T extends Element = Element> { | |
private readonly _computes: ((get: ComputeGet) => void)[] = []; | |
private readonly _element: T; | |
constructor( | |
tag: string, | |
ns: string | undefined, | |
className: ValueOrList<string | undefined | false> | undefined, | |
attributes: Attributes<T>, | |
children: Children | |
) { | |
this._element = (ns ? document.createElementNS(ns, tag) : document.createElement(tag)) as unknown as T; | |
if (className) { | |
if (hasVal(className)) { | |
this._computes.push(get => { | |
setClassName(this._element, getClassName(className, get)); | |
}) | |
} else { | |
setClassName(this._element, getClassName(className, _get_)); | |
} | |
} | |
for (const [key, value] of Object.entries(attributes)) { | |
if (key === 'style') { | |
for (const [cssKey, cssValue] of Object.entries(value)) { | |
const key = hyphenate(cssKey); | |
if (isVal(cssValue)) { | |
this._computes.push(get => { | |
this._element.style.setProperty(key, convertCssValue(get(cssValue))) | |
}) | |
} else { | |
this._element.style.setProperty(key, convertCssValue(cssValue)); | |
} | |
} | |
} else if (key === 'tabIndex') { | |
if (isVal(value)) { | |
this._computes.push(get => { | |
this._element.tabIndex = get(value); | |
}) | |
} else { | |
this._element.tabIndex = value; | |
} | |
} else if (key.startsWith('on')) { | |
this._element[key] = value; | |
} else if (key === 'bind:value') { | |
if (isVal(value)) { | |
this._computes.push(get => { | |
const v = get(value); | |
if (v !== (this._element as any).value) { | |
(this._element as any).value = v; | |
} | |
}) | |
this._element.oninput = (e) => { | |
setValue(value, (e.target as any).value) | |
}; | |
} else { | |
this._element[key] = value; | |
} | |
} else { | |
if (isVal(value)) { | |
this._computes.push(get => { | |
setOrRemoveAttribute(this._element, key, get(value)); | |
}) | |
} else { | |
setOrRemoveAttribute(this._element, key, value); | |
} | |
} | |
} | |
if (children) { | |
function getChildren(children: Children, get: ComputeGet): (Element | string)[] { | |
if (isVal(children)) { | |
return getChildren(get(children), get); | |
} | |
if (Array.isArray(children)) { | |
return children.flatMap((child: Children) => getChildren(child, get)); | |
} | |
if (children instanceof ObserverNode) { | |
children.compute(get); | |
return [children._element]; | |
} | |
const str = _str_(children); | |
return str ? [str] : []; | |
} | |
const compute = (get: ComputeGet) => { | |
this._element.replaceChildren(...getChildren(children, get)); | |
}; | |
this._computes.push(compute); | |
if (!hasVal(children)) { | |
compute(_get_); | |
} | |
} | |
} | |
get element(): T { | |
return this._element; | |
} | |
compute(get: ComputeGet): void { | |
this._computes.forEach(compute => compute(get)); | |
} | |
toLiveElement(): LiveElement<T> { | |
return new LiveElement(this._element, subscribe(compute(get => this.compute(get)), _noop_)); | |
} | |
} | |
class LiveElement<T extends Element = Element> { | |
constructor(readonly element: T, readonly dispose: () => void) { } | |
} | |
export function h( | |
tag: string, | |
attributes: Attributes<HTMLElement> & { class?: ValueOrList<string | false | undefined> } | null, | |
...children: any[] | |
): ObserverNode<Element> { | |
if (attributes) { | |
const className = attributes.class; | |
delete attributes.className; | |
return new ObserverNode(tag, undefined, className, attributes, children); | |
} else { | |
return new ObserverNode(tag, undefined, undefined, {}, children); | |
} | |
} | |
export function render( | |
node: ObserverNode<Element>, | |
container: HTMLElement, | |
): () => void { | |
const liveElement = node.toLiveElement(); | |
container.replaceChildren(liveElement.element); | |
return () => { | |
liveElement.dispose(); | |
container.replaceChildren(); | |
} | |
} | |
export namespace h.JSX { | |
export type Element = ObserverNode<HTMLElement | SVGElement>; | |
export type ElementClass = ObserverNode<HTMLElement | SVGElement>; | |
export interface IntrinsicElements { [tag: string]: any; } | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment