@oazmi/tsignal
Version:
a topological order respecting signals library inspired by SolidJS
199 lines (198 loc) • 8.9 kB
JavaScript
/** 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 => { }, {})