From 2e21c70f142c21ce9e02fc02805e2db3012f44f0 Mon Sep 17 00:00:00 2001 From: PatrickJS Date: Thu, 24 Oct 2024 00:07:33 -0700 Subject: [PATCH 001/129] fix: asyncIterator stream --- .../custom-element-signals/src/signal-list.ts | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/custom-element-signals/src/signal-list.ts b/packages/custom-element-signals/src/signal-list.ts index 702e4f9..1f4b65b 100644 --- a/packages/custom-element-signals/src/signal-list.ts +++ b/packages/custom-element-signals/src/signal-list.ts @@ -90,6 +90,23 @@ export class SignalList extends HTMLElement { if (!this._template) return; try { + // Handle async iterators immediately + if ("asyncIterator" in Symbol && + (Symbol as any).asyncIterator in Object(newValue)) { + // Clear existing items for async iterator + this.innerHTML = ""; + this.itemElements.clear(); + this.items = []; + + // Process async iterator values as they arrive + for await (const item of newValue as AsyncIterable) { + this.items.push(item); + this.currentIndex = this.items.length - 1; + this.appendItem(item); + } + return; + } + const newItems: unknown[] = []; if (newValue != null) { @@ -119,7 +136,7 @@ export class SignalList extends HTMLElement { // Handle mixed arrays by treating each item according to its type this.handleMixedArray(newItems); } else { - // Iterator path remains unchanged + // Handle synchronous iterators await this.collectIteratorItems(newValue, newItems); this.innerHTML = ""; this.itemElements.clear(); @@ -268,19 +285,9 @@ export class SignalList extends HTMLElement { ); } - private async collectIteratorItems( - value: unknown, - items: unknown[], - ): Promise { - // es2018 - if ( - "asyncIterator" in Symbol && - (Symbol as any).asyncIterator in Object(value) - ) { - for await (const item of value as AsyncIterable) { - items.push(item); - } - } else if (Symbol.iterator in Object(value)) { + // Update collectIteratorItems to handle only synchronous iterators + private async collectIteratorItems(value: unknown, items: unknown[]): Promise { + if (Symbol.iterator in Object(value)) { for (const item of value as Iterable) { items.push(item); } From b97c93b3f246835a4d57be74c1e3e1407af3403d Mon Sep 17 00:00:00 2001 From: PatrickJS Date: Fri, 25 Oct 2024 14:32:02 -0700 Subject: [PATCH 002/129] fix: signals --- .../custom-element-signals/src/signal-list.ts | 7 + packages/custom-signals/instance.ts | 3 + packages/custom-signals/signal.ts | 10 + packages/custom-signals/signalRegistry.ts | 16 ++ .../resource-management/createResource.js | 41 +++ .../resource-management/deleteResource.js | 13 + .../examples/resource-management/index.html | 268 ++++++++++++++++++ .../resource-management/prevent-default.js | 3 + .../prevent-stop-default.js | 4 + .../resource-management/show-alert.js | 65 +++++ .../resource-management/toggleStatus.js | 17 ++ 11 files changed, 447 insertions(+) create mode 100644 packages/examples/resource-management/createResource.js create mode 100644 packages/examples/resource-management/deleteResource.js create mode 100644 packages/examples/resource-management/index.html create mode 100644 packages/examples/resource-management/prevent-default.js create mode 100644 packages/examples/resource-management/prevent-stop-default.js create mode 100644 packages/examples/resource-management/show-alert.js create mode 100644 packages/examples/resource-management/toggleStatus.js diff --git a/packages/custom-element-signals/src/signal-list.ts b/packages/custom-element-signals/src/signal-list.ts index 1f4b65b..1e92a46 100644 --- a/packages/custom-element-signals/src/signal-list.ts +++ b/packages/custom-element-signals/src/signal-list.ts @@ -32,6 +32,13 @@ export class SignalList extends HTMLElement { constructor() { super(); + // if ((globalThis as any).signalRegistry) { + // console.log("signal-list: using global signalRegistry"); + // this._signalRegistry = (globalThis as any).signalRegistry; + // } else { + // console.log("signal-list: using signalStore"); + // this._signalRegistry = signalStore; + // } this._signalRegistry = window.signalRegistry || signalStore; } diff --git a/packages/custom-signals/instance.ts b/packages/custom-signals/instance.ts index 37fc295..155c9f2 100644 --- a/packages/custom-signals/instance.ts +++ b/packages/custom-signals/instance.ts @@ -1,2 +1,5 @@ import { SignalRegistry } from "./signalRegistry.ts"; export const signalRegistry = new SignalRegistry(); +if (typeof globalThis !== "undefined") { + globalThis.signalRegistry = signalRegistry; +} diff --git a/packages/custom-signals/signal.ts b/packages/custom-signals/signal.ts index 130425b..e667be8 100644 --- a/packages/custom-signals/signal.ts +++ b/packages/custom-signals/signal.ts @@ -19,6 +19,16 @@ export class Signal { this._notifyObservers(oldValue, newVal); } } + get(): T { + return this._value; + } + set(newVal: T) { + this.value = newVal; + } + + notifyObservers() { + this._notifyObservers(this._value, this._value); + } private _notifyObservers(oldValue: T, newValue: T) { for (const observer of this._observers) { diff --git a/packages/custom-signals/signalRegistry.ts b/packages/custom-signals/signalRegistry.ts index c2672ab..0ee7dd2 100644 --- a/packages/custom-signals/signalRegistry.ts +++ b/packages/custom-signals/signalRegistry.ts @@ -16,6 +16,19 @@ export class SignalRegistry { this.registry.set(id, signal); return signal; } + updateOrCreate(id: string, initialValue: T): Signal { + if (this.registry.has(id)) { + const signal = this.registry.get(id) as Signal; + signal.value = initialValue; + return signal; + } + const signal = createSignal(initialValue); + this.registry.set(id, signal); + return signal; + } + set(id: string, value: T): Signal { + return this.getOrCreate(id, value); + } // Why: To get a signal by its ID get(id: string): Signal | undefined { @@ -31,6 +44,9 @@ export class SignalRegistry { remove(id: string): void { this.registry.delete(id); } + delete(id: string): void { + this.registry.delete(id); + } // Why: To clear all signals from the registry clear(): void { diff --git a/packages/examples/resource-management/createResource.js b/packages/examples/resource-management/createResource.js new file mode 100644 index 0000000..a69d221 --- /dev/null +++ b/packages/examples/resource-management/createResource.js @@ -0,0 +1,41 @@ +import { showAlert } from "./show-alert.js"; + +function generateUUID() { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0, + v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +export default function createResource({ element, signals }) { + const titleInput = element.querySelector("#title"); + const detailsInput = element.querySelector("#details"); + const dataInput = element.querySelector("#data"); + const resourcesSignal = signals.get("resources"); + + let parsedData = {}; + try { + parsedData = dataInput.value.trim() ? JSON.parse(dataInput.value) : {}; + } catch (error) { + console.error(error); + showAlert("Invalid JSON data format", "error"); + return; + } + + const newResource = { + code: generateUUID(), + title: titleInput.value.trim(), + details: detailsInput.value.trim(), + data: parsedData, + deactivated: false, + valid: true, + }; + + const resources = resourcesSignal.get(); + resourcesSignal.set([newResource, ...resources]); + + // Reset form + element.reset(); + showAlert("Resource created successfully"); +} diff --git a/packages/examples/resource-management/deleteResource.js b/packages/examples/resource-management/deleteResource.js new file mode 100644 index 0000000..be1beff --- /dev/null +++ b/packages/examples/resource-management/deleteResource.js @@ -0,0 +1,13 @@ +import { showAlert } from './show-alert.js'; + +export default function deleteResource({ element, signals }) { + if (!confirm('Are you sure you want to delete this resource?')) return; + + const code = element.dataset.code; + const resourcesSignal = signals.get("resources"); + const resources = resourcesSignal.get(); + + const updatedResources = resources.filter(resource => resource.code !== code); + resourcesSignal.set(updatedResources); + showAlert('Resource deleted successfully'); +} diff --git a/packages/examples/resource-management/index.html b/packages/examples/resource-management/index.html new file mode 100644 index 0000000..6d95d88 --- /dev/null +++ b/packages/examples/resource-management/index.html @@ -0,0 +1,268 @@ + + + + + + Codestin Search App + + + + + + + + + + + + + + +
+ +
+ +
+ + [] +
+
+ + +
+

Create New Resource

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + +
+

Resources

+ [] +
+ + + + + + + + + + + + + + + + +
+ Code + + Title + + Details + + Data + + Status + + Actions +
+
+
+
+ + + + diff --git a/packages/examples/resource-management/prevent-default.js b/packages/examples/resource-management/prevent-default.js new file mode 100644 index 0000000..279ad3d --- /dev/null +++ b/packages/examples/resource-management/prevent-default.js @@ -0,0 +1,3 @@ +export default function preventStopDefault({ event }) { + event.preventDefault(); +} diff --git a/packages/examples/resource-management/prevent-stop-default.js b/packages/examples/resource-management/prevent-stop-default.js new file mode 100644 index 0000000..794f5b5 --- /dev/null +++ b/packages/examples/resource-management/prevent-stop-default.js @@ -0,0 +1,4 @@ +export default function preventStopDefault({ event }) { + event.preventDefault(); + event.stopPropagation(); +} diff --git a/packages/examples/resource-management/show-alert.js b/packages/examples/resource-management/show-alert.js new file mode 100644 index 0000000..65c4321 --- /dev/null +++ b/packages/examples/resource-management/show-alert.js @@ -0,0 +1,65 @@ +// Queue for managing alerts +const alertQueue = []; +let isProcessing = false; + +// Create alert container if needed and return it +function getAlertContainer() { + let container = document.getElementById("alertContainer"); + if (!container) { + container = document.createElement("div"); + container.id = "alertContainer"; + container.className = "fixed top-4 right-4 z-50 flex flex-col gap-2"; + document.body.appendChild(container); + } + return container; +} + +export async function processAlertQueue() { + if (isProcessing || alertQueue.length === 0) return; + + isProcessing = true; + const container = getAlertContainer(); + + while (alertQueue.length > 0) { + const { message, type } = alertQueue.shift(); + + const alert = document.createElement("div"); + alert.className = `mb-4 px-4 py-3 rounded shadow-md text-white transform transition-all duration-200 ${ + type === "success" ? "bg-green-500" : "bg-red-500" + }`; + alert.textContent = message; + + // Add with animation + alert.style.opacity = "0"; + alert.style.transform = "translateX(100%)"; + container.appendChild(alert); + + // Trigger animation + await new Promise((resolve) => setTimeout(resolve, 50)); + alert.style.opacity = "1"; + alert.style.transform = "translateX(0)"; + + // Wait and remove + await new Promise((resolve) => setTimeout(resolve, 3000)); + alert.style.opacity = "0"; + alert.style.transform = "translateX(100%)"; + + await new Promise((resolve) => setTimeout(resolve, 200)); + container.removeChild(alert); + + if (container.childElementCount === 0) { + container.remove(); + } + } + + isProcessing = false; +} + +// TODO: refactor to signals and async-framework +export function showAlert(message, type = "success") { + const newAlert = { message, type, id: Date.now() }; + + alertQueue.push(newAlert); + console.log("showAlert: alerts", alertQueue.map((alert) => alert.message).join(", ")); + processAlertQueue(); +} diff --git a/packages/examples/resource-management/toggleStatus.js b/packages/examples/resource-management/toggleStatus.js new file mode 100644 index 0000000..3ffb778 --- /dev/null +++ b/packages/examples/resource-management/toggleStatus.js @@ -0,0 +1,17 @@ +import { showAlert } from './show-alert.js'; + +export default function toggleStatus({ element, signals }) { + const code = element.dataset.code; + const resourcesSignal = signals.get("resources"); + const resources = resourcesSignal.get(); + + const updatedResources = resources.map(resource => { + if (resource.code === code) { + return { ...resource, deactivated: !resource.deactivated }; + } + return resource; + }); + + resourcesSignal.set(updatedResources); + showAlert('Resource status updated successfully'); +} From 015fcbb6b82fde9505cff26e497868f3c765c8f8 Mon Sep 17 00:00:00 2001 From: PatrickJS Date: Fri, 25 Oct 2024 16:28:36 -0700 Subject: [PATCH 003/129] refactor: toggle modal --- .../examples/resource-management/index.html | 210 +++++++++++------- .../resource-management/toggleModal.js | 57 +++++ 2 files changed, 181 insertions(+), 86 deletions(-) create mode 100644 packages/examples/resource-management/toggleModal.js diff --git a/packages/examples/resource-management/index.html b/packages/examples/resource-management/index.html index 6d95d88..a6e521c 100644 --- a/packages/examples/resource-management/index.html +++ b/packages/examples/resource-management/index.html @@ -29,6 +29,35 @@ } + + +
@@ -110,106 +139,108 @@

Create New Resource

Resources

[]
- - - - - - - - - - - - - - + + + + {"isOpen": false, "content": null} + + diff --git a/packages/examples/resource-management/toggleModal.js b/packages/examples/resource-management/toggleModal.js new file mode 100644 index 0000000..a7fa69a --- /dev/null +++ b/packages/examples/resource-management/toggleModal.js @@ -0,0 +1,57 @@ +// Format JSON with syntax highlighting +function formatJSON(json) { + if (!json) return ""; + let obj; + try { + obj = typeof json === "string" ? JSON.parse(json) : json; + } catch (error) { + console.error("Error parsing JSON:", error); + return ""; + } + return JSON.stringify(obj, null, 2) + .replace(/&/g, "&") + .replace(//g, ">") + .replace( + /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, + function (match) { + let cls = "text-purple-600"; // number + if (/^"/.test(match)) { + if (/:$/.test(match)) { + cls = "text-gray-700 font-semibold"; // key + } else { + cls = "text-green-600"; // string + } + } else if (/true|false/.test(match)) { + cls = "text-blue-600"; // boolean + } else if (/null/.test(match)) { + cls = "text-red-600"; // null + } + return `${match}`; + } + ); +} + +export default function toggleModal({ event, signals }) { + const button = event.target; + const jsonData = button.dataset.json; + + // Get or initialize the modal state signal + const modalSignal = signals.getOrCreate("modalState", { + isOpen: false, + content: "", + }); + + // Toggle modal and set content + if (!modalSignal.value.isOpen) { + modalSignal.value = { + isOpen: true, + content: formatJSON(jsonData), + }; + } else { + modalSignal.value = { + isOpen: false, + content: "", + }; + } +} From 875aa1daba3f8de30e6cfd47c7d37a5f92863050 Mon Sep 17 00:00:00 2001 From: PatrickJS Date: Fri, 25 Oct 2024 16:37:03 -0700 Subject: [PATCH 004/129] feat: add interpolate to signal list --- .../custom-element-signals/src/signal-list.ts | 70 +++++++++---------- .../resource-management/toggleModal.js | 1 + 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/packages/custom-element-signals/src/signal-list.ts b/packages/custom-element-signals/src/signal-list.ts index 1e92a46..090fa1f 100644 --- a/packages/custom-element-signals/src/signal-list.ts +++ b/packages/custom-element-signals/src/signal-list.ts @@ -348,44 +348,16 @@ export class SignalList extends HTMLElement { ); } - // Replace item placeholders with proper property access - processedTemplate = processedTemplate - // Handle property access first (e.g., ${item.id}, ${item.name}) - .replace( - new RegExp(`\\\${${this.letItem}\\.([^}]+)}`, "g"), - (_, prop) => { - if (typeof item === "object" && item !== null) { - const value = this.getNestedValue( - item as Record, - prop, - ); - return this.escapeHtml(String(value)); - } - return ""; - }, - ) - // Handle array access (e.g., ${item[0]}) - .replace( - new RegExp(`\\\${${this.letItem}\\[([^\\]]+)\\]}`, "g"), - (_, index) => { - if (Array.isArray(item)) { - const value = item[Number(index)]; - return this.escapeHtml(String(value)); - } - return ""; - }, - ) - // Handle direct item references last (e.g., ${item}) - .replace( - new RegExp(`\\\${${this.letItem}}`, "g"), - this.escapeHtml(String(item)), - ); + // Use interpolate method for template processing + processedTemplate = this.interpolate(processedTemplate, { + [this.letItem]: item, + [this.letIndex]: this.currentIndex + }); temp.innerHTML = processedTemplate; const element = temp.content.firstElementChild; if (element instanceof HTMLElement) { - // No need to store patterns in dataset anymore this.updateItemIndex(element, this.currentIndex); this.appendChild(element); const key = this.isPrimitive(item) @@ -417,8 +389,14 @@ export class SignalList extends HTMLElement { // (this as any)._signalRegistry = null; } - private escapeHtml(unsafe: string): string { - return unsafe + private escapeHtml(value: unknown): string { + // Handle null or undefined + if (value == null) return ''; + + // Convert to string if not already + const str = String(value); + + return str .replace(/&/g, "&") .replace(//g, ">") @@ -500,4 +478,26 @@ export class SignalList extends HTMLElement { } return `${path.join(">")}@${attrName}`; } + + private interpolate(template: string, context: Record): string { + return template.replace(/\${([^}]+)}/g, (match, expr) => { + try { + // Handle JSON.stringify specifically + if (expr.includes('JSON.stringify')) { + const objPath = expr.match(/JSON\.stringify\((.*?)\)/)?.[1]; + if (!objPath) return ''; + + const value = new Function(...Object.keys(context), `return ${objPath}`)(...Object.values(context)); + return this.escapeHtml(JSON.stringify(value)); + } + + // Regular expression evaluation + const value = new Function(...Object.keys(context), `return ${expr}`)(...Object.values(context)); + return this.escapeHtml(value); + } catch (error) { + console.error('Error interpolating template:', error); + return ''; + } + }); + } } diff --git a/packages/examples/resource-management/toggleModal.js b/packages/examples/resource-management/toggleModal.js index a7fa69a..d5c5a4f 100644 --- a/packages/examples/resource-management/toggleModal.js +++ b/packages/examples/resource-management/toggleModal.js @@ -35,6 +35,7 @@ function formatJSON(json) { export default function toggleModal({ event, signals }) { const button = event.target; const jsonData = button.dataset.json; + console.log("jsonData", jsonData); // Get or initialize the modal state signal const modalSignal = signals.getOrCreate("modalState", { From d3d781090fef2e206c73edb32995c035b0dddfc4 Mon Sep 17 00:00:00 2001 From: PatrickJS Date: Fri, 25 Oct 2024 16:47:01 -0700 Subject: [PATCH 005/129] feat: signal-html --- packages/custom-element-signals/src/index.ts | 5 +- .../custom-element-signals/src/let-signal.ts | 2 +- .../custom-element-signals/src/signal-html.ts | 87 +++++++++++++++++++ .../custom-element-signals/src/signal-list.ts | 42 +-------- .../src/{ => utils}/parse-attribute-value.ts | 0 .../src/utils/template-utils.ts | 55 ++++++++++++ .../src/web-components.ts | 2 + .../examples/resource-management/index.html | 4 +- 8 files changed, 154 insertions(+), 43 deletions(-) create mode 100644 packages/custom-element-signals/src/signal-html.ts rename packages/custom-element-signals/src/{ => utils}/parse-attribute-value.ts (100%) create mode 100644 packages/custom-element-signals/src/utils/template-utils.ts diff --git a/packages/custom-element-signals/src/index.ts b/packages/custom-element-signals/src/index.ts index eb3916a..ac4a6e4 100644 --- a/packages/custom-element-signals/src/index.ts +++ b/packages/custom-element-signals/src/index.ts @@ -1,6 +1,7 @@ -export * from "./let-signal"; +export * from "./signal-list"; export * from "./signal-text"; -export * from "./signal-store"; +export * from "./let-signal"; +export * from "./signal-html"; export { signalStore } from "./signal-store-instance"; diff --git a/packages/custom-element-signals/src/let-signal.ts b/packages/custom-element-signals/src/let-signal.ts index 84f0e1f..0bffcf1 100644 --- a/packages/custom-element-signals/src/let-signal.ts +++ b/packages/custom-element-signals/src/let-signal.ts @@ -1,4 +1,4 @@ -import { parseAttributeValue } from "./parse-attribute-value"; +import { parseAttributeValue } from "./utils/parse-attribute-value"; import { Signal } from "./signal-store"; import { signalStore } from "./signal-store-instance"; diff --git a/packages/custom-element-signals/src/signal-html.ts b/packages/custom-element-signals/src/signal-html.ts new file mode 100644 index 0000000..20541ce --- /dev/null +++ b/packages/custom-element-signals/src/signal-html.ts @@ -0,0 +1,87 @@ +import { Signal } from "./signal-store"; +import { signalStore } from "./signal-store-instance"; + +export class SignalHtml extends HTMLElement { + static observedAttributes = ["name"]; + + private signal: Signal | null = null; + private cleanUp: (() => void) | null = null; + private template: string | null = null; + private signalRegistry: typeof signalStore; + + constructor() { + super(); + // if ((globalThis as any).signalRegistry) { + // console.log("signal-html: using global signalRegistry"); + // this._signalRegistry = (globalThis as any).signalRegistry; + // } else { + // console.log("signal-html: using signalStore"); + // this._signalRegistry = signalStore; + this.signalRegistry = window.signalRegistry || signalStore; + } + + connectedCallback() { + const name = this.getAttribute("name"); + if (!name) { + throw new Error("signal-html must have a name attribute"); + } + + // Get template content + const templateElement = this.querySelector("template"); + if (templateElement) { + this.template = templateElement.innerHTML; + templateElement.remove(); + } else { + this.template = this.innerHTML; + } + + this.signal = this.signalRegistry.get(name) ?? null; + + if (!this.signal) { + console.warn(`No signal found with name: ${name}`); + return; + } + + // Subscribe to changes + this.cleanUp = this.signal.subscribe((value) => { + this.render(value); + }); + + // Initial render + this.render(this.signal.get()); + } + + private render(value: any) { + if (!this.template) { + // If no template, just render the value directly + this.innerHTML = this.interpolate("${value}", { value }); + return; + } + + // Use template with value as context + this.innerHTML = this.interpolate(this.template, { value, signal: this.signal }); + } + + disconnectedCallback() { + this.cleanUp?.(); + this.signal = null; + } + + private escapeHtml(value: unknown): string { + // Don't escape HTML content - that's the point of signal-html + if (value == null) return ''; + return String(value); + } + + private interpolate(template: string, context: Record): string { + return template.replace(/\${([^}]+)}/g, (match, expr) => { + try { + const value = new Function(...Object.keys(context), `return ${expr}`)(...Object.values(context)); + return this.escapeHtml(value); + } catch (error) { + console.error('Error interpolating template:', error); + return ''; + } + }); + } +} \ No newline at end of file diff --git a/packages/custom-element-signals/src/signal-list.ts b/packages/custom-element-signals/src/signal-list.ts index 090fa1f..2df7d81 100644 --- a/packages/custom-element-signals/src/signal-list.ts +++ b/packages/custom-element-signals/src/signal-list.ts @@ -1,5 +1,6 @@ import { Signal } from "./signal-store"; import { signalStore } from "./signal-store-instance"; +import { interpolateTemplate } from "./utils/template-utils"; export class SignalList extends HTMLElement { static observedAttributes = ["name", "template", "let-item", "let-index"]; @@ -65,6 +66,8 @@ export class SignalList extends HTMLElement { if (templateElement) { templateContent = templateElement.innerHTML; templateElement.remove(); + } else { + templateContent = this.innerHTML; } if (!templateContent) { @@ -349,7 +352,7 @@ export class SignalList extends HTMLElement { } // Use interpolate method for template processing - processedTemplate = this.interpolate(processedTemplate, { + processedTemplate = interpolateTemplate(processedTemplate, { [this.letItem]: item, [this.letIndex]: this.currentIndex }); @@ -389,21 +392,6 @@ export class SignalList extends HTMLElement { // (this as any)._signalRegistry = null; } - private escapeHtml(value: unknown): string { - // Handle null or undefined - if (value == null) return ''; - - // Convert to string if not already - const str = String(value); - - return str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - } - private isArrayLike(value: unknown): boolean { return Array.isArray(value) || (typeof value === "object" && value !== null && "length" in value); @@ -478,26 +466,4 @@ export class SignalList extends HTMLElement { } return `${path.join(">")}@${attrName}`; } - - private interpolate(template: string, context: Record): string { - return template.replace(/\${([^}]+)}/g, (match, expr) => { - try { - // Handle JSON.stringify specifically - if (expr.includes('JSON.stringify')) { - const objPath = expr.match(/JSON\.stringify\((.*?)\)/)?.[1]; - if (!objPath) return ''; - - const value = new Function(...Object.keys(context), `return ${objPath}`)(...Object.values(context)); - return this.escapeHtml(JSON.stringify(value)); - } - - // Regular expression evaluation - const value = new Function(...Object.keys(context), `return ${expr}`)(...Object.values(context)); - return this.escapeHtml(value); - } catch (error) { - console.error('Error interpolating template:', error); - return ''; - } - }); - } } diff --git a/packages/custom-element-signals/src/parse-attribute-value.ts b/packages/custom-element-signals/src/utils/parse-attribute-value.ts similarity index 100% rename from packages/custom-element-signals/src/parse-attribute-value.ts rename to packages/custom-element-signals/src/utils/parse-attribute-value.ts diff --git a/packages/custom-element-signals/src/utils/template-utils.ts b/packages/custom-element-signals/src/utils/template-utils.ts new file mode 100644 index 0000000..243892d --- /dev/null +++ b/packages/custom-element-signals/src/utils/template-utils.ts @@ -0,0 +1,55 @@ +/** + * Escapes HTML special characters in a string + * @param value - The value to escape + * @param escapeHtml - Whether to escape HTML characters (default: true) + */ +export function escapeValue(value: unknown, escapeHtml = true): string { + // Handle null or undefined + if (value == null) return ''; + + // Convert to string if not already + const str = String(value); + + if (!escapeHtml) return str; + + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Interpolates a template string with values from a context object + * @param template - The template string containing ${expressions} + * @param context - Object containing values for interpolation + * @param options - Configuration options + */ +export function interpolateTemplate( + template: string, + context: Record, + options: { escapeHtml?: boolean } = {} +): string { + const { escapeHtml = true } = options; + + return template.replace(/\${([^}]+)}/g, (match, expr) => { + try { + // Handle JSON.stringify specifically + if (expr.includes('JSON.stringify')) { + const objPath = expr.match(/JSON\.stringify\((.*?)\)/)?.[1]; + if (!objPath) return ''; + + const value = new Function(...Object.keys(context), `return ${objPath}`)(...Object.values(context)); + return escapeValue(JSON.stringify(value), escapeHtml); + } + + // Regular expression evaluation + const value = new Function(...Object.keys(context), `return ${expr}`)(...Object.values(context)); + return escapeValue(value, escapeHtml); + } catch (error) { + console.error('Error interpolating template:', error); + return ''; + } + }); +} diff --git a/packages/custom-element-signals/src/web-components.ts b/packages/custom-element-signals/src/web-components.ts index b950b86..69768bc 100644 --- a/packages/custom-element-signals/src/web-components.ts +++ b/packages/custom-element-signals/src/web-components.ts @@ -1,7 +1,9 @@ import { LetSignal } from "./let-signal"; import { SignalText } from "./signal-text"; import { SignalList } from "./signal-list"; +import { SignalHtml } from "./signal-html"; customElements.define("let-signal", LetSignal); customElements.define("signal-text", SignalText); customElements.define("signal-list", SignalList); +customElements.define("signal-html", SignalHtml); diff --git a/packages/examples/resource-management/index.html b/packages/examples/resource-management/index.html index a6e521c..7768073 100644 --- a/packages/examples/resource-management/index.html +++ b/packages/examples/resource-management/index.html @@ -59,7 +59,7 @@ - +
-
+
[]
From efe76d8eeb660f8bb44182227cd68991f60ae67a Mon Sep 17 00:00:00 2001 From: PatrickJS Date: Fri, 25 Oct 2024 18:16:30 -0700 Subject: [PATCH 006/129] refactor: template registry --- .../custom-element-signals/src/signal-html.ts | 86 +++++++------- .../custom-element-signals/src/signal-list.ts | 48 +++----- .../src/signal-modal.ts | 107 ++++++++++++++++++ .../src/utils/template-helpers.ts | 34 ++++++ .../src/utils/template-registry.ts | 32 ++++++ .../src/utils/template-utils.ts | 47 +++++++- .../src/web-components.ts | 2 + .../examples/resource-management/index.html | 38 ++----- .../resource-management/toggleModal.js | 7 +- 9 files changed, 288 insertions(+), 113 deletions(-) create mode 100644 packages/custom-element-signals/src/signal-modal.ts create mode 100644 packages/custom-element-signals/src/utils/template-helpers.ts create mode 100644 packages/custom-element-signals/src/utils/template-registry.ts diff --git a/packages/custom-element-signals/src/signal-html.ts b/packages/custom-element-signals/src/signal-html.ts index 20541ce..c983c94 100644 --- a/packages/custom-element-signals/src/signal-html.ts +++ b/packages/custom-element-signals/src/signal-html.ts @@ -1,41 +1,50 @@ import { Signal } from "./signal-store"; import { signalStore } from "./signal-store-instance"; +import { interpolateTemplate, transformTemplate, generateTemplateId } from "./utils/template-utils"; +import { getOrCreateTemplate } from "./utils/template-registry"; +import { getTemplateContent } from "./utils/template-helpers"; export class SignalHtml extends HTMLElement { - static observedAttributes = ["name"]; - + static observedAttributes = ["name", "template-id"]; + attributes!: NamedNodeMap & { + name: { value: string }; + "template-id"?: { value: string }; + }; + private signal: Signal | null = null; private cleanUp: (() => void) | null = null; - private template: string | null = null; - private signalRegistry: typeof signalStore; + private template: string = ''; + private _signalRegistry: typeof signalStore; constructor() { super(); - // if ((globalThis as any).signalRegistry) { - // console.log("signal-html: using global signalRegistry"); - // this._signalRegistry = (globalThis as any).signalRegistry; - // } else { - // console.log("signal-html: using signalStore"); - // this._signalRegistry = signalStore; - this.signalRegistry = window.signalRegistry || signalStore; + if ((globalThis as any).signalRegistry) { + // console.log("signal-html: using global signalRegistry"); + this._signalRegistry = (globalThis as any).signalRegistry; + } else { + // console.log("signal-html: using signalStore"); + this._signalRegistry = signalStore; + } + } - + connectedCallback() { - const name = this.getAttribute("name"); + if (!this.isConnected) return; + const name = this.attributes["name"]?.value; if (!name) { throw new Error("signal-html must have a name attribute"); } - // Get template content - const templateElement = this.querySelector("template"); - if (templateElement) { - this.template = templateElement.innerHTML; - templateElement.remove(); + const templateId = this.attributes["template-id"]?.value || generateTemplateId(this); + const content = getTemplateContent(this, templateId, "signal-html"); + + if (content) { + this.template = transformTemplate(content); } else { - this.template = this.innerHTML; + return; } - this.signal = this.signalRegistry.get(name) ?? null; + this.signal = this._signalRegistry.get(name) ?? null; if (!this.signal) { console.warn(`No signal found with name: ${name}`); @@ -48,40 +57,21 @@ export class SignalHtml extends HTMLElement { }); // Initial render - this.render(this.signal.get()); + this.render(); } - private render(value: any) { - if (!this.template) { - // If no template, just render the value directly - this.innerHTML = this.interpolate("${value}", { value }); - return; + private render(value?: unknown) { + try { + const signalValue = value || this.signal?.get() || {}; + const context = { $this: signalValue }; + this.innerHTML = interpolateTemplate(this.template, context, { escapeHtml: false }); + } catch (error) { + this.innerHTML = ''; // Clear content on error } - - // Use template with value as context - this.innerHTML = this.interpolate(this.template, { value, signal: this.signal }); } disconnectedCallback() { this.cleanUp?.(); this.signal = null; } - - private escapeHtml(value: unknown): string { - // Don't escape HTML content - that's the point of signal-html - if (value == null) return ''; - return String(value); - } - - private interpolate(template: string, context: Record): string { - return template.replace(/\${([^}]+)}/g, (match, expr) => { - try { - const value = new Function(...Object.keys(context), `return ${expr}`)(...Object.values(context)); - return this.escapeHtml(value); - } catch (error) { - console.error('Error interpolating template:', error); - return ''; - } - }); - } -} \ No newline at end of file +} diff --git a/packages/custom-element-signals/src/signal-list.ts b/packages/custom-element-signals/src/signal-list.ts index 2df7d81..6df931d 100644 --- a/packages/custom-element-signals/src/signal-list.ts +++ b/packages/custom-element-signals/src/signal-list.ts @@ -1,6 +1,8 @@ import { Signal } from "./signal-store"; import { signalStore } from "./signal-store-instance"; -import { interpolateTemplate } from "./utils/template-utils"; +import { interpolateTemplate, getAttributeKey, generateTemplateId } from "./utils/template-utils"; +import { getOrCreateTemplate } from "./utils/template-registry"; +import { getTemplateContent } from "./utils/template-helpers"; export class SignalList extends HTMLElement { static observedAttributes = ["name", "template", "let-item", "let-index"]; @@ -52,31 +54,22 @@ export class SignalList extends HTMLElement { connectedCallback() { const name = this.getAttribute("name"); + const templateId = this.getAttribute("template-id") || generateTemplateId(this); + if (!name) { throw new Error("signal-list must have a name attribute"); } - this.letItem = this.getAttribute("let-item") || "item"; - this.letIndex = this.getAttribute("let-index") || "index"; - - // Get template - let templateContent = this.getAttribute("template"); - const templateElement = this.querySelector("template"); - - if (templateElement) { - templateContent = templateElement.innerHTML; - templateElement.remove(); + const content = getTemplateContent(this, templateId, "signal-list"); + if (content) { + this._template = content; } else { - templateContent = this.innerHTML; + return; } - if (!templateContent) { - throw new Error( - "signal-list must have a template attribute or template child element", - ); - } + this.letItem = this.getAttribute("let-item") || "item"; + this.letIndex = this.getAttribute("let-index") || "index"; - this._template = templateContent; this.signal = this._signalRegistry.get(name) ?? null; if (!this.signal) { @@ -315,7 +308,7 @@ export class SignalList extends HTMLElement { if (this.templateInfo.hasIndexInAttributes && this._attributePatterns) { this.walkElements(element, (el) => { Array.from(el.attributes || []).forEach((attr) => { - const key = this.getAttributeKey(el, attr.name); + const key = getAttributeKey(el, attr.name); const pattern = this._attributePatterns[key]; if (pattern) { const value = pattern.pattern.replace( @@ -326,7 +319,7 @@ export class SignalList extends HTMLElement { } }); }); - } + }); } private appendItem(item: unknown): void { @@ -433,7 +426,7 @@ export class SignalList extends HTMLElement { const attributes = Array.from(element.attributes || []); attributes.forEach((attr) => { if (attr.value.includes(`\${${this.letIndex}}`)) { - const key = this.getAttributeKey(element, attr.name); + const key = getAttributeKey(element, attr.name); this._attributePatterns[key] = { element, attributeName: attr.name, @@ -453,17 +446,4 @@ export class SignalList extends HTMLElement { `\${${this.letIndex}}`, ); } - - // Helper to generate a unique key for each attribute - private getAttributeKey(element: Element, attrName: string): string { - // Create a path to the element - const path: string[] = []; - let current = element; - while (current.parentElement) { - const index = Array.from(current.parentElement.children).indexOf(current); - path.unshift(`${current.tagName}:${index}`); - current = current.parentElement; - } - return `${path.join(">")}@${attrName}`; - } } diff --git a/packages/custom-element-signals/src/signal-modal.ts b/packages/custom-element-signals/src/signal-modal.ts new file mode 100644 index 0000000..1398603 --- /dev/null +++ b/packages/custom-element-signals/src/signal-modal.ts @@ -0,0 +1,107 @@ +import { Signal } from "./signal-store"; +import { signalStore } from "./signal-store-instance"; +import { getOrCreateTemplate } from "./utils/template-registry"; +import { generateTemplateId } from "./utils/template-utils"; +import { getTemplateContent } from "./utils/template-helpers"; + +export class SignalModal extends HTMLElement { + static observedAttributes = ["name", "watch"]; + + private signal: Signal | null = null; + private cleanUp: (() => void) | null = null; + private signalRegistry: typeof signalStore; + private handleKeyDown: ((e: KeyboardEvent) => void) | null = null; + private handleClickOutside: ((e: MouseEvent) => void) | null = null; + private modalId: string; + + constructor() { + super(); + this.signalRegistry = window.signalRegistry || signalStore; + this.modalId = `modal-${Math.random().toString(36).substr(2, 9)}`; + } + + connectedCallback() { + const name = this.getAttribute("name"); + const watchProp = this.getAttribute("watch") || "isOpen"; + const templateId = this.getAttribute("template-id") || generateTemplateId(this); + + if (!name) { + throw new Error("signal-modal must have a name attribute"); + } + + const content = getTemplateContent(this, templateId, "signal-modal"); + if (!content) { + return; + } + + // Create modal wrapper with template content + this.innerHTML = ` + + `; + + // Get modal element using ID + const modalElement = document.getElementById(this.modalId) as HTMLElement; + + // Subscribe to signal changes + this.signal = this.signalRegistry.get(name) ?? null; + if (!this.signal) { + console.warn(`No signal found with name: ${name}`); + return; + } + + this.cleanUp = this.signal.subscribe((state) => { + if (modalElement) { + modalElement.classList.toggle('hidden', !state[watchProp]); + } + }); + + // Create bound event handlers that we can remove later + this.handleClickOutside = (e: MouseEvent) => { + if (e.target === modalElement) { + this.signal?.set({ + ...this.signal.get(), + [watchProp]: false + }); + } + }; + + this.handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && this.signal?.get()[watchProp]) { + this.signal.set({ + ...this.signal.get(), + [watchProp]: false + }); + } + }; + + // Add event listeners + modalElement?.addEventListener('click', this.handleClickOutside); + window.addEventListener('keydown', this.handleKeyDown); + } + + disconnectedCallback() { + // Clean up signal subscription + this.cleanUp?.(); + this.signal = null; + + // Clean up event listeners + if (this.handleKeyDown) { + window.removeEventListener('keydown', this.handleKeyDown); + this.handleKeyDown = null; + } + + const modalElement = document.getElementById(this.modalId); + if (modalElement && this.handleClickOutside) { + modalElement.removeEventListener('click', this.handleClickOutside as EventListener); + this.handleClickOutside = null; + } + } +} diff --git a/packages/custom-element-signals/src/utils/template-helpers.ts b/packages/custom-element-signals/src/utils/template-helpers.ts new file mode 100644 index 0000000..859a48f --- /dev/null +++ b/packages/custom-element-signals/src/utils/template-helpers.ts @@ -0,0 +1,34 @@ +import { getOrCreateTemplate } from "./template-registry"; + +// Why: Provides consistent template handling and warning messages across components +export function getTemplateContent( + element: HTMLElement, + templateId: string | null, + componentName: string +): string | null { + // Try template registry first if templateId exists + const registryTemplate = getOrCreateTemplate(templateId, () => { + const templateElement = element.querySelector("template"); + if (templateElement) { + const content = templateElement.innerHTML; + templateElement.remove(); + return content; + } + // Use innerHTML if available + if (element.innerHTML.trim()) { + return element.innerHTML; + } + return ''; + }); + if (registryTemplate) { + return registryTemplate; + } + + // Warn if no template found + console.warn(`${componentName} must have either: + 1. A template element with id="${templateId}" + 2. An inline
- Code - - Title - - Details - - Data - - Status - - Actions -