UNPKG

@oazmi/tsignal

Version:

a topological order respecting signals library inspired by SolidJS

199 lines (198 loc) 8.9 kB
/** this module allows one to create reactive DOM elements. * * @module */ import { isFunction, isPrimitive, symbol_iterator } from "./deps.js"; import { SimpleSignal_Factory } from "./signal.js"; import { SignalUpdateStatus } from "./typedefs.js"; export const default_dom_value_equality = (prev_nodeValue, new_nodeValue) => { // we first standardize the nullability value to `undefined` new_nodeValue ??= undefined; prev_nodeValue ??= undefined; return prev_nodeValue === new_nodeValue?.toString(); }; const is_DOM_node = (obj) => { return obj instanceof Node; }, as_DOM_node_array = (obj) => { obj = is_DOM_node(obj) ? [obj] : obj; if (isPrimitive(obj) || !(symbol_iterator in obj)) { return; } const item_iterator = obj[symbol_iterator](), first_item = item_iterator.next().value; return is_DOM_node(first_item) ? [first_item, ...item_iterator] : undefined; }; export const DOMSignal_Factory = (ctx) => { return class DOMSignal extends ctx.getClass(SimpleSignal_Factory) { /** the previous parent element is stored in case this node is {@link detach | detached}, followed by a request to {@link reattach}. */ prev_parent = null; constructor(node, fn, config = {}) { config.equals ??= default_dom_value_equality; super(node, config); if (fn !== undefined) { this.fn = isFunction(fn) ? fn : (() => fn); } if ((config?.defer ?? false) === false) { this.get(); } } get(observer_id) { if (this.rid) { this.run(); this.rid = 0; } return super.get(observer_id); } // @ts-ignore: signature is incompatible with super class set(new_value) { const node = this.value, old_value = node.nodeValue; new_value = isFunction(new_value) ? new_value(old_value) : new_value; this.setNodeValue(new_value); return !this.equals(old_value, new_value); } getParentElement() { return this.value.parentElement; } removeFromParentElement() { return this.getParentElement()?.removeChild(this.value) ? true : false; } appendToElement(element) { element.appendChild(this.value); } getNodeValue() { return this.value.nodeValue; } setNodeValue(new_value) { const is_null = new_value === null || new_value === undefined; return (this.value.nodeValue = (is_null ? null : new_value.toString())); } /** detach the DOM Node from its parent. * the most recent parent will be remembered when you call the {@link reattach | `reattach`} method. */ detach() { const current_parent_elem = this.getParentElement(); if (current_parent_elem) { this.prev_parent = current_parent_elem; this.removeFromParentElement(); return true; } return false; } /** reattach the element back to its original parent node. * if the parent node is not available, an optional `fallback_element` will be used. * otherwise, this signal's node won't get attached, and a `false` will be returned. */ reattach(fallback_element) { const element = this.prev_parent ?? fallback_element; if (element) { this.appendToElement(element); return true; } return false; } }; }; export const TextNodeSignal_Factory = (ctx) => { // TODO: implement ctx.onDelete const onDelete = ctx.onDelete; return class TextNodeSignal extends ctx.getClass(DOMSignal_Factory) { constructor(dependency_signal, config) { const text_node = config?.value ?? document.createTextNode(""); super(text_node, dependency_signal, config); } run(forced) { return this.set(this.fn(this.rid)) ? SignalUpdateStatus.UPDATED : SignalUpdateStatus.UNCHANGED; } // TODO: in the `set` method, think whether or not we should remove the text node if the new value is null. // if yes, then you should override the `this.setNodeValue` method, and call the `this.detach` method in there if the null value condition is met. static create(dependency_signal, config) { const new_signal = new this(dependency_signal, config); return [ new_signal.id, new_signal.bindMethod("get"), new_signal.value, ]; } }; }; export const AttrSignal_Factory = (ctx) => { return class AttrNodeSignal extends ctx.getClass(DOMSignal_Factory) { constructor(attribute, dependency_signal, config) { const attr_node = typeof attribute === "string" ? document.createAttribute(attribute) : attribute; super(attr_node, dependency_signal, config); } run(forced) { return this.set(this.fn(this.rid)) ? SignalUpdateStatus.UPDATED : SignalUpdateStatus.UNCHANGED; } getParentElement() { // and attribute node's `parentElement` is always null, even when attached. // we must use `ownerElement` to figure out the node this attribute is attached to. return this.value.ownerElement; } removeFromParentElement() { return this.getParentElement()?.removeAttributeNode(this.value) ? true : false; } appendToElement(element) { element.setAttributeNode(this.value); } setNodeValue(new_value) { // we remove the attribute if the `new_value` is `undefined` or `null`. // otherwise, we stringify the result. // note that an empty string (`""`) will keep the attribute, but without the equals sign. const current_parent_elem = this.getParentElement(), // remember that empty string is also falsey, so we cannot rely on ternary expression should_remove_attr = new_value === undefined || new_value === null; if (should_remove_attr) { super.detach(); } else if (!current_parent_elem) { // the attribute is currently not attached, but the new value is not null, // so we attach it back to its previous most recent parent. this.reattach(); } return super.setNodeValue(new_value); } static create(attribute, dependency_signal, config) { const new_signal = new this(attribute, dependency_signal, config); return [ new_signal.id, new_signal.bindMethod("get"), new_signal.value, ]; } }; }; // TODO: create an example and test this. moreover, maybe add it as an addon to your JSX runtime `h` function. export const HtmlNodeSignal_Factory = (ctx) => { return class HtmlNodeSignal extends ctx.getClass(DOMSignal_Factory) { constructor(element, dependency_signal, config) { const element_node = typeof element === "string" ? document.createElement(element) : element; super(element_node, dependency_signal, config); } run(forced) { return this.set(this.fn(this.rid)) ? SignalUpdateStatus.UPDATED : SignalUpdateStatus.UNCHANGED; } setNodeValue(new_value) { // TODO: for the time being, I am naively assigning the innerHTML, without considering potential side effects. // there might be bad consequences to that. I should look into it later. const element = this.value, new_value_as_node_array = as_DOM_node_array(new_value); if (new_value_as_node_array) { element.replaceChildren(...new_value_as_node_array); return null; } const is_null = new_value === null || new_value === undefined; return (element.innerHTML = (is_null ? "" : new_value.toString())); } static create(element, dependency_signal, config) { const new_signal = new this(element, dependency_signal, config); return [ new_signal.id, new_signal.bindMethod("get"), new_signal.value, ]; } }; }; // TODO: implement this after you've implemented hyperscript scopes // export const EventStateSignal_Factory = (ctx: Context) => { // return class EventStateSignal extends ctx.getClass(SimpleSignal_Factory) { // } // } // // EventListener || EventListenerOrEventListenerObject // const a = new Image() // a.addEventListener("", (s: Event): void => { }, {})