Refactor signal and effect functions for clarity
New fetch, storage, web sockets,
This commit is contained in:
291
sigpro.js
291
sigpro.js
@@ -28,12 +28,12 @@ const flushEffectQueue = () => {
|
|||||||
*/
|
*/
|
||||||
export const $ = (initialValue) => {
|
export const $ = (initialValue) => {
|
||||||
const subscribers = new Set();
|
const subscribers = new Set();
|
||||||
|
|
||||||
if (typeof initialValue === "function") {
|
if (typeof initialValue === "function") {
|
||||||
// Computed signal case
|
// Computed signal case
|
||||||
let isDirty = true;
|
let isDirty = true;
|
||||||
let cachedValue;
|
let cachedValue;
|
||||||
|
|
||||||
const computedEffect = {
|
const computedEffect = {
|
||||||
dependencies: new Set(),
|
dependencies: new Set(),
|
||||||
cleanupHandlers: new Set(),
|
cleanupHandlers: new Set(),
|
||||||
@@ -50,7 +50,7 @@ export const $ = (initialValue) => {
|
|||||||
// Clear old dependencies
|
// Clear old dependencies
|
||||||
computedEffect.dependencies.forEach((dependencySet) => dependencySet.delete(computedEffect));
|
computedEffect.dependencies.forEach((dependencySet) => dependencySet.delete(computedEffect));
|
||||||
computedEffect.dependencies.clear();
|
computedEffect.dependencies.clear();
|
||||||
|
|
||||||
const previousEffect = activeEffect;
|
const previousEffect = activeEffect;
|
||||||
activeEffect = computedEffect;
|
activeEffect = computedEffect;
|
||||||
try {
|
try {
|
||||||
@@ -61,7 +61,7 @@ export const $ = (initialValue) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (activeEffect) {
|
if (activeEffect) {
|
||||||
subscribers.add(activeEffect);
|
subscribers.add(activeEffect);
|
||||||
@@ -71,7 +71,7 @@ export const $ = (initialValue) => {
|
|||||||
return cachedValue;
|
return cachedValue;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular signal case
|
// Regular signal case
|
||||||
return (...args) => {
|
return (...args) => {
|
||||||
if (args.length) {
|
if (args.length) {
|
||||||
@@ -101,7 +101,7 @@ export const $ = (initialValue) => {
|
|||||||
* @param {Function} effectFn - The effect function to run
|
* @param {Function} effectFn - The effect function to run
|
||||||
* @returns {Function} Cleanup function to stop the effect
|
* @returns {Function} Cleanup function to stop the effect
|
||||||
*/
|
*/
|
||||||
export const $$ = (effectFn) => {
|
export const $effect = (effectFn) => {
|
||||||
const effect = {
|
const effect = {
|
||||||
dependencies: new Set(),
|
dependencies: new Set(),
|
||||||
cleanupHandlers: new Set(),
|
cleanupHandlers: new Set(),
|
||||||
@@ -109,11 +109,11 @@ export const $$ = (effectFn) => {
|
|||||||
// Run cleanup handlers
|
// Run cleanup handlers
|
||||||
this.cleanupHandlers.forEach((handler) => handler());
|
this.cleanupHandlers.forEach((handler) => handler());
|
||||||
this.cleanupHandlers.clear();
|
this.cleanupHandlers.clear();
|
||||||
|
|
||||||
// Clear old dependencies
|
// Clear old dependencies
|
||||||
this.dependencies.forEach((dependencySet) => dependencySet.delete(this));
|
this.dependencies.forEach((dependencySet) => dependencySet.delete(this));
|
||||||
this.dependencies.clear();
|
this.dependencies.clear();
|
||||||
|
|
||||||
const previousEffect = activeEffect;
|
const previousEffect = activeEffect;
|
||||||
activeEffect = this;
|
activeEffect = this;
|
||||||
try {
|
try {
|
||||||
@@ -129,12 +129,37 @@ export const $$ = (effectFn) => {
|
|||||||
this.cleanupFunction?.();
|
this.cleanupFunction?.();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (activeEffect) activeEffect.cleanupHandlers.add(() => effect.stop());
|
if (activeEffect) activeEffect.cleanupHandlers.add(() => effect.stop());
|
||||||
effect.run();
|
effect.run();
|
||||||
return () => effect.stop();
|
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
|
* Tagged template literal for creating reactive HTML
|
||||||
* @param {string[]} strings - Template strings
|
* @param {string[]} strings - Template strings
|
||||||
@@ -143,15 +168,14 @@ export const $$ = (effectFn) => {
|
|||||||
*/
|
*/
|
||||||
export const html = (strings, ...values) => {
|
export const html = (strings, ...values) => {
|
||||||
const templateCache = html._templateCache ?? (html._templateCache = new WeakMap());
|
const templateCache = html._templateCache ?? (html._templateCache = new WeakMap());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets a node by path from root
|
* Gets a node by path from root
|
||||||
* @param {Node} root - Root node
|
* @param {Node} root - Root node
|
||||||
* @param {number[]} path - Path indices
|
* @param {number[]} path - Path indices
|
||||||
* @returns {Node} Target node
|
* @returns {Node} Target node
|
||||||
*/
|
*/
|
||||||
const getNodeByPath = (root, path) =>
|
const getNodeByPath = (root, path) => path.reduce((node, index) => node?.childNodes?.[index], root);
|
||||||
path.reduce((node, index) => node?.childNodes?.[index], root);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies reactive text content to a node
|
* Applies reactive text content to a node
|
||||||
@@ -173,30 +197,28 @@ export const html = (strings, ...values) => {
|
|||||||
parent.insertBefore(endMarker, node);
|
parent.insertBefore(endMarker, node);
|
||||||
|
|
||||||
let lastResult;
|
let lastResult;
|
||||||
$$(() => {
|
$effect(() => {
|
||||||
let result = typeof currentValue === "function" ? currentValue() : currentValue;
|
let result = typeof currentValue === "function" ? currentValue() : currentValue;
|
||||||
if (result === lastResult) return;
|
if (result === lastResult) return;
|
||||||
lastResult = result;
|
lastResult = result;
|
||||||
|
|
||||||
if (typeof result !== "object" && !Array.isArray(result)) {
|
if (typeof result !== "object" && !Array.isArray(result)) {
|
||||||
const textNode = startMarker.nextSibling;
|
const textNode = startMarker.nextSibling;
|
||||||
if (textNode !== endMarker && textNode?.nodeType === 3) {
|
if (textNode !== endMarker && textNode?.nodeType === 3) {
|
||||||
textNode.textContent = result ?? "";
|
textNode.textContent = result ?? "";
|
||||||
} else {
|
} else {
|
||||||
while (startMarker.nextSibling !== endMarker)
|
while (startMarker.nextSibling !== endMarker) parent.removeChild(startMarker.nextSibling);
|
||||||
parent.removeChild(startMarker.nextSibling);
|
|
||||||
parent.insertBefore(document.createTextNode(result ?? ""), endMarker);
|
parent.insertBefore(document.createTextNode(result ?? ""), endMarker);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle arrays or objects
|
// Handle arrays or objects
|
||||||
while (startMarker.nextSibling !== endMarker)
|
while (startMarker.nextSibling !== endMarker) parent.removeChild(startMarker.nextSibling);
|
||||||
parent.removeChild(startMarker.nextSibling);
|
|
||||||
|
|
||||||
const items = Array.isArray(result) ? result : [result];
|
const items = Array.isArray(result) ? result : [result];
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
items.forEach(item => {
|
items.forEach((item) => {
|
||||||
if (item == null || item === false) return;
|
if (item == null || item === false) return;
|
||||||
const nodeItem = item instanceof Node ? item : document.createTextNode(item);
|
const nodeItem = item instanceof Node ? item : document.createTextNode(item);
|
||||||
fragment.appendChild(nodeItem);
|
fragment.appendChild(nodeItem);
|
||||||
@@ -213,10 +235,10 @@ export const html = (strings, ...values) => {
|
|||||||
if (!cachedTemplate) {
|
if (!cachedTemplate) {
|
||||||
const template = document.createElement("template");
|
const template = document.createElement("template");
|
||||||
template.innerHTML = strings.join("{{part}}");
|
template.innerHTML = strings.join("{{part}}");
|
||||||
|
|
||||||
const dynamicNodes = [];
|
const dynamicNodes = [];
|
||||||
const treeWalker = document.createTreeWalker(template.content, 133); // NodeFilter.SHOW_ALL
|
const treeWalker = document.createTreeWalker(template.content, 133); // NodeFilter.SHOW_ALL
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets path indices for a node
|
* Gets path indices for a node
|
||||||
* @param {Node} node - Target node
|
* @param {Node} node - Target node
|
||||||
@@ -226,24 +248,24 @@ export const html = (strings, ...values) => {
|
|||||||
const path = [];
|
const path = [];
|
||||||
while (node && node !== template.content) {
|
while (node && node !== template.content) {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
for (let sibling = node.previousSibling; sibling; sibling = sibling.previousSibling)
|
for (let sibling = node.previousSibling; sibling; sibling = sibling.previousSibling) index++;
|
||||||
index++;
|
|
||||||
path.push(index);
|
path.push(index);
|
||||||
node = node.parentNode;
|
node = node.parentNode;
|
||||||
}
|
}
|
||||||
return path.reverse();
|
return path.reverse();
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentNode;
|
let currentNode;
|
||||||
while ((currentNode = treeWalker.nextNode())) {
|
while ((currentNode = treeWalker.nextNode())) {
|
||||||
let isDynamic = false;
|
let isDynamic = false;
|
||||||
const nodeInfo = {
|
const nodeInfo = {
|
||||||
type: currentNode.nodeType,
|
type: currentNode.nodeType,
|
||||||
path: getNodePath(currentNode),
|
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++) {
|
for (let i = 0; i < currentNode.attributes.length; i++) {
|
||||||
const attribute = currentNode.attributes[i];
|
const attribute = currentNode.attributes[i];
|
||||||
if (attribute.value.includes("{{part}}")) {
|
if (attribute.value.includes("{{part}}")) {
|
||||||
@@ -255,44 +277,75 @@ export const html = (strings, ...values) => {
|
|||||||
// Text node
|
// Text node
|
||||||
isDynamic = true;
|
isDynamic = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDynamic) dynamicNodes.push(nodeInfo);
|
if (isDynamic) dynamicNodes.push(nodeInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
templateCache.set(strings, (cachedTemplate = { template, dynamicNodes }));
|
templateCache.set(strings, (cachedTemplate = { template, dynamicNodes }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const fragment = cachedTemplate.template.content.cloneNode(true);
|
const fragment = cachedTemplate.template.content.cloneNode(true);
|
||||||
let valueIndex = 0;
|
let valueIndex = 0;
|
||||||
|
|
||||||
// Get target nodes before applyTextContent modifies the DOM
|
// Get target nodes before applyTextContent modifies the DOM
|
||||||
const targets = cachedTemplate.dynamicNodes.map((nodeInfo) => ({
|
const targets = cachedTemplate.dynamicNodes.map((nodeInfo) => ({
|
||||||
node: getNodeByPath(fragment, nodeInfo.path),
|
node: getNodeByPath(fragment, nodeInfo.path),
|
||||||
info: nodeInfo
|
info: nodeInfo,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
targets.forEach(({ node, info }) => {
|
targets.forEach(({ node, info }) => {
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
if (info.type === 1) { // Element node
|
if (info.type === 1) {
|
||||||
|
// Element node
|
||||||
info.parts.forEach((part) => {
|
info.parts.forEach((part) => {
|
||||||
const currentValue = values[valueIndex++];
|
const currentValue = values[valueIndex++];
|
||||||
const attributeName = part.name;
|
const attributeName = part.name;
|
||||||
const firstChar = attributeName[0];
|
const firstChar = attributeName[0];
|
||||||
|
|
||||||
if (firstChar === "@") {
|
if (firstChar === "@") {
|
||||||
// Event listener
|
const [eventName, ...modifiers] = attributeName.slice(1).split(".");
|
||||||
node.addEventListener(attributeName.slice(1), currentValue);
|
|
||||||
|
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 === ":") {
|
} else if (firstChar === ":") {
|
||||||
// Two-way binding
|
// Two-way binding
|
||||||
const propertyName = attributeName.slice(1);
|
const propertyName = attributeName.slice(1);
|
||||||
const eventType = node.type === "checkbox" || node.type === "radio" ? "change" : "input";
|
const eventType = node.type === "checkbox" || node.type === "radio" ? "change" : "input";
|
||||||
|
|
||||||
$$(() => {
|
$effect(() => {
|
||||||
const value = typeof currentValue === "function" ? currentValue() : currentValue;
|
const value = typeof currentValue === "function" ? currentValue() : currentValue;
|
||||||
if (node[propertyName] !== value) node[propertyName] = value;
|
if (node[propertyName] !== value) node[propertyName] = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
node.addEventListener(eventType, () => {
|
node.addEventListener(eventType, () => {
|
||||||
const value = eventType === "change" ? node.checked : node.value;
|
const value = eventType === "change" ? node.checked : node.value;
|
||||||
if (typeof currentValue === "function") currentValue(value);
|
if (typeof currentValue === "function") currentValue(value);
|
||||||
@@ -300,14 +353,14 @@ export const html = (strings, ...values) => {
|
|||||||
} else if (firstChar === "?") {
|
} else if (firstChar === "?") {
|
||||||
// Boolean attribute
|
// Boolean attribute
|
||||||
const attrName = attributeName.slice(1);
|
const attrName = attributeName.slice(1);
|
||||||
$$(() => {
|
$effect(() => {
|
||||||
const result = typeof currentValue === "function" ? currentValue() : currentValue;
|
const result = typeof currentValue === "function" ? currentValue() : currentValue;
|
||||||
node.toggleAttribute(attrName, !!result);
|
node.toggleAttribute(attrName, !!result);
|
||||||
});
|
});
|
||||||
} else if (firstChar === ".") {
|
} else if (firstChar === ".") {
|
||||||
// Property binding
|
// Property binding
|
||||||
const propertyName = attributeName.slice(1);
|
const propertyName = attributeName.slice(1);
|
||||||
$$(() => {
|
$effect(() => {
|
||||||
let result = typeof currentValue === "function" ? currentValue() : currentValue;
|
let result = typeof currentValue === "function" ? currentValue() : currentValue;
|
||||||
node[propertyName] = result;
|
node[propertyName] = result;
|
||||||
if (result != null && typeof result !== "object" && typeof result !== "boolean") {
|
if (result != null && typeof result !== "object" && typeof result !== "boolean") {
|
||||||
@@ -317,19 +370,20 @@ export const html = (strings, ...values) => {
|
|||||||
} else {
|
} else {
|
||||||
// Regular attribute
|
// Regular attribute
|
||||||
if (typeof currentValue === "function") {
|
if (typeof currentValue === "function") {
|
||||||
$$(() => node.setAttribute(attributeName, currentValue()));
|
$effect(() => node.setAttribute(attributeName, currentValue()));
|
||||||
} else {
|
} else {
|
||||||
node.setAttribute(attributeName, currentValue);
|
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;
|
const placeholderCount = node.textContent.split("{{part}}").length - 1;
|
||||||
applyTextContent(node, values.slice(valueIndex, valueIndex + placeholderCount));
|
applyTextContent(node, values.slice(valueIndex, valueIndex + placeholderCount));
|
||||||
valueIndex += placeholderCount;
|
valueIndex += placeholderCount;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return fragment;
|
return fragment;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -341,28 +395,28 @@ export const html = (strings, ...values) => {
|
|||||||
*/
|
*/
|
||||||
export const $component = (tagName, setupFunction, observedAttributes = []) => {
|
export const $component = (tagName, setupFunction, observedAttributes = []) => {
|
||||||
if (customElements.get(tagName)) return;
|
if (customElements.get(tagName)) return;
|
||||||
|
|
||||||
customElements.define(
|
customElements.define(
|
||||||
tagName,
|
tagName,
|
||||||
class extends HTMLElement {
|
class extends HTMLElement {
|
||||||
static get observedAttributes() {
|
static get observedAttributes() {
|
||||||
return observedAttributes;
|
return observedAttributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this._propertySignals = {};
|
this._propertySignals = {};
|
||||||
this.cleanupFunctions = [];
|
this.cleanupFunctions = [];
|
||||||
observedAttributes.forEach((attr) => (this._propertySignals[attr] = $(undefined)));
|
observedAttributes.forEach((attr) => (this._propertySignals[attr] = $(undefined)));
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
const frozenChildren = [...this.childNodes];
|
const frozenChildren = [...this.childNodes];
|
||||||
this.innerHTML = "";
|
this.innerHTML = "";
|
||||||
|
|
||||||
observedAttributes.forEach((attr) => {
|
observedAttributes.forEach((attr) => {
|
||||||
const initialValue = this.hasOwnProperty(attr) ? this[attr] : this.getAttribute(attr);
|
const initialValue = this.hasOwnProperty(attr) ? this[attr] : this.getAttribute(attr);
|
||||||
|
|
||||||
Object.defineProperty(this, attr, {
|
Object.defineProperty(this, attr, {
|
||||||
get: () => this._propertySignals[attr](),
|
get: () => this._propertySignals[attr](),
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
@@ -371,10 +425,10 @@ export const $component = (tagName, setupFunction, observedAttributes = []) => {
|
|||||||
},
|
},
|
||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (initialValue !== null && initialValue !== undefined) this[attr] = initialValue;
|
if (initialValue !== null && initialValue !== undefined) this[attr] = initialValue;
|
||||||
});
|
});
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
select: (selector) => this.querySelector(selector),
|
select: (selector) => this.querySelector(selector),
|
||||||
slot: (name) =>
|
slot: (name) =>
|
||||||
@@ -386,15 +440,15 @@ export const $component = (tagName, setupFunction, observedAttributes = []) => {
|
|||||||
host: this,
|
host: this,
|
||||||
onUnmount: (cleanupFn) => this.cleanupFunctions.push(cleanupFn),
|
onUnmount: (cleanupFn) => this.cleanupFunctions.push(cleanupFn),
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = setupFunction(this._propertySignals, context);
|
const result = setupFunction(this._propertySignals, context);
|
||||||
if (result instanceof Node) this.appendChild(result);
|
if (result instanceof Node) this.appendChild(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
attributeChangedCallback(name, oldValue, newValue) {
|
attributeChangedCallback(name, oldValue, newValue) {
|
||||||
if (this[name] !== newValue) this[name] = newValue;
|
if (this[name] !== newValue) this[name] = newValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
this.cleanupFunctions.forEach((cleanupFn) => cleanupFn());
|
this.cleanupFunctions.forEach((cleanupFn) => cleanupFn());
|
||||||
this.cleanupFunctions = [];
|
this.cleanupFunctions = [];
|
||||||
@@ -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
|
* Creates a router for hash-based navigation
|
||||||
* @param {Array<{path: string|RegExp, component: Function}>} routes - Route configurations
|
* @param {Array<{path: string|RegExp, component: Function}>} routes - Route configurations
|
||||||
@@ -414,21 +492,21 @@ export const $router = (routes) => {
|
|||||||
* @returns {string} Current path
|
* @returns {string} Current path
|
||||||
*/
|
*/
|
||||||
const getCurrentPath = () => window.location.hash.replace(/^#/, "") || "/";
|
const getCurrentPath = () => window.location.hash.replace(/^#/, "") || "/";
|
||||||
|
|
||||||
const currentPath = $(getCurrentPath());
|
const currentPath = $(getCurrentPath());
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
container.style.display = "contents";
|
container.style.display = "contents";
|
||||||
|
|
||||||
window.addEventListener("hashchange", () => {
|
window.addEventListener("hashchange", () => {
|
||||||
const nextPath = getCurrentPath();
|
const nextPath = getCurrentPath();
|
||||||
if (currentPath() !== nextPath) currentPath(nextPath);
|
if (currentPath() !== nextPath) currentPath(nextPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
$$(() => {
|
$effect(() => {
|
||||||
const path = currentPath();
|
const path = currentPath();
|
||||||
let matchedRoute = null;
|
let matchedRoute = null;
|
||||||
let routeParams = {};
|
let routeParams = {};
|
||||||
|
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
if (route.path instanceof RegExp) {
|
if (route.path instanceof RegExp) {
|
||||||
const match = path.match(route.path);
|
const match = path.match(route.path);
|
||||||
@@ -442,25 +520,23 @@ export const $router = (routes) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousEffect = activeEffect;
|
const previousEffect = activeEffect;
|
||||||
activeEffect = null;
|
activeEffect = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const view = matchedRoute
|
const view = matchedRoute
|
||||||
? matchedRoute.component(routeParams)
|
? matchedRoute.component(routeParams)
|
||||||
: html`
|
: html`
|
||||||
<h1>404</h1>
|
<h1>404</h1>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
container.replaceChildren(
|
container.replaceChildren(view instanceof Node ? view : document.createTextNode(view ?? ""));
|
||||||
view instanceof Node ? view : document.createTextNode(view ?? "")
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
activeEffect = previousEffect;
|
activeEffect = previousEffect;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -471,4 +547,77 @@ export const $router = (routes) => {
|
|||||||
$router.go = (path) => {
|
$router.go = (path) => {
|
||||||
const targetPath = path.startsWith("/") ? path : `/${path}`;
|
const targetPath = path.startsWith("/") ? path : `/${path}`;
|
||||||
if (window.location.hash !== `#${targetPath}`) window.location.hash = targetPath;
|
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