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 +20 -1
- package/package.json +1 -1
- package/src/ui/PanelExpandible.tsx +299 -0
- package/src/ui/index.ts +2 -0
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
|
@@ -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';
|