UNPKG

rahisi

Version:

UI library for prototyping ideas for reactive programming.

610 lines (395 loc) 15.6 kB
import { A0, A1, Either, F0, F1 } from "rahisi-type-utils"; export const createRef = ( () => { let id = 0; // possible collision return () => `id_${id++}`; } )(); export const mounted = "mounted"; export const unmounted = "unmounted"; const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === "childList") { mutation.addedNodes.forEach((n) => n.dispatchEvent(new Event(mounted))); mutation.removedNodes.forEach((n) => n.dispatchEvent(new Event(unmounted))); } }); }); document.addEventListener( "DOMContentLoaded", () => observer.observe(document.body, { attributes: false, characterData: false, childList: true, subtree: true, }), false); class Notifier { private nextId = 0; private readonly subscribers = new Map<number, A0>(); public start() { const notify = () => { this.subscribers.forEach((v) => v()); window.requestAnimationFrame(notify); }; window.requestAnimationFrame(notify); } public subscribe(onNext: A0, dependency: Node) { const currentId = this.nextId; this.nextId++; this.subscribers.set(currentId, onNext); dependency.addEventListener(unmounted, () => this.subscribers.delete(currentId)); } } export interface Attribute { set(o: HTMLElement | SVGElement, watch: Notifier, isSvg: boolean): void; } export interface Renderable { mount(parent: HTMLElement | SVGElement | DocumentFragment): HTMLElement | SVGElement | Text; render(parent: HTMLElement | SVGElement | DocumentFragment, watch: Notifier, isSvg: boolean): HTMLElement | SVGElement | Text; } interface KeyValuePair<K, V> { key: K; value: V; } export class VersionedList<T> { private nextKey = 0; constructor(private items: Array<KeyValuePair<number, T>> = new Array<KeyValuePair<number, T>>()) { } public getItems(): ReadonlyArray<T> { return this.items.map((a) => a.value); } public getItem(index: number): T { return this.items[index].value; } public count() { return this.items.length; } public add(item: T) { const val = { key: this.nextKey, value: item }; this.items.push(val); this.nextKey++; this.addListener([val]); } public delete(itemIndex: number) { const val = this.items[itemIndex]; this.items.splice(itemIndex, 1); this.nextKey++; this.removeListener([val]); } public remove(item: T) { this.delete(this.indexOf(item)); } public clear() { const cleared = this.items.splice(0); this.items.length = 0; this.nextKey++; this.removeListener(cleared); } public indexOf(obj: T, fromIndex = 0): number { if (fromIndex < 0) { fromIndex = Math.max(0, this.items.length + fromIndex); } for (let i = fromIndex, j = this.items.length; i < j; i++) { if (this.items[i].value === obj) { return i; } } return -1; } public forEach(action: A1<T>) { this.getItems().forEach(action); } public filter(filter: F1<T, boolean>) { return this.getItems().filter(filter); } public setListeners( addListener: A1<Array<KeyValuePair<number, T>>>, removeListener: A1<Array<KeyValuePair<number, T>>>) { this.addListener = addListener; this.removeListener = removeListener; this.addListener(this.items); } // tslint:disable-next-line:no-empty private addListener: A1<Array<KeyValuePair<number, T>>> = () => { }; // tslint:disable-next-line:no-empty private removeListener: A1<Array<KeyValuePair<number, T>>> = () => { }; } export class BaseElement implements Renderable { constructor( private readonly elementName: string | undefined, private readonly attributes: Attribute[] = new Array<Attribute>(), private readonly children: Renderable[] = new Array<Renderable>()) { } // factor out public mount(parent: HTMLElement) { const notifier = new Notifier(); const v = this.render(parent, notifier, false); notifier.start(); return v; } public render(parent: HTMLElement, watch: Notifier, isSvg: boolean) { const useSvg = isSvg || this.elementName === "svg"; if (this.elementName == null) { // it's a fragment const view = document.createDocumentFragment(); this.children.forEach((a) => a.render(view, watch, useSvg)); parent.appendChild(view); return parent; } const view = useSvg ? document.createElementNS("http://www.w3.org/2000/svg", this.elementName) : document.createElement(this.elementName); this.attributes.forEach((a) => a.set(view, watch, useSvg)); this.children.forEach((a) => a.render(view, watch, useSvg)); parent.appendChild(view); return view; } } export interface ConditionalElement { test: F0<boolean>; renderable: F0<Renderable>; } export class ConditionalRenderElement implements Renderable { private currentSource: ConditionalElement; private currentNode: HTMLElement | SVGElement | Text = document.createTextNode(""); private fallback: ConditionalElement; constructor(private readonly source: ConditionalElement[], private readonly def: F0<Renderable>) { this.fallback = { test: () => true, renderable: def }; this.currentSource = source.find((a) => a.test()) || this.fallback; } public mount(parent: HTMLElement) { const notifier = new Notifier(); const v = this.render(parent, notifier, false); notifier.start(); return v; } public render(parent: HTMLElement, watch: Notifier, isSvg: boolean) { this.currentNode = this.currentSource .renderable() .render(parent, watch, isSvg); const gen = this.source; watch.subscribe( () => { const s = gen.find((a) => a.test()); if (this.currentSource !== s) { this.currentSource = s || this.fallback; const replacement = this.currentSource .renderable() .render(document.createDocumentFragment(), watch, isSvg); parent.replaceChild(replacement, this.currentNode); this.currentNode = replacement; } }, parent, ); return this.currentNode; } } export class TemplateElement<T> implements Renderable { private nodes = new Map<number, Node>(); private currentValue = new VersionedList<T>(); public constructor( private readonly source: Either<VersionedList<T>>, private readonly template: F1<T, Renderable>, private readonly placeholder: Renderable | null) { } public mount(parent: HTMLElement) { const notifier = new Notifier(); const v = this.render(parent, notifier, false); notifier.start(); return v; } public render(o: HTMLElement, watch: Notifier, isSvg: boolean) { const placeholderNode = this.placeholder ? this.placeholder.render(document.createDocumentFragment(), watch, isSvg) : null; const showPlaceHolder = () => { if (!placeholderNode) { return; } if (this.nodes.size === 0) { const _ = placeholderNode.parentElement === o || o.appendChild(placeholderNode); } else { const _ = placeholderNode.parentElement === o && o.removeChild(placeholderNode); } }; const subscribe = () => { this.nodes.forEach((child, _) => o.removeChild(child)); this.nodes.clear(); this.currentValue.setListeners( (items) => { const fragment = document.createDocumentFragment(); items.forEach((i) => { const child = this.template(i.value).render(fragment, watch, isSvg); this.nodes.set(i.key, child); }); o.appendChild(fragment); showPlaceHolder(); }, (items) => { items.forEach((i) => { o.removeChild(this.nodes.get(i.key)!); this.nodes.delete(i.key); }); showPlaceHolder(); }, ); showPlaceHolder(); }; if (this.source instanceof VersionedList) { this.currentValue = this.source; subscribe(); } else { this.currentValue = this.source(); subscribe(); const gen = this.source; watch.subscribe( () => { const s = gen(); if (this.currentValue !== s) { this.currentValue = s; subscribe(); } }, o, ); } return o; } } export class TextElement implements Renderable { private currentValue = ""; constructor(private readonly textContent: Either<string>) { } public mount(parent: HTMLElement) { const notifier = new Notifier(); const v = this.render(parent, notifier, false); notifier.start(); return v; } public render(parent: HTMLElement, watch: Notifier, _: boolean) { const o = document.createTextNode(""); if (typeof this.textContent !== "function") { this.currentValue = typeof this.textContent === "boolean" ? "" : this.textContent; o.textContent = this.currentValue; } else { const gen = this.textContent; const getValue = () => typeof gen() === "boolean" ? "" : gen(); this.currentValue = getValue(); o.textContent = this.currentValue; watch.subscribe( () => { const s = getValue(); if (this.currentValue !== s) { this.currentValue = s; o.textContent = this.currentValue; } }, o, ); } parent.appendChild(o); return o; } } // xss via href export class NativeAttribute implements Attribute { private static setAttribute = (attribute: string, element: any, value: any, isSvg: boolean) => { if (attribute === "style") { for (const key of Object.keys(value)) { const style = value == null || value[key] == null ? "" : value[key]; if (key[0] === "-") { element[attribute].setProperty(key, style); } else { element[attribute][key] = style; } } } else if ( attribute in element && attribute !== "list" && attribute !== "type" && attribute !== "draggable" && attribute !== "spellcheck" && attribute !== "translate" && !isSvg ) { element[attribute] = value == null ? "" : value; } else if (value != null && value !== false) { element.setAttribute(attribute, value); } if (value == null || value === false) { element.removeAttribute(attribute); } } private currentValue = ""; public constructor(private readonly attribute: string, private readonly value: Either<string>) { } public set(o: HTMLElement, watch: Notifier, isSvg: boolean) { if (typeof this.value !== "function") { this.currentValue = this.value; NativeAttribute.setAttribute(this.attribute, o, this.currentValue, isSvg); } else { this.currentValue = this.value(); NativeAttribute.setAttribute(this.attribute, o, this.currentValue, isSvg); const gen = this.value; watch.subscribe( () => { const s = gen(); if (this.currentValue !== s) { this.currentValue = s; NativeAttribute.setAttribute(this.attribute, o, this.currentValue, isSvg); } }, o, ); } } } // lose focus when body is clicked export class FocusA implements Attribute { private currentValue = false; public constructor(private readonly focus: Either<boolean>) { } public set(o: HTMLElement, watch: Notifier) { if (typeof this.focus !== "function") { this.currentValue = this.focus; if (this.currentValue) { o.focus(); } } else { this.currentValue = this.focus(); if (this.currentValue) { o.focus(); } const gen = this.focus; watch.subscribe( () => { const s = gen(); if (this.currentValue !== s) { this.currentValue = s; } if (this.currentValue && document.activeElement !== o) { o.focus(); } }, o, ); } } } export class OnHandlerA<K extends keyof HTMLElementEventMap> implements Attribute { public constructor( private readonly eventName: K | "mounted" | "unmounted", private readonly handler: F1<HTMLElementEventMap[K], any>) { } public set(o: HTMLElement) { o.addEventListener(this.eventName, this.handler); } } interface TemplateParams<T> { source: VersionedList<T> | (() => VersionedList<T>); template: ((t: T) => Renderable); placeholder?: Renderable; } export const Template = <T>(props: TemplateParams<T>) => { const { source, template, placeholder } = props; return new TemplateElement(source, template, placeholder || null) as any; // no props };