kaizen-lib 0.2.13 → 0.2.14

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
@@ -1,337 +1,338 @@
1
- # kaizen-lib
2
-
3
- Tokens y componentes compartidos entre los fronts de Kaizen. Un solo paquete, con
4
- subpath exports: cada parte se importa por separado y el resto no entra al bundle.
5
-
6
- Se publica en **TypeScript crudo**, sin build. Los consumidores son Next + React 19 +
7
- styled-components con `moduleResolution: "bundler"`, así que lo transpilan ellos.
8
-
9
- ## Estado
10
-
11
- | Subpath | Qué es | Estado |
12
- |---|---|---|
13
- | `kaizen-lib/tokens` | Referencias `var(--kz-*)` tipadas | listo |
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`, `PanelExpandible`, `DiscoHaxball`) | en curso |
16
-
17
- ## ¿Qué uso para qué?
18
-
19
- Antes de escribir un `styled.button` a mano o un ciclador de idioma propio, revisá esta
20
- tabla. Si tu caso está acá, es un defecto reconstruirlo local — usá esto, y si no
21
- alcanza, extendé el componente en vez de bypassearlo (ver `implement.md` del harness:
22
- "un duplicado local de algo que ya existe compartido se reemplaza por el compartido").
23
-
24
- | Necesito... | Uso | No es... |
25
- |---|---|---|
26
- | Un botón redondo de sólo ícono en una topbar (notificaciones, perfil, cerrar) | `IconButton` | — |
27
- | Que ese botón tenga fondo teñido cuando está seleccionado (la página actual, un filtro aplicado) | `IconButton` con `active` | `ReactionButton` (ver abajo, distinción a propósito) |
28
- | Un ícono que cambia de color al reaccionar (me gusta, guardar, favorito) — **sin** fondo teñido | `ReactionButton` | `IconButton` con `active` |
29
- | El relieve neumórfico (círculo con sombra, sin relleno) alrededor de un `IconButton` | `Relieve` envolviendo el `IconButton` | una prop de `IconButton` — ver por qué en su docblock |
30
- | Un ciclador de idioma con banderas en la topbar | `LangSelector` | reconstruir el ciclado a mano — ya lo resuelve, con `Relieve` incluido |
31
- | Un panel flotante que bloquea el resto de la página hasta que se resuelve (formulario, confirmación, contenido a pantalla completa) | `Modal` | un overlay propio — portal, foco atrapado, `Escape`, scroll bloqueado y anidamiento ya están resueltos ahí |
32
- | Un arco/medidor que muestra cuánto de un total está ocupado | `Medidor` | un `<progress>` o una barra propia |
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
- | 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 |
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-*)` |
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 |
38
-
39
- Ningún componente trae íconos ni banderas: los recibe como `children`/`icono`, y el
40
- producto elige de dónde salen (`react-icons`, `react-country-flag`, SVG propio).
41
-
42
- ## Instalación
43
-
44
- ```bash
45
- npm install kaizen-lib
46
- ```
47
-
48
- `react` y `styled-components` son **peerDependencies** y las pone el consumidor. Si
49
- alguna vez quedan como `dependencies`, el consumidor termina con dos instancias de
50
- styled-components y se rompen el theming y el SSR de un modo difícil de diagnosticar.
51
-
52
- En `next.config`:
53
-
54
- ```ts
55
- const nextConfig = { transpilePackages: ['kaizen-lib'] };
56
- ```
57
-
58
- ## Tokens
59
-
60
- El contrato es uno solo: **los nombres**. Los valores los define cada producto.
61
-
62
- ```ts
63
- import styled from 'styled-components';
64
- import { surface, fg, space, radius } from 'kaizen-lib/tokens';
65
-
66
- const Card = styled.div`
67
- background: ${surface[2]};
68
- color: ${fg.DEFAULT};
69
- padding: ${space[4]};
70
- border-radius: ${radius.md};
71
- `;
72
- ```
73
-
74
- ```css
75
- /* globals.css — opcional: valores por defecto, después los pisás con los tuyos */
76
- @import 'kaizen-lib/tokens.css';
77
-
78
- :root {
79
- --kz-accent: #e8318f; /* la marca del producto */
80
- }
81
- ```
82
-
83
- **Adopción parcial.** Si un producto redefine sólo algunos tokens, el resto cae al
84
- valor por defecto. Si no importa `tokens.css` y no define un token que un componente
85
- usa, el estilo simplemente no se aplica — no hay error. Por eso los tests exigen que
86
- todo token de la API esté definido en `defaults.css`.
87
-
88
- **Tema.** Oscuro en `:root`; claro cuando `<html>` lleva la clase `light`. Es el
89
- mecanismo de tutipoker-school-front, el único consumidor con toggle real.
90
-
91
- **Elevación.** Cuatro niveles de uso fijo: `0` página, `1` panel, `2` card, `3` overlay.
92
- `sunken` es el pozo (inputs, tracks) y va más oscuro que su contenedor.
93
-
94
- ## Componentes
95
-
96
- ```tsx
97
- import { IconButton } from 'kaizen-lib/ui';
98
-
99
- <IconButton label="Cambiar tema" size="lg" onClick={alternar}>
100
- <IconoLuna />
101
- </IconButton>
102
-
103
- <IconButton as={Link} href="/notificaciones" label="Notificaciones, 3 sin leer" badge={3}>
104
- <IconoCampana />
105
- </IconButton>
106
- ```
107
-
108
- **`IconButton`** es el botón redondo de sólo ícono de una topbar. `label` es obligatorio
109
- (es el nombre accesible). Con `badge` dibuja un contador, y ese número es decorativo: la
110
- cuenta va dentro del `label`, porque el idioma no es asunto de la librería. Con `as` toma
111
- otro elemento —un `<Link>` de Next— sin que la librería dependa de Next. `size="lg"` es el
112
- área táctil de 44px. Con `motivo` queda bloqueado pero enfocable, para poder mostrar por
113
- qué.
114
-
115
- La librería no trae íconos: cada producto usa los suyos.
116
-
117
- ```tsx
118
- import { ReactionButton } from 'kaizen-lib/ui';
119
-
120
- <ReactionButton tone="danger" active={likeado} glow count={likes} label={`Me gusta, ${likes}`}>
121
- <IconoCorazon />
122
- </ReactionButton>
123
- ```
124
-
125
- **`ReactionButton`** es un ícono que cambia de color al reaccionar (`tone`: `neutral` |
126
- `accent` | `success` | `danger` | `warning` | `info`), con resplandor opcional (`glow`,
127
- del mismo color vía `currentColor`) y una cuenta al lado (`count`; `0` se muestra, no es
128
- una ausencia). No es `IconButton` con más props: **nunca tiñe el fondo**. `IconButton` es
129
- "esto está seleccionado" (la página actual, un filtro aplicado) y por eso tiñe el círculo
130
- entero; `ReactionButton` es "reaccioné con esto" (un me gusta, un guardado), y ahí sólo el
131
- ícono cambia — un corazón marcado no necesita, además, un círculo rosa detrás.
132
-
133
- Un color que los seis tonos no cubren —la marca de un producto, no una categoría
134
- semántica— se pisa con `style`, porque el color activo sale de la variable CSS
135
- `--kz-icon-on` y un estilo en línea le gana a la regla de la librería:
136
-
137
- ```tsx
138
- <ReactionButton
139
- active={guardado}
140
- style={{ '--kz-icon-on': 'var(--mi-color-de-marca)' } as React.CSSProperties}
141
- label="Guardar"
142
- >
143
- <IconoMarcador />
144
- </ReactionButton>
145
- ```
146
-
147
- ```tsx
148
- import { Medidor } from 'kaizen-lib/ui';
149
-
150
- <Medidor valor={8} maximo={10} sentido="menos-es-mejor" label="Anotados: 8 de 10" />
151
- ```
152
-
153
- **`Medidor`** es un arco que dice cuánto de un total está ocupado, pintado por escalón. No es
154
- un control: es un `role="img"`, así que si el dato tiene que llevar a algún lado lo envuelve
155
- un link o un botón del producto. `label` es obligatorio y va en palabras — el arco y el
156
- número del centro son decorativos, y el color nunca es el único portador del significado.
157
-
158
- Los cuatro escalones salen de la **proporción** `valor / maximo`, no del valor (8 no significa
159
- lo mismo sobre 10 que sobre 22). Sobre una escala de 0 a 10: `10` tope, `8` a `9,99` alto,
160
- `4` a `7,99` medio, menos de `4` bajo. Cada corte es inclusivo abajo.
161
-
162
- `sentido` es obligatorio y decide qué extremo es el bueno, porque eso lo define lo que el
163
- número *significa* en el producto y no el componente:
164
-
165
- | Escalón | `menos-es-mejor` | `mas-es-mejor` |
166
- |---|---|---|
167
- | bajo (< 40%) | verde | rojo |
168
- | medio (40–79%) | azul | naranja |
169
- | alto (80–99%) | naranja | azul |
170
- | tope (100%) | rojo | verde |
171
-
172
- `menos-es-mejor` es "cuánto lugar queda": vacío es una oportunidad, lleno una puerta
173
- cerrada. `mas-es-mejor` es un progreso o una meta. Usa los tonos `success`, `info`,
174
- `warning` y `danger`, así que un producto que ya los definió no declara nada nuevo. El
175
- centro muestra `valor/maximo` y se reemplaza con `children` cuando el formato es del producto.
176
-
177
- ```tsx
178
- import { IconButton, Relieve } from 'kaizen-lib/ui';
179
- import Link from 'next/link';
180
-
181
- <Relieve>
182
- <IconButton label="Notificaciones, 3 sin leer" badge={3}><IconoCampana /></IconButton>
183
- </Relieve>
184
-
185
- <Relieve $prendido={enPerfil}>
186
- <IconButton as={Link} href="/perfil" active={enPerfil} label="Perfil"><IconoPerfil /></IconButton>
187
- </Relieve>
188
- ```
189
-
190
- **`Relieve`** envuelve a `IconButton` con el relieve neumórfico: un círculo sin relleno ni
191
- borde, dibujado por un par de sombras opuestas (`--kz-relief-out`) que se invierten al
192
- presionar (`--kz-relief-in`) o con `$prendido` (el equivalente sostenido de "esto está
193
- activo" o "vos estás acá"). Es un `<span>` alrededor del control y no un
194
- `styled(IconButton)`: `IconButton` es polimórfico (`as`), y styled-components
195
- intercepta el `as` de cualquier componente que envuelve, así que envolverlo así lo haría
196
- saltear toda su lógica. El relieve es neutro por defecto (negro/blanco) — un producto con
197
- marca lo tiñe redefiniendo esos tres tokens, igual que con cualquier otro.
198
-
199
- ```tsx
200
- import { LangSelector } from 'kaizen-lib/ui';
201
-
202
- <LangSelector
203
- actual={i18n.language}
204
- onChange={(codigo) => i18n.changeLanguage(codigo)}
205
- etiqueta={(siguiente) => `Cambiar idioma a ${siguiente.nombre}`}
206
- opciones={[
207
- { codigo: 'es', nombre: 'Español', icono: <ReactCountryFlag countryCode="AR" /> },
208
- { codigo: 'en', nombre: 'English', icono: <ReactCountryFlag countryCode="US" /> },
209
- ]}
210
- />
211
- ```
212
-
213
- **`LangSelector`** es el botón de idioma de una topbar: cicla a la siguiente opción en
214
- cada tap y muestra el ícono del idioma actual, con el relieve de `Relieve` ya incluido
215
- (a diferencia de `IconButton`, acá no hace falta envolverlo aparte — todo consumidor lo
216
- quería). No trae banderas — mismo criterio que `IconButton` no trae íconos —, así que
217
- `icono` es cosa del producto; `react-country-flag` (ya usado en tuxon-front) es una
218
- opción probada. `etiqueta` arma el nombre accesible a partir de la opción siguiente y la
219
- actual — es obligatoria y sin default: la frase y el idioma son del producto (dos ya
220
- difieren: uno anuncia el destino, "Cambiar idioma a English"; otro el estado, "Idioma:
221
- English. Cambiar idioma"). `onChange` recibe el código de la opción siguiente; qué hacer
222
- con eso (`i18n.changeLanguage`, `next-intl`, lo que sea) es del producto — la librería no
223
- sabe qué mecanismo de traducción hay atrás.
224
-
225
- ```tsx
226
- import { Modal } from 'kaizen-lib/ui';
227
-
228
- <Modal
229
- open={abierto}
230
- onClose={cerrar}
231
- title="Eliminar cepa"
232
- description="Esta acción no se puede deshacer."
233
- footer={<Acciones><Boton onClick={cerrar}>Cancelar</Boton><Boton onClick={eliminar}>Eliminar</Boton></Acciones>}
234
- >
235
- <p>Se van a borrar también sus lotes asociados.</p>
236
- </Modal>
237
- ```
238
-
239
- **`Modal`** es el shell único de un panel flotante: portalea a `document.body`, atrapa el
240
- foco y lo devuelve a quien lo abrió al cerrar, cierra con `Escape`/click afuera/la X, y
241
- bloquea el scroll del fondo. Lleva una pila interna para el anidamiento real (un modal de
242
- confirmación abierto encima de un formulario): sólo el de más arriba atiende `Escape`, y el
243
- scroll se libera recién cuando se cierra el último.
244
-
245
- Sin `title` no hay nombre accesible propio — pasá `ariaLabel`. `size` (`sm`|`md`|`lg`|`xl`)
246
- fija el ancho máximo del panel; `maxWidth` lo pisa con un valor explícito. `disableClose`
247
- bloquea la X, el overlay y `Escape` a la vez — para una operación en curso, no para "no
248
- quiero que cierre nunca". `dismissOnOverlay={false}` sólo desactiva el click afuera, para
249
- un formulario a medio llenar que sigue queriendo cerrar con la X o `Escape`. `closeLabel`
250
- (default `'Cerrar'`) es el nombre accesible de la X — mismo criterio que `etiqueta` de
251
- `LangSelector`: un consumidor con más de un idioma lo pisa, el resto no lo toca.
252
-
253
- Lo que **no** trae: un `placement` lateral (drawer) y un padding compacto de cuerpo son
254
- casos de un solo consumidor cada uno (`components/ui/Dialog` de valle-verde) — se resuelven
255
- ahí con un wrapper local hasta que un segundo consumidor los pida acá.
256
-
257
- ```tsx
258
- import { Badge } from 'kaizen-lib/ui';
259
-
260
- <Badge tone="success">Activo</Badge>
261
- <Badge tone="danger" bordered>Vencido</Badge>
262
- ```
263
-
264
- **`Badge`** es una píldora de estado: fondo lavado + texto del mismo tono (`neutral` |
265
- `accent` | `success` | `danger` | `warning` | `info`, default `neutral`), mayúsculas
266
- apretadas. Elicitado de tres implementaciones ya duplicadas (taboo-next, valle-verde,
267
- kaizen-next) — el look es el de valle-verde. `bordered` agrega un borde de 1px del color
268
- pleno del tono, para cuando el fondo lavado solo no alcanza de contraste contra la
269
- superficie de atrás; no es una segunda estética, es el mismo tono con más peso.
270
-
271
- ```tsx
272
- import Link from 'next/link';
273
- import { BottomNav, bottomNavOffset } from 'kaizen-lib/ui';
274
-
275
- <BottomNav
276
- items={[{ id: 'inicio', label: 'Inicio', icon: <IoHomeOutline />, href: '/' }, ...]}
277
- activeId={activo}
278
- maxWidth={640}
279
- ariaLabel="Secciones"
280
- linkAs={Link}
281
- />
282
-
283
- const Contenido = styled.main`padding-bottom: ${bottomNavOffset};`;
284
- ```
285
-
286
- **`BottomNav`** es la barra de navegación fija abajo del teléfono: ícono + rótulo por
287
- destino, el activo con color de acento y una franja arriba (no sólo color). Elicitada de
288
- cuatro barras ya duplicadas (mixbol, valle-verde, tuxon, platenzen). Existe hasta
289
- `maxWidth` px y arriba se apaga por CSS, sin desmontarse, para no parpadear al hidratar.
290
-
291
- Qué decide el producto: `activeId` (si un destino matchea exacto o por prefijo es regla de
292
- sus rutas), `linkAs` (su router; sin él, `<a>`), y `ariaLabel` (su idioma, sin default).
293
- `onSelect(item, event)` se llama antes de navegar; con `event.preventDefault()` el
294
- destino hace otra cosa sin dejar de ser un link.
295
-
296
- Mientras se ve, publica `--kz-bottom-nav-offset` (su alto más el inset inferior del
297
- dispositivo). La barra es fija, así que el contenido lleva `bottomNavOffset` de padding
298
- inferior y lo que flota (FAB, avisos) lo suma a su `bottom`; donde la barra no está, vale
299
- `0px`.
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
-
319
- ## Desarrollo
320
-
321
- ```bash
322
- npm run check # typecheck + tests
323
- ```
324
-
325
- Los tests verifican dos cosas:
326
-
327
- - **Paridad** (`tokens.test.ts`): todo token de la API está definido en el CSS y todo
328
- token del CSS está en la API. El tema claro no inventa tokens ni deja colores sin
329
- redefinir.
330
- - **Contraste** (`defaults.test.ts`): los pares texto/fondo de la paleta por defecto
331
- cumplen WCAG 2.1 (4.5:1 texto, 3:1 no textual), en los dos temas.
332
-
333
- Si cambiás un color, el número lo da la suite, no el ojo.
334
-
335
- ## Licencia
336
-
337
- MIT
1
+ # kaizen-lib
2
+
3
+ Tokens y componentes compartidos entre los fronts de Kaizen. Un solo paquete, con
4
+ subpath exports: cada parte se importa por separado y el resto no entra al bundle.
5
+
6
+ Se publica en **TypeScript crudo**, sin build. Los consumidores son Next + React 19 +
7
+ styled-components con `moduleResolution: "bundler"`, así que lo transpilan ellos.
8
+
9
+ ## Estado
10
+
11
+ | Subpath | Qué es | Estado |
12
+ |---|---|---|
13
+ | `kaizen-lib/tokens` | Referencias `var(--kz-*)` tipadas | listo |
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`, `PanelExpandible`, `Stepper`, `DiscoHaxball`) | en curso |
16
+
17
+ ## ¿Qué uso para qué?
18
+
19
+ Antes de escribir un `styled.button` a mano o un ciclador de idioma propio, revisá esta
20
+ tabla. Si tu caso está acá, es un defecto reconstruirlo local — usá esto, y si no
21
+ alcanza, extendé el componente en vez de bypassearlo (ver `implement.md` del harness:
22
+ "un duplicado local de algo que ya existe compartido se reemplaza por el compartido").
23
+
24
+ | Necesito... | Uso | No es... |
25
+ |---|---|---|
26
+ | Un botón redondo de sólo ícono en una topbar (notificaciones, perfil, cerrar) | `IconButton` | — |
27
+ | Que ese botón tenga fondo teñido cuando está seleccionado (la página actual, un filtro aplicado) | `IconButton` con `active` | `ReactionButton` (ver abajo, distinción a propósito) |
28
+ | Un ícono que cambia de color al reaccionar (me gusta, guardar, favorito) — **sin** fondo teñido | `ReactionButton` | `IconButton` con `active` |
29
+ | El relieve neumórfico (círculo con sombra, sin relleno) alrededor de un `IconButton` | `Relieve` envolviendo el `IconButton` | una prop de `IconButton` — ver por qué en su docblock |
30
+ | Un ciclador de idioma con banderas en la topbar | `LangSelector` | reconstruir el ciclado a mano — ya lo resuelve, con `Relieve` incluido |
31
+ | Un panel flotante que bloquea el resto de la página hasta que se resuelve (formulario, confirmación, contenido a pantalla completa) | `Modal` | un overlay propio — portal, foco atrapado, `Escape`, scroll bloqueado y anidamiento ya están resueltos ahí |
32
+ | Un arco/medidor que muestra cuánto de un total está ocupado | `Medidor` | un `<progress>` o una barra propia |
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
+ | 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 |
36
+ | Los pasos de un recorrido (un formulario en etapas, un trámite), con el actual marcado | `Stepper`; la paleta se pisa con `--kz-stepper-*` (ver su docblock) | una barra de progreso propia — las líneas se llenan en orden y respeta `prefers-reduced-motion` |
37
+ | 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-*)` |
38
+ | 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 |
39
+
40
+ Ningún componente trae íconos ni banderas: los recibe como `children`/`icono`, y el
41
+ producto elige de dónde salen (`react-icons`, `react-country-flag`, SVG propio).
42
+
43
+ ## Instalación
44
+
45
+ ```bash
46
+ npm install kaizen-lib
47
+ ```
48
+
49
+ `react` y `styled-components` son **peerDependencies** y las pone el consumidor. Si
50
+ alguna vez quedan como `dependencies`, el consumidor termina con dos instancias de
51
+ styled-components y se rompen el theming y el SSR de un modo difícil de diagnosticar.
52
+
53
+ En `next.config`:
54
+
55
+ ```ts
56
+ const nextConfig = { transpilePackages: ['kaizen-lib'] };
57
+ ```
58
+
59
+ ## Tokens
60
+
61
+ El contrato es uno solo: **los nombres**. Los valores los define cada producto.
62
+
63
+ ```ts
64
+ import styled from 'styled-components';
65
+ import { surface, fg, space, radius } from 'kaizen-lib/tokens';
66
+
67
+ const Card = styled.div`
68
+ background: ${surface[2]};
69
+ color: ${fg.DEFAULT};
70
+ padding: ${space[4]};
71
+ border-radius: ${radius.md};
72
+ `;
73
+ ```
74
+
75
+ ```css
76
+ /* globals.css — opcional: valores por defecto, después los pisás con los tuyos */
77
+ @import 'kaizen-lib/tokens.css';
78
+
79
+ :root {
80
+ --kz-accent: #e8318f; /* la marca del producto */
81
+ }
82
+ ```
83
+
84
+ **Adopción parcial.** Si un producto redefine sólo algunos tokens, el resto cae al
85
+ valor por defecto. Si no importa `tokens.css` y no define un token que un componente
86
+ usa, el estilo simplemente no se aplica — no hay error. Por eso los tests exigen que
87
+ todo token de la API esté definido en `defaults.css`.
88
+
89
+ **Tema.** Oscuro en `:root`; claro cuando `<html>` lleva la clase `light`. Es el
90
+ mecanismo de tutipoker-school-front, el único consumidor con toggle real.
91
+
92
+ **Elevación.** Cuatro niveles de uso fijo: `0` página, `1` panel, `2` card, `3` overlay.
93
+ `sunken` es el pozo (inputs, tracks) y va más oscuro que su contenedor.
94
+
95
+ ## Componentes
96
+
97
+ ```tsx
98
+ import { IconButton } from 'kaizen-lib/ui';
99
+
100
+ <IconButton label="Cambiar tema" size="lg" onClick={alternar}>
101
+ <IconoLuna />
102
+ </IconButton>
103
+
104
+ <IconButton as={Link} href="/notificaciones" label="Notificaciones, 3 sin leer" badge={3}>
105
+ <IconoCampana />
106
+ </IconButton>
107
+ ```
108
+
109
+ **`IconButton`** es el botón redondo de sólo ícono de una topbar. `label` es obligatorio
110
+ (es el nombre accesible). Con `badge` dibuja un contador, y ese número es decorativo: la
111
+ cuenta va dentro del `label`, porque el idioma no es asunto de la librería. Con `as` toma
112
+ otro elemento —un `<Link>` de Next— sin que la librería dependa de Next. `size="lg"` es el
113
+ área táctil de 44px. Con `motivo` queda bloqueado pero enfocable, para poder mostrar por
114
+ qué.
115
+
116
+ La librería no trae íconos: cada producto usa los suyos.
117
+
118
+ ```tsx
119
+ import { ReactionButton } from 'kaizen-lib/ui';
120
+
121
+ <ReactionButton tone="danger" active={likeado} glow count={likes} label={`Me gusta, ${likes}`}>
122
+ <IconoCorazon />
123
+ </ReactionButton>
124
+ ```
125
+
126
+ **`ReactionButton`** es un ícono que cambia de color al reaccionar (`tone`: `neutral` |
127
+ `accent` | `success` | `danger` | `warning` | `info`), con resplandor opcional (`glow`,
128
+ del mismo color vía `currentColor`) y una cuenta al lado (`count`; `0` se muestra, no es
129
+ una ausencia). No es `IconButton` con más props: **nunca tiñe el fondo**. `IconButton` es
130
+ "esto está seleccionado" (la página actual, un filtro aplicado) y por eso tiñe el círculo
131
+ entero; `ReactionButton` es "reaccioné con esto" (un me gusta, un guardado), y ahí sólo el
132
+ ícono cambia — un corazón marcado no necesita, además, un círculo rosa detrás.
133
+
134
+ Un color que los seis tonos no cubren —la marca de un producto, no una categoría
135
+ semántica— se pisa con `style`, porque el color activo sale de la variable CSS
136
+ `--kz-icon-on` y un estilo en línea le gana a la regla de la librería:
137
+
138
+ ```tsx
139
+ <ReactionButton
140
+ active={guardado}
141
+ style={{ '--kz-icon-on': 'var(--mi-color-de-marca)' } as React.CSSProperties}
142
+ label="Guardar"
143
+ >
144
+ <IconoMarcador />
145
+ </ReactionButton>
146
+ ```
147
+
148
+ ```tsx
149
+ import { Medidor } from 'kaizen-lib/ui';
150
+
151
+ <Medidor valor={8} maximo={10} sentido="menos-es-mejor" label="Anotados: 8 de 10" />
152
+ ```
153
+
154
+ **`Medidor`** es un arco que dice cuánto de un total está ocupado, pintado por escalón. No es
155
+ un control: es un `role="img"`, así que si el dato tiene que llevar a algún lado lo envuelve
156
+ un link o un botón del producto. `label` es obligatorio y va en palabras — el arco y el
157
+ número del centro son decorativos, y el color nunca es el único portador del significado.
158
+
159
+ Los cuatro escalones salen de la **proporción** `valor / maximo`, no del valor (8 no significa
160
+ lo mismo sobre 10 que sobre 22). Sobre una escala de 0 a 10: `10` tope, `8` a `9,99` alto,
161
+ `4` a `7,99` medio, menos de `4` bajo. Cada corte es inclusivo abajo.
162
+
163
+ `sentido` es obligatorio y decide qué extremo es el bueno, porque eso lo define lo que el
164
+ número *significa* en el producto y no el componente:
165
+
166
+ | Escalón | `menos-es-mejor` | `mas-es-mejor` |
167
+ |---|---|---|
168
+ | bajo (< 40%) | verde | rojo |
169
+ | medio (40–79%) | azul | naranja |
170
+ | alto (80–99%) | naranja | azul |
171
+ | tope (100%) | rojo | verde |
172
+
173
+ `menos-es-mejor` es "cuánto lugar queda": vacío es una oportunidad, lleno una puerta
174
+ cerrada. `mas-es-mejor` es un progreso o una meta. Usa los tonos `success`, `info`,
175
+ `warning` y `danger`, así que un producto que ya los definió no declara nada nuevo. El
176
+ centro muestra `valor/maximo` y se reemplaza con `children` cuando el formato es del producto.
177
+
178
+ ```tsx
179
+ import { IconButton, Relieve } from 'kaizen-lib/ui';
180
+ import Link from 'next/link';
181
+
182
+ <Relieve>
183
+ <IconButton label="Notificaciones, 3 sin leer" badge={3}><IconoCampana /></IconButton>
184
+ </Relieve>
185
+
186
+ <Relieve $prendido={enPerfil}>
187
+ <IconButton as={Link} href="/perfil" active={enPerfil} label="Perfil"><IconoPerfil /></IconButton>
188
+ </Relieve>
189
+ ```
190
+
191
+ **`Relieve`** envuelve a `IconButton` con el relieve neumórfico: un círculo sin relleno ni
192
+ borde, dibujado por un par de sombras opuestas (`--kz-relief-out`) que se invierten al
193
+ presionar (`--kz-relief-in`) o con `$prendido` (el equivalente sostenido de "esto está
194
+ activo" o "vos estás acá"). Es un `<span>` alrededor del control y no un
195
+ `styled(IconButton)`: `IconButton` es polimórfico (`as`), y styled-components
196
+ intercepta el `as` de cualquier componente que envuelve, así que envolverlo así lo haría
197
+ saltear toda su lógica. El relieve es neutro por defecto (negro/blanco) — un producto con
198
+ marca lo tiñe redefiniendo esos tres tokens, igual que con cualquier otro.
199
+
200
+ ```tsx
201
+ import { LangSelector } from 'kaizen-lib/ui';
202
+
203
+ <LangSelector
204
+ actual={i18n.language}
205
+ onChange={(codigo) => i18n.changeLanguage(codigo)}
206
+ etiqueta={(siguiente) => `Cambiar idioma a ${siguiente.nombre}`}
207
+ opciones={[
208
+ { codigo: 'es', nombre: 'Español', icono: <ReactCountryFlag countryCode="AR" /> },
209
+ { codigo: 'en', nombre: 'English', icono: <ReactCountryFlag countryCode="US" /> },
210
+ ]}
211
+ />
212
+ ```
213
+
214
+ **`LangSelector`** es el botón de idioma de una topbar: cicla a la siguiente opción en
215
+ cada tap y muestra el ícono del idioma actual, con el relieve de `Relieve` ya incluido
216
+ (a diferencia de `IconButton`, acá no hace falta envolverlo aparte — todo consumidor lo
217
+ quería). No trae banderas — mismo criterio que `IconButton` no trae íconos —, así que
218
+ `icono` es cosa del producto; `react-country-flag` (ya usado en tuxon-front) es una
219
+ opción probada. `etiqueta` arma el nombre accesible a partir de la opción siguiente y la
220
+ actual — es obligatoria y sin default: la frase y el idioma son del producto (dos ya
221
+ difieren: uno anuncia el destino, "Cambiar idioma a English"; otro el estado, "Idioma:
222
+ English. Cambiar idioma"). `onChange` recibe el código de la opción siguiente; qué hacer
223
+ con eso (`i18n.changeLanguage`, `next-intl`, lo que sea) es del producto — la librería no
224
+ sabe qué mecanismo de traducción hay atrás.
225
+
226
+ ```tsx
227
+ import { Modal } from 'kaizen-lib/ui';
228
+
229
+ <Modal
230
+ open={abierto}
231
+ onClose={cerrar}
232
+ title="Eliminar cepa"
233
+ description="Esta acción no se puede deshacer."
234
+ footer={<Acciones><Boton onClick={cerrar}>Cancelar</Boton><Boton onClick={eliminar}>Eliminar</Boton></Acciones>}
235
+ >
236
+ <p>Se van a borrar también sus lotes asociados.</p>
237
+ </Modal>
238
+ ```
239
+
240
+ **`Modal`** es el shell único de un panel flotante: portalea a `document.body`, atrapa el
241
+ foco y lo devuelve a quien lo abrió al cerrar, cierra con `Escape`/click afuera/la X, y
242
+ bloquea el scroll del fondo. Lleva una pila interna para el anidamiento real (un modal de
243
+ confirmación abierto encima de un formulario): sólo el de más arriba atiende `Escape`, y el
244
+ scroll se libera recién cuando se cierra el último.
245
+
246
+ Sin `title` no hay nombre accesible propio — pasá `ariaLabel`. `size` (`sm`|`md`|`lg`|`xl`)
247
+ fija el ancho máximo del panel; `maxWidth` lo pisa con un valor explícito. `disableClose`
248
+ bloquea la X, el overlay y `Escape` a la vez — para una operación en curso, no para "no
249
+ quiero que cierre nunca". `dismissOnOverlay={false}` sólo desactiva el click afuera, para
250
+ un formulario a medio llenar que sigue queriendo cerrar con la X o `Escape`. `closeLabel`
251
+ (default `'Cerrar'`) es el nombre accesible de la X — mismo criterio que `etiqueta` de
252
+ `LangSelector`: un consumidor con más de un idioma lo pisa, el resto no lo toca.
253
+
254
+ Lo que **no** trae: un `placement` lateral (drawer) y un padding compacto de cuerpo son
255
+ casos de un solo consumidor cada uno (`components/ui/Dialog` de valle-verde) — se resuelven
256
+ ahí con un wrapper local hasta que un segundo consumidor los pida acá.
257
+
258
+ ```tsx
259
+ import { Badge } from 'kaizen-lib/ui';
260
+
261
+ <Badge tone="success">Activo</Badge>
262
+ <Badge tone="danger" bordered>Vencido</Badge>
263
+ ```
264
+
265
+ **`Badge`** es una píldora de estado: fondo lavado + texto del mismo tono (`neutral` |
266
+ `accent` | `success` | `danger` | `warning` | `info`, default `neutral`), mayúsculas
267
+ apretadas. Elicitado de tres implementaciones ya duplicadas (taboo-next, valle-verde,
268
+ kaizen-next) — el look es el de valle-verde. `bordered` agrega un borde de 1px del color
269
+ pleno del tono, para cuando el fondo lavado solo no alcanza de contraste contra la
270
+ superficie de atrás; no es una segunda estética, es el mismo tono con más peso.
271
+
272
+ ```tsx
273
+ import Link from 'next/link';
274
+ import { BottomNav, bottomNavOffset } from 'kaizen-lib/ui';
275
+
276
+ <BottomNav
277
+ items={[{ id: 'inicio', label: 'Inicio', icon: <IoHomeOutline />, href: '/' }, ...]}
278
+ activeId={activo}
279
+ maxWidth={640}
280
+ ariaLabel="Secciones"
281
+ linkAs={Link}
282
+ />
283
+
284
+ const Contenido = styled.main`padding-bottom: ${bottomNavOffset};`;
285
+ ```
286
+
287
+ **`BottomNav`** es la barra de navegación fija abajo del teléfono: ícono + rótulo por
288
+ destino, el activo con color de acento y una franja arriba (no sólo color). Elicitada de
289
+ cuatro barras ya duplicadas (mixbol, valle-verde, tuxon, platenzen). Existe hasta
290
+ `maxWidth` px y arriba se apaga por CSS, sin desmontarse, para no parpadear al hidratar.
291
+
292
+ Qué decide el producto: `activeId` (si un destino matchea exacto o por prefijo es regla de
293
+ sus rutas), `linkAs` (su router; sin él, `<a>`), y `ariaLabel` (su idioma, sin default).
294
+ `onSelect(item, event)` se llama antes de navegar; con `event.preventDefault()` el
295
+ destino hace otra cosa sin dejar de ser un link.
296
+
297
+ Mientras se ve, publica `--kz-bottom-nav-offset` (su alto más el inset inferior del
298
+ dispositivo). La barra es fija, así que el contenido lleva `bottomNavOffset` de padding
299
+ inferior y lo que flota (FAB, avisos) lo suma a su `bottom`; donde la barra no está, vale
300
+ `0px`.
301
+
302
+ ```tsx
303
+ import { PanelExpandible } from 'kaizen-lib/ui';
304
+
305
+ <PanelExpandible titulo="Comprometido" indicador="30 g" leyenda="faltan entregar">
306
+ <Tabla />
307
+ </PanelExpandible>
308
+ ```
309
+
310
+ **`PanelExpandible`** es un panel de pantalla: cabecera con el título y un indicador
311
+ (`indicador` + `leyenda`, o varios con `indicadores`), y un cuerpo que se pliega. Plegado
312
+ **no desmonta**: el contenido conserva filtros y selección. El control accesible es el
313
+ chevron (`aria-expanded`); toda la cabecera responde al click. Controlado (`abierto` +
314
+ `onCambiar`) o no (`abiertoInicial`). Con `plegable={false}` conserva el formato sin
315
+ chevron, para una pantalla de un solo panel. `duracionMs` (default 300) fija la animación
316
+ y el momento en que el cuerpo se oculta. `etiquetaPlegar`/`etiquetaDesplegar` traducen el
317
+ nombre del chevron. La columna del plegable es `minmax(0, 1fr)`: una tabla ancha adentro
318
+ se compacta o scrollea, no empuja el panel fuera de la pantalla.
319
+
320
+ ## Desarrollo
321
+
322
+ ```bash
323
+ npm run check # typecheck + tests
324
+ ```
325
+
326
+ Los tests verifican dos cosas:
327
+
328
+ - **Paridad** (`tokens.test.ts`): todo token de la API está definido en el CSS y todo
329
+ token del CSS está en la API. El tema claro no inventa tokens ni deja colores sin
330
+ redefinir.
331
+ - **Contraste** (`defaults.test.ts`): los pares texto/fondo de la paleta por defecto
332
+ cumplen WCAG 2.1 (4.5:1 texto, 3:1 no textual), en los dos temas.
333
+
334
+ Si cambiás un color, el número lo da la suite, no el ojo.
335
+
336
+ ## Licencia
337
+
338
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-lib",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
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,265 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, type ReactNode } from 'react';
4
+ import styled, { css } from 'styled-components';
5
+ import { accent, fg, font, hairline, motion, space, surface } from '../tokens';
6
+
7
+ /**
8
+ * Pasos de un recorrido: círculos unidos por líneas. Elicitado del stepper del
9
+ * formulario de adhesión de valle-verde (`FormV2Stepper`).
10
+ *
11
+ * Cada paso es un estado de tres: **completo** (antes del actual), **actual** y
12
+ * **pendiente**. Completo y actual se pintan igual —el actual suma un halo y la
13
+ * etiqueta en negrita—; la línea que une dos pasos se llena cuando el de la
14
+ * derecha se alcanza.
15
+ *
16
+ * ## Decisiones
17
+ *
18
+ * - **Se anima por defecto.** Al avanzar, la línea se llena primero y el
19
+ * círculo se pinta cuando ella llega; al retroceder, al revés. Un salto de
20
+ * varios pasos llena las líneas en cadena, una tras otra. Con
21
+ * `prefers-reduced-motion` (o `animado={false}`) cambia de una.
22
+ * - **Es un `<ol>`, no un control.** El paso actual lleva
23
+ * `aria-current="step"`. Si los pasos tienen que ser clickeables, es otro
24
+ * componente: un stepper que navega tiene que resolver qué pasos se pueden
25
+ * visitar, y eso es del producto.
26
+ * - **La paleta sale de los tokens**, y cada producto la pisa con variables
27
+ * propias del componente sobre la raíz (o un ancestro), sin forkearlo:
28
+ *
29
+ * | Variable | Qué pinta | Default |
30
+ * |---|---|---|
31
+ * | `--kz-stepper-pleno` | La línea llena, el borde y el ícono de un paso alcanzado | `accent` |
32
+ * | `--kz-stepper-lavado` | El fondo de un paso alcanzado | `accent.soft` |
33
+ * | `--kz-stepper-pista` | La línea vacía | `hairline.strong` |
34
+ * | `--kz-stepper-pendiente` | El fondo de un paso pendiente | `surface.sunken` |
35
+ * | `--kz-stepper-tamano` | El diámetro del círculo | `40px` |
36
+ * | `--kz-stepper-grosor` | El grosor de la línea | `4px` |
37
+ *
38
+ * - **Ocupa el ancho de su contenedor**, en columnas iguales: para un stepper
39
+ * compacto, el contenedor le pone `max-width`.
40
+ *
41
+ * ```tsx
42
+ * <Stepper
43
+ * label="Pasos de la adhesión"
44
+ * actual={paso}
45
+ * pasos={[
46
+ * { etiqueta: 'Datos', icono: <FaUser /> },
47
+ * { etiqueta: 'Necesidades', icono: <FaLeaf /> },
48
+ * { etiqueta: 'Confirmación', icono: <FaCheck /> },
49
+ * ]}
50
+ * />
51
+ * ```
52
+ */
53
+
54
+ export type PasoStepper = {
55
+ /** El nombre del paso. Se lee siempre, aunque `ocultarEtiquetasBajo` lo esconda a la vista. */
56
+ etiqueta: ReactNode;
57
+ /** Lo que va en el círculo. Sin ícono, el número del paso. */
58
+ icono?: ReactNode;
59
+ };
60
+
61
+ export type StepperProps = {
62
+ pasos: PasoStepper[];
63
+ /** Índice del paso actual, desde 0. Fuera de rango se recorta. */
64
+ actual: number;
65
+ /** Nombre accesible de la lista ("Pasos de la adhesión"). */
66
+ label: string;
67
+ /** Si los cambios de paso se animan (por defecto, sí). */
68
+ animado?: boolean;
69
+ /** Cuánto tarda en llenarse cada línea, en ms. Default 300. */
70
+ duracionMs?: number;
71
+ /**
72
+ * Debajo de este ancho de pantalla (px) las etiquetas no se dibujan; quedan
73
+ * para el lector de pantalla. Sin esto, se ven siempre.
74
+ */
75
+ ocultarEtiquetasBajo?: number;
76
+ className?: string;
77
+ };
78
+
79
+ type Estado = 'completo' | 'actual' | 'pendiente';
80
+
81
+ const PLENO = `var(--kz-stepper-pleno, ${accent.DEFAULT})`;
82
+ const LAVADO = `var(--kz-stepper-lavado, ${accent.soft})`;
83
+ const PISTA = `var(--kz-stepper-pista, ${hairline.strong})`;
84
+ const PENDIENTE = `var(--kz-stepper-pendiente, ${surface.sunken})`;
85
+ const TAMANO = 'var(--kz-stepper-tamano, 40px)';
86
+ const GROSOR = 'var(--kz-stepper-grosor, 4px)';
87
+
88
+ /** Aire entre el círculo y la punta de la línea. */
89
+ const SEPARACION = '6px';
90
+
91
+ const Lista = styled.ol`
92
+ display: grid;
93
+ grid-auto-columns: minmax(0, 1fr);
94
+ grid-auto-flow: column;
95
+ margin: 0;
96
+ padding: 0;
97
+ list-style: none;
98
+ `;
99
+
100
+ const Paso = styled.li`
101
+ position: relative;
102
+ display: flex;
103
+ flex-direction: column;
104
+ align-items: center;
105
+ gap: ${space[2]};
106
+ min-width: 0;
107
+ `;
108
+
109
+ /**
110
+ * La línea va del borde derecho de este círculo al izquierdo del siguiente: la
111
+ * dibuja cada paso salvo el último. El relleno es un `scaleX` (no un `width`),
112
+ * que el navegador anima sin recalcular el layout.
113
+ */
114
+ const Linea = styled.span<{ $llena: boolean; $duracion: number; $demora: number }>`
115
+ position: absolute;
116
+ top: calc(${TAMANO} / 2 - ${GROSOR} / 2);
117
+ left: calc(50% + ${TAMANO} / 2 + ${SEPARACION});
118
+ right: calc(-50% + ${TAMANO} / 2 + ${SEPARACION});
119
+ height: ${GROSOR};
120
+ border-radius: 999px;
121
+ background: ${PISTA};
122
+ overflow: hidden;
123
+
124
+ &::after {
125
+ content: '';
126
+ position: absolute;
127
+ inset: 0;
128
+ border-radius: inherit;
129
+ background: ${PLENO};
130
+ transform-origin: left center;
131
+ transform: scaleX(${({ $llena }) => ($llena ? 1 : 0)});
132
+ transition: transform ${({ $duracion }) => $duracion}ms ${motion.ease} ${({ $demora }) => $demora}ms;
133
+ }
134
+
135
+ @media (prefers-reduced-motion: reduce) {
136
+ &::after { transition: none; }
137
+ }
138
+ `;
139
+
140
+ const Circulo = styled.span<{ $estado: Estado; $duracion: number; $demora: number }>`
141
+ position: relative;
142
+ display: inline-flex;
143
+ align-items: center;
144
+ justify-content: center;
145
+ width: ${TAMANO};
146
+ height: ${TAMANO};
147
+ flex-shrink: 0;
148
+ border-radius: 50%;
149
+ font-size: calc(${TAMANO} * 0.4);
150
+ font-weight: 700;
151
+ font-variant-numeric: tabular-nums;
152
+ line-height: 1;
153
+ ${({ $estado }) => ($estado === 'pendiente'
154
+ ? css`
155
+ color: ${fg.subtle};
156
+ background: ${PENDIENTE};
157
+ border: 1px solid ${hairline.DEFAULT};
158
+ box-shadow: 0 0 0 0 transparent;
159
+ `
160
+ : css`
161
+ color: ${PLENO};
162
+ background: ${LAVADO};
163
+ border: 1px solid color-mix(in srgb, ${PLENO} 35%, transparent);
164
+ box-shadow: 0 0 0 ${$estado === 'actual' ? '4px' : '0'} color-mix(in srgb, ${PLENO} 16%, transparent);
165
+ `)}
166
+ transition-property: color, background-color, border-color, box-shadow;
167
+ transition-duration: ${({ $duracion }) => $duracion}ms;
168
+ transition-timing-function: ${motion.ease};
169
+ transition-delay: ${({ $demora }) => $demora}ms;
170
+
171
+ @media (prefers-reduced-motion: reduce) {
172
+ transition: none;
173
+ }
174
+ `;
175
+
176
+ const Etiqueta = styled.span<{ $estado: Estado; $ocultarBajo?: number }>`
177
+ max-width: 100%;
178
+ font-family: ${font.family};
179
+ font-size: ${font.xs};
180
+ font-weight: ${({ $estado }) => ($estado === 'actual' ? 700 : 500)};
181
+ line-height: 1.3;
182
+ color: ${({ $estado }) => ($estado === 'actual' ? fg.DEFAULT : fg.muted)};
183
+ text-align: center;
184
+ overflow-wrap: anywhere;
185
+
186
+ ${({ $ocultarBajo }) => $ocultarBajo !== undefined && css`
187
+ @media (max-width: ${$ocultarBajo - 0.02}px) {
188
+ position: absolute;
189
+ width: 1px;
190
+ height: 1px;
191
+ overflow: hidden;
192
+ clip-path: inset(50%);
193
+ white-space: nowrap;
194
+ }
195
+ `}
196
+ `;
197
+
198
+ const estadoDe = (indice: number, actual: number): Estado => {
199
+ if (indice < actual) return 'completo';
200
+ if (indice === actual) return 'actual';
201
+ return 'pendiente';
202
+ };
203
+
204
+ /**
205
+ * Cuánto espera cada pieza para que el cambio se lea en orden. Avanzando de
206
+ * `desde` a `hasta`, la línea `i` (entre los pasos `i` e `i + 1`) se llena
207
+ * después de las anteriores, y el círculo `i + 1` se pinta cuando su línea
208
+ * termina. Retrocediendo, el espejo: el círculo se apaga a la vez que empieza a
209
+ * vaciarse su línea, y el anterior cuando ella termina.
210
+ */
211
+ function demoras(desde: number, hasta: number, duracion: number) {
212
+ const linea = (i: number): number => {
213
+ if (hasta > desde && i >= desde && i < hasta) return (i - desde) * duracion;
214
+ if (hasta < desde && i >= hasta && i < desde) return (desde - 1 - i) * duracion;
215
+ return 0;
216
+ };
217
+ const circulo = (i: number): number => {
218
+ if (hasta > desde && i > desde && i <= hasta) return (i - desde) * duracion;
219
+ if (hasta < desde && i > hasta && i <= desde) return (desde - i) * duracion;
220
+ return 0;
221
+ };
222
+ return { linea, circulo };
223
+ }
224
+
225
+ export function Stepper({
226
+ pasos,
227
+ actual,
228
+ label,
229
+ animado = true,
230
+ duracionMs = 300,
231
+ ocultarEtiquetasBajo,
232
+ className,
233
+ }: StepperProps) {
234
+ const ultimo = Math.max(0, pasos.length - 1);
235
+ const indice = Number.isFinite(actual) ? Math.min(ultimo, Math.max(0, Math.trunc(actual))) : 0;
236
+
237
+ // El paso que se mostraba antes de este render: de ahí sale el sentido del cambio.
238
+ const previo = useRef(indice);
239
+ const desde = previo.current;
240
+ useEffect(() => {
241
+ previo.current = indice;
242
+ }, [indice]);
243
+
244
+ const duracion = animado ? Math.max(0, duracionMs) : 0;
245
+ const { linea, circulo } = demoras(desde, indice, duracion);
246
+
247
+ return (
248
+ <Lista aria-label={label} className={className}>
249
+ {pasos.map((paso, i) => {
250
+ const estado = estadoDe(i, indice);
251
+ return (
252
+ <Paso key={i} aria-current={estado === 'actual' ? 'step' : undefined} data-estado={estado}>
253
+ {i < ultimo && (
254
+ <Linea aria-hidden="true" $llena={i < indice} $duracion={duracion} $demora={linea(i)} />
255
+ )}
256
+ <Circulo aria-hidden="true" $estado={estado} $duracion={duracion} $demora={circulo(i)}>
257
+ {paso.icono ?? i + 1}
258
+ </Circulo>
259
+ <Etiqueta $estado={estado} $ocultarBajo={ocultarEtiquetasBajo}>{paso.etiqueta}</Etiqueta>
260
+ </Paso>
261
+ );
262
+ })}
263
+ </Lista>
264
+ );
265
+ }
package/src/ui/index.ts CHANGED
@@ -17,3 +17,5 @@ export { DiscoHaxball, CAMISETAS_HAXBALL, recortarAvatarHaxball, LUGARES_AVATAR_
17
17
  export type { CamisetaHaxball, NombreCamiseta } from './DiscoHaxball';
18
18
  export { PanelExpandible } from './PanelExpandible';
19
19
  export type { PanelExpandibleProps } from './PanelExpandible';
20
+ export { Stepper } from './Stepper';
21
+ export type { PasoStepper, StepperProps } from './Stepper';