360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
import { buildIcon, getIcon, iconLoaded, loadIcon, type IconifyIcon } from 'iconify-icon';
|
|
import { loading } from './components.util';
|
|
import { deepEquals } from './general.util';
|
|
|
|
export type RedrawableHTML = HTMLElement & { update?: (recursive: boolean) => void }
|
|
export type Node = HTMLElement & { update?: (recursive: boolean) => void } | SVGElement | Text | undefined;
|
|
export type NodeChildren = Array<Reactive<Node>> | undefined;
|
|
|
|
export type Class = Reactive<string | Array<Class> | Record<string, boolean> | undefined>;
|
|
type Listener<K extends keyof HTMLElementEventMap> = | ((this: RedrawableHTML, ev: HTMLElementEventMap[K]) => any) | {
|
|
options?: boolean | AddEventListenerOptions;
|
|
listener: (this: RedrawableHTML, ev: HTMLElementEventMap[K]) => any;
|
|
} | undefined;
|
|
|
|
export type Reactive<T> = T | (() => T);
|
|
export interface DOMList<T> extends Array<T>{
|
|
render(redraw?: boolean): void;
|
|
};
|
|
|
|
export interface NodeProperties
|
|
{
|
|
attributes?: Record<string, Reactive<string | undefined | boolean | number>>;
|
|
text?: Reactive<string | number | Text>;
|
|
class?: Class;
|
|
style?: Reactive<Record<string, string | undefined | boolean | number> | string>;
|
|
listeners?: {
|
|
[K in keyof HTMLElementEventMap]?: Listener<K>
|
|
};
|
|
}
|
|
|
|
let defered = false, _deferSet = new Set<() => void>();
|
|
const _defer = (fn: () => void) => {
|
|
if(!defered)
|
|
{
|
|
defered = true;
|
|
queueMicrotask(() => {
|
|
_deferSet.forEach(e => e());
|
|
_deferSet.clear();
|
|
defered = false;
|
|
});
|
|
}
|
|
|
|
_deferSet.add(fn);
|
|
}
|
|
let reactiveEffect: (() => void) | null = null;
|
|
const _reactiveCache = new WeakMap();
|
|
// Store a Weak map of all the tracked object.
|
|
// For each object, we have a map of its properties, allowing us to effectively listen to absolutely everything on the object
|
|
// For a given property, we have a set of "effect" (function called on value update)
|
|
const _tracker = new WeakMap<{}, Map<string | symbol, Set<() => void>>>();
|
|
function trigger<T extends {}>(target: T, key: string | symbol)
|
|
{
|
|
const dependencies = _tracker.get(target)
|
|
if(!dependencies) return;
|
|
|
|
const set = dependencies.get(key);
|
|
set?.forEach(e => e());
|
|
}
|
|
function track<T extends {}>(target: T, key: string | symbol)
|
|
{
|
|
if(!reactiveEffect) return;
|
|
|
|
let dependencies = _tracker.get(target);
|
|
if(!dependencies)
|
|
{
|
|
dependencies = new Map();
|
|
_tracker.set(target, dependencies);
|
|
}
|
|
|
|
let set = dependencies.get(key);
|
|
if(!set)
|
|
{
|
|
set = new Set();
|
|
dependencies.set(key, set);
|
|
}
|
|
|
|
set.add(reactiveEffect);
|
|
}
|
|
export function reactive<T extends {}>(obj: T): T
|
|
{
|
|
if(_reactiveCache.has(obj))
|
|
return _reactiveCache.get(obj)!;
|
|
|
|
const proxy = new Proxy<T>(obj, {
|
|
get: (target, key, receiver) => {
|
|
track(target, key);
|
|
const value = Reflect.get(target, key, receiver);
|
|
|
|
if(value && typeof value === 'object')
|
|
return reactive(value);
|
|
|
|
return value;
|
|
},
|
|
set: (target, key, value, receiver) => {
|
|
const old = Reflect.get(target, key, receiver);
|
|
const result = Reflect.set(target, key, value, receiver);
|
|
|
|
if(old !== value)
|
|
_defer(() => trigger(target, key));
|
|
|
|
return result;
|
|
},
|
|
});
|
|
|
|
_reactiveCache.set(obj, proxy);
|
|
return proxy;
|
|
}
|
|
function requireReactive<T>(reactiveProperty: Reactive<T>, effect: (processed: T) => void)
|
|
{
|
|
if(typeof reactiveProperty !== 'function')
|
|
return effect(reactiveProperty);
|
|
else
|
|
{
|
|
// Function wrapping to keep the context safe and secured.
|
|
// Also useful to retrigger the tracking system if the reactive property provides new properties (via conditions for example)
|
|
const secureEffect = () => effect((reactiveProperty as () => T)());
|
|
const secureContext = () => {
|
|
reactiveEffect = secureContext;
|
|
try {
|
|
secureEffect();
|
|
} finally {
|
|
reactiveEffect = null;
|
|
}
|
|
};
|
|
|
|
secureContext();
|
|
}
|
|
}
|
|
|
|
export const cancelPropagation = (e: Event) => e.stopImmediatePropagation();
|
|
export function dom<T extends keyof HTMLElementTagNameMap>(tag: T, properties?: NodeProperties, children?: NodeChildren): HTMLElementTagNameMap[T] & { update?: (recursive: boolean) => void };
|
|
export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T, properties?: NodeProperties, children?: { render: (data: U) => Node, list?: Array<U>, redraw?: boolean }): HTMLElementTagNameMap[T] & { array?: DOMList<U>, update?: (recursive: boolean) => void };
|
|
export function dom<T extends keyof HTMLElementTagNameMap, U extends any>(tag: T, properties?: NodeProperties, children?: NodeChildren | { render: (data: U) => Node, list?: Array<U>, redraw?: boolean }): HTMLElementTagNameMap[T] & { array?: DOMList<U>, update?: (recursive: boolean) => void }
|
|
{
|
|
const element = document.createElement(tag) as HTMLElementTagNameMap[T] & { array?: DOMList<U>, update: (recursive: boolean) => void };
|
|
let setup = true, updating = false;
|
|
|
|
const _cache = new Map<U, Node>();
|
|
|
|
const update = (recursive: boolean) => {
|
|
updating = true;
|
|
|
|
if(children !== undefined && (setup || recursive))
|
|
{
|
|
if(Array.isArray(children))
|
|
{
|
|
for(const c of children)
|
|
{
|
|
const child = typeof c === 'function' ? c() : c;
|
|
if(child !== undefined)
|
|
{
|
|
element.appendChild(child);
|
|
recursive && 'update' in child && _defer(() => child.update!(true));
|
|
}
|
|
}
|
|
}
|
|
else if(children.list !== undefined)
|
|
{
|
|
if(setup)
|
|
{
|
|
children.list.forEach(e => _cache.set(e, children.render(e)));
|
|
|
|
const _push = children.list.push;
|
|
children.list.push = (...items: U[]) => {
|
|
items.forEach(e => {
|
|
if(!_cache.has(e))
|
|
{
|
|
const dom = children.render(e);
|
|
_cache.set(e, dom);
|
|
dom && element.appendChild(dom);
|
|
}
|
|
else
|
|
{
|
|
const dom = _cache.get(e);
|
|
dom && element.appendChild(dom);
|
|
}
|
|
});
|
|
if(children.redraw) _defer(() => update(false));
|
|
return _push.bind(children.list)(...items);
|
|
};
|
|
const _splice = children.list.splice;
|
|
children.list.splice = (start: number, deleteCount: number, ...items: U[]) => {
|
|
const list = _splice.bind(children.list)(start, deleteCount, ...items);
|
|
list.forEach(e => { if(!children.list!.find(_e => _e === e)) _cache.delete(e); });
|
|
element.array!.render();
|
|
return list;
|
|
};
|
|
}
|
|
else if(recursive)
|
|
_cache.forEach((v, k) => v && 'update' in v && v.update!(true));
|
|
|
|
element.array = children.list as DOMList<U>;
|
|
element.array.render = (redraw?: boolean) => {
|
|
element.replaceChildren(...children.list?.map(e => _cache.get(e)).filter(e => !!e) ?? []);
|
|
if((redraw !== undefined || children.redraw !== undefined) && !updating) _defer(() => update(redraw ?? children.redraw!));
|
|
}
|
|
|
|
element.array.render();
|
|
}
|
|
}
|
|
|
|
if(properties?.attributes)
|
|
{
|
|
for(const [k, v] of Object.entries(properties.attributes))
|
|
{
|
|
if(!setup && typeof v !== 'function') continue;
|
|
|
|
const value = typeof v === 'function' ? v() : v;
|
|
if(typeof value === 'string' || typeof value === 'number') element.setAttribute(k, value.toString(10));
|
|
else if(typeof value === 'boolean') element.toggleAttribute(k, value);
|
|
}
|
|
}
|
|
|
|
if(properties?.text && (setup || typeof properties.text === 'function'))
|
|
{
|
|
requireReactive(properties.text, (text) => {
|
|
if(typeof text === 'string')
|
|
element.textContent = text;
|
|
else if(typeof text === 'number')
|
|
element.textContent = text.toString();
|
|
else
|
|
element.appendChild(text as Text);
|
|
})
|
|
}
|
|
|
|
if(properties?.listeners && setup)
|
|
{
|
|
for(let [k, v] of Object.entries(properties.listeners))
|
|
{
|
|
const key = k as keyof HTMLElementEventMap, value = v as Listener<typeof key>;
|
|
if(typeof value === 'function')
|
|
element.addEventListener(key, value.bind(element));
|
|
else if(value)
|
|
element.addEventListener(key, value.listener.bind(element), value.options);
|
|
}
|
|
}
|
|
|
|
styling(element, properties ?? {});
|
|
updating = false;
|
|
};
|
|
|
|
update(false);
|
|
setup = false;
|
|
element.update = update;
|
|
|
|
return element;
|
|
}
|
|
export function div(cls?: Class, children?: NodeChildren): HTMLElementTagNameMap['div'] & { update?: (recursive: boolean) => void }
|
|
export function div<U extends any>(cls?: Class, children?: { render: (data: U) => Node, list?: Array<U>, redraw?: boolean }): HTMLElementTagNameMap['div'] & { array: DOMList<U>, update?: (recursive: boolean) => void }
|
|
export function div<U extends any>(cls?: Class, children?: NodeChildren | { render: (data: U) => Node, list?: Array<U>, redraw?: boolean }): HTMLElementTagNameMap['div'] & { array?: DOMList<U>, update?: (recursive: boolean) => void }
|
|
{
|
|
//@ts-expect-error
|
|
return dom("div", { class: cls }, children);
|
|
}
|
|
export function span(cls?: Class, text?: Reactive<string | number | Text>): HTMLElementTagNameMap['span'] & { update?: (recursive: boolean) => void }
|
|
{
|
|
return dom("span", { class: cls, text: text });
|
|
}
|
|
export function svg<K extends keyof SVGElementTagNameMap>(tag: K, properties?: NodeProperties, children?: SVGElement[]): SVGElementTagNameMap[K]
|
|
{
|
|
const element = document.createElementNS("http://www.w3.org/2000/svg", tag);
|
|
|
|
if(children && children.length > 0)
|
|
for(const c of children) if(c !== undefined) element.appendChild(c);
|
|
|
|
if(properties?.attributes)
|
|
for(const [k, v] of Object.entries(properties.attributes))
|
|
if(typeof v === 'string') element.setAttribute(k, v);
|
|
else if(typeof v === 'boolean') element.toggleAttribute(k, v);
|
|
|
|
if(properties?.text && typeof properties.text === 'string')
|
|
element.textContent = properties.text;
|
|
|
|
styling(element, properties ?? {});
|
|
|
|
return element;
|
|
}
|
|
export function text(data: Reactive<string | number>): Text
|
|
{
|
|
const text = document.createTextNode('');
|
|
requireReactive(data, (txt) => text.textContent = txt.toString());
|
|
return text;
|
|
}
|
|
export function styling(element: SVGElement | RedrawableHTML, properties: {
|
|
class?: Class;
|
|
style?: Reactive<Record<string, string | undefined | boolean | number> | string>;
|
|
}): SVGElement | RedrawableHTML
|
|
{
|
|
if(properties?.class)
|
|
{
|
|
element.setAttribute('class', mergeClasses(properties.class));
|
|
}
|
|
|
|
if(properties?.style)
|
|
{
|
|
if(typeof properties.style === 'string')
|
|
{
|
|
element.setAttribute('style', properties.style);
|
|
}
|
|
else
|
|
for(const [k, v] of Object.entries(properties.style)) if(v !== undefined && v !== false) element.attributeStyleMap.set(k, v);
|
|
}
|
|
|
|
return element;
|
|
}
|
|
|
|
export interface IconProperties
|
|
{
|
|
mode?: 'svg' | 'style' | 'bg' | 'mask';
|
|
inline?: boolean;
|
|
width?: string | number;
|
|
height?: string | number;
|
|
hFlip?: boolean;
|
|
vFlip?: boolean;
|
|
rotate?: number;
|
|
style?: Reactive<Record<string, string | undefined> | string>;
|
|
class?: Class;
|
|
}
|
|
const iconLoadingRegistry: Map<string, Promise<Required<IconifyIcon>> | null | undefined> = new Map();
|
|
export function icon(name: string, properties?: IconProperties)
|
|
{
|
|
const element = dom('div', { class: properties?.class, style: properties?.style });
|
|
const build = (icon: IconifyIcon | null | undefined) => {
|
|
if(!icon) return element.replaceChildren();
|
|
const built = buildIcon(icon, properties);
|
|
const dom = svg('svg', { attributes: built.attributes });
|
|
dom.innerHTML = built.body;
|
|
element.replaceChildren(dom);
|
|
}
|
|
if(!iconLoaded(name))
|
|
{
|
|
element.appendChild(loading('small'));
|
|
if(!iconLoadingRegistry.has(name)) iconLoadingRegistry.set(name, loadIcon(name));
|
|
iconLoadingRegistry.get(name)?.then(build);
|
|
}
|
|
else build(getIcon(name));
|
|
|
|
return element;
|
|
}
|
|
|
|
export function mergeClasses(cls: Class): string
|
|
{
|
|
const classes = typeof cls === 'function' ? cls() : cls;
|
|
if(typeof classes === 'string')
|
|
{
|
|
return classes.trim();
|
|
}
|
|
else if(Array.isArray(classes))
|
|
{
|
|
return classes.map(e => mergeClasses(e)).join(' ');
|
|
}
|
|
else if(classes)
|
|
{
|
|
return Object.entries(classes).filter(e => e[1]).map(e => e[0].trim()).join(' ');
|
|
}
|
|
else
|
|
{
|
|
return '';
|
|
}
|
|
} |