flysoft-react-ui 1.4.0 → 1.4.3

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/README.md CHANGED
@@ -528,11 +528,11 @@ Las contribuciones son bienvenidas. Por favor, abre un issue o un pull request.
528
528
  ## 🔧 Scripts de Mantenimiento
529
529
 
530
530
  ```bash
531
- # Actualizar documentación automáticamente
532
- npm run update-docs
531
+ # Regenerar la referencia de API de la skill desde los tipos
532
+ npm run docs:skill
533
533
 
534
- # Validar que toda la documentación esté sincronizada
535
- npm run validate-docs
534
+ # Validar que la referencia commiteada esté al día
535
+ npm run docs:skill:check
536
536
 
537
537
  # Ver ejemplos completos
538
538
  npm run dev
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Copia la skill `flysoft-ui` desde node_modules hacia el .claude/skills/ de la
5
+ * app consumidora.
6
+ *
7
+ * Claude Code no levanta skills desde node_modules, así que hace falta este paso
8
+ * de copia. La copia es DESTRUCTIVA Y COMPLETA: se borra el destino y se vuelve
9
+ * a copiar entero. Nunca hay merge. Si alguien editó la copia, esa edición se
10
+ * pierde — la fuente de verdad es el repo de flysoft-react-ui.
11
+ *
12
+ * Si el paquete instalado no trae `claude/` (versión anterior a la 1.4.0), avisa
13
+ * y sale con 0: no rompe el `update-libraries` de la app.
14
+ *
15
+ * Este archivo viaja dentro del paquete. Se puede correr de dos formas:
16
+ *
17
+ * node node_modules/flysoft-react-ui/claude/install-skill.mjs
18
+ *
19
+ * o copiándolo al repo de la app (recomendado, ver AGENT_INSTRUCTIONS del repo
20
+ * de la librería) para que el script exista aunque el paquete no lo traiga:
21
+ *
22
+ * cp node_modules/flysoft-react-ui/claude/install-skill.mjs scripts/
23
+ * node scripts/install-skill.mjs
24
+ */
25
+
26
+ import fs from "node:fs";
27
+ import path from "node:path";
28
+
29
+ const SKILL_NAME = "flysoft-ui";
30
+ const PACKAGE_NAME = "flysoft-react-ui";
31
+
32
+ // Los scripts de npm corren con el cwd en la raíz del paquete de la app.
33
+ const appRoot = process.cwd();
34
+ const packageRoot = path.join(appRoot, "node_modules", PACKAGE_NAME);
35
+ const source = path.join(packageRoot, "claude", "skills", SKILL_NAME);
36
+ const target = path.join(appRoot, ".claude", "skills", SKILL_NAME);
37
+
38
+ const label = `${PACKAGE_NAME} → .claude/skills/${SKILL_NAME}`;
39
+
40
+ if (!fs.existsSync(packageRoot)) {
41
+ console.warn(
42
+ `[skill] ${PACKAGE_NAME} no está instalado. Se omite la sincronización de la skill.`
43
+ );
44
+ process.exit(0);
45
+ }
46
+
47
+ if (!fs.existsSync(source)) {
48
+ let version = "desconocida";
49
+ try {
50
+ version = JSON.parse(
51
+ fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")
52
+ ).version;
53
+ } catch {
54
+ // Sin package.json legible no vale la pena insistir: el aviso alcanza.
55
+ }
56
+ console.warn(
57
+ `[skill] ${PACKAGE_NAME}@${version} no incluye claude/skills/${SKILL_NAME}. ` +
58
+ `Se omite la sincronización (hace falta 1.4.0 o superior).`
59
+ );
60
+ process.exit(0);
61
+ }
62
+
63
+ let version = "desconocida";
64
+ try {
65
+ version = JSON.parse(
66
+ fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")
67
+ ).version;
68
+ } catch {
69
+ // Se sigue igual: la versión es informativa.
70
+ }
71
+
72
+ try {
73
+ // Destructivo a propósito: cualquier edición local del destino se descarta.
74
+ fs.rmSync(target, { recursive: true, force: true });
75
+ fs.mkdirSync(path.dirname(target), { recursive: true });
76
+ fs.cpSync(source, target, { recursive: true });
77
+ } catch (error) {
78
+ console.error(`[skill] Falló la copia ${label}: ${error.message}`);
79
+ process.exit(1);
80
+ }
81
+
82
+ const count = fs
83
+ .readdirSync(target, { recursive: true, withFileTypes: true })
84
+ .filter((entry) => entry.isFile()).length;
85
+
86
+ console.log(`[skill] ${label} — ${count} archivos desde v${version}.`);
@@ -0,0 +1,100 @@
1
+ ---
2
+ name: flysoft-ui
3
+ description: Catálogo y referencia de API de flysoft-react-ui — componentes React themeados, hooks, providers, apiClient y templates. Usar al construir o modificar cualquier UI (formularios, listados, tablas, diálogos, filtros) en una app que tenga flysoft-react-ui como dependencia, y antes de escribir a mano un control que la librería probablemente ya tenga.
4
+ ---
5
+
6
+ # flysoft-react-ui
7
+
8
+ Librería de componentes React con tema y densidad centralizados. Todo se importa
9
+ desde el paquete raíz — nunca desde rutas internas:
10
+
11
+ ```tsx
12
+ import { Card, DataTable, Input, Button, useAsyncRequest } from "flysoft-react-ui";
13
+ ```
14
+
15
+ ## Cómo entran los estilos
16
+
17
+ Los estilos entran una sola vez. **Importalos desde el CSS de la app, dentro de
18
+ una capa**, no con un `import "flysoft-react-ui/styles"` en el entry de JS:
19
+
20
+ ```css
21
+ /* index.css de la app — el orden de capas va antes que cualquier @import */
22
+ @layer flysoft, base, components, utilities;
23
+
24
+ @import "flysoft-react-ui/styles" layer(flysoft);
25
+ @import "tailwindcss";
26
+ ```
27
+
28
+ El motivo importa, porque el modo de fallar es silencioso. La librería importa
29
+ `tailwindcss/theme` y `tailwindcss/utilities` por separado para saltear
30
+ Preflight, y al hacerlo pierde la asignación de capa que Tailwind hace en su
31
+ `index.css`: **sus utilities se publican sin capa**. En CSS, lo que no está en
32
+ ninguna capa le gana a todo lo que sí — así que sin este `layer(flysoft)` los
33
+ estilos de la librería pisan los de la app aunque la app tenga más
34
+ especificidad, y no hay forma de ganarles salvo con `!important`.
35
+
36
+ Declarando `flysoft` como la primera capa, la librería queda con la prioridad
37
+ más baja y la app la puede sobreescribir normalmente.
38
+
39
+ Si la app no usa capas para nada, `import "flysoft-react-ui/styles"` en el entry
40
+ alcanza. En cuanto la app pone algo en una capa —lo hace cualquier proyecto con
41
+ Tailwind v4— necesita la forma de arriba.
42
+
43
+ Los componentes se pintan con variables CSS del tema (`--color-*`, `--radius-*`)
44
+ y escalan con la densidad activa. No lleva colores hardcodeados y no hace falta
45
+ configurarle nada por componente: el tema y la densidad se setean una vez en el
46
+ provider.
47
+
48
+ ## Antes de escribir un componente, fijate si ya existe
49
+
50
+ Cuando te pidan "un formulario con un buscador de clientes y una fecha", la
51
+ respuesta no es escribir un `<select>` con búsqueda ni un calendario. Casi
52
+ siempre ya hay un componente para eso, y uno hecho a mano queda fuera del tema,
53
+ fuera de la densidad y sin el comportamiento de teclado y accesibilidad que el
54
+ de la librería ya tiene.
55
+
56
+ | Si necesitás | Usá | No escribas |
57
+ |---|---|---|
58
+ | Un desplegable con búsqueda sobre una lista que ya tenés en memoria | `AutocompleteInput` | `<select>`, combobox propio |
59
+ | Lo mismo pero el dataset es grande y se busca contra el backend | `SearchSelectInput` | fetch + lista propia |
60
+ | Un campo de fecha | `DateInput` | `<input type="date">`, calendario propio |
61
+ | Un calendario suelto, sin input | `DatePicker` | — |
62
+ | Un importe en pesos | `CurrencyInput` | `<input>` + formateo a mano |
63
+ | Una tabla con datos, orden y acciones por fila | `DataTable` | `<table>` |
64
+ | Filtros que viven en la URL | `Filter` | `useSearchParams` + inputs sueltos |
65
+ | Paginación | `Pagination` | controles propios |
66
+ | Un modal | `Dialog` | portal propio |
67
+ | Un toast / notificación | `useSnackbar()` | librería de toasts |
68
+ | Mostrar un par etiqueta-valor | `DataField` | `<div><label>` |
69
+ | Apilar o alinear elementos con separación | `Collection` | `<div className="flex gap-4">` |
70
+ | Un contenedor con título, acciones y footer | `Card` | `<div>` con bordes |
71
+ | Estado de carga | `Loader` o `Skeleton` | spinner propio |
72
+
73
+ Antes de dar por hecho que algo no existe, buscalo en la referencia de abajo:
74
+ son 33 componentes más 7 templates, y hay bastante más de lo que parece.
75
+
76
+ Si de verdad no existe, componelo con los que sí (`Card` + `Collection` +
77
+ `Input`) antes de escribir CSS nuevo, y usá siempre las variables del tema
78
+ (`references/theming.md`), nunca colores literales.
79
+
80
+ ## Dónde está cada cosa
81
+
82
+ Las referencias se cargan sueltas: leé sólo la que necesites.
83
+
84
+ | Necesitás | Leé |
85
+ |---|---|
86
+ | Inputs, botones, selects, fechas, importes, paginación | `references/forms.md` |
87
+ | Tablas, campos de datos, badges, avatares, loaders, skeletons | `references/display.md` |
88
+ | Cards, layout de página, tabs, acordeones, diálogos, menús, filtros | `references/layout.md` |
89
+ | Providers, hooks y lo que devuelve cada uno | `references/hooks.md` |
90
+ | `apiClient`, helpers, interfaces compartidas | `references/services.md` |
91
+ | Formularios y páginas pre-armadas | `references/templates.md` |
92
+ | Variables CSS de tema y densidad | `references/theming.md` |
93
+ | **Cuándo usar qué, patrones de página, densidad, contratos de los genéricos** | **`patterns.md`** |
94
+
95
+ Los archivos bajo `references/` se generan desde los tipos de la librería en
96
+ cada build. Si una tabla no coincide con el código, el que está mal es el tipo,
97
+ no la tabla.
98
+
99
+ `patterns.md` es lo escrito a mano: empezá por ahí si la pregunta es "cómo se
100
+ arma una página de listado" y no "qué props tiene `DataTable`".
@@ -0,0 +1,352 @@
1
+ # Cómo se usa flysoft-react-ui
2
+
3
+ Esto es lo que los tipos no dicen: cuándo elegir un componente sobre otro, qué
4
+ forma tiene que tener un genérico, y cómo se arma una página completa. Para
5
+ firmas y props, `references/`.
6
+
7
+ ---
8
+
9
+ ## El sistema de densidad
10
+
11
+ Es lo que más se malinterpreta, porque no se ve en las props de ningún
12
+ componente.
13
+
14
+ La densidad es **global y se setea una sola vez**, en el provider:
15
+
16
+ ```tsx
17
+ <AppLayoutProvider density="compact">
18
+ <App />
19
+ </AppLayoutProvider>
20
+ ```
21
+
22
+ Los valores son `"comfortable" | "compact" | "dense"`. El provider escribe 26
23
+ variables CSS (`--flysoft-density-*`) en el `<html>`, y **todos** los componentes
24
+ de la librería leen de ahí: padding, tipografía, alto de los controles, alto de
25
+ las filas de `DataTable`, radio de los inputs, separación de `Collection`.
26
+
27
+ La consecuencia práctica es la que importa:
28
+
29
+ > **No pases `compact` ni `size="sm"` componente por componente para achicar la
30
+ > UI.** Esas props existen y funcionan, pero son overrides puntuales. Si las usás
31
+ > para lograr una UI compacta, terminás con una densidad distinta en cada
32
+ > pantalla, y el día que cambie la densidad global esos componentes no la siguen.
33
+
34
+ Si toda la app tiene que verse más compacta, se cambia una palabra en el
35
+ provider. Si necesitás densidad distinta sólo en una sección, hay un override
36
+ por subárbol:
37
+
38
+ ```tsx
39
+ <Collection density="dense">
40
+ {/* estos hijos y sus descendientes usan la escala "dense" */}
41
+ </Collection>
42
+ ```
43
+
44
+ La densidad se persiste en `localStorage` bajo `"flysoft-density"` y se puede
45
+ cambiar en runtime:
46
+
47
+ ```tsx
48
+ const { density, setDensity } = useAppLayout();
49
+ ```
50
+
51
+ Para forzar un valor e ignorar lo guardado, `forceInitialDensity`. Los valores
52
+ concretos de cada token están en `references/theming.md`.
53
+
54
+ ---
55
+
56
+ ## Qué forma tiene que tener `T`
57
+
58
+ Varios componentes son genéricos sin restricción: el tipo acepta cualquier `T`,
59
+ pero el componente en runtime espera algo. Este es el contrato real.
60
+
61
+ ### `AutocompleteInput<T, K>` y `SearchSelectInput<T, K>`
62
+
63
+ Sin `getOptionLabel` / `getOptionValue`, el componente lee `item.label` e
64
+ `item.value`. O sea que `T` tiene que ser `{ label: string; value: string }`
65
+ —las interfaces `AutocompleteOption` y `SearchSelectOption` son exactamente eso.
66
+
67
+ Con tus propios objetos, pasás los dos getters y `T` puede ser lo que quieras:
68
+
69
+ ```tsx
70
+ <AutocompleteInput<Cliente, number>
71
+ label="Cliente"
72
+ options={clientes}
73
+ getOptionLabel={(c) => c.razonSocial}
74
+ getOptionValue={(c) => c.id}
75
+ />
76
+ ```
77
+
78
+ Si omitís los getters con un `T` que no tiene `label`, no hay error de
79
+ compilación: la lista se renderiza con todas las etiquetas vacías. Es el modo de
80
+ fallar más común de estos dos componentes.
81
+
82
+ ### `DataTable<T>`
83
+
84
+ `column.value` acepta un string, que se usa como nombre de propiedad de la fila.
85
+ **Si la propiedad no existe, se renderiza el string tal cual**, como texto
86
+ literal. Un typo en el nombre del campo no rompe: te muestra `"nomrbe"` en la
87
+ celda. El tipo no puede detectarlo porque `value` es `string`, no `keyof T`.
88
+
89
+ Cuando el dato no sale de una propiedad directa, usá la forma de función, que sí
90
+ está tipada:
91
+
92
+ ```tsx
93
+ { header: "Total", value: (row) => currencyFormat(row.importe * row.cantidad) }
94
+ ```
95
+
96
+ ### `Menu<T>` y `DropdownMenu<T>`
97
+
98
+ Mismo contrato que `AutocompleteInput`: sin `getOptionLabel`, leen `item.label`.
99
+
100
+ ### `CrudProvider<T>` / `useCrud<T>`
101
+
102
+ `T` es la entidad. Ojo con la forma de lo que devuelve `useCrud`: las
103
+ operaciones **no son funciones**, son objetos con `execute` e `isLoading`
104
+ propios.
105
+
106
+ ```tsx
107
+ const { list, fetchItems, createItem } = useCrud<Usuario>();
108
+
109
+ await fetchItems.execute({ pagina: 2 }); // correcto
110
+ await fetchItems(); // no compila
111
+ ```
112
+
113
+ ---
114
+
115
+ ## Cuándo usar qué
116
+
117
+ **`Card` vs `Collection` vs `DataField`.** `Card` es una superficie con título,
118
+ acciones y footer — un bloque de contenido con identidad. `Collection` no dibuja
119
+ nada: sólo apila o alinea con la separación de la densidad activa; es el
120
+ reemplazo de `<div className="flex gap-4">`. `DataField` es un par
121
+ etiqueta-valor para pantallas de detalle, no un contenedor.
122
+
123
+ **`AutocompleteInput` vs `SearchSelectInput`.** Si las opciones ya están en
124
+ memoria y son unas pocas decenas, `AutocompleteInput`. Si hay que ir al backend
125
+ a buscarlas, `SearchSelectInput`, que abre un diálogo y recibe dos promesas: una
126
+ para buscar por texto y otra para resolver un valor ya seleccionado
127
+ (`onSingleSearchPromiseFn`, necesaria para poder mostrar el label cuando la
128
+ pantalla carga con un valor preexistente).
129
+
130
+ **`DataTable` vs `Collection` de `Card`s.** Tabla cuando las filas comparten
131
+ columnas y se comparan entre sí. Cards cuando cada ítem tiene forma propia.
132
+
133
+ **`Dialog` vs `DropdownPanel`.** `Dialog` bloquea e interrumpe: confirmaciones y
134
+ formularios. `DropdownPanel` es un popover anclado a un disparador, para filtros
135
+ y opciones.
136
+
137
+ **Template vs composición.** Los templates (`ListPattern`, `FormPattern`,
138
+ `LoginForm`) resuelven un caso entero. Sirven mientras tu caso sea el suyo; en
139
+ cuanto necesitás desviarte, componé con `Card` + `DataTable` + `Filter` en vez
140
+ de pelearle las props. `ListPattern` es el que más rinde porque el listado
141
+ paginado con filtros es siempre igual.
142
+
143
+ ---
144
+
145
+ ## Patrones
146
+
147
+ ### Formulario con guardado async
148
+
149
+ `useAsyncRequest` envuelve la promesa, maneja el `isLoading` y dispara el
150
+ snackbar de éxito o error. Necesita un `SnackbarProvider` arriba —
151
+ `AppLayoutProvider` ya lo incluye.
152
+
153
+ ```tsx
154
+ const { execute, isLoading } = useAsyncRequest({
155
+ successMessage: "Guardado exitosamente",
156
+ errorMessage: (err) => getErrorMessage(err),
157
+ });
158
+
159
+ const guardar = () =>
160
+ execute(() => apiClient.post({ url: "/api/usuarios", body: { nombre, email } }));
161
+
162
+ return (
163
+ <Card title="Nuevo usuario">
164
+ <Collection>
165
+ <Input label="Nombre" value={nombre} onChange={(e) => setNombre(e.target.value)} icon="fa-user" />
166
+ <Input label="Email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} icon="fa-envelope" />
167
+ <Button icon="fa-save" loading={isLoading} onClick={guardar}>Guardar</Button>
168
+ </Collection>
169
+ </Card>
170
+ );
171
+ ```
172
+
173
+ `execute` devuelve el resultado de la promesa, o `undefined` si falló — así que
174
+ podés encadenar sin try/catch:
175
+
176
+ ```tsx
177
+ const creado = await execute(() => apiClient.post<Usuario>({ url: "/api/usuarios", body }));
178
+ if (creado) navigate(`/usuarios/${creado.id}`);
179
+ ```
180
+
181
+ ### Confirmación con Dialog
182
+
183
+ `isOpen`, `title` y `children` son requeridas. Las acciones van en `footer`.
184
+
185
+ ```tsx
186
+ <Dialog
187
+ isOpen={mostrarConfirmacion}
188
+ title="Confirmar eliminación"
189
+ onClose={() => setMostrarConfirmacion(false)}
190
+ footer={
191
+ <>
192
+ <Button variant="ghost" onClick={() => setMostrarConfirmacion(false)}>Cancelar</Button>
193
+ <Button color="danger" loading={isLoading} onClick={eliminar}>Eliminar</Button>
194
+ </>
195
+ }
196
+ >
197
+ <p>¿Está seguro? Esta acción no se puede deshacer.</p>
198
+ </Dialog>
199
+ ```
200
+
201
+ Por defecto el click en el overlay **no** cierra el diálogo (`closeOnOverlayClick`
202
+ es `false`), que es lo que querés en una confirmación destructiva.
203
+
204
+ ### Página de listado
205
+
206
+ Los `Filter` se sincronizan solos con el query string a través de `paramName`, y
207
+ `Pagination` hace lo mismo con `fieldName` (por defecto `"pagina"`). No hace
208
+ falta cablear nada entre ellos: el efecto que dispara la búsqueda escucha los
209
+ search params.
210
+
211
+ ```tsx
212
+ function ListadoUsuarios() {
213
+ const [searchParams] = useSearchParams();
214
+ const [usuarios, setUsuarios] = useState<Usuario[]>([]);
215
+ const [paginacion, setPaginacion] = useState({ page: 1, pages: 1, total: 0 });
216
+ const [isLoading, setIsLoading] = useState(false);
217
+ const { showSnackbar } = useSnackbar();
218
+
219
+ useEffect(() => {
220
+ const buscar = async () => {
221
+ setIsLoading(true);
222
+ try {
223
+ const params = Object.fromEntries(searchParams);
224
+ const data = await apiClient.get<PaginationInterface<Usuario>>({
225
+ url: "/api/usuarios",
226
+ params,
227
+ });
228
+ setUsuarios(data.list);
229
+ setPaginacion({ page: data.page, pages: data.pages, total: data.total });
230
+ } catch (error) {
231
+ showSnackbar(getErrorMessage(error), "danger");
232
+ } finally {
233
+ setIsLoading(false);
234
+ }
235
+ };
236
+ buscar();
237
+ }, [searchParams]);
238
+
239
+ const columns: DataTableColumn<Usuario>[] = [
240
+ { header: "Nombre", value: "nombre" },
241
+ { header: "Email", value: "email" },
242
+ { header: "Alta", value: "fechaAlta", type: "date" },
243
+ {
244
+ actions: (row) => [
245
+ <LinkButton key="ver" to={`/usuarios/${row.id}`} variant="ghost" icon="fa-eye" />,
246
+ ],
247
+ },
248
+ ];
249
+
250
+ return (
251
+ <Collection>
252
+ <Collection direction="row">
253
+ <Filter filterType="search" paramName="nombre" label="Nombre" />
254
+ <Filter
255
+ filterType="autocomplete"
256
+ paramName="rol"
257
+ label="Rol"
258
+ options={[
259
+ { label: "Administrador", value: "admin" },
260
+ { label: "Operador", value: "operador" },
261
+ ]}
262
+ />
263
+ </Collection>
264
+
265
+ <Card
266
+ title="Usuarios"
267
+ headerActions={<LinkButton to="/usuarios/nuevo" icon="fa-plus">Nuevo</LinkButton>}
268
+ >
269
+ <DataTable columns={columns} rows={usuarios} isLoading={isLoading} />
270
+ <Pagination {...paginacion} isLoading={isLoading} />
271
+ </Card>
272
+ </Collection>
273
+ );
274
+ }
275
+ ```
276
+
277
+ `DataTable` formatea solo según `type`: `"currency"` a miles con dos decimales y
278
+ sin símbolo, `"numeric"` según el `locale` (por defecto `"es-AR"`), `"date"` a
279
+ `DD/MM/YYYY`. Las columnas `currency` y `numeric` además se alinean solas a la
280
+ derecha, no hace falta pasar `align`.
281
+
282
+ ### El mismo listado con ListPattern
283
+
284
+ Cuando el caso es el estándar, `ListPattern` reemplaza todo el JSX de arriba:
285
+
286
+ ```tsx
287
+ <ListPattern<Usuario>
288
+ title="Usuarios"
289
+ columns={columns}
290
+ rows={usuarios}
291
+ searchParamName="nombre"
292
+ addButtonText="Nuevo"
293
+ onAdd={() => navigate("/usuarios/nuevo")}
294
+ filtersNode={<Filter filterType="autocomplete" paramName="rol" label="Rol" options={roles} />}
295
+ page={paginacion.page}
296
+ pages={paginacion.pages}
297
+ total={paginacion.total}
298
+ isLoading={isLoading}
299
+ />
300
+ ```
301
+
302
+ ### Token de autenticación en el apiClient
303
+
304
+ Se registra una vez, y todas las llamadas lo mandan:
305
+
306
+ ```tsx
307
+ setApiClientTokenProvider(() => user?.token?.accessToken);
308
+ ```
309
+
310
+ Es un *provider*, no un valor: se evalúa en cada request, así que un refresh de
311
+ token se refleja solo sin volver a registrar nada.
312
+
313
+ ### Controlar el drawer desde el contenido
314
+
315
+ En desktop el drawer está siempre visible; en móvil es un panel con overlay que
316
+ queda abierto después de navegar. Por eso los links del drawer lo cierran:
317
+
318
+ ```tsx
319
+ const { closeLeftDrawer } = useLeftDrawer();
320
+
321
+ <LinkButton to="/inicio" onClick={closeLeftDrawer}>Inicio</LinkButton>
322
+ ```
323
+
324
+ `useLeftDrawer()` lanza error fuera de un `AppLayout`. Para componentes que se
325
+ usan dentro y fuera, `useOptionalLeftDrawer()` devuelve `undefined`.
326
+
327
+ ---
328
+
329
+ ## Íconos
330
+
331
+ FontAwesome 5, estilo light. Se pasa sólo el nombre y la librería lo normaliza a
332
+ `fal fa-*`:
333
+
334
+ ```tsx
335
+ <Button icon="fa-save">Guardar</Button>
336
+ ```
337
+
338
+ No mezclar con otras librerías de íconos: no van a heredar el color ni el tamaño
339
+ del tema.
340
+
341
+ ---
342
+
343
+ ## Trampas verificadas
344
+
345
+ - **`CurrencyInput.onChange` está tipado `(value: any) => void`.** El valor real
346
+ que recibís es `number`. Tipá tu handler como `number` igual.
347
+ - **`DataTable` con un `value` string que no existe en la fila** renderiza el
348
+ string como texto, no falla. Revisá los nombres de campo.
349
+ - **`useCrud()` devuelve objetos, no funciones** (`fetchItems.execute()`).
350
+ - **`NavbarInterface.fullWidthNavbar` es requerida**, no opcional.
351
+ - **`Collection` es `direction="column"` por defecto**, no `row`.
352
+ - **`SnackbarContainer` es `position="top-right"` por defecto.**