syndicate-js/packages/html2/src/index.ts

255 lines
8.0 KiB
TypeScript

/// SPDX-License-Identifier: GPL-3.0-or-later
/// SPDX-FileCopyrightText: Copyright © 2023-2024 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
import { Turn, Facet, Dataflow, Dataspace, Ref } from "@syndicate-lang/core";
import { HtmlTemplater, template } from "./html";
export { HtmlTemplater, template, HtmlFragment } from "./html";
export assertion type LocationHash(hash: string);
export type LocationHash = ReturnType<typeof LocationHash>;
export function boot(ds = Dataspace.global) {
spawnLocationHashTracker(ds);
}
type Wrapped = {
wrapped: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions | boolean,
};
export type NodeGenerator = (t: HtmlTemplater) => ReturnType<HtmlTemplater>;
export class Widget implements EventTarget {
readonly nodeGenerator: NodeGenerator;
readonly facet: Facet;
private _node: ChildNode | null = null;
parentField: Dataflow.Field<ParentNode | null>;
callbacks = new Map<string, Map<EventListenerOrEventListenerObject, Wrapped>>();
get node(): ChildNode {
return this._node!;
}
constructor (nodeGenerator: NodeGenerator);
constructor (template: string | HTMLTemplateElement, data: object);
constructor (arg0: NodeGenerator | string | HTMLTemplateElement, data?: object) {
if (data === void 0) {
this.nodeGenerator = arg0 as NodeGenerator;
} else {
this.nodeGenerator = templateGenerator(arg0 as (string | HTMLTemplateElement), data);
}
this.facet = Turn.activeFacet;
const cancelAtExit = this.facet.actor.atExit(() => this.node.remove());
on stop {
this.node.remove();
cancelAtExit();
}
const thisTemplate = template();
dataflow {
const nodes = this.nodeGenerator(thisTemplate);
if (nodes.length !== 1) {
throw new Error(`@syndicate-lang/html2: Expected exactly one node from template`);
}
if (this._node === null) {
this._node = nodes[0];
} else if (this._node !== nodes[0]) {
throw new Error(`@syndicate-lang/html2: Node generator is not stable`);
}
}
field parentField: ParentNode | null = this._node?.parentNode ?? null;
this.parentField = parentField;
dataflow {
const p = this.parentField.value;
if (this.node.parentNode !== p) {
if (p === null) {
this.node.remove();
} else {
p.appendChild(this.node);
}
}
}
}
get parent(): ParentNode | null {
return this.parentField.value;
}
set parent(p: string | ParentNode | null) {
this.setParent(p);
}
setParent(p: string | ParentNode | null, wrt: ParentNode = document): this {
if (typeof p === 'string') {
p = wrt.querySelector(p);
}
this.parentField.value = p;
return this;
}
on(type: string, callback: EventListenerOrEventListenerObject): this {
this.addEventListener(type, callback);
return this;
}
once(type: string, callback: EventListenerOrEventListenerObject): this {
this.addEventListener(type, callback, { once: true });
return this;
}
off(type: string, callback: EventListenerOrEventListenerObject): this {
this.removeEventListener(type, callback);
return this;
}
addEventListener(
type: string,
callback: EventListenerOrEventListenerObject | null,
options?: AddEventListenerOptions | boolean,
): void {
if (callback === null) return;
let cbs = this.callbacks.get(type);
if (cbs === void 0) {
cbs = new Map();
this.callbacks.set(type, cbs);
} else {
if (cbs.has(callback)) return;
}
const entry: Wrapped = {
wrapped: (typeof callback === 'function')
? evt => this.facet.turn(() => callback(evt))
: evt => this.facet.turn(() => callback.handleEvent(evt)),
options,
};
cbs.set(callback, entry);
this.node.addEventListener(type, entry.wrapped, options);
}
dispatchEvent(event: Event): boolean {
return this.node.dispatchEvent(event);
}
removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject | null,
options?: boolean | EventListenerOptions | undefined,
): void {
if (callback === null) return;
const cbs = this.callbacks.get(type);
if (cbs === void 0) return;
const r = cbs.get(callback);
if (r === void 0) return;
this.node.removeEventListener(type, r.wrapped, options);
cbs.delete(callback);
if (cbs.size === 0) this.callbacks.delete(type);
}
}
export class ValueWidget extends Widget {
_value: Dataflow.Field<string>;
_valueAsNumber: Dataflow.Field<number>;
constructor (nodeGenerator: NodeGenerator, triggerEvent?: 'change' | 'input');
constructor (template: string | HTMLTemplateElement, data: object, triggerEvent?: 'change' | 'input');
constructor (arg0: NodeGenerator | string | HTMLTemplateElement, arg1?: object | 'change' | 'input', arg2?: 'change' | 'input') {
super(arg0 as any, arg1 as any);
const triggerEvent = typeof arg1 === 'string' ? arg1 : typeof arg2 === 'string' ? arg2 : 'change';
field value: string = '';
this._value = value;
field valueAsNumber: number = NaN;
this._valueAsNumber = valueAsNumber;
if ('value' in this.node) {
const readValues = (n: any) => {
this.suppressCycleWarning();
this._value.value = n?.value ?? '';
this._valueAsNumber.value = n?.valueAsNumber ?? NaN;
};
this.on(triggerEvent, e => readValues(e.target));
readValues(this.node);
dataflow { this.valueAsNumber = this._valueAsNumber.value; }
dataflow { this.value = this._value.value; }
}
}
get value(): string {
return this._value.value;
}
set value(v: string) {
(this.node as any).value = v;
this._value.value = v;
}
get valueAsNumber(): number {
return this._valueAsNumber.value;
}
set valueAsNumber(v: number) {
(this.node as any).value = Number.isNaN(v) ? '' : '' + v;
this._valueAsNumber.value = v;
}
suppressCycleWarning(): void {
this._value.suppressCycleWarning();
this._valueAsNumber.suppressCycleWarning();
}
}
function spawnLocationHashTracker(ds: Ref) {
spawn named 'LocationHashTracker' {
at ds {
field hashValue: string = '/';
const loadHash = () => {
var h = window.location.hash;
if (h.length && h[0] === '#') {
h = h.slice(1);
}
hashValue.value = h || '/';
};
const facet = Turn.activeFacet;
const handlerClosure = () => facet.turn(loadHash);
window.addEventListener('hashchange', handlerClosure);
on stop window.removeEventListener('hashchange', handlerClosure);
loadHash();
assert LocationHash(hashValue.value);
on message LocationHash($newHash: string) => {
window.location.hash = newHash;
}
}
}
}
export function templateGenerator(
template0: string | HTMLTemplateElement,
data: object,
): NodeGenerator {
const template = typeof template0 === 'string' ? document.querySelector(template0) : template0;
if (template === null) throw new Error('Cannot find template: ' + template0);
const body = `return t => t\`${template.innerHTML.trim().split('`').join('\\`')}\``;
const kvs = Object.entries(data);
const keys = kvs.map(e => e[0]);
const values = kvs.map(e => e[1]);
const factory = new Function(... keys, body);
return factory(... values);
}