- seems to be working

- added some utility
This commit is contained in:
Aeolin Ferjünnoz 2026-04-13 21:19:07 +02:00
parent 11ebf22ec9
commit 0f2874e6df
3 changed files with 209 additions and 33 deletions

View File

@ -211,9 +211,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._eventBindings.forEach((b) => b.detach()); this._eventBindings.forEach((b) => b.detach());
} }
@ -221,26 +221,21 @@ export class Component extends EventTarget {
// ─── Router init ─── // ─── Router init ───
export function initRouter(outletId, includeId, routes) { export function initRouter(outletId, routes) {
const outletRef = document.getElementById(outletId); const outletRef = document.getElementById(outletId);
const includeRef = document.getElementById(includeId);
if (!outletRef) { if (!outletRef) {
console.error(`Outlet element with id '${outletId}' not found`); console.error(`Outlet element with id '${outletId}' not found`);
return null; return null;
} }
if (!includeRef) { return new Router(outletRef, routes);
console.error(`Include element with id '${includeId}' not found`);
return null;
}
return new Router(outletRef, includeRef, routes);
} }
// ─── Router ─── // ─── Router ───
export class Router { export class Router {
constructor(outletRef, includeRef, routes) { constructor(outletRef, routes) {
this.outletRef = outletRef; this.outletRef = outletRef;
this.includeRef = includeRef; this.shadowRoot = outletRef.attachShadow({ mode: "open" });
this.routes = routes; this.routes = routes;
this.templateCache = new Map(); this.templateCache = new Map();
this.activeController = null; this.activeController = null;
@ -248,8 +243,7 @@ export class Router {
this._boundUpdateHandler = null; this._boundUpdateHandler = null;
this._rendering = false; this._rendering = false;
this._pendingUpdate = false; this._pendingUpdate = false;
outletRef.textContent = "Loading..."; this.shadowRoot.textContent = "Loading...";
includeRef.innerHTML = "";
navigation.addEventListener("navigate", this.handleNavigate.bind(this)); navigation.addEventListener("navigate", this.handleNavigate.bind(this));
} }
@ -312,7 +306,10 @@ export class Router {
} }
insertIncludes(route) { insertIncludes(route) {
this.includeRef.innerHTML = ""; // Remove previous component styles from shadow root
this.shadowRoot
.querySelectorAll("link[data-component-style]")
.forEach((l) => l.remove());
const paths = route.ComponentDefinition.stylesPath; 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];
@ -320,7 +317,8 @@ export class Router {
const link = document.createElement("link"); const link = document.createElement("link");
link.rel = "stylesheet"; link.rel = "stylesheet";
link.href = p; link.href = p;
this.includeRef.appendChild(link); link.setAttribute("data-component-style", "");
this.shadowRoot.appendChild(link);
} }
} }
@ -335,13 +333,20 @@ export class Router {
const result = this.activeTemplate.render(this.activeController); const result = this.activeTemplate.render(this.activeController);
this.activeRenderResult = result; this.activeRenderResult = result;
// Clear outlet, append fragment // Clear shadow root content (keep style links), append fragment
this.outletRef.textContent = ""; for (const child of Array.from(this.shadowRoot.childNodes)) {
this.outletRef.appendChild(result.fragment); if (
child.nodeType !== Node.ELEMENT_NODE ||
!child.hasAttribute("data-component-style")
) {
child.remove();
}
}
this.shadowRoot.appendChild(result.fragment);
// Wire events + two-way bindings on the live DOM // Wire events + two-way bindings on the live shadow DOM
this.wireEvents(this.activeController, this.outletRef); this.wireEvents(this.activeController, this.shadowRoot);
this.wireViewChildren(this.activeController, this.outletRef); this.wireViewChildren(this.activeController, this.shadowRoot);
this.activeController.onAfterRender(); this.activeController.onAfterRender();
this._rendering = false; this._rendering = false;

View File

@ -1,11 +1,5 @@
import { import { initRouter, Route, ViewChild, Component, ComponentInput, ComponentDefinition } from "./node-pwa.js";
initRouter, import { IntInput } from "./utils.js";
Route,
ViewChild,
Component,
ComponentInput,
ComponentDefinition,
} from "./node-pwa.js";
class TestComponent extends Component { class TestComponent extends Component {
static get definition() { static get definition() {
@ -21,14 +15,15 @@ class TestComponent extends Component {
}); });
} }
count = new ComponentInput(0); count = new ComponentInput(0, IntInput);
name = new ComponentInput("World"); name = new ComponentInput("World");
incrementCount() { incrementCount() {
this.count++; const currentCount = this.count();
this.count.set(currentCount + 1);
} }
} }
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", [route1, route2]);

View File

@ -0,0 +1,176 @@
// ─── parseDate (C# DateTime.ParseExact style) ───
// Parses a date string using an exact format pattern.
// Supported tokens:
// yyyy — 4-digit year
// yy — 2-digit year (assumes 2000+)
// MM — 2-digit month (0112)
// M — 1 or 2-digit month
// dd — 2-digit day (0131)
// d — 1 or 2-digit day
// HH — 2-digit hour 24h (0023)
// H — 1 or 2-digit hour 24h
// hh — 2-digit hour 12h (0112)
// h — 1 or 2-digit hour 12h
// mm — 2-digit minute (0059)
// m — 1 or 2-digit minute
// ss — 2-digit second (0059)
// s — 1 or 2-digit second
// fff — 3-digit milliseconds
// ff — 2-digit (tenths + hundredths)
// f — 1-digit (tenths)
// tt — AM/PM
// t — A/P
// Z — literal 'Z' (UTC marker)
// K — timezone offset (+HH:mm, -HH:mm, or Z)
//
// All other characters are matched literally.
// Returns Date on success, null on failure.
const TOKEN_ORDER = [
"yyyy", "yy",
"MM", "M",
"dd", "d",
"HH", "H", "hh", "h",
"mm", "m",
"ss", "s",
"fff", "ff", "f",
"tt", "t",
"K", "Z",
];
const TOKEN_PATTERNS = {
yyyy: "(\\d{4})",
yy: "(\\d{2})",
MM: "(\\d{2})",
M: "(\\d{1,2})",
dd: "(\\d{2})",
d: "(\\d{1,2})",
HH: "(\\d{2})",
H: "(\\d{1,2})",
hh: "(\\d{2})",
h: "(\\d{1,2})",
mm: "(\\d{2})",
m: "(\\d{1,2})",
ss: "(\\d{2})",
s: "(\\d{1,2})",
fff: "(\\d{3})",
ff: "(\\d{2})",
f: "(\\d{1})",
tt: "(AM|PM|am|pm)",
t: "([AaPp])",
K: "(Z|[+-]\\d{2}:\\d{2})",
Z: "(Z)",
};
function buildFormatRegex(format) {
const groups = [];
let regexStr = "";
let i = 0;
while (i < format.length) {
let matched = false;
for (const token of TOKEN_ORDER) {
if (format.startsWith(token, i)) {
groups.push(token);
regexStr += TOKEN_PATTERNS[token];
i += token.length;
matched = true;
break;
}
}
if (!matched) {
// Escape literal character
regexStr += format[i].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
i++;
}
}
return { regex: new RegExp("^" + regexStr + "$"), groups };
}
export function parseDate(input, format) {
if (input == null || format == null) return null;
if (input instanceof Date) return input;
const str = String(input);
const { regex, groups } = buildFormatRegex(format);
const match = str.match(regex);
if (!match) return null;
let year = 0, month = 1, day = 1;
let hour = 0, minute = 0, second = 0, ms = 0;
let isPM = false, isAM = false;
let tzOffset = null; // minutes
for (let g = 0; g < groups.length; g++) {
const token = groups[g];
const val = match[g + 1];
switch (token) {
case "yyyy": year = parseInt(val, 10); break;
case "yy": year = 2000 + parseInt(val, 10); break;
case "MM":
case "M": month = parseInt(val, 10); break;
case "dd":
case "d": day = parseInt(val, 10); break;
case "HH":
case "H": hour = parseInt(val, 10); break;
case "hh":
case "h": hour = parseInt(val, 10); break;
case "mm":
case "m": minute = parseInt(val, 10); break;
case "ss":
case "s": second = parseInt(val, 10); break;
case "fff": ms = parseInt(val, 10); break;
case "ff": ms = parseInt(val, 10) * 10; break;
case "f": ms = parseInt(val, 10) * 100; break;
case "tt": isPM = val.toUpperCase() === "PM"; isAM = val.toUpperCase() === "AM"; break;
case "t": isPM = val.toUpperCase() === "P"; isAM = val.toUpperCase() === "A"; break;
case "Z": tzOffset = 0; break;
case "K":
if (val === "Z") {
tzOffset = 0;
} else {
const sign = val[0] === "+" ? 1 : -1;
const [h, m] = val.substring(1).split(":").map(Number);
tzOffset = sign * (h * 60 + m);
}
break;
}
}
// 12h → 24h conversion
if (isPM && hour < 12) hour += 12;
if (isAM && hour === 12) hour = 0;
if (tzOffset !== null) {
// Build UTC date, apply offset
const utcMs = Date.UTC(year, month - 1, day, hour, minute, second, ms);
return new Date(utcMs - tzOffset * 60000);
}
return new Date(year, month - 1, day, hour, minute, second, ms);
}
// ─── Input transform presets ───
export const IntInput = { transform: parseInt };
export const FloatInput = { transform: parseFloat };
export const BoolInput = {
transform: (v) => {
if (typeof v === "boolean") return v;
if (typeof v === "string") {
if (v.toLowerCase() === "true") return true;
if (v.toLowerCase() === "false") return false;
}
return Boolean(v);
},
};
export function DateInput(format) {
return {
transform: (v) => parseDate(v, format),
};
}
export const IsoDateInput = DateInput("yyyy-MM-ddTHH:mm:ssK");