- 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) {
this.id = crypto.randomUUID();
this.componentClass = componentClass;
this.ComponentDefinition = componentClass.definition;
this.componentDefinition = componentClass.definition;
this.fragments = pattern.split("/");
}
@ -27,9 +27,7 @@ export class Route {
params[key] = value;
});
const pathFragments = parsedUrl.pathname
.split("/")
.filter((f) => f.length > 0);
const pathFragments = parsedUrl.pathname.split("/");
for (let i = 0; i < this.fragments.length; i++) {
const fragment = this.fragments[i];
@ -144,12 +142,14 @@ export class ComponentDefinition {
templatesPath: null,
template: null,
stylesPath: null,
style: null,
scriptsPath: null,
},
) {
this.templatesPath = options.templatesPath;
this.template = options.template;
this.stylesPath = options.stylesPath;
this.style = options.style;
this.scriptsPath = options.scriptsPath;
}
}
@ -257,6 +257,7 @@ export class Router {
}
async handleNavigate(event) {
if (event.navigationType === 'replace' && event.destination.url === this.activeRoute) return;
const routeMatch = this.findMatchingRoute(event.destination.url);
if (!routeMatch) return;
@ -270,6 +271,7 @@ export class Router {
this.destroyCurrent();
this.activeController = controller;
this.activeTemplate = template;
this.activeRoute = event.destination.url;
this.insertIncludes(routeMatch.route);
this._boundUpdateHandler = this.handleUpdateRequest.bind(this);
@ -279,7 +281,7 @@ export class Router {
);
this.renderActiveComponent();
history.pushState(null, "", routeMatch.path);
history.replaceState({}, "", event.destination.url);
}
destroyCurrent() {
@ -308,9 +310,17 @@ export class Router {
insertIncludes(route) {
// Remove previous component styles from shadow root
this.shadowRoot
.querySelectorAll("link[data-component-style]")
.querySelectorAll("link[data-component-style], style[data-component-style]")
.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;
const list = Array.isArray(paths) ? paths : [paths];
for (const p of list) {
@ -400,20 +410,20 @@ export class Router {
const allElements = root.querySelectorAll("*");
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)) {
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+)\)$/);
if (eventMatch) {
const eventName = eventMatch[1];

View File

@ -1,4 +1,5 @@
import "./linq.js";
import { formatDate } from "./utils.js";
// ─── Expression evaluation ───
@ -87,33 +88,7 @@ function formatNumber(value, spec) {
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) {
return (v) => {
@ -196,6 +171,11 @@ class BindingGraph {
// ─── String helpers ───
function skipWhitespace(str, pos) {
while (pos < str.length && /\s/.test(str[pos])) pos++;
return pos;
}
function skipString(str, i) {
const q = str[i];
i++;
@ -260,18 +240,67 @@ function parseExpression(content) {
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
// ─────────────────────────────────────────────────
//
// 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([
"area",
@ -366,13 +395,13 @@ function parseText(str, pos) {
if (str[pos] === "{" && str[pos + 1] === "{") {
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);
if (end === -1) throw new Error("Unclosed {{ }}");
const { expression, formatter } = parseExpression(
str.substring(pos + 2, end).trim(),
);
nodes.push({ type: "expr", expression, formatter });
nodes.push(new ExprInstr(expression, formatter));
pos = end + 2;
start = pos;
continue;
@ -382,7 +411,7 @@ function parseText(str, pos) {
}
if (pos > start)
nodes.push({ type: "text", value: str.substring(start, pos) });
nodes.push(new TextInstr(str.substring(start, pos)));
return { nodes, pos };
}
@ -393,7 +422,7 @@ function parseAttrValue(str, pos) {
let end = pos;
while (end < str.length && !/[\s\/>]/.test(str[end])) end++;
return {
parts: [{ type: "text", value: str.substring(pos, end) }],
parts: [new TextInstr(str.substring(pos, end))],
pos: end,
};
}
@ -405,13 +434,13 @@ function parseAttrValue(str, pos) {
while (pos < str.length && str[pos] !== quote) {
if (str[pos] === "{" && str[pos + 1] === "{") {
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);
if (end === -1) throw new Error("Unclosed {{ }} in attribute");
const { expression, formatter } = parseExpression(
str.substring(pos + 2, end).trim(),
);
parts.push({ type: "expr", expression, formatter });
parts.push(new ExprInstr(expression, formatter));
pos = end + 2;
start = pos;
continue;
@ -420,7 +449,7 @@ function parseAttrValue(str, pos) {
}
if (pos > start)
parts.push({ type: "text", value: str.substring(start, pos) });
parts.push(new TextInstr(str.substring(start, pos)));
pos++; // closing quote
return { parts, pos };
@ -436,7 +465,7 @@ function parseElement(str, pos) {
const attrs = [];
while (pos < str.length) {
while (pos < str.length && /\s/.test(str[pos])) pos++;
pos = skipWhitespace(str, pos);
if (str[pos] === ">") {
pos++;
@ -444,32 +473,51 @@ function parseElement(str, pos) {
}
if (str[pos] === "/" && str[pos + 1] === ">") {
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;
while (nameEnd < str.length && !/[\s=\/>]/.test(str[nameEnd])) nameEnd++;
const attrName = str.substring(pos, nameEnd);
const rawAttrName = str.substring(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] !== "=") {
attrs.push({ name: attrName, parts: [] }); // boolean attr
attrs.push(new AttrInstr(attrName, [])); // boolean attr
continue;
}
pos++; // skip =
while (pos < str.length && /\s/.test(str[pos])) pos++;
pos = skipWhitespace(str, pos);
const r = parseAttrValue(str, pos);
attrs.push({ name: attrName, parts: r.parts });
pos = r.pos;
if (isBracketExpr) {
const quote = str[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())) {
return { node: { type: "element", tag, attrs, children: [] }, pos };
return { node: new ElementInstr(tag, attrs, []), pos };
}
const { children, pos: childEnd } = parseChildren(str, pos);
@ -481,7 +529,7 @@ function parseElement(str, pos) {
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) {
@ -521,14 +569,7 @@ function parseFor(str, pos) {
}
return {
node: {
type: "for",
varNames,
expression: rest,
trackExpr,
children,
empty,
},
node: new ForInstr(varNames, rest, trackExpr, children, empty),
pos: afterBlock,
};
}
@ -555,7 +596,7 @@ function parseIf(str, pos) {
}
return {
node: { type: "if", expression, trueChildren, falseChildren },
node: new IfInstr(expression, trueChildren, falseChildren),
pos: afterBlock,
};
}
@ -567,11 +608,10 @@ function parseLet(str, pos) {
const eqIdx = decl.indexOf("=");
if (eqIdx === -1) throw new Error("@let missing =");
return {
node: {
type: "let",
name: decl.substring(0, eqIdx).trim(),
expression: decl.substring(eqIdx + 1).trim(),
},
node: new LetInstr(
decl.substring(0, eqIdx).trim(),
decl.substring(eqIdx + 1).trim(),
),
pos: end + 1,
};
}
@ -603,7 +643,7 @@ class AttrBinding {
update(ctx) {
let val = "";
for (const p of this.parts) {
if (p.type === "text") {
if (p instanceof TextInstr) {
val += p.value;
} else {
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 {
constructor(name, expression) {
this.name = name;
@ -625,7 +690,7 @@ class LetBinding {
declare(scope, ctx) {
scope.set(this.name, evaluate(ctx, this.expression));
}
update() {}
update() { }
}
class ForBinding {
@ -826,75 +891,85 @@ function instantiate(instructions, ctx, parentScope) {
function buildNodes(instructions, parent, ctx, bindings, graph) {
for (const instr of instructions) {
switch (instr.type) {
case "text": {
parent.appendChild(document.createTextNode(instr.value));
break;
}
if (instr instanceof TextInstr) {
parent.appendChild(document.createTextNode(instr.value));
} else if (instr instanceof ExprInstr) {
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": {
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));
break;
}
for (const attr of instr.attrs) {
// Bracket attrs ([]/[()]) → stash as metadata, not real DOM attrs
if (attr.bracket) {
if (!el.__templateMeta) el.__templateMeta = [];
const expr = attr.parts.length === 1 && attr.parts[0] instanceof ExprInstr
? attr.parts[0].expression : null;
el.__templateMeta.push({ name: attr.name, bracket: attr.bracket, expression: expr });
case "element": {
const el = document.createElement(instr.tag);
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 {
// Still create bindings for [] attrs that affect rendering
if (attr.bracket === "[]") {
const deps = new Set();
for (const p of attr.parts) {
if (p.type === "expr")
if (p instanceof ExprInstr) {
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);
parent.appendChild(el);
break;
const hasDynamic = attr.parts.some((p) => p instanceof ExprInstr);
if (!hasDynamic) {
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": {
const start = document.createComment("for");
const end = document.createComment("/for");
parent.appendChild(start);
parent.appendChild(end);
const b = new ForBinding(start, end, instr);
bindings.push(b);
graph.register(b, extractDeps(instr.expression));
break;
}
case "if": {
const start = document.createComment("if");
const end = document.createComment("/if");
parent.appendChild(start);
parent.appendChild(end);
const b = new IfBinding(start, end, instr);
bindings.push(b);
graph.register(b, extractDeps(instr.expression));
break;
}
case "let": {
bindings.push(new LetBinding(instr.name, instr.expression));
break;
}
buildNodes(instr.children, el, ctx, bindings, graph);
parent.appendChild(el);
} else if (instr instanceof ForInstr) {
const start = document.createComment("for");
const end = document.createComment("/for");
parent.appendChild(start);
parent.appendChild(end);
const b = new ForBinding(start, end, instr);
bindings.push(b);
graph.register(b, extractDeps(instr.expression));
} else if (instr instanceof IfInstr) {
const start = document.createComment("if");
const end = document.createComment("/if");
parent.appendChild(start);
parent.appendChild(end);
const b = new IfBinding(start, end, instr);
bindings.push(b);
graph.register(b, extractDeps(instr.expression));
} else if (instr instanceof LetInstr) {
bindings.push(new LetBinding(instr.name, instr.expression));
}
}
}

View File

@ -8,10 +8,46 @@ class TestComponent extends Component {
template: `<div>
<h1>Test Component</h1>
<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" />
<p>Hello, {{name()}}! Counter is {{count()}}</p>
</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 route2 = new Route("counter/:count", TestComponent);
const route1 = new Route("/counter", TestComponent);
const route2 = new Route("/counter/:count", TestComponent);
initRouter("routerOutlet", [route1, route2]);

View File

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

View File

@ -14,8 +14,32 @@ class TestComponent extends Component {
template: `<div>
<h1>Test Component</h1>
<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>`,
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 route2 = new Route("counter/:count", TestComponent);
const route1 = new Route("/counter", TestComponent);
const route2 = new Route("/counter/:count", TestComponent);
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);
}
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 ───
export const IntInput = { transform: parseInt };

View File

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