kaizen-lib 0.2.12 → 0.2.13

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
@@ -12,7 +12,7 @@ styled-components con `moduleResolution: "bundler"`, así que lo transpilan ello
12
12
  |---|---|---|
13
13
  | `kaizen-lib/tokens` | Referencias `var(--kz-*)` tipadas | listo |
14
14
  | `kaizen-lib/tokens.css` | Valores por defecto, tema oscuro y claro | listo |
15
- | `kaizen-lib/ui` | Componentes (`IconButton`, `ReactionButton`, `Medidor`, `Relieve`, `LangSelector`, `Modal`, `Badge`, `BottomNav`, `DiscoHaxball`) | en curso |
15
+ | `kaizen-lib/ui` | Componentes (`IconButton`, `ReactionButton`, `Medidor`, `Relieve`, `LangSelector`, `Modal`, `Badge`, `BottomNav`, `PanelExpandible`, `DiscoHaxball`) | en curso |
16
16
 
17
17
  ## ¿Qué uso para qué?
18
18
 
@@ -32,6 +32,7 @@ alcanza, extendé el componente en vez de bypassearlo (ver `implement.md` del ha
32
32
  | Un arco/medidor que muestra cuánto de un total está ocupado | `Medidor` | un `<progress>` o una barra propia |
33
33
  | Una píldora de estado (activo, vencido, un rol, una etiqueta) con fondo lavado y texto del mismo tono | `Badge` | un `styled.span` propio por producto — es exactamente lo que ya había triplicado |
34
34
  | La navegación principal fija abajo en el teléfono (ícono + rótulo por destino) | `BottomNav`, y `bottomNavOffset` de padding inferior en lo que no debe quedar tapado | una barra propia — ya estaba cuadruplicada |
35
+ | Un panel de pantalla con título, un número que resume el contenido y un cuerpo que se pliega (sin desmontarse) | `PanelExpandible` | un `<details>` o un acordeón propio — el indicador queda a la vista con el panel cerrado, que es lo que justifica plegarlo |
35
36
  | Espaciado, radios, color, tipografía, sombra | `kaizen-lib/tokens` (`space`, `radius`, `fg`, `accent`, `font`, `shadow`, `relief`...) | un valor a mano — cada uno es una referencia `var(--kz-*)` |
36
37
  | Mostrar la ficha/disco de Haxball de un equipo (para elegir un equipo representativo, o para lucir el catálogo de camisetas) | `DiscoHaxball` + `CAMISETAS_HAXBALL`; el avatar (`texto`) se valida al guardarlo con `recortarAvatarHaxball` | una imagen propia — es una aproximación visual generada por SVG, no requiere ningún asset |
37
38
 
@@ -297,6 +298,24 @@ dispositivo). La barra es fija, así que el contenido lleva `bottomNavOffset` de
297
298
  inferior y lo que flota (FAB, avisos) lo suma a su `bottom`; donde la barra no está, vale
298
299
  `0px`.
299
300
 
301
+ ```tsx
302
+ import { PanelExpandible } from 'kaizen-lib/ui';
303
+
304
+ <PanelExpandible titulo="Comprometido" indicador="30 g" leyenda="faltan entregar">
305
+ <Tabla />
306
+ </PanelExpandible>
307
+ ```
308
+
309
+ **`PanelExpandible`** es un panel de pantalla: cabecera con el título y un indicador
310
+ (`indicador` + `leyenda`, o varios con `indicadores`), y un cuerpo que se pliega. Plegado
311
+ **no desmonta**: el contenido conserva filtros y selección. El control accesible es el
312
+ chevron (`aria-expanded`); toda la cabecera responde al click. Controlado (`abierto` +
313
+ `onCambiar`) o no (`abiertoInicial`). Con `plegable={false}` conserva el formato sin
314
+ chevron, para una pantalla de un solo panel. `duracionMs` (default 300) fija la animación
315
+ y el momento en que el cuerpo se oculta. `etiquetaPlegar`/`etiquetaDesplegar` traducen el
316
+ nombre del chevron. La columna del plegable es `minmax(0, 1fr)`: una tabla ancha adentro
317
+ se compacta o scrollea, no empuja el panel fuera de la pantalla.
318
+
300
319
  ## Desarrollo
301
320
 
302
321
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-lib",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "description": "Tokens y componentes compartidos entre los fronts de Kaizen.",
5
5
  "license": "MIT",
6
6
  "author": "Nicolás Sandobal",
@@ -0,0 +1,299 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useId, useState, type ReactNode } from 'react';
4
+ import styled from 'styled-components';
5
+ import { fg, font, hairline, motion, radius, ring, shadow, space, surface } from '../tokens';
6
+
7
+ /**
8
+ * Un **panel** de una pantalla: una elevación partida en dos. Elicitado de
9
+ * valle-verde (v1.3.13), donde ya lo usaban siete pantallas.
10
+ *
11
+ * - **Cabecera**: el título y, bien a la vista, el **indicador** que resume lo
12
+ * que hay adentro, con una leyenda chica debajo que dice qué es ese número.
13
+ * En el extremo, el chevron.
14
+ * - **Cuerpo**: el contenido. Se pliega y se despliega.
15
+ *
16
+ * El indicador es lo que justifica plegar: con el panel cerrado la cabecera
17
+ * sigue diciendo lo que importa, y la pantalla entra entera sin scrollear.
18
+ *
19
+ * ## Decisiones
20
+ *
21
+ * - **Plegado no desmonta.** El cuerpo queda en el DOM con `hidden`: una tabla
22
+ * con filas seleccionadas o filtros cargados los conserva al reabrir, y no
23
+ * vuelve a pedir sus datos.
24
+ * - **El control accesible es el chevron**, un botón con `aria-expanded` y
25
+ * `aria-controls`. Toda la cabecera responde al click por comodidad de mouse
26
+ * y de dedo, pero no es un botón: adentro lleva un encabezado y un número, y
27
+ * un botón no puede contener un encabezado.
28
+ * - **Controlado o no**: `abierto` + `onCambiar`, o `abiertoInicial`.
29
+ * - **Se compacta, no desborda.** El plegable es un grid con la columna en
30
+ * `minmax(0, 1fr)`: sin columna declarada, la implícita medía el ancho
31
+ * máximo del contenido y una tabla ancha empujaba el panel fuera de la
32
+ * pantalla (valle-verde 1.4.3).
33
+ *
34
+ * ```tsx
35
+ * <PanelExpandible titulo="Comprometido" indicador={12} leyenda="pacientes sin entrega">
36
+ * <Tabla />
37
+ * </PanelExpandible>
38
+ * ```
39
+ */
40
+
41
+ const Raiz = styled.section`
42
+ border-radius: ${radius.lg};
43
+ background: ${surface[1]};
44
+ box-shadow: ${shadow.sm};
45
+ min-width: 0;
46
+ `;
47
+
48
+ const Cabecera = styled.div<{ $abierto: boolean; $plegable: boolean }>`
49
+ display: flex;
50
+ align-items: center;
51
+ gap: ${space[4]};
52
+ padding: ${space[4]} ${space[5]};
53
+ border-radius: ${({ $abierto }) => ($abierto ? `${radius.lg} ${radius.lg} 0 0` : radius.lg)};
54
+ cursor: ${({ $plegable }) => ($plegable ? 'pointer' : 'default')};
55
+ user-select: none;
56
+ transition: background ${motion.fast} ${motion.ease};
57
+
58
+ &:hover {
59
+ background: ${({ $plegable }) => ($plegable ? surface.hover : 'transparent')};
60
+ }
61
+ `;
62
+
63
+ const Titulo = styled.h2`
64
+ flex: 1 1 auto;
65
+ min-width: 0;
66
+ margin: 0;
67
+ font-size: ${font.xl};
68
+ font-weight: 600;
69
+ line-height: 1.2;
70
+ color: ${fg.DEFAULT};
71
+ overflow-wrap: anywhere;
72
+ `;
73
+
74
+ const Indicador = styled.div`
75
+ flex: 0 1 auto;
76
+ min-width: 0;
77
+ display: flex;
78
+ flex-direction: column;
79
+ align-items: flex-end;
80
+ text-align: right;
81
+ `;
82
+
83
+ const Valor = styled.strong`
84
+ font-size: ${font['2xl']};
85
+ font-weight: 600;
86
+ line-height: 1.1;
87
+ font-variant-numeric: tabular-nums;
88
+ color: ${fg.DEFAULT};
89
+ white-space: nowrap;
90
+ `;
91
+
92
+ const Leyenda = styled.span`
93
+ font-size: ${font.xs};
94
+ color: ${fg.subtle};
95
+ `;
96
+
97
+ const Indicadores = styled.div`
98
+ flex: 0 1 auto;
99
+ min-width: 0;
100
+ display: flex;
101
+ flex-wrap: wrap;
102
+ justify-content: flex-end;
103
+ gap: ${space[2]} ${space[5]};
104
+ `;
105
+
106
+ const Chevron = styled.button<{ $abierto: boolean; $duracion: number }>`
107
+ flex: 0 0 auto;
108
+ display: inline-flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+ width: 36px;
112
+ height: 36px;
113
+ padding: 0;
114
+ border: none;
115
+ border-radius: ${radius.md};
116
+ background: transparent;
117
+ color: ${fg.muted};
118
+ cursor: pointer;
119
+ outline: 2px solid transparent;
120
+ outline-offset: 2px;
121
+
122
+ &:focus-visible { outline-color: ${ring}; }
123
+
124
+ svg {
125
+ transform: rotate(${({ $abierto }) => ($abierto ? '180deg' : '0deg')});
126
+ transition: transform ${({ $duracion }) => Math.min($duracion, 300)}ms ${motion.ease};
127
+ }
128
+
129
+ @media (prefers-reduced-motion: reduce) {
130
+ svg { transition: none; }
131
+ }
132
+ `;
133
+
134
+ const Cuerpo = styled.div`
135
+ padding: ${space[5]};
136
+ border-top: 1px solid ${hairline.DEFAULT};
137
+ min-width: 0;
138
+ `;
139
+
140
+ /*
141
+ * El alto se anima con `grid-template-rows` de 0fr a 1fr: anima hasta el alto
142
+ * real del contenido sin medirlo, que con `height` no se puede (no anima a
143
+ * `auto`). El hijo directo recorta lo que no entra mientras tanto.
144
+ */
145
+ const Plegable = styled.div<{ $expandido: boolean; $duracion: number }>`
146
+ display: grid;
147
+ grid-template-columns: minmax(0, 1fr);
148
+ grid-template-rows: ${({ $expandido }) => ($expandido ? '1fr' : '0fr')};
149
+ transition: grid-template-rows ${({ $duracion }) => $duracion}ms ${motion.ease};
150
+
151
+ > div {
152
+ min-height: 0;
153
+ overflow: hidden;
154
+ }
155
+
156
+ &[hidden] { display: none; }
157
+
158
+ @media (prefers-reduced-motion: reduce) {
159
+ transition: none;
160
+ }
161
+ `;
162
+
163
+ /**
164
+ * ¿Se anima? Con `prefers-reduced-motion`, y donde el navegador no sabe
165
+ * preguntarlo, el panel se abre y se cierra de una (el estado seguro).
166
+ */
167
+ const puedeAnimar = (): boolean =>
168
+ typeof window !== 'undefined'
169
+ && typeof window.matchMedia === 'function'
170
+ && typeof window.requestAnimationFrame === 'function'
171
+ && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
172
+
173
+ export type PanelExpandibleProps = {
174
+ /** El nombre del panel. Se escribe como se lee: "Comprometido", no "COMPROMETIDO". */
175
+ titulo: string;
176
+ /** El número (o dato) que resume el contenido. Va grande, a la derecha de la cabecera. */
177
+ indicador?: ReactNode;
178
+ /** Qué es el indicador, en una línea corta. */
179
+ leyenda?: ReactNode;
180
+ /** Más de un indicador, cada uno con su leyenda. Con esto, `indicador` y `leyenda` no se miran. */
181
+ indicadores?: { valor: ReactNode; leyenda?: ReactNode }[];
182
+ /**
183
+ * Si el panel se pliega (por defecto, sí). Con `false` conserva el formato pero no
184
+ * tiene chevron ni responde al click: para la pantalla que tiene **un solo** panel.
185
+ */
186
+ plegable?: boolean;
187
+ /** Arranca abierto (por defecto) o plegado, cuando el panel maneja su propio estado. */
188
+ abiertoInicial?: boolean;
189
+ /** Controlado desde afuera. Con esto, `abiertoInicial` no se mira. */
190
+ abierto?: boolean;
191
+ onCambiar?: (abierto: boolean) => void;
192
+ /** Cuánto tarda en plegarse o desplegarse, en ms. Default 300. */
193
+ duracionMs?: number;
194
+ /**
195
+ * Nombre accesible del chevron, sin el título (que se agrega solo). Defaults
196
+ * `'Plegar'` y `'Desplegar'`: un consumidor con más de un idioma los pisa.
197
+ */
198
+ etiquetaPlegar?: string;
199
+ etiquetaDesplegar?: string;
200
+ className?: string;
201
+ children: ReactNode;
202
+ };
203
+
204
+ export function PanelExpandible({
205
+ titulo,
206
+ indicador,
207
+ leyenda,
208
+ indicadores,
209
+ plegable = true,
210
+ abiertoInicial = true,
211
+ abierto: abiertoControlado,
212
+ onCambiar,
213
+ duracionMs = 300,
214
+ etiquetaPlegar = 'Plegar',
215
+ etiquetaDesplegar = 'Desplegar',
216
+ className,
217
+ children,
218
+ }: PanelExpandibleProps) {
219
+ const [abiertoPropio, setAbiertoPropio] = useState(abiertoInicial);
220
+ const abierto = !plegable || (abiertoControlado ?? abiertoPropio);
221
+
222
+ /*
223
+ * Dos estados además de `abierto`: si el cuerpo está en el árbol visible
224
+ * (`montado`) y si está desplegado (`expandido`). Al abrir, primero se
225
+ * muestra plegado y en el cuadro siguiente se despliega, para que haya algo
226
+ * que animar. Al cerrar, se pliega y recién al terminar se oculta: así lo
227
+ * plegado no queda enfocable ni lo lee un lector de pantalla. Por eso la
228
+ * duración es una prop y no sólo CSS: el temporizador tiene que coincidir.
229
+ */
230
+ const [montado, setMontado] = useState(abierto);
231
+ const [expandido, setExpandido] = useState(abierto);
232
+ useEffect(() => {
233
+ if (!puedeAnimar()) {
234
+ setMontado(abierto);
235
+ setExpandido(abierto);
236
+ return undefined;
237
+ }
238
+ if (abierto) {
239
+ setMontado(true);
240
+ const cuadro = window.requestAnimationFrame(() => setExpandido(true));
241
+ return () => window.cancelAnimationFrame(cuadro);
242
+ }
243
+ setExpandido(false);
244
+ const espera = window.setTimeout(() => setMontado(false), duracionMs);
245
+ return () => window.clearTimeout(espera);
246
+ }, [abierto, duracionMs]);
247
+ const idCuerpo = useId();
248
+
249
+ const alternar = () => {
250
+ if (!plegable) return;
251
+ if (abiertoControlado === undefined) setAbiertoPropio(!abierto);
252
+ onCambiar?.(!abierto);
253
+ };
254
+
255
+ const hayIndicador = indicador !== undefined && indicador !== null;
256
+
257
+ return (
258
+ <Raiz className={className}>
259
+ <Cabecera $abierto={abierto} $plegable={plegable} onClick={alternar}>
260
+ <Titulo>{titulo}</Titulo>
261
+ {indicadores ? (
262
+ <Indicadores>
263
+ {indicadores.map((it, i) => (
264
+ <Indicador key={i}>
265
+ <Valor>{it.valor}</Valor>
266
+ {it.leyenda && <Leyenda>{it.leyenda}</Leyenda>}
267
+ </Indicador>
268
+ ))}
269
+ </Indicadores>
270
+ ) : (hayIndicador || leyenda) && (
271
+ <Indicador>
272
+ {hayIndicador && <Valor>{indicador}</Valor>}
273
+ {leyenda && <Leyenda>{leyenda}</Leyenda>}
274
+ </Indicador>
275
+ )}
276
+ {plegable && (
277
+ <Chevron
278
+ type="button"
279
+ $abierto={abierto}
280
+ $duracion={duracionMs}
281
+ aria-expanded={abierto}
282
+ aria-controls={idCuerpo}
283
+ aria-label={`${abierto ? etiquetaPlegar : etiquetaDesplegar} ${titulo}`}
284
+ // El click sube a la cabecera, que es la que alterna: acá no se repite.
285
+ >
286
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
287
+ <polyline points="6 9 12 15 18 9" />
288
+ </svg>
289
+ </Chevron>
290
+ )}
291
+ </Cabecera>
292
+ <Plegable id={idCuerpo} $expandido={expandido} $duracion={duracionMs} hidden={!montado}>
293
+ <div>
294
+ <Cuerpo>{children}</Cuerpo>
295
+ </div>
296
+ </Plegable>
297
+ </Raiz>
298
+ );
299
+ }
package/src/ui/index.ts CHANGED
@@ -15,3 +15,5 @@ export { BottomNav, bottomNavOffset } from './BottomNav';
15
15
  export type { BottomNavItem } from './BottomNav';
16
16
  export { DiscoHaxball, CAMISETAS_HAXBALL, recortarAvatarHaxball, LUGARES_AVATAR_HAXBALL } from './DiscoHaxball';
17
17
  export type { CamisetaHaxball, NombreCamiseta } from './DiscoHaxball';
18
+ export { PanelExpandible } from './PanelExpandible';
19
+ export type { PanelExpandibleProps } from './PanelExpandible';