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

199 lines
6.1 KiB
TypeScript

/// SPDX-License-Identifier: GPL-3.0-or-later
/// SPDX-FileCopyrightText: Copyright © 2023 Tony Garnock-Jones <tonyg@leastfixedpoint.com>
import { Turn, Facet, Dataflow, Dataspace, Ref } from "@syndicate-lang/core";
import { HtmlFragmentsTemplate, HtmlFragment, template } from "./html";
export * 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 class Widget implements EventTarget {
facet: Facet;
node: Dataflow.Field<ChildNode | null>;
callbacks =
new Map<string, Map<EventListenerOrEventListenerObject, Wrapped>>();
_parent: Dataflow.Field<ParentNode | null>;
constructor (private nodeGenerator: (t: HtmlFragmentsTemplate) => HtmlFragment) {
this.facet = Turn.activeFacet;
on stop {
this.node.value?.remove();
}
field node: ChildNode | null = null;
this.node = node;
field parent: ParentNode | null = null;
this._parent = parent;
dataflow {
const oldNode = this.node.value;
const fragments = template`${this.nodeGenerator(template)}`;
const newNodes = fragments.nodes();
if (newNodes.length > 1) {
throw new Error(`@syndicate-lang/html2: Multiple nodes returned from template: ${fragments.toString()}`);
}
const newNode = newNodes[0];
if (oldNode !== newNode) {
if (oldNode !== null) {
for (const [type, cbs] of this.callbacks.entries()) {
for (const entry of cbs.values()) {
oldNode.removeEventListener(type, entry.wrapped, entry.options);
}
}
}
if (newNode !== null) {
for (const [type, cbs] of this.callbacks.entries()) {
for (const entry of cbs.values()) {
newNode.addEventListener(type, entry.wrapped, entry.options);
}
}
}
const wasFocus = oldNode && document.activeElement === oldNode;
oldNode?.parentNode?.replaceChild(newNode, oldNode);
if (wasFocus) {
if (newNode && newNode instanceof HTMLElement) {
newNode.focus();
}
}
this.node.value = newNode;
}
}
dataflow {
if (this.node.value === null) return;
const p = this._parent.value;
if (this.node.value.parentNode !== p) {
if (p === null) {
this.node.value.remove();
} else {
p.appendChild(this.node.value);
}
}
}
}
get parent(): ParentNode | null {
return this._parent.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._parent.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.value?.addEventListener(type, entry.wrapped, options);
}
dispatchEvent(event: Event): boolean {
return !this.node.value || this.node.value.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.value?.removeEventListener(type, r.wrapped, options);
cbs.delete(callback);
if (cbs.size === 0) this.callbacks.delete(type);
}
}
function spawnLocationHashTracker(ds: Ref) {
spawn named 'LocationHashTracker' {
at ds {
field hashValue: string = '/';
assert LocationHash(hashValue.value);
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);
loadHash();
window.addEventListener('hashchange', handlerClosure);
on stop window.removeEventListener('hashchange', handlerClosure);
on message LocationHash($newHash: string) => {
window.location.hash = newHash;
}
}
}
}