- fixed two way binding

- added support for list like attributes to toggle classes for example
This commit is contained in:
Aeolin Ferjünnoz 2026-04-14 15:00:11 +02:00
parent 0f2874e6df
commit 870630063a
7 changed files with 333 additions and 159 deletions

View File

@ -16,7 +16,7 @@ export class Route {
constructor(pattern, componentClass) { constructor(pattern, componentClass) {
this.id = crypto.randomUUID(); this.id = crypto.randomUUID();
this.componentClass = componentClass; this.componentClass = componentClass;
this.ComponentDefinition = componentClass.definition; this.componentDefinition = componentClass.definition;
this.fragments = pattern.split("/"); this.fragments = pattern.split("/");
} }
@ -27,9 +27,7 @@ export class Route {
params[key] = value; params[key] = value;
}); });
const pathFragments = parsedUrl.pathname const pathFragments = parsedUrl.pathname.split("/");
.split("/")
.filter((f) => f.length > 0);
for (let i = 0; i < this.fragments.length; i++) { for (let i = 0; i < this.fragments.length; i++) {
const fragment = this.fragments[i]; const fragment = this.fragments[i];
@ -144,12 +142,14 @@ export class ComponentDefinition {
templatesPath: null, templatesPath: null,
template: null, template: null,
stylesPath: null, stylesPath: null,
style: null,
scriptsPath: null, scriptsPath: null,
}, },
) { ) {
this.templatesPath = options.templatesPath; this.templatesPath = options.templatesPath;
this.template = options.template; this.template = options.template;
this.stylesPath = options.stylesPath; this.stylesPath = options.stylesPath;
this.style = options.style;
this.scriptsPath = options.scriptsPath; this.scriptsPath = options.scriptsPath;
} }
} }
@ -257,6 +257,7 @@ export class Router {
} }
async handleNavigate(event) { async handleNavigate(event) {
if (event.navigationType === 'replace' && event.destination.url === this.activeRoute) return;
const routeMatch = this.findMatchingRoute(event.destination.url); const routeMatch = this.findMatchingRoute(event.destination.url);
if (!routeMatch) return; if (!routeMatch) return;
@ -270,6 +271,7 @@ export class Router {
this.destroyCurrent(); this.destroyCurrent();
this.activeController = controller; this.activeController = controller;
this.activeTemplate = template; this.activeTemplate = template;
this.activeRoute = event.destination.url;
this.insertIncludes(routeMatch.route); this.insertIncludes(routeMatch.route);
this._boundUpdateHandler = this.handleUpdateRequest.bind(this); this._boundUpdateHandler = this.handleUpdateRequest.bind(this);
@ -279,7 +281,7 @@ export class Router {
); );
this.renderActiveComponent(); this.renderActiveComponent();
history.pushState(null, "", routeMatch.path); history.replaceState({}, "", event.destination.url);
} }
destroyCurrent() { destroyCurrent() {
@ -308,9 +310,17 @@ export class Router {
insertIncludes(route) { insertIncludes(route) {
// Remove previous component styles from shadow root // Remove previous component styles from shadow root
this.shadowRoot this.shadowRoot
.querySelectorAll("link[data-component-style]") .querySelectorAll("link[data-component-style], style[data-component-style]")
.forEach((l) => l.remove()); .forEach((l) => l.remove());
const paths = route.ComponentDefinition.stylesPath;
if (route.componentDefinition.style) {
const styleEl = document.createElement("style");
styleEl.textContent = route.componentDefinition.style;
styleEl.setAttribute("data-component-style", "");
this.shadowRoot.appendChild(styleEl);
}
const paths = route.componentDefinition.stylesPath;
if (!paths) return; if (!paths) return;
const list = Array.isArray(paths) ? paths : [paths]; const list = Array.isArray(paths) ? paths : [paths];
for (const p of list) { for (const p of list) {
@ -400,20 +410,20 @@ export class Router {
const allElements = root.querySelectorAll("*"); const allElements = root.querySelectorAll("*");
allElements.forEach((element) => { allElements.forEach((element) => {
// ─── Two-way bindings from __templateMeta ───
if (element.__templateMeta) {
for (const meta of element.__templateMeta) {
if (meta.bracket === "[()]" && meta.expression) {
const domProp = meta.name.replace(/^\(|\)$/g, ""); // strip parens
this._bindTwoWay(controller, element, domProp, meta.expression);
}
}
}
// ─── Event bindings from DOM attributes ───
for (const attr of Array.from(element.attributes)) { for (const attr of Array.from(element.attributes)) {
const name = attr.name; const name = attr.name;
// ─── Two-way binding: [(prop)]="field" ───
const twoWayMatch = name.match(/^\[\((\w+)\)\]$/);
if (twoWayMatch) {
const domProp = twoWayMatch[1]; // e.g. "value"
const field = attr.value.trim(); // e.g. "name" (controller property)
this._bindTwoWay(controller, element, domProp, field);
continue;
}
// ─── Event binding: (event)="handler()" ───
const eventMatch = name.match(/^\((\w+)\)$/); const eventMatch = name.match(/^\((\w+)\)$/);
if (eventMatch) { if (eventMatch) {
const eventName = eventMatch[1]; const eventName = eventMatch[1];

View File

@ -1,4 +1,5 @@
import "./linq.js"; import "./linq.js";
import { formatDate } from "./utils.js";
// ─── Expression evaluation ─── // ─── Expression evaluation ───
@ -87,33 +88,7 @@ function formatNumber(value, spec) {
return result; return result;
} }
function formatDate(date, spec) {
const pad = (n, l = 2) => String(n).padStart(l, "0");
const tokens = {
yyyy: date.getFullYear(),
yy: String(date.getFullYear()).slice(-2),
MM: pad(date.getMonth() + 1),
M: date.getMonth() + 1,
dd: pad(date.getDate()),
d: date.getDate(),
HH: pad(date.getHours()),
H: date.getHours(),
hh: pad(date.getHours() % 12 || 12),
h: date.getHours() % 12 || 12,
mm: pad(date.getMinutes()),
m: date.getMinutes(),
ss: pad(date.getSeconds()),
s: date.getSeconds(),
fff: pad(date.getMilliseconds(), 3),
};
let result = spec;
for (const [tok, val] of Object.entries(tokens).sort(
(a, b) => b[0].length - a[0].length,
)) {
result = result.replaceAll(tok, String(val));
}
return result;
}
function createFormatter(spec) { function createFormatter(spec) {
return (v) => { return (v) => {
@ -196,6 +171,11 @@ class BindingGraph {
// ─── String helpers ─── // ─── String helpers ───
function skipWhitespace(str, pos) {
while (pos < str.length && /\s/.test(str[pos])) pos++;
return pos;
}
function skipString(str, i) { function skipString(str, i) {
const q = str[i]; const q = str[i];
i++; i++;
@ -260,18 +240,67 @@ function parseExpression(content) {
return { expression: content, formatter: null }; return { expression: content, formatter: null };
} }
// ─────────────────────────────────────────────────
// Instruction classes — parser output
// ─────────────────────────────────────────────────
class TextInstr {
constructor(value) {
this.value = value;
}
}
class ExprInstr {
constructor(expression, formatter) {
this.expression = expression;
this.formatter = formatter;
}
}
class ElementInstr {
constructor(tag, attrs, children) {
this.tag = tag;
this.attrs = attrs;
this.children = children;
}
}
class AttrInstr {
constructor(name, parts, bracket = null) {
this.name = name;
this.parts = parts;
this.bracket = bracket; // null | "[]" | "[()]"
}
}
class ForInstr {
constructor(varNames, expression, trackExpr, children, empty) {
this.varNames = varNames;
this.expression = expression;
this.trackExpr = trackExpr;
this.children = children;
this.empty = empty;
}
}
class IfInstr {
constructor(expression, trueChildren, falseChildren) {
this.expression = expression;
this.trueChildren = trueChildren;
this.falseChildren = falseChildren;
}
}
class LetInstr {
constructor(name, expression) {
this.name = name;
this.expression = expression;
}
}
// ───────────────────────────────────────────────── // ─────────────────────────────────────────────────
// Parser — template string → instruction tree // Parser — template string → instruction tree
// ───────────────────────────────────────────────── // ─────────────────────────────────────────────────
//
// Produces pure-data nodes. No DOM, no strings.
//
// { type: 'text', value }
// { type: 'expr', expression, formatter }
// { type: 'element', tag, attrs:[{name, parts}], children }
// { type: 'for', varNames, expression, trackExpr, children, empty }
// { type: 'if', expression, trueChildren, falseChildren }
// { type: 'let', name, expression }
const VOID_ELEMENTS = new Set([ const VOID_ELEMENTS = new Set([
"area", "area",
@ -366,13 +395,13 @@ function parseText(str, pos) {
if (str[pos] === "{" && str[pos + 1] === "{") { if (str[pos] === "{" && str[pos + 1] === "{") {
if (pos > start) if (pos > start)
nodes.push({ type: "text", value: str.substring(start, pos) }); nodes.push(new TextInstr(str.substring(start, pos)));
const end = str.indexOf("}}", pos + 2); const end = str.indexOf("}}", pos + 2);
if (end === -1) throw new Error("Unclosed {{ }}"); if (end === -1) throw new Error("Unclosed {{ }}");
const { expression, formatter } = parseExpression( const { expression, formatter } = parseExpression(
str.substring(pos + 2, end).trim(), str.substring(pos + 2, end).trim(),
); );
nodes.push({ type: "expr", expression, formatter }); nodes.push(new ExprInstr(expression, formatter));
pos = end + 2; pos = end + 2;
start = pos; start = pos;
continue; continue;
@ -382,7 +411,7 @@ function parseText(str, pos) {
} }
if (pos > start) if (pos > start)
nodes.push({ type: "text", value: str.substring(start, pos) }); nodes.push(new TextInstr(str.substring(start, pos)));
return { nodes, pos }; return { nodes, pos };
} }
@ -393,7 +422,7 @@ function parseAttrValue(str, pos) {
let end = pos; let end = pos;
while (end < str.length && !/[\s\/>]/.test(str[end])) end++; while (end < str.length && !/[\s\/>]/.test(str[end])) end++;
return { return {
parts: [{ type: "text", value: str.substring(pos, end) }], parts: [new TextInstr(str.substring(pos, end))],
pos: end, pos: end,
}; };
} }
@ -405,13 +434,13 @@ function parseAttrValue(str, pos) {
while (pos < str.length && str[pos] !== quote) { while (pos < str.length && str[pos] !== quote) {
if (str[pos] === "{" && str[pos + 1] === "{") { if (str[pos] === "{" && str[pos + 1] === "{") {
if (pos > start) if (pos > start)
parts.push({ type: "text", value: str.substring(start, pos) }); parts.push(new TextInstr(str.substring(start, pos)));
const end = str.indexOf("}}", pos + 2); const end = str.indexOf("}}", pos + 2);
if (end === -1) throw new Error("Unclosed {{ }} in attribute"); if (end === -1) throw new Error("Unclosed {{ }} in attribute");
const { expression, formatter } = parseExpression( const { expression, formatter } = parseExpression(
str.substring(pos + 2, end).trim(), str.substring(pos + 2, end).trim(),
); );
parts.push({ type: "expr", expression, formatter }); parts.push(new ExprInstr(expression, formatter));
pos = end + 2; pos = end + 2;
start = pos; start = pos;
continue; continue;
@ -420,7 +449,7 @@ function parseAttrValue(str, pos) {
} }
if (pos > start) if (pos > start)
parts.push({ type: "text", value: str.substring(start, pos) }); parts.push(new TextInstr(str.substring(start, pos)));
pos++; // closing quote pos++; // closing quote
return { parts, pos }; return { parts, pos };
@ -436,7 +465,7 @@ function parseElement(str, pos) {
const attrs = []; const attrs = [];
while (pos < str.length) { while (pos < str.length) {
while (pos < str.length && /\s/.test(str[pos])) pos++; pos = skipWhitespace(str, pos);
if (str[pos] === ">") { if (str[pos] === ">") {
pos++; pos++;
@ -444,32 +473,51 @@ function parseElement(str, pos) {
} }
if (str[pos] === "/" && str[pos + 1] === ">") { if (str[pos] === "/" && str[pos + 1] === ">") {
pos += 2; pos += 2;
return { node: { type: "element", tag, attrs, children: [] }, pos }; return { node: new ElementInstr(tag, attrs, []), pos };
} }
// Attribute name — supports (event)="..." syntax // Attribute name — supports (event)="...", [attr]="...", [(attr)]="..." syntax
let nameEnd = pos; let nameEnd = pos;
while (nameEnd < str.length && !/[\s=\/>]/.test(str[nameEnd])) nameEnd++; while (nameEnd < str.length && !/[\s=\/>]/.test(str[nameEnd])) nameEnd++;
const attrName = str.substring(pos, nameEnd); const rawAttrName = str.substring(pos, nameEnd);
pos = nameEnd; pos = nameEnd;
while (pos < str.length && /\s/.test(str[pos])) pos++; // [attr]="expr" or [(attr)]="expr" — entire value is a single expression
const bracketMatch = rawAttrName.match(/^\[(\(?\w[\w.\-]*\)?)]/);
const isBracketExpr = bracketMatch !== null;
const attrName = isBracketExpr ? bracketMatch[1] : rawAttrName;
const bracket = isBracketExpr
? (attrName.startsWith("(") && attrName.endsWith(")") ? "[()]" : "[]")
: null;
pos = skipWhitespace(str, pos);
if (str[pos] !== "=") { if (str[pos] !== "=") {
attrs.push({ name: attrName, parts: [] }); // boolean attr attrs.push(new AttrInstr(attrName, [])); // boolean attr
continue; continue;
} }
pos++; // skip = pos++; // skip =
while (pos < str.length && /\s/.test(str[pos])) pos++; pos = skipWhitespace(str, pos);
const r = parseAttrValue(str, pos); if (isBracketExpr) {
attrs.push({ name: attrName, parts: r.parts }); const quote = str[pos];
pos = r.pos; if (quote !== '"' && quote !== "'") throw new Error(`[${attrName}] requires a quoted value`);
pos++; // skip opening quote
const closeQuote = str.indexOf(quote, pos);
if (closeQuote === -1) throw new Error(`Unclosed quote for [${attrName}]`);
const { expression, formatter } = parseExpression(str.substring(pos, closeQuote).trim());
attrs.push(new AttrInstr(attrName, [new ExprInstr(expression, formatter)], bracket));
pos = closeQuote + 1;
} else {
const r = parseAttrValue(str, pos);
attrs.push(new AttrInstr(attrName, r.parts));
pos = r.pos;
}
} }
if (VOID_ELEMENTS.has(tag.toLowerCase())) { if (VOID_ELEMENTS.has(tag.toLowerCase())) {
return { node: { type: "element", tag, attrs, children: [] }, pos }; return { node: new ElementInstr(tag, attrs, []), pos };
} }
const { children, pos: childEnd } = parseChildren(str, pos); const { children, pos: childEnd } = parseChildren(str, pos);
@ -481,7 +529,7 @@ function parseElement(str, pos) {
if (closeEnd !== -1) pos = closeEnd + 1; if (closeEnd !== -1) pos = closeEnd + 1;
} }
return { node: { type: "element", tag, attrs, children }, pos }; return { node: new ElementInstr(tag, attrs, children), pos };
} }
function parseFor(str, pos) { function parseFor(str, pos) {
@ -521,14 +569,7 @@ function parseFor(str, pos) {
} }
return { return {
node: { node: new ForInstr(varNames, rest, trackExpr, children, empty),
type: "for",
varNames,
expression: rest,
trackExpr,
children,
empty,
},
pos: afterBlock, pos: afterBlock,
}; };
} }
@ -555,7 +596,7 @@ function parseIf(str, pos) {
} }
return { return {
node: { type: "if", expression, trueChildren, falseChildren }, node: new IfInstr(expression, trueChildren, falseChildren),
pos: afterBlock, pos: afterBlock,
}; };
} }
@ -567,11 +608,10 @@ function parseLet(str, pos) {
const eqIdx = decl.indexOf("="); const eqIdx = decl.indexOf("=");
if (eqIdx === -1) throw new Error("@let missing ="); if (eqIdx === -1) throw new Error("@let missing =");
return { return {
node: { node: new LetInstr(
type: "let", decl.substring(0, eqIdx).trim(),
name: decl.substring(0, eqIdx).trim(), decl.substring(eqIdx + 1).trim(),
expression: decl.substring(eqIdx + 1).trim(), ),
},
pos: end + 1, pos: end + 1,
}; };
} }
@ -603,7 +643,7 @@ class AttrBinding {
update(ctx) { update(ctx) {
let val = ""; let val = "";
for (const p of this.parts) { for (const p of this.parts) {
if (p.type === "text") { if (p instanceof TextInstr) {
val += p.value; val += p.value;
} else { } else {
const raw = evaluate(ctx, p.expression); const raw = evaluate(ctx, p.expression);
@ -617,6 +657,31 @@ class AttrBinding {
} }
} }
class AttrListBinding {
constructor(element, attrName, attrListPart, expression) {
this.element = element;
this.attrName = attrName;
this.attrListPart = attrListPart.trim();
this.expression = expression;
this.pattern = new RegExp(`\\b${attrListPart}\\b`);
}
update(ctx) {
const val = evaluate(ctx, this.expression);
const attrList = (this.element.getAttribute(this.attrName) ?? "").trim();
const match = this.pattern.exec(attrList);
if (val && !match) {
// works because upper trim() removes all whitespace, so empty string means no attributes
this.element.setAttribute(this.attrName, attrList.length === 0 ? this.attrListPart : `${attrList} ${this.attrListPart}`);
} else if (!val && match) {
const start = match.index;
const end = match.index + this.attrListPart.length;
const newAttrList = attrList.slice(0, start) + attrList.slice(end);
this.element.setAttribute(this.attrName, newAttrList.trim());
}
}
}
class LetBinding { class LetBinding {
constructor(name, expression) { constructor(name, expression) {
this.name = name; this.name = name;
@ -625,7 +690,7 @@ class LetBinding {
declare(scope, ctx) { declare(scope, ctx) {
scope.set(this.name, evaluate(ctx, this.expression)); scope.set(this.name, evaluate(ctx, this.expression));
} }
update() {} update() { }
} }
class ForBinding { class ForBinding {
@ -826,75 +891,85 @@ function instantiate(instructions, ctx, parentScope) {
function buildNodes(instructions, parent, ctx, bindings, graph) { function buildNodes(instructions, parent, ctx, bindings, graph) {
for (const instr of instructions) { for (const instr of instructions) {
switch (instr.type) { if (instr instanceof TextInstr) {
case "text": { parent.appendChild(document.createTextNode(instr.value));
parent.appendChild(document.createTextNode(instr.value)); } else if (instr instanceof ExprInstr) {
break; const node = document.createTextNode("");
} parent.appendChild(node);
const b = new TextBinding(node, instr.expression, instr.formatter);
bindings.push(b);
graph.register(b, extractDeps(instr.expression));
} else if (instr instanceof ElementInstr) {
const el = document.createElement(instr.tag);
case "expr": { for (const attr of instr.attrs) {
const node = document.createTextNode(""); // Bracket attrs ([]/[()]) → stash as metadata, not real DOM attrs
parent.appendChild(node); if (attr.bracket) {
const b = new TextBinding(node, instr.expression, instr.formatter); if (!el.__templateMeta) el.__templateMeta = [];
bindings.push(b); const expr = attr.parts.length === 1 && attr.parts[0] instanceof ExprInstr
graph.register(b, extractDeps(instr.expression)); ? attr.parts[0].expression : null;
break; el.__templateMeta.push({ name: attr.name, bracket: attr.bracket, expression: expr });
}
case "element": { // Still create bindings for [] attrs that affect rendering
const el = document.createElement(instr.tag); if (attr.bracket === "[]") {
for (const attr of instr.attrs) {
const hasDynamic = attr.parts.some((p) => p.type === "expr");
if (!hasDynamic) {
const val =
attr.parts.length === 0
? "" // boolean attr
: attr.parts.map((p) => p.value).join("");
el.setAttribute(attr.name, val);
} else {
const deps = new Set(); const deps = new Set();
for (const p of attr.parts) { for (const p of attr.parts) {
if (p.type === "expr") if (p instanceof ExprInstr) {
for (const d of extractDeps(p.expression)) deps.add(d); for (const d of extractDeps(p.expression)) deps.add(d);
}
}
if (attr.name.includes(".") && attr.parts.length === 1 && attr.parts[0] instanceof ExprInstr) {
const [attrName, attrListPart] = attr.name.split(".");
const b = new AttrListBinding(el, attrName, attrListPart, attr.parts[0].expression);
bindings.push(b);
graph.register(b, deps);
} else {
const b = new AttrBinding(el, attr.name, attr.parts);
bindings.push(b);
graph.register(b, deps);
} }
const b = new AttrBinding(el, attr.name, attr.parts);
bindings.push(b);
graph.register(b, deps);
} }
continue;
} }
buildNodes(instr.children, el, ctx, bindings, graph); const hasDynamic = attr.parts.some((p) => p instanceof ExprInstr);
parent.appendChild(el); if (!hasDynamic) {
break; const val = attr.parts.length === 0 ? "" : attr.parts.map((p) => p.value).join("");
el.setAttribute(attr.name, val);
} else {
const deps = new Set();
for (const p of attr.parts) {
if (p instanceof ExprInstr) {
for (const d of extractDeps(p.expression)) deps.add(d);
}
}
const b = new AttrBinding(el, attr.name, attr.parts);
bindings.push(b);
graph.register(b, deps);
}
} }
case "for": { buildNodes(instr.children, el, ctx, bindings, graph);
const start = document.createComment("for"); parent.appendChild(el);
const end = document.createComment("/for"); } else if (instr instanceof ForInstr) {
parent.appendChild(start); const start = document.createComment("for");
parent.appendChild(end); const end = document.createComment("/for");
const b = new ForBinding(start, end, instr); parent.appendChild(start);
bindings.push(b); parent.appendChild(end);
graph.register(b, extractDeps(instr.expression)); const b = new ForBinding(start, end, instr);
break; bindings.push(b);
} graph.register(b, extractDeps(instr.expression));
} else if (instr instanceof IfInstr) {
case "if": { const start = document.createComment("if");
const start = document.createComment("if"); const end = document.createComment("/if");
const end = document.createComment("/if"); parent.appendChild(start);
parent.appendChild(start); parent.appendChild(end);
parent.appendChild(end); const b = new IfBinding(start, end, instr);
const b = new IfBinding(start, end, instr); bindings.push(b);
bindings.push(b); graph.register(b, extractDeps(instr.expression));
graph.register(b, extractDeps(instr.expression)); } else if (instr instanceof LetInstr) {
break; bindings.push(new LetBinding(instr.name, instr.expression));
}
case "let": {
bindings.push(new LetBinding(instr.name, instr.expression));
break;
}
} }
} }
} }

View File

@ -8,10 +8,46 @@ class TestComponent extends Component {
template: `<div> template: `<div>
<h1>Test Component</h1> <h1>Test Component</h1>
<p>This is a test component.</p> <p>This is a test component.</p>
<button id="test-button" (click)="incrementCount()">Count: {{count()}}</button> <button
id="test-button"
[class.red-button]="count() % 2 === 0"
[class.green-button]="count() % 2 !== 0"
class="counter-button"
(click)="incrementCount()"
>Count: {{count()}}</button>
<input type="text" [(value)]="name" placeholder="Enter your name" /> <input type="text" [(value)]="name" placeholder="Enter your name" />
<p>Hello, {{name()}}! Counter is {{count()}}</p> <p>Hello, {{name()}}! Counter is {{count()}}</p>
</div>`, </div>`,
style: `
.red-button {
background-color: red;
color: white;
border: none;
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
.green-button {
background-color: green;
color: white;
border: none;
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
.counter-button {
border-radius: 10px;
margin: 15px 30px;
font-size: 18px;
transition: opacity 0.3s ease;
}
.counter-button:hover {
opacity: 0.8;
}
`
}); });
} }
@ -24,6 +60,6 @@ class TestComponent extends Component {
} }
} }
const route1 = new Route("counter", TestComponent); const route1 = new Route("/counter", TestComponent);
const route2 = new Route("counter/:count", TestComponent); const route2 = new Route("/counter/:count", TestComponent);
initRouter("routerOutlet", [route1, route2]); initRouter("routerOutlet", [route1, route2]);

View File

@ -136,6 +136,7 @@ export class ComponentDefinition {
templatesPath: null, templatesPath: null,
template: null, template: null,
stylesPath: null, stylesPath: null,
style: null,
scriptsPath: null, scriptsPath: null,
}, },
) { ) {
@ -199,9 +200,9 @@ export class Component extends EventTarget {
throw new Error("Component definition is not defined"); throw new Error("Component definition is not defined");
} }
onInit() {} onInit() { }
onBeforeRender() {} onBeforeRender() { }
onAfterRender() {} onAfterRender() { }
onDestroy() { onDestroy() {
this._eventListeners.forEach((listener) => listener.detach()); this._eventListeners.forEach((listener) => listener.detach());
} }

View File

@ -14,8 +14,32 @@ class TestComponent extends Component {
template: `<div> template: `<div>
<h1>Test Component</h1> <h1>Test Component</h1>
<p>This is a test component.</p> <p>This is a test component.</p>
<button id="test-button" (click)="incrementCount()">Count: {{count}}</button> <button
id="test-button"
(click)="incrementCount()"
[class.green-button]="count % 2 !== 0"
[class.red-button]="count % 2 === 0"
>Count: {{count}}</button>
</div>`, </div>`,
style: `
.red-button {
background-color: red;
color: white;
border: none;
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
.green-button {
background-color: green;
color: white;
border: none;
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
`
}); });
} }
@ -26,6 +50,6 @@ class TestComponent extends Component {
} }
} }
const route1 = new Route("counter", TestComponent); const route1 = new Route("/counter", TestComponent);
const route2 = new Route("counter/:count", TestComponent); const route2 = new Route("/counter/:count", TestComponent);
initRouter("routerOutlet", "styleOutlet", [route1, route2]); initRouter("routerOutlet", "styleOutlet", [route1, route2]);

View File

@ -152,6 +152,34 @@ export function parseDate(input, format) {
return new Date(year, month - 1, day, hour, minute, second, ms); return new Date(year, month - 1, day, hour, minute, second, ms);
} }
export function formatDate(date, spec) {
const pad = (n, l = 2) => String(n).padStart(l, "0");
const tokens = {
yyyy: date.getFullYear(),
yy: String(date.getFullYear()).slice(-2),
MM: pad(date.getMonth() + 1),
M: date.getMonth() + 1,
dd: pad(date.getDate()),
d: date.getDate(),
HH: pad(date.getHours()),
H: date.getHours(),
hh: pad(date.getHours() % 12 || 12),
h: date.getHours() % 12 || 12,
mm: pad(date.getMinutes()),
m: date.getMinutes(),
ss: pad(date.getSeconds()),
s: date.getSeconds(),
fff: pad(date.getMilliseconds(), 3),
};
let result = spec;
for (const [tok, val] of Object.entries(tokens).sort(
(a, b) => b[0].length - a[0].length,
)) {
result = result.replaceAll(tok, String(val));
}
return result;
}
// ─── Input transform presets ─── // ─── Input transform presets ───
export const IntInput = { transform: parseInt }; export const IntInput = { transform: parseInt };

View File

@ -15,9 +15,9 @@
<body> <body>
<div> <div>
<h1>Test Site</h1> <h1>Test Site</h1>
<a href="counter/1">Go to count 1</a> <a href="/counter/1">Go to count 1</a>
<a href="counter/2">Go to count 2</a> <a href="/counter/2">Go to count 2</a>
<a href="counter/3">Go to count 3</a> <a href="/counter/3">Go to count 3</a>
<div id="routerOutlet"> <div id="routerOutlet">
Hello There Hello There
</div> </div>