@axzydev/axzy_ui_system 1.7.3 → 1.8.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.
- package/dist/index.cjs +1845 -721
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.cts +386 -15
- package/dist/index.d.ts +386 -15
- package/dist/index.js +1532 -418
- package/dist/index.js.map +1 -1
- package/dist/layered.css +1 -1
- package/package.json +4 -2
- package/src/App.tsx +6 -8
- package/src/components/atoms/button/button.tsx +7 -0
- package/src/components/atoms/flex/flex.props.ts +3 -1
- package/src/components/atoms/flex/flex.tsx +11 -3
- package/src/components/atoms/input/input.tsx +5 -0
- package/src/components/atoms/segmented-control/segmented-control.tsx +22 -5
- package/src/components/atoms/textarea/textarea.props.ts +4 -0
- package/src/components/atoms/textarea/textarea.tsx +13 -3
- package/src/components/molecules/card/card.props.ts +14 -0
- package/src/components/molecules/card/card.tsx +66 -23
- package/src/components/molecules/chip-input/chip-input.tsx +2 -0
- package/src/components/molecules/line-bar-chart/line-bar-chart.props.ts +4 -0
- package/src/components/molecules/line-bar-chart/line-bar-chart.tsx +57 -41
- package/src/components/molecules/multi-select/multi-select.tsx +2 -0
- package/src/components/molecules/tabs/tabs.tsx +22 -8
- package/src/components/organisms/dropfile/dropfile.props.ts +8 -2
- package/src/components/organisms/dropfile/dropfile.tsx +17 -0
- package/src/components/organisms/page-header/page-header.tsx +22 -6
- package/src/components/organisms/sidebar/sidebar.tsx +8 -5
- package/src/components/organisms/topbar/topbar.props.ts +2 -0
- package/src/components/organisms/topbar/topbar.tsx +56 -34
- package/src/components/templates/layout/layout.tsx +3 -2
- package/src/components/theme-provider/themeProvider.props.ts +1 -1
- package/src/components/theme-provider/themeProvider.tsx +54 -1
- package/src/i18n/locales/en.ts +7 -0
- package/src/i18n/locales/es.ts +7 -0
- package/src/index.ts +35 -7
- package/src/json-renderer/actions/effects.test.tsx +115 -0
- package/src/json-renderer/actions/effects.ts +291 -0
- package/src/json-renderer/actions/effectsChain.test.ts +117 -0
- package/src/json-renderer/binding/binding.test.tsx +168 -0
- package/src/json-renderer/binding/resolveValue.ts +193 -0
- package/src/json-renderer/catalog/axzyCatalog.ts +5 -0
- package/src/json-renderer/estado-acciones.test.tsx +166 -0
- package/src/json-renderer/form/FormNode.tsx +92 -0
- package/src/json-renderer/form/form.test.tsx +148 -0
- package/src/json-renderer/form/formLength.test.tsx +99 -0
- package/src/json-renderer/form/rules.ts +162 -0
- package/src/json-renderer/index.ts +21 -0
- package/src/json-renderer/kernel/SchemaRenderer.tsx +83 -7
- package/src/json-renderer/kernel/capabilities.ts +30 -0
- package/src/json-renderer/kernel/renderNode.tsx +256 -3
- package/src/json-renderer/kernel/schemaRenderer.props.ts +29 -3
- package/src/json-renderer/kernel/slots.test.tsx +104 -0
- package/src/json-renderer/kernel/slots.ts +34 -0
- package/src/json-renderer/kernel/types.ts +4 -0
- package/src/json-renderer/routing/match.ts +82 -0
- package/src/json-renderer/routing/navigation.ts +71 -0
- package/src/json-renderer/routing/routes.ts +179 -0
- package/src/json-renderer/routing/routing.test.ts +147 -0
- package/src/json-renderer/seguridad.test.tsx +299 -0
- package/src/json-renderer/shared/errors.ts +1 -0
- package/src/json-renderer/stream/tolerantParse.ts +5 -0
- package/src/json-renderer/stream/useSchemaStream.test.tsx +143 -0
- package/src/json-renderer/stream/useSchemaStream.ts +227 -0
- package/src/json-renderer/validation/jsonSchema.test.ts +105 -0
- package/src/json-renderer/validation/jsonSchema.ts +317 -0
- package/src/json-renderer/validation/validate.ts +17 -0
- package/src/sandbox/axzyConfig.test.ts +4 -0
- package/src/sandbox/axzyConfig.ts +1 -1
- package/src/sandbox/i18n.ts +44 -0
- package/src/sandbox/useHashRoute.ts +2 -1
- package/src/showcases/DemoJsonDashboard.tsx +218 -103
- package/src/showcases/DemoJsonDocs.tsx +11 -11
- package/src/showcases/FeedbackShowcases.tsx +2 -1
- package/src/showcases/JsonScreenShowcase.tsx +43 -53
- package/src/showcases/StructureShowcases.tsx +17 -2
- package/src/showcases/demoApp/demoJson.test.tsx +241 -0
- package/src/showcases/demoApp/routes.test.ts +63 -0
- package/src/showcases/demoApp/schemas.test.ts +6 -17
- package/src/theme/theme-context.ts +10 -2
- package/src/theme/theme.ts +1 -1
- package/src/theme/theme.types.ts +2 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useMemo,
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
2
|
import { FaBolt, FaBug, FaCheck, FaTrashAlt } from "react-icons/fa";
|
|
3
3
|
import {
|
|
4
4
|
ITAlert,
|
|
@@ -11,8 +11,9 @@ import {
|
|
|
11
11
|
ITText,
|
|
12
12
|
type JsonEngineIssue,
|
|
13
13
|
type ScreenSchema,
|
|
14
|
-
|
|
14
|
+
type StreamSource,
|
|
15
15
|
axzyCatalogTypes,
|
|
16
|
+
useSchemaStream,
|
|
16
17
|
} from "../index";
|
|
17
18
|
import { ShowcaseLayout } from "./ShowcaseLayout";
|
|
18
19
|
|
|
@@ -53,7 +54,7 @@ const EJEMPLO: ScreenSchema = {
|
|
|
53
54
|
props: { title: "Listo para pintar" },
|
|
54
55
|
children: [
|
|
55
56
|
{ type: "ITText", text: "Este árbol viene de un JSON, no de TSX." },
|
|
56
|
-
{ type: "
|
|
57
|
+
{ type: "ITText", props: { muted: true }, text: "Progreso: 64 de 100" },
|
|
57
58
|
],
|
|
58
59
|
},
|
|
59
60
|
],
|
|
@@ -77,9 +78,17 @@ const EJEMPLO: ScreenSchema = {
|
|
|
77
78
|
/** Texto inicial del editor: el esquema anterior, formateado. */
|
|
78
79
|
const TEXTO_INICIAL = JSON.stringify(EJEMPLO, null, 2);
|
|
79
80
|
|
|
80
|
-
/** Velocidad del stream simulado (caracteres por
|
|
81
|
+
/** Velocidad del stream simulado (caracteres por trozo). */
|
|
81
82
|
const CHUNK = 6;
|
|
82
83
|
|
|
84
|
+
/** Emite un texto en trozos de `CHUNK` caracteres, con una pausa entre ellos, como haría un modelo. */
|
|
85
|
+
async function* emitir(total: string): AsyncGenerator<string> {
|
|
86
|
+
for (let cursor = 0; cursor < total.length; cursor += CHUNK) {
|
|
87
|
+
await new Promise((resolve) => window.setTimeout(resolve, 24));
|
|
88
|
+
yield total.slice(cursor, cursor + CHUNK);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
83
92
|
/**
|
|
84
93
|
* Laboratorio del motor de pantallas declarativas.
|
|
85
94
|
*
|
|
@@ -92,63 +101,40 @@ const CHUNK = 6;
|
|
|
92
101
|
*/
|
|
93
102
|
export const JsonScreenShowcase: React.FC = () => {
|
|
94
103
|
const [texto, setTexto] = useState(TEXTO_INICIAL);
|
|
95
|
-
const [streaming, setStreaming] = useState(false);
|
|
96
|
-
const [emitido, setEmitido] = useState(TEXTO_INICIAL.length);
|
|
97
104
|
const [avisos, setAvisos] = useState<JsonEngineIssue[]>([]);
|
|
98
|
-
|
|
105
|
+
/** Fuente del stream simulado. Mientras existe, el laboratorio está emitiendo. */
|
|
106
|
+
const [fuente, setFuente] = useState<StreamSource | null>(null);
|
|
107
|
+
const stream = useSchemaStream(fuente);
|
|
108
|
+
const streaming = fuente !== null;
|
|
109
|
+
const emitido = stream.text.length;
|
|
99
110
|
|
|
100
|
-
/** Estado del esquema: mientras se emite, se
|
|
111
|
+
/** Estado del esquema: mientras se emite, lo da el hook; si no, se lee el texto del editor. */
|
|
101
112
|
const estado = useMemo(() => {
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
const completo = JSON.parse(texto) as ScreenSchema;
|
|
105
|
-
return { tree: completo, status: "complete" as const, pending: 0, roto: false };
|
|
106
|
-
} catch {
|
|
107
|
-
return { tree: null, status: "complete" as const, pending: 0, roto: true };
|
|
108
|
-
}
|
|
113
|
+
if (streaming) {
|
|
114
|
+
return { tree: stream.tree, status: stream.status, pending: stream.pendingNodes, roto: stream.broken };
|
|
109
115
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
tree:
|
|
113
|
-
|
|
114
|
-
pending:
|
|
115
|
-
roto: parcial.broken,
|
|
116
|
-
};
|
|
117
|
-
}, [texto, streaming]);
|
|
118
|
-
|
|
119
|
-
/** Detiene el stream y limpia el temporizador. */
|
|
120
|
-
const detener = useCallback(() => {
|
|
121
|
-
if (timer.current !== null) {
|
|
122
|
-
window.clearInterval(timer.current);
|
|
123
|
-
timer.current = null;
|
|
116
|
+
try {
|
|
117
|
+
const completo = JSON.parse(texto) as ScreenSchema;
|
|
118
|
+
return { tree: completo, status: "complete" as const, pending: 0, roto: false };
|
|
119
|
+
} catch {
|
|
120
|
+
return { tree: null, status: "complete" as const, pending: 0, roto: true };
|
|
124
121
|
}
|
|
125
|
-
|
|
126
|
-
}, []);
|
|
122
|
+
}, [texto, streaming, stream]);
|
|
127
123
|
|
|
128
|
-
|
|
124
|
+
/** Al terminar la emisión, el texto completo pasa al editor y el laboratorio vuelve a ser editable. */
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (fuente !== null && !stream.streaming && stream.text.length > 0) {
|
|
127
|
+
setTexto(stream.text);
|
|
128
|
+
setFuente(null);
|
|
129
|
+
}
|
|
130
|
+
}, [fuente, stream.streaming, stream.text]);
|
|
129
131
|
|
|
130
|
-
/** Emite el esquema
|
|
132
|
+
/** Emite el esquema por trozos, como haría un modelo. */
|
|
131
133
|
const emitirStream = useCallback(() => {
|
|
132
|
-
detener();
|
|
133
134
|
setAvisos([]);
|
|
134
|
-
setEmitido(0);
|
|
135
135
|
setTexto("");
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const total = TEXTO_INICIAL;
|
|
139
|
-
let cursor = 0;
|
|
140
|
-
|
|
141
|
-
timer.current = window.setInterval(() => {
|
|
142
|
-
cursor = Math.min(total.length, cursor + CHUNK);
|
|
143
|
-
setTexto(total.slice(0, cursor));
|
|
144
|
-
setEmitido(cursor);
|
|
145
|
-
if (cursor >= total.length) {
|
|
146
|
-
window.clearInterval(timer.current as number);
|
|
147
|
-
timer.current = null;
|
|
148
|
-
setStreaming(false);
|
|
149
|
-
}
|
|
150
|
-
}, 24);
|
|
151
|
-
}, [detener]);
|
|
136
|
+
setFuente(emitir(TEXTO_INICIAL));
|
|
137
|
+
}, []);
|
|
152
138
|
|
|
153
139
|
/** Registro de acciones del host: el esquema solo puede pedir estos nombres. */
|
|
154
140
|
const resolveAction = useCallback(
|
|
@@ -166,6 +152,11 @@ export const JsonScreenShowcase: React.FC = () => {
|
|
|
166
152
|
);
|
|
167
153
|
}, []);
|
|
168
154
|
|
|
155
|
+
/** Los avisos del stream (JR080: falló, o terminó sin cerrar) pasan al panel de avisos. */
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
stream.issues.forEach(registrarAviso);
|
|
158
|
+
}, [stream.issues, registrarAviso]);
|
|
159
|
+
|
|
169
160
|
const codigo = `<ITSchemaRenderer
|
|
170
161
|
schema={parsePartialSchema(texto).tree}
|
|
171
162
|
onIssue={(issue) => registrar(issue)}
|
|
@@ -246,9 +237,8 @@ export const JsonScreenShowcase: React.FC = () => {
|
|
|
246
237
|
variant="outlined"
|
|
247
238
|
color="gray"
|
|
248
239
|
onClick={() => {
|
|
249
|
-
|
|
240
|
+
setFuente(null);
|
|
250
241
|
setTexto(TEXTO_INICIAL);
|
|
251
|
-
setEmitido(TEXTO_INICIAL.length);
|
|
252
242
|
setAvisos([]);
|
|
253
243
|
}}
|
|
254
244
|
/>
|
|
@@ -372,6 +372,19 @@ export const CardShowcase = () => {
|
|
|
372
372
|
}
|
|
373
373
|
gallery={
|
|
374
374
|
<ITStack spacing={8}>
|
|
375
|
+
<GallerySection
|
|
376
|
+
title={t("showcase.structure.cardToneTitle")}
|
|
377
|
+
subtitle={t("showcase.structure.cardToneSubtitle")}
|
|
378
|
+
>
|
|
379
|
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
380
|
+
{(["primary", "secondary", "ternary", "success", "info", "warning", "danger"] as const).map((tone) => (
|
|
381
|
+
<ITCard key={tone} tone={tone} title={tone}>
|
|
382
|
+
{t("showcase.structure.cardToneBody")}
|
|
383
|
+
</ITCard>
|
|
384
|
+
))}
|
|
385
|
+
</div>
|
|
386
|
+
</GallerySection>
|
|
387
|
+
|
|
375
388
|
<GallerySection
|
|
376
389
|
title={t("showcase.structure.galleryRealCases")}
|
|
377
390
|
subtitle={t("showcase.structure.galleryCardsSubtitle")}
|
|
@@ -537,13 +550,14 @@ export const CardShowcase = () => {
|
|
|
537
550
|
}
|
|
538
551
|
doc={{
|
|
539
552
|
summary: "Contenedor de tarjeta con imagen, título, cuerpo y pie de acciones opcionales; con onClick se vuelve interactivo.",
|
|
540
|
-
description: "Pieza base de composición para agrupar contenido relacionado. Borde, radio y sombra se adaptan al aspecto del tema (flat /
|
|
553
|
+
description: "Pieza base de composición para agrupar contenido relacionado. Borde, radio y sombra se adaptan al aspecto del tema (soft / flat / bento).",
|
|
541
554
|
examples: [
|
|
542
555
|
'<ITCard title="Ingresos del mes" actions={<ITButton label="Ver detalle" size="sm" />}>$48.230</ITCard>',
|
|
543
556
|
'<ITCard title="Curso" image="/courses/ts.jpg" imageClassName="h-32" onClick={() => open(course.id)}>…</ITCard>',
|
|
544
557
|
'<ITCard className="max-w-sm"><ITText muted>Tarjeta estática, sin cabecera ni acciones.</ITText></ITCard>',
|
|
545
558
|
],
|
|
546
559
|
props: [
|
|
560
|
+
{ name: "tone", type: '"primary" | "secondary" | "ternary" | "success" | "info" | "warning" | "danger"', default: "—", description: "Tarjeta pastel: tiñe el fondo con el token --color-<tone>-soft (sustituye al blanco). En soft/flat usa --color-<tone>-soft-border como borde; en bento sigue sin borde." },
|
|
547
561
|
{ name: "onClick", type: "() => void", default: "—", description: "Al recibirlo la tarjeta se vuelve clicable: cursor pointer, transición de sombra y elevación en hover. Sin él es estática." },
|
|
548
562
|
{ name: "title", type: "string", default: "—", description: "Título de la cabecera; se renderiza como h3 mediante ITText." },
|
|
549
563
|
{ name: "image", type: "string", default: "—", description: "URL de la imagen superior, a ancho completo con h-48 object-cover." },
|
|
@@ -559,7 +573,8 @@ export const CardShowcase = () => {
|
|
|
559
573
|
notes: [
|
|
560
574
|
"El aspecto del tema decide borde y sombra: en flat el borde es un hairline y solo hay sombra en hover si la tarjeta es clicable; en soft flota con --shadow-sm y sube a --shadow-lg con translateY(-2px).",
|
|
561
575
|
"Variables CSS: --it-card-bg, --it-card-border, --it-card-flat-border y --it-card-radius.",
|
|
562
|
-
"
|
|
576
|
+
"En bento la tarjeta no tiene borde, usa --it-bento-card-shadow (más marcada en hover si es clicable, sin translateY), radio de 24 px y padding de 1.5rem; un radius/shadow explícito en el provider gana.",
|
|
577
|
+
"El padding es fijo (cuerpo 1.25rem, 1.5rem en bento; pie 1rem): no hay prop de tamaño.",
|
|
563
578
|
],
|
|
564
579
|
}}
|
|
565
580
|
/>
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pruebas de los JSON del demo (`public/demo/app/*.json`).
|
|
3
|
+
*
|
|
4
|
+
* Los JSON son contrato con el host: cada acción, slot, fuente, parámetro y clave de
|
|
5
|
+
* traducción que nombran tiene que existir del lado del host. Estas pruebas lo
|
|
6
|
+
* comprueban para todas las páginas y para `routes.json`, y renderizan cada página
|
|
7
|
+
* con el motor exigiendo cero avisos.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React from "react";
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { render } from "@testing-library/react";
|
|
13
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { ITSchemaRenderer } from "../../json-renderer/kernel/SchemaRenderer";
|
|
16
|
+
import type { JsonEngineIssue } from "../../json-renderer/shared/errors";
|
|
17
|
+
import type { SlotRegistry } from "../../json-renderer/kernel/slots";
|
|
18
|
+
import { validateSchema, validateRoutes, flattenRoutes, matchRoute, type RoutesSchema } from "../../json-renderer";
|
|
19
|
+
import { sandboxMessages } from "../../sandbox/i18n";
|
|
20
|
+
import { DEMO_ACTIONS, DEMO_SLOT_NAMES } from "../DemoJsonDashboard";
|
|
21
|
+
import { isDemoSource } from "./demoData";
|
|
22
|
+
|
|
23
|
+
const DIR = join(process.cwd(), "public/demo/app");
|
|
24
|
+
const NO_SON_PAGINAS = ["layout.json", "routes.json"];
|
|
25
|
+
|
|
26
|
+
/** Lee un JSON del demo. */
|
|
27
|
+
function leer<T = unknown>(nombre: string): T {
|
|
28
|
+
return JSON.parse(readFileSync(join(DIR, nombre), "utf8")) as T;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Recorre todos los objetos de un JSON y llama a `visita` con cada uno. */
|
|
32
|
+
function recorrer(valor: unknown, visita: (objeto: Record<string, unknown>) => void): void {
|
|
33
|
+
if (Array.isArray(valor)) {
|
|
34
|
+
valor.forEach((item) => recorrer(item, visita));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (valor && typeof valor === "object") {
|
|
38
|
+
visita(valor as Record<string, unknown>);
|
|
39
|
+
Object.values(valor).forEach((item) => recorrer(item, visita));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Todas las claves `{ i18n }` de un JSON. */
|
|
44
|
+
function clavesI18n(json: unknown): string[] {
|
|
45
|
+
const claves: string[] = [];
|
|
46
|
+
recorrer(json, (objeto) => {
|
|
47
|
+
if (typeof objeto.i18n === "string") claves.push(objeto.i18n);
|
|
48
|
+
});
|
|
49
|
+
return claves;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Todos los nombres de acción (`{ action: "…" }`) de un JSON. */
|
|
53
|
+
function nombresDeAccion(json: unknown): string[] {
|
|
54
|
+
const nombres: string[] = [];
|
|
55
|
+
recorrer(json, (objeto) => {
|
|
56
|
+
if (typeof objeto.action === "string") nombres.push(objeto.action);
|
|
57
|
+
});
|
|
58
|
+
return nombres;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Todos los `{ $param: "…" }` de un JSON. */
|
|
62
|
+
function parametrosUsados(json: unknown): string[] {
|
|
63
|
+
const nombres: string[] = [];
|
|
64
|
+
recorrer(json, (objeto) => {
|
|
65
|
+
if (typeof objeto.$param === "string") nombres.push(objeto.$param);
|
|
66
|
+
});
|
|
67
|
+
return nombres;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Nodos `slot` de un JSON, por nombre de slot. */
|
|
71
|
+
function slotsUsados(json: unknown): string[] {
|
|
72
|
+
const nombres: string[] = [];
|
|
73
|
+
recorrer(json, (objeto) => {
|
|
74
|
+
if (objeto.type === "slot" && typeof (objeto.props as { name?: unknown } | undefined)?.name === "string") {
|
|
75
|
+
nombres.push((objeto.props as { name: string }).name);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return nombres;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Todos los `{ $source: "…" }` de un JSON. */
|
|
82
|
+
function fuentesUsadas(json: unknown): string[] {
|
|
83
|
+
const nombres: string[] = [];
|
|
84
|
+
recorrer(json, (objeto) => {
|
|
85
|
+
if (typeof objeto.$source === "string") nombres.push(objeto.$source);
|
|
86
|
+
});
|
|
87
|
+
return nombres;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Páginas de pantalla: todos los JSON salvo el shell y las rutas. */
|
|
91
|
+
const PAGINAS = readdirSync(DIR).filter((archivo) => archivo.endsWith(".json") && !NO_SON_PAGINAS.includes(archivo));
|
|
92
|
+
|
|
93
|
+
const rutas = leer<RoutesSchema>("routes.json");
|
|
94
|
+
const planas = flattenRoutes(rutas.routes);
|
|
95
|
+
|
|
96
|
+
/** Pantalla que pinta una página, según la ruta que la declara (params a "1"). */
|
|
97
|
+
function ejemploDePagina(pagina: string) {
|
|
98
|
+
const ruta = planas.find((item) => item.page === pagina);
|
|
99
|
+
if (!ruta) return null;
|
|
100
|
+
const segmentos = ruta.segments.map((segmento) => (segmento.startsWith(":") ? "1" : segmento));
|
|
101
|
+
return matchRoute(planas, segmentos, ruta.query);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Diccionario de traducción en español para resolver `{ i18n }` al renderizar. */
|
|
105
|
+
const es = sandboxMessages.es as Record<string, string>;
|
|
106
|
+
const en = sandboxMessages.en as Record<string, string>;
|
|
107
|
+
|
|
108
|
+
describe("routes.json", () => {
|
|
109
|
+
it("es un documento de rutas válido", () => {
|
|
110
|
+
expect(validateRoutes(rutas)).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("cada ruta hoja pinta una página que existe (docs es la página TSX)", () => {
|
|
114
|
+
for (const ruta of planas) {
|
|
115
|
+
if (ruta.page === "docs") continue;
|
|
116
|
+
expect(existsSync(join(DIR, `${ruta.page}.json`)), `${ruta.id} → ${ruta.page}.json`).toBe(true);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("no hay páginas huérfanas: cada JSON de pantalla tiene una ruta que lo pinta", () => {
|
|
121
|
+
const paginasConRuta = new Set(planas.map((ruta) => ruta.page));
|
|
122
|
+
for (const archivo of PAGINAS) {
|
|
123
|
+
expect(paginasConRuta.has(archivo.replace(/\.json$/, "")), `${archivo} sin ruta`).toBe(true);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("los ids de ruta son únicos", () => {
|
|
128
|
+
const ids = planas.map((ruta) => ruta.id);
|
|
129
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("cada clave de traducción existe en español e inglés", () => {
|
|
133
|
+
for (const clave of clavesI18n(rutas)) {
|
|
134
|
+
expect(es[clave], `es: ${clave}`).toBeTruthy();
|
|
135
|
+
expect(en[clave], `en: ${clave}`).toBeTruthy();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("cada ruta con :param declara un parámetro que su página usa, y viceversa", () => {
|
|
140
|
+
for (const ruta of planas) {
|
|
141
|
+
const declarados = ruta.segments.filter((segmento) => segmento.startsWith(":")).map((segmento) => segmento.slice(1));
|
|
142
|
+
if (declarados.length === 0 || ruta.page === "docs") continue;
|
|
143
|
+
const pagina = leer(`${ruta.page}.json`);
|
|
144
|
+
for (const usado of parametrosUsados(pagina)) {
|
|
145
|
+
expect(declarados, `${ruta.page} usa $param "${usado}"`).toContain(usado);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe.each(PAGINAS)("%s", (archivo) => {
|
|
152
|
+
const pagina = leer(archivo);
|
|
153
|
+
const nombre = archivo.replace(/\.json$/, "");
|
|
154
|
+
|
|
155
|
+
it("es un esquema válido, sin nodos pendientes", () => {
|
|
156
|
+
const informe = validateSchema(pagina);
|
|
157
|
+
expect(informe.errors, JSON.stringify(informe.errors)).toHaveLength(0);
|
|
158
|
+
expect(informe.verdict).toBe("valid");
|
|
159
|
+
expect(informe.pendingNodes).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("solo usa acciones que el host resuelve", () => {
|
|
163
|
+
for (const accion of nombresDeAccion(pagina)) {
|
|
164
|
+
expect(DEMO_ACTIONS as readonly string[], `acción "${accion}"`).toContain(accion);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("cada clave de traducción existe en español e inglés", () => {
|
|
169
|
+
for (const clave of clavesI18n(pagina)) {
|
|
170
|
+
expect(es[clave], `es: ${clave}`).toBeTruthy();
|
|
171
|
+
expect(en[clave], `en: ${clave}`).toBeTruthy();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("cada slot nombra un bloque que el host registra", () => {
|
|
176
|
+
for (const slot of slotsUsados(pagina)) {
|
|
177
|
+
expect(DEMO_SLOT_NAMES, `slot "${slot}"`).toContain(slot);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("cada $source usa una fuente declarada en `data`, con un handler que existe", () => {
|
|
182
|
+
const data = ((pagina as { data?: Record<string, { handler: string }> }).data ?? {}) as Record<string, { handler: string }>;
|
|
183
|
+
for (const fuente of fuentesUsadas(pagina)) {
|
|
184
|
+
expect(data[fuente], `fuente "${fuente}" sin declarar en data`).toBeTruthy();
|
|
185
|
+
expect(isDemoSource(data[fuente].handler), `handler "${data[fuente].handler}"`).toBe(true);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("cada $param tiene una ruta que lo proporciona", () => {
|
|
190
|
+
const ejemplo = ejemploDePagina(nombre);
|
|
191
|
+
if (parametrosUsados(pagina).length === 0) return;
|
|
192
|
+
expect(ejemplo, `${nombre} necesita una ruta con :param`).not.toBeNull();
|
|
193
|
+
for (const usado of parametrosUsados(pagina)) {
|
|
194
|
+
expect(ejemplo?.params, `$param "${usado}"`).toHaveProperty(usado);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("se pinta con el motor sin avisos (ni errores ni advertencias)", () => {
|
|
199
|
+
const issues: JsonEngineIssue[] = [];
|
|
200
|
+
const ejemplo = ejemploDePagina(nombre);
|
|
201
|
+
const slots: SlotRegistry = Object.fromEntries(
|
|
202
|
+
DEMO_SLOT_NAMES.map((nombreSlot) => [nombreSlot, () => <div data-slot={nombreSlot} />])
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
render(
|
|
206
|
+
<ITSchemaRenderer
|
|
207
|
+
schema={pagina as never}
|
|
208
|
+
params={ejemplo?.params ?? {}}
|
|
209
|
+
// Como el host real: todos los efectos del demo están implementados.
|
|
210
|
+
effects={{ toast: () => undefined, navigate: () => undefined, refresh: () => undefined, openOverlay: () => undefined, closeOverlay: () => undefined }}
|
|
211
|
+
// Como el host real: solo las acciones de DEMO_ACTIONS tienen handler.
|
|
212
|
+
resolveAction={(_node, _prop, accion) => {
|
|
213
|
+
const nombre = (accion as { action?: unknown } | null)?.action;
|
|
214
|
+
return typeof nombre === "string" && (DEMO_ACTIONS as readonly string[]).includes(nombre) ? () => undefined : undefined;
|
|
215
|
+
}}
|
|
216
|
+
slots={slots}
|
|
217
|
+
dataHandlers={{
|
|
218
|
+
usuarios: async () => ({ data: [], total: 0 }),
|
|
219
|
+
pedidos: async () => ({ data: [], total: 0 }),
|
|
220
|
+
productos: async () => ({ data: [], total: 0 }),
|
|
221
|
+
}}
|
|
222
|
+
resolveValue={(valor) =>
|
|
223
|
+
valor && typeof valor === "object" && typeof (valor as { i18n?: unknown }).i18n === "string"
|
|
224
|
+
? es[(valor as { i18n: string }).i18n]
|
|
225
|
+
: valor
|
|
226
|
+
}
|
|
227
|
+
onIssue={(issue) => issues.push(issue)}
|
|
228
|
+
/>
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
expect(issues.map((issue) => `${issue.code} ${issue.path}: ${issue.message}`)).toEqual([]);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("layout.json", () => {
|
|
236
|
+
it("declara la marca y nada más: el menú sale de routes.json", () => {
|
|
237
|
+
const layout = leer<{ layout: { brand?: { text?: string }; modules?: unknown } }>("layout.json");
|
|
238
|
+
expect(layout.layout.brand?.text).toBeTruthy();
|
|
239
|
+
expect(layout.layout.modules).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { buildNavigationTree, flattenRoutes, matchRoute, routeHref, validateRoutes, type NavNode, type RoutesSchema } from "../../json-renderer";
|
|
5
|
+
import { sandboxMessages } from "../../sandbox/i18n";
|
|
6
|
+
|
|
7
|
+
const DIR = join(process.cwd(), "public/demo/app");
|
|
8
|
+
const rutas = JSON.parse(readFileSync(join(DIR, "routes.json"), "utf8")) as RoutesSchema;
|
|
9
|
+
const planas = flattenRoutes(rutas.routes);
|
|
10
|
+
|
|
11
|
+
/** Todas las claves `{ i18n }` de un árbol de navegación. */
|
|
12
|
+
function claves(nodos: NavNode[]): string[] {
|
|
13
|
+
return nodos.flatMap((nodo) => [
|
|
14
|
+
...(typeof nodo.label === "object" && nodo.label !== null && "i18n" in nodo.label ? [String(nodo.label.i18n)] : []),
|
|
15
|
+
...(nodo.children ? claves(nodo.children) : []),
|
|
16
|
+
]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("routes.json del demo", () => {
|
|
20
|
+
it("es un documento de rutas válido", () => {
|
|
21
|
+
expect(validateRoutes(rutas)).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("cada página de una hoja tiene su JSON (docs es la página TSX)", () => {
|
|
25
|
+
for (const ruta of planas) {
|
|
26
|
+
if (ruta.page === "docs") continue;
|
|
27
|
+
expect(existsSync(join(DIR, `${ruta.page}.json`)), `${ruta.id} → ${ruta.page}.json`).toBe(true);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("todas las etiquetas del menú tienen traducción en el catálogo del sandbox", () => {
|
|
32
|
+
for (const clave of claves(buildNavigationTree(rutas.routes))) {
|
|
33
|
+
expect(sandboxMessages.es[clave], clave).toBeTruthy();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("el menú sale de las rutas: grupos con sus sub-items y la marca de pedidos", () => {
|
|
38
|
+
const arbol = buildNavigationTree(rutas.routes);
|
|
39
|
+
expect(arbol.map((entrada) => entrada.id)).toEqual(["inicio", "usuarios", "pedidos", "productos", "reportes", "docs"]);
|
|
40
|
+
expect(arbol.find((entrada) => entrada.id === "pedidos")?.badge).toBe(11);
|
|
41
|
+
expect(arbol.find((entrada) => entrada.id === "usuarios")?.children?.map((hijo) => hijo.href)).toEqual([
|
|
42
|
+
"/usuarios",
|
|
43
|
+
"/usuarios?estado=Activo",
|
|
44
|
+
"/usuarios?estado=Inactivo",
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("cada dirección del menú casa con la ruta que la declara", () => {
|
|
49
|
+
for (const ruta of planas) {
|
|
50
|
+
const casada = matchRoute(planas, ruta.segments, ruta.query);
|
|
51
|
+
expect(casada?.route.id, routeHref(ruta)).toBe(ruta.id);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("la ruta de detalle recibe el id como :param y no compite con /usuarios", () => {
|
|
56
|
+
expect(matchRoute(planas, ["usuarios", "7"])).toMatchObject({ route: { id: "usuario-detalle" }, params: { id: "7" } });
|
|
57
|
+
expect(matchRoute(planas, ["usuarios"])?.route.id).toBe("u-todos");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("una query sin ruta específica cae en la ruta base del mismo path", () => {
|
|
61
|
+
expect(matchRoute(planas, ["pedidos"], { estado: "Cancelado" })?.route.id).toBe("p-todos");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -10,7 +10,9 @@ function leerJson(nombre: string): unknown {
|
|
|
10
10
|
return JSON.parse(readFileSync(join(DIR, nombre), "utf8"));
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// layout.json (marca) y routes.json (rutas) no son pantallas: se prueban aparte.
|
|
14
|
+
const NO_SON_PAGINAS = ["layout.json", "routes.json"];
|
|
15
|
+
const paginas = readdirSync(DIR).filter((archivo) => archivo.endsWith(".json") && !NO_SON_PAGINAS.includes(archivo));
|
|
14
16
|
|
|
15
17
|
describe("JSON del demo", () => {
|
|
16
18
|
it("hay páginas y un layout", () => {
|
|
@@ -25,23 +27,10 @@ describe("JSON del demo", () => {
|
|
|
25
27
|
expect(informe.pendingNodes).toBe(0);
|
|
26
28
|
});
|
|
27
29
|
|
|
28
|
-
it("el layout declara marca
|
|
29
|
-
const layout = leerJson("layout.json") as {
|
|
30
|
-
layout: { brand?: { text?: string }; modules: { id: string; page: string; icon?: string }[] };
|
|
31
|
-
};
|
|
30
|
+
it("el layout declara la marca; el menú vive en routes.json", () => {
|
|
31
|
+
const layout = leerJson("layout.json") as { layout: { brand?: { text?: string }; modules?: unknown } };
|
|
32
32
|
expect(layout.layout.brand?.text).toBeTruthy();
|
|
33
|
-
expect(layout.layout.modules.
|
|
34
|
-
for (const modulo of layout.layout.modules) {
|
|
35
|
-
expect(modulo.id, "cada módulo necesita id").toBeTruthy();
|
|
36
|
-
expect(modulo.page, `${modulo.id} necesita page`).toBeTruthy();
|
|
37
|
-
}
|
|
38
|
-
// Todo módulo apunta a una página que existe (docs no tiene archivo propio).
|
|
39
|
-
const ids = new Set(layout.layout.modules.map((modulo) => modulo.page));
|
|
40
|
-
expect(ids.has("docs")).toBe(true);
|
|
41
|
-
for (const pagina of ids) {
|
|
42
|
-
if (pagina === "docs") continue;
|
|
43
|
-
expect(paginas, `falta ${pagina}.json`).toContain(`${pagina}.json`);
|
|
44
|
-
}
|
|
33
|
+
expect(layout.layout.modules, "los módulos ya no van en layout.json").toBeUndefined();
|
|
45
34
|
});
|
|
46
35
|
|
|
47
36
|
it("las páginas declaran título y descripción traducibles", () => {
|
|
@@ -38,10 +38,18 @@ export const useITThemeSafe = (): ITThemeContextType | undefined => {
|
|
|
38
38
|
return useContext(ITThemeContext);
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Lenguaje visual activo (`"soft"` | `"flat"` | `"bento"`).
|
|
43
|
+
* Fuera de un ITThemeProvider devuelve `"soft"` (aspecto por defecto).
|
|
44
|
+
*/
|
|
45
|
+
export const useITAppearance = (): ITThemeAppearance => {
|
|
46
|
+
return useContext(ITThemeContext)?.appearance ?? "soft";
|
|
47
|
+
};
|
|
48
|
+
|
|
41
49
|
/**
|
|
42
50
|
* `true` cuando el provider pide el lenguaje visual plano (`appearance="flat"`).
|
|
43
51
|
* Fuera de un ITThemeProvider devuelve `false` (aspecto por defecto).
|
|
44
52
|
*/
|
|
45
53
|
export const useITFlatAppearance = (): boolean => {
|
|
46
|
-
return
|
|
47
|
-
};
|
|
54
|
+
return useITAppearance() === "flat";
|
|
55
|
+
};
|
package/src/theme/theme.ts
CHANGED
|
@@ -322,7 +322,7 @@ export const components = {
|
|
|
322
322
|
input: {
|
|
323
323
|
backgroundColor: 'var(--it-input-bg, #ffffff)',
|
|
324
324
|
borderColor: `var(--it-input-border, ${semanticColors.gray[300]})`,
|
|
325
|
-
borderRadius: 'var(--radius-md)',
|
|
325
|
+
borderRadius: 'var(--it-input-radius, var(--radius-md))',
|
|
326
326
|
focus: {
|
|
327
327
|
borderColor: `var(--it-input-focus-border, ${semanticColors.primary[500]})`,
|
|
328
328
|
ring: `var(--it-input-focus-ring, 0 0 0 3px ${semanticColors.primary[100]})`,
|
package/src/theme/theme.types.ts
CHANGED
|
@@ -16,8 +16,9 @@ export type ColorScale = {
|
|
|
16
16
|
* Visual language of the whole system.
|
|
17
17
|
* - `"soft"`: soft depth — floating cards, tinted shadows, glass sidebar and top bar (default).
|
|
18
18
|
* - `"flat"`: flat surfaces — hairline borders instead of shadows, plain sidebar and top bar, compact page headers.
|
|
19
|
+
* - `"bento"`: modern pastel "bento" dashboards — light-gray canvas, borderless white cards with a diffuse shadow and 24px radius, pill controls (buttons, inputs), ink-pill navigation and pastel tiles via `ITCard` `tone`.
|
|
19
20
|
*/
|
|
20
|
-
export type ITThemeAppearance = "soft" | "flat";
|
|
21
|
+
export type ITThemeAppearance = "soft" | "flat" | "bento";
|
|
21
22
|
|
|
22
23
|
export interface ITThemePalette {
|
|
23
24
|
/** Main brand accent color (hex). Drives primary buttons, active nav items, focus rings, links, and the `--color-primary` CSS variable. @default "#06b6d4" */
|