Refactor signal and effect functions for clarity
New fetch, storage, web sockets,
This commit is contained in:
201
sigpro.js
201
sigpro.js
@@ -101,7 +101,7 @@ export const $ = (initialValue) => {
|
||||
* @param {Function} effectFn - The effect function to run
|
||||
* @returns {Function} Cleanup function to stop the effect
|
||||
*/
|
||||
export const $$ = (effectFn) => {
|
||||
export const $effect = (effectFn) => {
|
||||
const effect = {
|
||||
dependencies: new Set(),
|
||||
cleanupHandlers: new Set(),
|
||||
@@ -135,6 +135,31 @@ export const $$ = (effectFn) => {
|
||||
return () => effect.stop();
|
||||
};
|
||||
|
||||
/**
|
||||
* Persistent signal with localStorage
|
||||
* @param {string} key - Storage key
|
||||
* @param {any} initialValue - Default value if none stored
|
||||
* @param {Storage} [storage=localStorage] - Storage type (localStorage/sessionStorage)
|
||||
* @returns {Function} Signal that persists to storage
|
||||
*/
|
||||
export const $$ = (key, initialValue, storage = localStorage) => {
|
||||
// Load saved value
|
||||
const saved = storage.getItem(key);
|
||||
const signal = $(saved !== null ? JSON.parse(saved) : initialValue);
|
||||
|
||||
// Auto-save on changes
|
||||
$effect(() => {
|
||||
const value = signal();
|
||||
if (value === undefined || value === null) {
|
||||
storage.removeItem(key);
|
||||
} else {
|
||||
storage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
});
|
||||
|
||||
return signal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tagged template literal for creating reactive HTML
|
||||
* @param {string[]} strings - Template strings
|
||||
@@ -150,8 +175,7 @@ export const html = (strings, ...values) => {
|
||||
* @param {number[]} path - Path indices
|
||||
* @returns {Node} Target node
|
||||
*/
|
||||
const getNodeByPath = (root, path) =>
|
||||
path.reduce((node, index) => node?.childNodes?.[index], root);
|
||||
const getNodeByPath = (root, path) => path.reduce((node, index) => node?.childNodes?.[index], root);
|
||||
|
||||
/**
|
||||
* Applies reactive text content to a node
|
||||
@@ -173,7 +197,7 @@ export const html = (strings, ...values) => {
|
||||
parent.insertBefore(endMarker, node);
|
||||
|
||||
let lastResult;
|
||||
$$(() => {
|
||||
$effect(() => {
|
||||
let result = typeof currentValue === "function" ? currentValue() : currentValue;
|
||||
if (result === lastResult) return;
|
||||
lastResult = result;
|
||||
@@ -183,20 +207,18 @@ export const html = (strings, ...values) => {
|
||||
if (textNode !== endMarker && textNode?.nodeType === 3) {
|
||||
textNode.textContent = result ?? "";
|
||||
} else {
|
||||
while (startMarker.nextSibling !== endMarker)
|
||||
parent.removeChild(startMarker.nextSibling);
|
||||
while (startMarker.nextSibling !== endMarker) parent.removeChild(startMarker.nextSibling);
|
||||
parent.insertBefore(document.createTextNode(result ?? ""), endMarker);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle arrays or objects
|
||||
while (startMarker.nextSibling !== endMarker)
|
||||
parent.removeChild(startMarker.nextSibling);
|
||||
while (startMarker.nextSibling !== endMarker) parent.removeChild(startMarker.nextSibling);
|
||||
|
||||
const items = Array.isArray(result) ? result : [result];
|
||||
const fragment = document.createDocumentFragment();
|
||||
items.forEach(item => {
|
||||
items.forEach((item) => {
|
||||
if (item == null || item === false) return;
|
||||
const nodeItem = item instanceof Node ? item : document.createTextNode(item);
|
||||
fragment.appendChild(nodeItem);
|
||||
@@ -226,8 +248,7 @@ export const html = (strings, ...values) => {
|
||||
const path = [];
|
||||
while (node && node !== template.content) {
|
||||
let index = 0;
|
||||
for (let sibling = node.previousSibling; sibling; sibling = sibling.previousSibling)
|
||||
index++;
|
||||
for (let sibling = node.previousSibling; sibling; sibling = sibling.previousSibling) index++;
|
||||
path.push(index);
|
||||
node = node.parentNode;
|
||||
}
|
||||
@@ -240,10 +261,11 @@ export const html = (strings, ...values) => {
|
||||
const nodeInfo = {
|
||||
type: currentNode.nodeType,
|
||||
path: getNodePath(currentNode),
|
||||
parts: []
|
||||
parts: [],
|
||||
};
|
||||
|
||||
if (currentNode.nodeType === 1) { // Element node
|
||||
if (currentNode.nodeType === 1) {
|
||||
// Element node
|
||||
for (let i = 0; i < currentNode.attributes.length; i++) {
|
||||
const attribute = currentNode.attributes[i];
|
||||
if (attribute.value.includes("{{part}}")) {
|
||||
@@ -268,27 +290,58 @@ export const html = (strings, ...values) => {
|
||||
// Get target nodes before applyTextContent modifies the DOM
|
||||
const targets = cachedTemplate.dynamicNodes.map((nodeInfo) => ({
|
||||
node: getNodeByPath(fragment, nodeInfo.path),
|
||||
info: nodeInfo
|
||||
info: nodeInfo,
|
||||
}));
|
||||
|
||||
targets.forEach(({ node, info }) => {
|
||||
if (!node) return;
|
||||
|
||||
if (info.type === 1) { // Element node
|
||||
if (info.type === 1) {
|
||||
// Element node
|
||||
info.parts.forEach((part) => {
|
||||
const currentValue = values[valueIndex++];
|
||||
const attributeName = part.name;
|
||||
const firstChar = attributeName[0];
|
||||
|
||||
if (firstChar === "@") {
|
||||
// Event listener
|
||||
node.addEventListener(attributeName.slice(1), currentValue);
|
||||
const [eventName, ...modifiers] = attributeName.slice(1).split(".");
|
||||
|
||||
const handlerWrapper = (e) => {
|
||||
if (modifiers.includes("prevent")) e.preventDefault();
|
||||
if (modifiers.includes("stop")) e.stopPropagation();
|
||||
if (modifiers.includes("self") && e.target !== node) return;
|
||||
|
||||
// Debounce
|
||||
if (modifiers.some((m) => m.startsWith("debounce"))) {
|
||||
const ms = modifiers.find((m) => m.startsWith("debounce"))?.split(":")[1] || 300;
|
||||
clearTimeout(node._debounceTimer);
|
||||
node._debounceTimer = setTimeout(() => currentValue(e), ms);
|
||||
return;
|
||||
}
|
||||
|
||||
// Once (auto-remover)
|
||||
if (modifiers.includes("once")) {
|
||||
node.removeEventListener(eventName, handlerWrapper);
|
||||
}
|
||||
|
||||
currentValue(e);
|
||||
};
|
||||
|
||||
node.addEventListener(eventName, handlerWrapper, {
|
||||
passive: modifiers.includes("passive"),
|
||||
capture: modifiers.includes("capture"),
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
if ($effect.onCleanup) {
|
||||
$effect.onCleanup(() => node.removeEventListener(eventName, handlerWrapper));
|
||||
}
|
||||
} else if (firstChar === ":") {
|
||||
// Two-way binding
|
||||
const propertyName = attributeName.slice(1);
|
||||
const eventType = node.type === "checkbox" || node.type === "radio" ? "change" : "input";
|
||||
|
||||
$$(() => {
|
||||
$effect(() => {
|
||||
const value = typeof currentValue === "function" ? currentValue() : currentValue;
|
||||
if (node[propertyName] !== value) node[propertyName] = value;
|
||||
});
|
||||
@@ -300,14 +353,14 @@ export const html = (strings, ...values) => {
|
||||
} else if (firstChar === "?") {
|
||||
// Boolean attribute
|
||||
const attrName = attributeName.slice(1);
|
||||
$$(() => {
|
||||
$effect(() => {
|
||||
const result = typeof currentValue === "function" ? currentValue() : currentValue;
|
||||
node.toggleAttribute(attrName, !!result);
|
||||
});
|
||||
} else if (firstChar === ".") {
|
||||
// Property binding
|
||||
const propertyName = attributeName.slice(1);
|
||||
$$(() => {
|
||||
$effect(() => {
|
||||
let result = typeof currentValue === "function" ? currentValue() : currentValue;
|
||||
node[propertyName] = result;
|
||||
if (result != null && typeof result !== "object" && typeof result !== "boolean") {
|
||||
@@ -317,13 +370,14 @@ export const html = (strings, ...values) => {
|
||||
} else {
|
||||
// Regular attribute
|
||||
if (typeof currentValue === "function") {
|
||||
$$(() => node.setAttribute(attributeName, currentValue()));
|
||||
$effect(() => node.setAttribute(attributeName, currentValue()));
|
||||
} else {
|
||||
node.setAttribute(attributeName, currentValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (info.type === 3) { // Text node
|
||||
} else if (info.type === 3) {
|
||||
// Text node
|
||||
const placeholderCount = node.textContent.split("{{part}}").length - 1;
|
||||
applyTextContent(node, values.slice(valueIndex, valueIndex + placeholderCount));
|
||||
valueIndex += placeholderCount;
|
||||
@@ -403,6 +457,30 @@ export const $component = (tagName, setupFunction, observedAttributes = []) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Ultra-simple fetch wrapper with optional loading signal
|
||||
* @param {string} url - Endpoint URL
|
||||
* @param {Object} data - Data to send (automatically JSON.stringify'd)
|
||||
* @param {Function} [loading] - Optional signal function to track loading state
|
||||
* @returns {Promise<Object|null>} Parsed JSON response or null on error
|
||||
*/
|
||||
export const $fetch = async (url, data, loading) => {
|
||||
if (loading) loading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
return null;
|
||||
} finally {
|
||||
if (loading) loading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a router for hash-based navigation
|
||||
* @param {Array<{path: string|RegExp, component: Function}>} routes - Route configurations
|
||||
@@ -424,7 +502,7 @@ export const $router = (routes) => {
|
||||
if (currentPath() !== nextPath) currentPath(nextPath);
|
||||
});
|
||||
|
||||
$$(() => {
|
||||
$effect(() => {
|
||||
const path = currentPath();
|
||||
let matchedRoute = null;
|
||||
let routeParams = {};
|
||||
@@ -453,9 +531,7 @@ export const $router = (routes) => {
|
||||
<h1>404</h1>
|
||||
`;
|
||||
|
||||
container.replaceChildren(
|
||||
view instanceof Node ? view : document.createTextNode(view ?? "")
|
||||
);
|
||||
container.replaceChildren(view instanceof Node ? view : document.createTextNode(view ?? ""));
|
||||
} finally {
|
||||
activeEffect = previousEffect;
|
||||
}
|
||||
@@ -472,3 +548,76 @@ $router.go = (path) => {
|
||||
const targetPath = path.startsWith("/") ? path : `/${path}`;
|
||||
if (window.location.hash !== `#${targetPath}`) window.location.hash = targetPath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple WebSocket wrapper with signals
|
||||
* @param {string} url - WebSocket URL
|
||||
* @param {Object} options - Auto-reconnect options
|
||||
* @returns {Object} WebSocket with reactive signals
|
||||
*/
|
||||
export const $ws = (url, options = {}) => {
|
||||
const {
|
||||
reconnect = true,
|
||||
maxReconnect = 5,
|
||||
reconnectInterval = 1000
|
||||
} = options;
|
||||
|
||||
const status = $('disconnected');
|
||||
const messages = $([]);
|
||||
const error = $(null);
|
||||
|
||||
let ws = null;
|
||||
let reconnectAttempts = 0;
|
||||
let reconnectTimer = null;
|
||||
|
||||
const connect = () => {
|
||||
status('connecting');
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
status('connected');
|
||||
reconnectAttempts = 0;
|
||||
error(null);
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
messages([...messages(), e.data]);
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
error(err);
|
||||
status('error');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
status('disconnected');
|
||||
|
||||
if (reconnect && reconnectAttempts < maxReconnect) {
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectAttempts++;
|
||||
connect();
|
||||
}, reconnectInterval * Math.pow(2, reconnectAttempts));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const send = (data) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(typeof data === 'string' ? data : JSON.stringify(data));
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
if ($effect?.onCleanup) $e.onCleanup(close);
|
||||
|
||||
return { status, messages, error, send, close };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user