547 lines
15 KiB
JavaScript
547 lines
15 KiB
JavaScript
import "./linq.js";
|
|
import { NodeTemplate } from "./node-template.js";
|
|
|
|
// ─── Route matching ───
|
|
|
|
class RouteMatch {
|
|
constructor(route, path, params) {
|
|
this.route = route;
|
|
this.path = path;
|
|
this.params = params;
|
|
this.segmentCount = route.fragments.length;
|
|
}
|
|
}
|
|
|
|
export class Route {
|
|
constructor(pattern, componentClass) {
|
|
this.id = crypto.randomUUID();
|
|
this.componentClass = componentClass;
|
|
this.ComponentDefinition = componentClass.definition;
|
|
this.fragments = pattern.split("/");
|
|
}
|
|
|
|
match(path) {
|
|
const parsedUrl = new URL(path, window.location.origin);
|
|
const params = {};
|
|
parsedUrl.searchParams.forEach((value, key) => {
|
|
params[key] = value;
|
|
});
|
|
|
|
const pathFragments = parsedUrl.pathname
|
|
.split("/")
|
|
.filter((f) => f.length > 0);
|
|
|
|
for (let i = 0; i < this.fragments.length; i++) {
|
|
const fragment = this.fragments[i];
|
|
const pathFragment = pathFragments[i];
|
|
if (fragment.startsWith(":")) {
|
|
params[fragment.substring(1)] = pathFragment;
|
|
continue;
|
|
} else if (fragment === "*") {
|
|
continue;
|
|
} else if (fragment === "**") {
|
|
return new RouteMatch(this, path, params);
|
|
} else if (fragment !== pathFragment) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return new RouteMatch(this, path, params);
|
|
}
|
|
}
|
|
|
|
// ─── ComponentInput (signal-like) ───
|
|
|
|
export class ComponentInput {
|
|
constructor(defaultValue, options = {}) {
|
|
this._value = defaultValue;
|
|
this.transform = options.transform || ((v) => v);
|
|
this.validate = options.validate || (() => true);
|
|
this.alias = options.alias || null;
|
|
this._isComponentInput = true;
|
|
this._owner = null;
|
|
const self = this;
|
|
const fn = function () {
|
|
return self._value;
|
|
};
|
|
return new Proxy(fn, {
|
|
get(target, prop) {
|
|
if (prop === "set") return (v) => self._set(v);
|
|
if (prop === "_isComponentInput") return true;
|
|
if (prop === "_self") return self;
|
|
if (prop === "alias") return self.alias;
|
|
if (prop === Symbol.toPrimitive) return () => self._value;
|
|
if (prop === "valueOf") return () => self._value;
|
|
if (prop === "toString") return () => String(self._value);
|
|
const inner = self._value;
|
|
if (inner == null) return undefined;
|
|
const val = inner[prop];
|
|
return typeof val === "function" ? val.bind(inner) : val;
|
|
},
|
|
set(target, prop, value) {
|
|
if (self._value != null) {
|
|
self._value[prop] = value;
|
|
if (self._owner) self._owner.requestUpdate();
|
|
}
|
|
return true;
|
|
},
|
|
apply(target, thisArg, args) {
|
|
return self._value;
|
|
},
|
|
});
|
|
}
|
|
|
|
_set(newValue) {
|
|
const transformed = this.transform(newValue);
|
|
if (!this.validate(transformed)) {
|
|
throw new Error("Invalid value");
|
|
}
|
|
this._value = transformed;
|
|
if (this._owner) this._owner.requestUpdate();
|
|
}
|
|
|
|
static [Symbol.hasInstance](instance) {
|
|
return instance?._isComponentInput === true;
|
|
}
|
|
}
|
|
|
|
// ─── ViewChild ───
|
|
|
|
export class ViewChild {
|
|
constructor(options = { selector: null, id: null, multiple: false }) {
|
|
this._selector = options.selector;
|
|
this._id = options.id;
|
|
this._multiple = options.multiple;
|
|
this._element = null;
|
|
this._isViewChild = true;
|
|
return new Proxy(this, {
|
|
get(target, prop) {
|
|
if (prop in target) return target[prop];
|
|
if (target._element) {
|
|
const value = target._element[prop];
|
|
return typeof value === "function"
|
|
? value.bind(target._element)
|
|
: value;
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
_setValue(element) {
|
|
this._element = element;
|
|
}
|
|
|
|
static [Symbol.hasInstance](instance) {
|
|
return instance?._isViewChild === true;
|
|
}
|
|
}
|
|
|
|
// ─── ComponentDefinition ───
|
|
|
|
export class ComponentDefinition {
|
|
constructor(
|
|
options = {
|
|
templatesPath: null,
|
|
template: null,
|
|
stylesPath: null,
|
|
scriptsPath: null,
|
|
},
|
|
) {
|
|
this.templatesPath = options.templatesPath;
|
|
this.template = options.template;
|
|
this.stylesPath = options.stylesPath;
|
|
this.scriptsPath = options.scriptsPath;
|
|
}
|
|
}
|
|
|
|
// ─── EventListener ───
|
|
|
|
class EventBinding {
|
|
constructor(event, element, handler) {
|
|
this.event = event;
|
|
this.element = element;
|
|
this.handler = handler;
|
|
}
|
|
|
|
attach() {
|
|
this.element.addEventListener(this.event, this.handler);
|
|
}
|
|
|
|
detach() {
|
|
this.element.removeEventListener(this.event, this.handler);
|
|
}
|
|
}
|
|
|
|
// ─── Component ───
|
|
|
|
export class Component extends EventTarget {
|
|
constructor() {
|
|
super();
|
|
this._eventBindings = [];
|
|
return new Proxy(this, {
|
|
get(target, prop, receiver) {
|
|
const value = Reflect.get(target, prop, target);
|
|
if (typeof value === "function" && typeof value.bind === "function") {
|
|
return prop in EventTarget.prototype
|
|
? value.bind(target)
|
|
: value.bind(receiver);
|
|
}
|
|
return value;
|
|
},
|
|
set(target, prop, value) {
|
|
if (target[prop] instanceof ComponentInput) {
|
|
target[prop].set(value);
|
|
return true;
|
|
} else if (target[prop] instanceof ViewChild) {
|
|
target[prop]._setValue(value);
|
|
} else {
|
|
target[prop] = value;
|
|
}
|
|
target.requestUpdate();
|
|
return true;
|
|
},
|
|
});
|
|
}
|
|
|
|
requestUpdate() {
|
|
this.dispatchEvent(new Event("requestUpdate"));
|
|
}
|
|
|
|
static get definition() {
|
|
throw new Error("Component definition is not defined");
|
|
}
|
|
|
|
onInit() { }
|
|
onBeforeRender() { }
|
|
onAfterRender() { }
|
|
onDestroy() {
|
|
this._eventBindings.forEach((b) => b.detach());
|
|
}
|
|
}
|
|
|
|
// ─── Router init ───
|
|
|
|
export function initRouter(outletId, routes) {
|
|
const outletRef = document.getElementById(outletId);
|
|
if (!outletRef) {
|
|
console.error(`Outlet element with id '${outletId}' not found`);
|
|
return null;
|
|
}
|
|
return new Router(outletRef, routes);
|
|
}
|
|
|
|
// ─── Router ───
|
|
|
|
export class Router {
|
|
constructor(outletRef, routes) {
|
|
this.outletRef = outletRef;
|
|
this.shadowRoot = outletRef.attachShadow({ mode: "open" });
|
|
this.routes = routes;
|
|
this.templateCache = new Map();
|
|
this.activeController = null;
|
|
this.activeRenderResult = null;
|
|
this._boundUpdateHandler = null;
|
|
this._rendering = false;
|
|
this._pendingUpdate = false;
|
|
this.shadowRoot.textContent = "Loading...";
|
|
navigation.addEventListener("navigate", this.handleNavigate.bind(this));
|
|
}
|
|
|
|
findMatchingRoute(path) {
|
|
return this.routes
|
|
.asEnumerable()
|
|
.select((route) => route.match(path))
|
|
.where((match) => match !== null)
|
|
.orderByDescending((match) => match.segmentCount)
|
|
.firstOrDefault();
|
|
}
|
|
|
|
async handleNavigate(event) {
|
|
const routeMatch = this.findMatchingRoute(event.destination.url);
|
|
if (!routeMatch) return;
|
|
|
|
event.preventDefault();
|
|
const template = await this.getCachedTemplate(routeMatch.route);
|
|
const controller = this.createComponentInstance(
|
|
routeMatch.route.componentClass,
|
|
routeMatch.params,
|
|
);
|
|
|
|
this.destroyCurrent();
|
|
this.activeController = controller;
|
|
this.activeTemplate = template;
|
|
this.insertIncludes(routeMatch.route);
|
|
|
|
this._boundUpdateHandler = this.handleUpdateRequest.bind(this);
|
|
this.activeController.addEventListener(
|
|
"requestUpdate",
|
|
this._boundUpdateHandler,
|
|
);
|
|
|
|
this.renderActiveComponent();
|
|
history.pushState(null, "", routeMatch.path);
|
|
}
|
|
|
|
destroyCurrent() {
|
|
if (this.activeController) {
|
|
this.activeController.removeEventListener(
|
|
"requestUpdate",
|
|
this._boundUpdateHandler,
|
|
);
|
|
this.activeController.onDestroy();
|
|
this.activeController = null;
|
|
}
|
|
if (this.activeRenderResult) {
|
|
this.activeRenderResult.destroy();
|
|
this.activeRenderResult = null;
|
|
}
|
|
}
|
|
|
|
handleUpdateRequest() {
|
|
if (this._rendering) {
|
|
this._pendingUpdate = true;
|
|
return;
|
|
}
|
|
this.updateActiveComponent();
|
|
}
|
|
|
|
insertIncludes(route) {
|
|
// Remove previous component styles from shadow root
|
|
this.shadowRoot
|
|
.querySelectorAll("link[data-component-style]")
|
|
.forEach((l) => l.remove());
|
|
const paths = route.ComponentDefinition.stylesPath;
|
|
if (!paths) return;
|
|
const list = Array.isArray(paths) ? paths : [paths];
|
|
for (const p of list) {
|
|
const link = document.createElement("link");
|
|
link.rel = "stylesheet";
|
|
link.href = p;
|
|
link.setAttribute("data-component-style", "");
|
|
this.shadowRoot.appendChild(link);
|
|
}
|
|
}
|
|
|
|
// ─── First render: build DOM from scratch ───
|
|
|
|
renderActiveComponent() {
|
|
this._rendering = true;
|
|
this._pendingUpdate = false;
|
|
|
|
this.activeController.onBeforeRender();
|
|
|
|
const result = this.activeTemplate.render(this.activeController);
|
|
this.activeRenderResult = result;
|
|
|
|
// Clear shadow root content (keep style links), append fragment
|
|
for (const child of Array.from(this.shadowRoot.childNodes)) {
|
|
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 shadow DOM
|
|
this.wireEvents(this.activeController, this.shadowRoot);
|
|
this.wireViewChildren(this.activeController, this.shadowRoot);
|
|
|
|
this.activeController.onAfterRender();
|
|
this._rendering = false;
|
|
|
|
if (this._pendingUpdate) {
|
|
this.updateActiveComponent();
|
|
}
|
|
}
|
|
|
|
// ─── Subsequent updates: patch bindings only ───
|
|
|
|
updateActiveComponent() {
|
|
this._rendering = true;
|
|
this._pendingUpdate = false;
|
|
|
|
this.activeController.onBeforeRender();
|
|
this.activeRenderResult.update(this.activeController);
|
|
this.activeController.onAfterRender();
|
|
|
|
this._rendering = false;
|
|
|
|
if (this._pendingUpdate) {
|
|
this.updateActiveComponent();
|
|
}
|
|
}
|
|
|
|
// ─── ViewChild wiring ───
|
|
|
|
wireViewChildren(controller, root) {
|
|
Object.entries(controller)
|
|
.filter(([_, value]) => value instanceof ViewChild)
|
|
.forEach(([key, viewChild]) => {
|
|
if (viewChild._id) {
|
|
const el = root.querySelector(`#${viewChild._id}`);
|
|
if (el) viewChild._setValue(el);
|
|
} else if (viewChild._selector) {
|
|
const el = root.querySelector(viewChild._selector);
|
|
if (el) viewChild._setValue(el);
|
|
} else {
|
|
console.warn(`ViewChild ${key}: no selector or id`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ─── Event wiring ───
|
|
// Supports:
|
|
// (click)="methodName()" — event binding
|
|
// [(value)]="propName" — two-way binding (banana-in-a-box)
|
|
|
|
wireEvents(controller, root) {
|
|
const allElements = root.querySelectorAll("*");
|
|
|
|
allElements.forEach((element) => {
|
|
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];
|
|
const attrValue = attr.value.trim();
|
|
const methodName = attrValue.endsWith("()")
|
|
? attrValue.slice(0, -2)
|
|
: attrValue;
|
|
|
|
let binding;
|
|
if (typeof controller[methodName] === "function") {
|
|
const handler = controller[methodName].bind(controller);
|
|
binding = new EventBinding(eventName, element, handler);
|
|
} else {
|
|
const fn = new Function("event", attrValue).bind(controller);
|
|
binding = new EventBinding(eventName, element, fn);
|
|
}
|
|
binding.attach();
|
|
controller._eventBindings.push(binding);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ─── Two-way bind: DOM property ↔ controller field ───
|
|
// Sets DOM prop from controller on each update.
|
|
// Listens for input/change events to write back to controller.
|
|
|
|
_bindTwoWay(controller, element, domProp, field) {
|
|
// Controller → DOM: set initial value
|
|
const getValue = () => {
|
|
const v = controller[field];
|
|
return typeof v === "function" ? v() : v;
|
|
};
|
|
|
|
element[domProp] = getValue() ?? "";
|
|
|
|
// DOM → Controller: on input (for text) + change (for select/checkbox)
|
|
const writeBack = () => {
|
|
const domValue = element[domProp];
|
|
if (controller[field] instanceof ComponentInput) {
|
|
controller[field].set(domValue);
|
|
} else {
|
|
controller[field] = domValue;
|
|
}
|
|
};
|
|
|
|
const inputBinding = new EventBinding("input", element, writeBack);
|
|
const changeBinding = new EventBinding("change", element, writeBack);
|
|
inputBinding.attach();
|
|
changeBinding.attach();
|
|
controller._eventBindings.push(inputBinding, changeBinding);
|
|
|
|
// Controller → DOM: patch on every update via RenderResult hook
|
|
// We piggyback on requestUpdate listener — the attr binding in
|
|
// node-template handles {{expr}} attrs, but for [(prop)] we need
|
|
// to sync the DOM *property* (not attribute) on update.
|
|
const updateBinding = new EventBinding("requestUpdate", controller, () => {
|
|
const val = getValue() ?? "";
|
|
if (element[domProp] !== val) element[domProp] = val;
|
|
});
|
|
updateBinding.attach();
|
|
controller._eventBindings.push(updateBinding);
|
|
}
|
|
|
|
// ─── Component instance creation ───
|
|
|
|
createComponentInstance(component, params) {
|
|
const instance = new component();
|
|
|
|
// Wire _owner for ComponentInput reactivity
|
|
for (const key of Object.keys(instance)) {
|
|
const val = instance[key];
|
|
if (val instanceof ComponentInput) {
|
|
val._self._owner = instance;
|
|
}
|
|
}
|
|
|
|
instance.onInit();
|
|
|
|
// Bind route/query params → ComponentInputs (by name or alias)
|
|
for (const [paramKey, paramValue] of Object.entries(params)) {
|
|
if (instance[paramKey] instanceof ComponentInput) {
|
|
instance[paramKey].set(paramValue);
|
|
continue;
|
|
}
|
|
for (const key of Object.keys(instance)) {
|
|
const val = instance[key];
|
|
if (val instanceof ComponentInput && val.alias === paramKey) {
|
|
val.set(paramValue);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return instance;
|
|
}
|
|
|
|
// ─── Template caching ───
|
|
|
|
async getCachedTemplate(route) {
|
|
if (this.templateCache.has(route.id)) {
|
|
return this.templateCache.get(route.id);
|
|
}
|
|
|
|
let templateContent = route.componentClass.definition.template;
|
|
if (!templateContent && route.componentClass.definition.templatesPath) {
|
|
const response = await fetch(
|
|
route.componentClass.definition.templatesPath,
|
|
);
|
|
|
|
if (!response.ok) {
|
|
return new NodeTemplate(
|
|
`<div class="error"><h1>Failed to load template</h1><p>${response.status} ${response.statusText}</p></div>`,
|
|
);
|
|
}
|
|
|
|
templateContent = await response.text();
|
|
}
|
|
|
|
try {
|
|
const template = new NodeTemplate(templateContent);
|
|
this.templateCache.set(route.id, template);
|
|
return template;
|
|
} catch (error) {
|
|
return new NodeTemplate(
|
|
`<div class="error"><h1>Failed to compile template</h1><p>${error.message}</p></div>`,
|
|
);
|
|
}
|
|
}
|
|
}
|