@deijose/nix-js 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,308 @@
1
+ // ═══════════════════════════════════════════════
2
+ // Nix.js ❄️ — Sistema de Reactividad
3
+ // ═══════════════════════════════════════════════
4
+ //
5
+ // Conceptos:
6
+ //
7
+ // signal(valor) → dato reactivo, al cambiar notifica
8
+ // effect(fn) → se re-ejecuta cuando sus signals cambian
9
+ // computed(fn) → valor derivado que se auto-actualiza
10
+ // batch(fn) → agrupa cambios en 1 sola actualización
11
+ //
12
+ // Cómo funciona:
13
+ //
14
+ // 1. Un effect se ejecuta y LEE signals
15
+ // 2. Cada signal registra "este effect me está leyendo"
16
+ // 3. Cuando el signal CAMBIA, re-ejecuta sus effects
17
+ // 4. El effect se des-suscribe de las viejas y se suscribe a las nuevas
18
+ //
19
+ // signal.value (get) → "Oye signal, te estoy leyendo"
20
+ // signal.value (set) → "Oye effects, cambié, re-ejecútense"
21
+
22
+ // ── Tracking: saber quién está observando ──
23
+
24
+ let activeEffect: (() => void) | null = null;
25
+ const effectStack: ((() => void) | null)[] = [];
26
+
27
+ let activeDeps: Set<Signal<any>> | null = null;
28
+ const depsStack: (Set<Signal<any>> | null)[] = [];
29
+
30
+ // ── Batching: agrupar notificaciones ──
31
+
32
+ let batchLevel = 0;
33
+ const pendingEffects = new Set<() => void>();
34
+
35
+ // ── Signal ──
36
+
37
+ export class Signal<T> {
38
+ private _value: T;
39
+ private _subs = new Set<() => void>();
40
+
41
+ constructor(initialValue: T) {
42
+ this._value = initialValue;
43
+ }
44
+
45
+ /**
46
+ * Leer el valor.
47
+ * Si hay un effect activo, se suscribe automáticamente.
48
+ */
49
+ get value(): T {
50
+ if (activeEffect) {
51
+ this._subs.add(activeEffect);
52
+ activeDeps?.add(this);
53
+ }
54
+ return this._value;
55
+ }
56
+
57
+ /**
58
+ * Escribir un nuevo valor.
59
+ * Si es diferente al actual, notifica a todos los effects suscritos.
60
+ */
61
+ set value(newValue: T) {
62
+ if (Object.is(this._value, newValue)) return;
63
+ this._value = newValue;
64
+ this._notify();
65
+ }
66
+
67
+ /**
68
+ * Modificar el valor con una función.
69
+ * count.update(n => n + 1)
70
+ */
71
+ update(fn: (current: T) => T): void {
72
+ this.value = fn(this._value);
73
+ }
74
+
75
+ /**
76
+ * Leer SIN suscribirse.
77
+ * Útil cuando necesitas el valor pero no quieres
78
+ * que el effect se re-ejecute si cambia.
79
+ */
80
+ peek(): T {
81
+ return this._value;
82
+ }
83
+
84
+ /** @internal */
85
+ _removeSub(sub: () => void): void {
86
+ this._subs.delete(sub);
87
+ }
88
+
89
+ private _notify(): void {
90
+ const subs = [...this._subs];
91
+
92
+ if (batchLevel > 0) {
93
+ subs.forEach((s) => pendingEffects.add(s));
94
+ } else {
95
+ subs.forEach((s) => s());
96
+ }
97
+ }
98
+
99
+ dispose(): void {
100
+ this._subs.clear();
101
+ }
102
+ }
103
+
104
+ // ── Factory functions ──
105
+
106
+ export function signal<T>(initialValue: T): Signal<T> {
107
+ return new Signal(initialValue);
108
+ }
109
+
110
+ /**
111
+ * Ejecuta una función y la RE-EJECUTA cada vez que
112
+ * algún signal leído dentro de ella cambie.
113
+ *
114
+ * Retorna una función dispose() para destruir el effect.
115
+ *
116
+ * const dispose = effect(() => {
117
+ * console.log(count.value);
118
+ * return () => console.log("cleanup");
119
+ * });
120
+ *
121
+ * dispose();
122
+ */
123
+ export function effect(fn: () => void | (() => void)): () => void {
124
+ let cleanup: (() => void) | void;
125
+ let deps = new Set<Signal<any>>();
126
+
127
+ const execute = () => {
128
+ if (typeof cleanup === "function") cleanup();
129
+
130
+ deps.forEach((dep) => dep._removeSub(execute));
131
+ deps = new Set();
132
+
133
+ effectStack.push(activeEffect);
134
+ depsStack.push(activeDeps);
135
+ activeEffect = execute;
136
+ activeDeps = deps;
137
+
138
+ try {
139
+ cleanup = fn();
140
+ } finally {
141
+ activeEffect = effectStack.pop() || null;
142
+ activeDeps = depsStack.pop() || null;
143
+ }
144
+ };
145
+
146
+ execute();
147
+
148
+ return () => {
149
+ if (typeof cleanup === "function") cleanup();
150
+ deps.forEach((dep) => dep._removeSub(execute));
151
+ deps.clear();
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Valor derivado que se recalcula automáticamente.
157
+ *
158
+ * const doubled = computed(() => count.value * 2);
159
+ */
160
+ export function computed<T>(fn: () => T): Signal<T> {
161
+ const s = new Signal<T>(undefined as T);
162
+
163
+ effect(() => {
164
+ s.value = fn();
165
+ });
166
+
167
+ return s;
168
+ }
169
+
170
+ /**
171
+ * Agrupa múltiples cambios para que los effects
172
+ * se ejecuten UNA sola vez al final.
173
+ *
174
+ * batch(() => {
175
+ * x.value = 1;
176
+ * y.value = 2;
177
+ * });
178
+ */
179
+ export function batch(fn: () => void): void {
180
+ batchLevel++;
181
+ try {
182
+ fn();
183
+ } finally {
184
+ batchLevel--;
185
+ if (batchLevel === 0) {
186
+ const pending = [...pendingEffects];
187
+ pendingEffects.clear();
188
+ pending.forEach((f) => f());
189
+ }
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Ejecuta `fn` sin suscribirse a ninguna signal que lea dentro de ella.
195
+ * Útil para leer valores reactivos sin que esas lecturas re-disparen el
196
+ * efecto actual (p.ej. en callbacks de `watch`).
197
+ *
198
+ * const total = untrack(() => price.value * qty.value); // no se suscribe
199
+ */
200
+ export function untrack<T>(fn: () => T): T {
201
+ const prevEffect = activeEffect;
202
+ const prevDeps = activeDeps;
203
+ activeEffect = null;
204
+ activeDeps = null;
205
+ try {
206
+ return fn();
207
+ } finally {
208
+ activeEffect = prevEffect;
209
+ activeDeps = prevDeps;
210
+ }
211
+ }
212
+
213
+ // ── watch ──
214
+
215
+ export interface WatchOptions {
216
+ /**
217
+ * Si es `true`, el callback se ejecuta inmediatamente con el valor
218
+ * actual antes de que haya ningún cambio. Por defecto: `false`.
219
+ */
220
+ immediate?: boolean;
221
+ /**
222
+ * Si es `true`, el watcher se elimina automáticamente después de la
223
+ * primera vez que el callback se dispara. Por defecto: `false`.
224
+ */
225
+ once?: boolean;
226
+ }
227
+
228
+ /**
229
+ * Observa una fuente reactiva y ejecuta `callback(newValue, oldValue)` cada
230
+ * vez que cambia.
231
+ *
232
+ * La fuente puede ser:
233
+ * - Un getter: `() => count.value + other.value`
234
+ * - Una Signal directamente: `count`
235
+ *
236
+ * Retorna una función `dispose()` para detener la observación.
237
+ *
238
+ * @example
239
+ * const stop = watch(
240
+ * () => user.value.name,
241
+ * (newName, oldName) => console.log(newName, oldName),
242
+ * { immediate: true }
243
+ * );
244
+ * stop(); // deja de observar
245
+ */
246
+ export function watch<T>(
247
+ source: Signal<T> | (() => T),
248
+ callback: (newValue: T, oldValue: T | undefined) => void,
249
+ options: WatchOptions = {}
250
+ ): () => void {
251
+ const { immediate = false, once = false } = options;
252
+
253
+ const getter: () => T =
254
+ source instanceof Signal ? () => source.value : source;
255
+
256
+ let oldValue: T | undefined;
257
+ let isFirst = true;
258
+ let disposed = false;
259
+
260
+ const dispose = effect(() => {
261
+ const newValue = getter();
262
+
263
+ if (isFirst) {
264
+ isFirst = false;
265
+ if (immediate && !disposed) {
266
+ const snap = newValue;
267
+ untrack(() => callback(snap, undefined));
268
+ if (once) { disposed = true; Promise.resolve().then(dispose); }
269
+ }
270
+ oldValue = newValue;
271
+ return;
272
+ }
273
+
274
+ if (!disposed) {
275
+ const snap = newValue;
276
+ const prev = oldValue;
277
+ oldValue = newValue;
278
+ untrack(() => callback(snap, prev));
279
+ if (once) { disposed = true; Promise.resolve().then(dispose); }
280
+ }
281
+ });
282
+
283
+ return () => {
284
+ disposed = true;
285
+ dispose();
286
+ };
287
+ }
288
+
289
+ // ── nextTick ──
290
+
291
+ /**
292
+ * Espera a que todos los efectos síncronos pendientes hayan corrido y el
293
+ * DOM esté actualizado, devolviendo una promesa que resuelve en el próximo
294
+ * microtask.
295
+ *
296
+ * Úsala cuando necesitas leer el DOM *después* de un cambio reactivo:
297
+ *
298
+ * count.value++;
299
+ * await nextTick();
300
+ * console.log(el.textContent); // ya refleja el nuevo valor
301
+ *
302
+ * También acepta un callback opcional:
303
+ *
304
+ * nextTick(() => el.focus());
305
+ */
306
+ export function nextTick(fn?: () => void): Promise<void> {
307
+ return fn ? Promise.resolve().then(fn) : Promise.resolve();
308
+ }
@@ -0,0 +1,393 @@
1
+ // src/nix/router.ts — Fase 6: Router History API (pushState)
2
+
3
+ import { signal } from "./reactivity";
4
+ import type { Signal } from "./reactivity";
5
+ import { NixComponent } from "./lifecycle";
6
+ import type { NixTemplate } from "./template";
7
+ import { html } from "./template";
8
+
9
+ // ── Tipos públicos ────────────────────────────────────────────────────────────
10
+
11
+ export interface RouteRecord {
12
+ /**
13
+ * Segmento de ruta. Soporta:
14
+ * - Literal: "/about", "/users"
15
+ * - Parámetro: "/users/:id", "/posts/:slug/comments/:cid"
16
+ * - Wildcard: "*" (fallback global o de prefijo con children)
17
+ *
18
+ * Los paths de children se concatenan con el del padre.
19
+ *
20
+ * @example
21
+ * { path: "/users/:id", component: UserDetail }
22
+ * // Navegar a "/users/42" → params.value = { id: "42" }
23
+ *
24
+ * @example
25
+ * { path: "/dash", component: DashLayout, children: [
26
+ * { path: "/users", component: UsersPage },
27
+ * ]}
28
+ * // Genera las rutas planas: /dash, /dash/users
29
+ */
30
+ path: string;
31
+ /** Factory que devuelve la vista a renderizar en este nivel */
32
+ component: () => NixTemplate | NixComponent;
33
+ /**
34
+ * Rutas hijas. Sus paths se unen con el del padre.
35
+ * El componente padre debe incluir `new RouterView(1)` para renderizarlas.
36
+ */
37
+ children?: RouteRecord[];
38
+ }
39
+
40
+ export interface Router {
41
+ /** Señal con la ruta activa actual (pathname, p.ej. "/users/42") */
42
+ readonly current: Signal<string>;
43
+ /**
44
+ * Señal con los parámetros dinámicos de la ruta activa (:id, :slug…).
45
+ * Se actualiza síncronamente con cada `navigate()`.
46
+ *
47
+ * @example
48
+ * // Ruta: "/users/:id" → navigate("/users/42")
49
+ * router.params.value // { id: "42" }
50
+ */
51
+ readonly params: Signal<Record<string, string>>;
52
+ /**
53
+ * Señal con los query params de la URL (?clave=valor…).
54
+ * Se actualiza síncronamente con cada `navigate()`.
55
+ *
56
+ * @example
57
+ * router.navigate("/users?page=2&sort=name")
58
+ * router.query.value // { page: "2", sort: "name" }
59
+ *
60
+ * @example
61
+ * router.navigate("/users", { page: 2, sort: "name" })
62
+ * router.query.value // { page: "2", sort: "name" }
63
+ */
64
+ readonly query: Signal<Record<string, string>>;
65
+ /**
66
+ * Navegar a una ruta nueva (pushState + actualiza señales síncronamente).
67
+ *
68
+ * @param path Ruta destino. Puede incluir query string: "/users?page=2"
69
+ * @param query Query params como objeto. Se mezclan con los del path.
70
+ * Un valor `null`/`undefined` elimina el parámetro.
71
+ */
72
+ navigate(path: string, query?: Record<string, string | number | boolean | null | undefined>): void;
73
+ /** Árbol de rutas original (tal como se pasó a createRouter) */
74
+ readonly routes: RouteRecord[];
75
+ }
76
+
77
+ // ── Internos ──────────────────────────────────────────────────────────────────
78
+
79
+ /** Un segmento de la ruta parseado */
80
+ type Segment =
81
+ | { kind: "literal"; value: string }
82
+ | { kind: "param"; name: string }
83
+ | { kind: "wildcard" };
84
+
85
+ interface FlatRoute {
86
+ fullPath: string;
87
+ segments: Segment[];
88
+ /** [componentePadre, componenteHijo, …] */
89
+ chain: Array<() => NixTemplate | NixComponent>;
90
+ }
91
+
92
+ interface RouterInternal extends Router {
93
+ _flat: FlatRoute[];
94
+ }
95
+
96
+ /** Singleton de módulo — la última instancia creada con createRouter() */
97
+ let _currentRouter: RouterInternal | null = null;
98
+
99
+ function getRouter(): RouterInternal {
100
+ if (!_currentRouter) {
101
+ throw new Error("[Nix] No hay router activo. Llama a createRouter() antes.");
102
+ }
103
+ return _currentRouter;
104
+ }
105
+
106
+ // ── Helpers internos ──────────────────────────────────────────────────────────
107
+ /** Convierte `window.location.search` (o cualquier string `?k=v`) en objeto */
108
+ function parseQuery(search: string): Record<string, string> {
109
+ const result: Record<string, string> = {};
110
+ new URLSearchParams(search).forEach((v, k) => { result[k] = v; });
111
+ return result;
112
+ }
113
+
114
+ /**
115
+ * Construye un query string a partir de un objeto.
116
+ * Valores `null`/`undefined`/`false` se omiten.
117
+ * Devuelve `""` si no hay claves, `"?k=v&..."` en caso contrario.
118
+ */
119
+ function buildQueryString(
120
+ q: Record<string, string | number | boolean | null | undefined>
121
+ ): string {
122
+ const p = new URLSearchParams();
123
+ for (const [k, v] of Object.entries(q)) {
124
+ if (v != null && v !== false) p.set(k, String(v));
125
+ }
126
+ const s = p.toString();
127
+ return s ? "?" + s : "";
128
+ }
129
+ /** Parsea un fullPath ya unido en sus segmentos */
130
+ function parseSegments(fullPath: string): Segment[] {
131
+ if (fullPath === "*") return [{ kind: "wildcard" }];
132
+ return fullPath
133
+ .split("/")
134
+ .filter(Boolean)
135
+ .map((part): Segment => {
136
+ if (part === "*") return { kind: "wildcard" };
137
+ if (part.startsWith(":")) return { kind: "param", name: part.slice(1) };
138
+ return { kind: "literal", value: part };
139
+ });
140
+ }
141
+
142
+ /** Une un path padre con un segmento hijo normalizando barras dobles */
143
+ function joinPaths(parent: string, child: string): string {
144
+ if (child === "*") return parent === "" ? "*" : parent + "/*";
145
+ const segment = child.startsWith("/") ? child : "/" + child;
146
+ return (parent + segment).replace(/\/+/g, "/") || "/";
147
+ }
148
+
149
+ /** Convierte el árbol de RouteRecord en una lista plana con cadena de componentes */
150
+ function flattenRoutes(
151
+ routes: RouteRecord[],
152
+ parentPath = "",
153
+ parentChain: Array<() => NixTemplate | NixComponent> = []
154
+ ): FlatRoute[] {
155
+ const result: FlatRoute[] = [];
156
+ for (const route of routes) {
157
+ const fullPath = joinPaths(parentPath, route.path);
158
+ const chain = [...parentChain, route.component];
159
+ const segments = parseSegments(fullPath);
160
+ result.push({ fullPath, segments, chain });
161
+ if (route.children?.length) {
162
+ result.push(...flattenRoutes(route.children, fullPath, chain));
163
+ }
164
+ }
165
+ return result;
166
+ }
167
+
168
+ /**
169
+ * Intenta hacer match de `path` contra una `FlatRoute`.
170
+ * Devuelve los params extraídos si hay coincidencia, o `null` si no.
171
+ */
172
+ function tryMatch(path: string, route: FlatRoute): Record<string, string> | null {
173
+ const parts = path.split("/").filter(Boolean);
174
+ const segs = route.segments;
175
+
176
+ // Wildcard global ("*") — coincide con todo
177
+ if (segs.length === 1 && segs[0].kind === "wildcard") return {};
178
+
179
+ // Wildcard de prefijo — el último segmento es "/*"
180
+ const lastIsWild = segs.length > 0 && segs[segs.length - 1].kind === "wildcard";
181
+ const fixedSegs = lastIsWild ? segs.slice(0, -1) : segs;
182
+
183
+ if (lastIsWild) {
184
+ if (parts.length < fixedSegs.length) return null;
185
+ } else {
186
+ if (parts.length !== fixedSegs.length) return null;
187
+ }
188
+
189
+ const params: Record<string, string> = {};
190
+ for (let i = 0; i < fixedSegs.length; i++) {
191
+ const seg = fixedSegs[i];
192
+ if (seg.kind === "literal") {
193
+ if (parts[i] !== seg.value) return null;
194
+ } else if (seg.kind === "param") {
195
+ params[seg.name] = decodeURIComponent(parts[i] ?? "");
196
+ }
197
+ }
198
+ return params;
199
+ }
200
+
201
+ /**
202
+ * Especificidad de una ruta: más literales = más específica.
203
+ * literal=2, param=1, wildcard=0 — mayor puntaje gana.
204
+ */
205
+ function specificity(route: FlatRoute): number {
206
+ return route.segments.reduce((acc, seg) => {
207
+ if (seg.kind === "literal") return acc + 2;
208
+ if (seg.kind === "param") return acc + 1;
209
+ return acc;
210
+ }, 0);
211
+ }
212
+
213
+ /** Encuentra la mejor ruta para el path dado junto con los params extraídos */
214
+ function matchFlat(
215
+ path: string,
216
+ flat: FlatRoute[]
217
+ ): { route: FlatRoute; params: Record<string, string> } | undefined {
218
+ let best: FlatRoute | undefined;
219
+ let bestParams: Record<string, string> = {};
220
+ let bestScore = -1;
221
+
222
+ for (const route of flat) {
223
+ const params = tryMatch(path, route);
224
+ if (params === null) continue;
225
+ const score = specificity(route);
226
+ if (score > bestScore) {
227
+ best = route;
228
+ bestParams = params;
229
+ bestScore = score;
230
+ }
231
+ }
232
+
233
+ return best ? { route: best, params: bestParams } : undefined;
234
+ }
235
+
236
+ // ── createRouter ──────────────────────────────────────────────────────────────
237
+
238
+ /**
239
+ * Crea el router History API y lo establece como singleton activo del módulo.
240
+ * Usa `history.pushState` — URLs limpias sin `#`.
241
+ * `RouterView` y `Link` lo consumen automáticamente — no necesitan que se los pases.
242
+ *
243
+ * @note En producción el servidor debe responder con `index.html` para cualquier
244
+ * ruta no-archivo. Vite dev y `vite preview` lo hacen automáticamente.
245
+ */
246
+ export function createRouter(routes: RouteRecord[]): Router {
247
+ function getPathname(): string {
248
+ return window.location.pathname || "/";
249
+ }
250
+
251
+ const initialPath = getPathname();
252
+ const flat = flattenRoutes(routes);
253
+ const initialMatch = matchFlat(initialPath, flat);
254
+
255
+ const current = signal(initialPath);
256
+ const params = signal<Record<string, string>>(initialMatch?.params ?? {});
257
+ const query = signal<Record<string, string>>(parseQuery(window.location.search));
258
+
259
+ // Botón atrás/adelante del navegador
260
+ window.addEventListener("popstate", () => {
261
+ const p = getPathname();
262
+ const m = matchFlat(p, flat);
263
+ params.value = m?.params ?? {};
264
+ query.value = parseQuery(window.location.search);
265
+ current.value = p;
266
+ });
267
+
268
+ function navigate(
269
+ path: string,
270
+ queryObj?: Record<string, string | number | boolean | null | undefined>
271
+ ): void {
272
+ // Separar pathname del query string incrustado en el path
273
+ const qIdx = path.indexOf("?");
274
+ const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
275
+ const inlineQ = qIdx === -1 ? {} : parseQuery(path.slice(qIdx));
276
+
277
+ // El queryObj tiene precedencia sobre el inline
278
+ const finalQuery = queryObj ? { ...inlineQ, ...queryObj } : inlineQ;
279
+
280
+ // Convertir todos los valores a string (omitir null/undefined/false)
281
+ const stringQuery: Record<string, string> = {};
282
+ for (const [k, v] of Object.entries(finalQuery)) {
283
+ if (v != null && v !== false) stringQuery[k] = String(v);
284
+ }
285
+
286
+ const m = matchFlat(pathname, flat);
287
+ params.value = m?.params ?? {};
288
+ query.value = stringQuery;
289
+ current.value = pathname;
290
+
291
+ const fullUrl = pathname + buildQueryString(stringQuery);
292
+ history.pushState(null, "", fullUrl); // cambia URL sin recargar página
293
+ }
294
+
295
+ const router: RouterInternal = { current, params, query, navigate, routes, _flat: flat };
296
+ _currentRouter = router;
297
+ return router;
298
+ }
299
+
300
+ /**
301
+ * Devuelve el router activo (singleton).
302
+ * Útil dentro de componentes para acceder a `params` y `current` sin prop-drilling.
303
+ *
304
+ * @example
305
+ * class UserDetail extends NixComponent {
306
+ * render() {
307
+ * return html`<div>User: ${() => useRouter().params.value.id}</div>`;
308
+ * }
309
+ * }
310
+ */
311
+ export function useRouter(): Router {
312
+ return getRouter();
313
+ }
314
+
315
+ // ── RouterView ────────────────────────────────────────────────────────────────
316
+
317
+ /**
318
+ * Renderiza el componente de la ruta activa en el nivel `depth`.
319
+ *
320
+ * - `new RouterView()` → nivel raíz (depth 0).
321
+ * - `new RouterView(1)` → primer nivel de rutas anidadas. Úsalo dentro del
322
+ * componente padre para que renderice el hijo correspondiente.
323
+ *
324
+ * Consume el router singleton — no requiere que se le pase el router.
325
+ */
326
+ export class RouterView extends NixComponent {
327
+ private _depth: number;
328
+
329
+ constructor(depth = 0) {
330
+ super();
331
+ this._depth = depth;
332
+ }
333
+
334
+ render(): NixTemplate {
335
+ const depth = this._depth;
336
+ return html`<div class="router-view">${() => {
337
+ const router = getRouter();
338
+ const matched = matchFlat(router.current.value, router._flat);
339
+
340
+ if (!matched) {
341
+ return html`<div style="color:#f87171;padding:16px 0">
342
+ 404 — Ruta no encontrada: <strong>${router.current.value}</strong>
343
+ </div>`;
344
+ }
345
+
346
+ if (depth >= matched.route.chain.length) {
347
+ // No hay componente registrado en este nivel de anidamiento
348
+ return html`<span></span>`;
349
+ }
350
+
351
+ return matched.route.chain[depth]();
352
+ }}</div>`;
353
+ }
354
+ }
355
+
356
+ // ── Link ──────────────────────────────────────────────────────────────────────
357
+
358
+ /**
359
+ * Enlace de navegación reactivo que se estiliza como activo/inactivo según la
360
+ * ruta actual. Consume el router singleton — no requiere que se le pase.
361
+ *
362
+ * @example
363
+ * new Link("/about", "About")
364
+ */
365
+ export class Link extends NixComponent {
366
+ private _to: string;
367
+ private _label: string;
368
+
369
+ constructor(to: string, label: string) {
370
+ super();
371
+ this._to = to;
372
+ this._label = label;
373
+ }
374
+
375
+ render(): NixTemplate {
376
+ const to = this._to;
377
+ const label = this._label;
378
+ const href = to; // History API — sin prefijo "#"
379
+ return html`<a
380
+ href=${href}
381
+ style=${() => {
382
+ const router = getRouter();
383
+ return router.current.value === to
384
+ ? "color:#38bdf8;font-weight:700;text-decoration:none;cursor:pointer;padding:4px 10px;border-radius:4px;background:#0c2a3a"
385
+ : "color:#a3a3a3;text-decoration:none;cursor:pointer;padding:4px 10px;border-radius:4px";
386
+ }}
387
+ @click=${(e: Event) => {
388
+ e.preventDefault();
389
+ getRouter().navigate(to);
390
+ }}
391
+ >${label}</a>`;
392
+ }
393
+ }