kaizen-lib 0.2.5 → 0.2.6

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`) | en curso |
15
+ | `kaizen-lib/ui` | Componentes (`IconButton`, `ReactionButton`, `Medidor`, `Relieve`, `LangSelector`, `Modal`) | en curso |
16
16
 
17
17
  ## ¿Qué uso para qué?
18
18
 
@@ -28,6 +28,7 @@ alcanza, extendé el componente en vez de bypassearlo (ver `implement.md` del ha
28
28
  | Un ícono que cambia de color al reaccionar (me gusta, guardar, favorito) — **sin** fondo teñido | `ReactionButton` | `IconButton` con `active` |
29
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
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í |
31
32
  | Un arco/medidor que muestra cuánto de un total está ocupado | `Medidor` | un `<progress>` o una barra propia |
32
33
  | 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-*)` |
33
34
 
@@ -217,6 +218,36 @@ English. Cambiar idioma"). `onChange` recibe el código de la opción siguiente;
217
218
  con eso (`i18n.changeLanguage`, `next-intl`, lo que sea) es del producto — la librería no
218
219
  sabe qué mecanismo de traducción hay atrás.
219
220
 
221
+ ```tsx
222
+ import { Modal } from 'kaizen-lib/ui';
223
+
224
+ <Modal
225
+ open={abierto}
226
+ onClose={cerrar}
227
+ title="Eliminar cepa"
228
+ description="Esta acción no se puede deshacer."
229
+ footer={<Acciones><Boton onClick={cerrar}>Cancelar</Boton><Boton onClick={eliminar}>Eliminar</Boton></Acciones>}
230
+ >
231
+ <p>Se van a borrar también sus lotes asociados.</p>
232
+ </Modal>
233
+ ```
234
+
235
+ **`Modal`** es el shell único de un panel flotante: portalea a `document.body`, atrapa el
236
+ foco y lo devuelve a quien lo abrió al cerrar, cierra con `Escape`/click afuera/la X, y
237
+ bloquea el scroll del fondo. Lleva una pila interna para el anidamiento real (un modal de
238
+ confirmación abierto encima de un formulario): sólo el de más arriba atiende `Escape`, y el
239
+ scroll se libera recién cuando se cierra el último.
240
+
241
+ Sin `title` no hay nombre accesible propio — pasá `ariaLabel`. `size` (`sm`|`md`|`lg`|`xl`)
242
+ fija el ancho máximo del panel; `maxWidth` lo pisa con un valor explícito. `disableClose`
243
+ bloquea la X, el overlay y `Escape` a la vez — para una operación en curso, no para "no
244
+ quiero que cierre nunca". `dismissOnOverlay={false}` sólo desactiva el click afuera, para
245
+ un formulario a medio llenar que sigue queriendo cerrar con la X o `Escape`.
246
+
247
+ Lo que **no** trae: un `placement` lateral (drawer) y un padding compacto de cuerpo son
248
+ casos de un solo consumidor cada uno (`components/ui/Dialog` de valle-verde) — se resuelven
249
+ ahí con un wrapper local hasta que un segundo consumidor los pida acá.
250
+
220
251
  ## Desarrollo
221
252
 
222
253
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-lib",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Tokens y componentes compartidos entre los fronts de Kaizen.",
5
5
  "license": "MIT",
6
6
  "author": "Nicolás Sandobal",
@@ -139,6 +139,13 @@
139
139
  encendido). Lo que necesite otra es una animación, no una transición. */
140
140
  --kz-duration-fast: 120ms;
141
141
  --kz-ease: cubic-bezier(0.2, 0, 0, 1);
142
+
143
+ /* ---- Velo ---------------------------------------------------------------
144
+ Fondo del overlay de un `Modal`. Negro semitransparente en los dos temas
145
+ a propósito: no es una superficie de la escala (no tiene texto encima
146
+ directo, va detrás del panel) y un velo claro en tema claro no separa el
147
+ panel del contenido que tapa. */
148
+ --kz-scrim: rgba(0, 0, 0, 0.5);
142
149
  }
143
150
 
144
151
  /*
@@ -57,6 +57,9 @@ export const hairline = {
57
57
  /** Anillo de foco. Elemento no textual: el mínimo de contraste es 3:1. */
58
58
  export const ring = 'var(--kz-ring)';
59
59
 
60
+ /** Velo detrás del panel de un `Modal`. Va fijo, no cambia con el tema. */
61
+ export const scrim = 'var(--kz-scrim)';
62
+
60
63
  /**
61
64
  * Acento de marca. `soft` es el fondo lavado de chips y estados
62
65
  * seleccionados; encima de `soft` va el acento mismo, no `fg`.
@@ -0,0 +1,342 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useId, useRef, useState } from 'react';
4
+ import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react';
5
+ import { createPortal } from 'react-dom';
6
+ import styled, { css } from 'styled-components';
7
+ import { fg, font, radius, ring, scrim, shadow, space, surface } from '../tokens';
8
+
9
+ /** Anchos del panel. `maxWidth` los pisa cuando un modal necesita otra medida. */
10
+ const ANCHOS = {
11
+ sm: '420px',
12
+ md: '560px',
13
+ lg: '760px',
14
+ xl: '980px',
15
+ } as const;
16
+
17
+ export type ModalSize = keyof typeof ANCHOS;
18
+
19
+ const FOCUSABLE = [
20
+ 'a[href]',
21
+ 'button:not([disabled])',
22
+ 'textarea:not([disabled])',
23
+ 'input:not([disabled]):not([type="hidden"])',
24
+ 'select:not([disabled])',
25
+ '[tabindex]:not([tabindex="-1"])',
26
+ ].join(',');
27
+
28
+ /**
29
+ * El contenido de un modal suele ser condicional (pasos, listas que cargan por
30
+ * fetch), así que se recalcula en cada Tab en vez de cachearse al montar — con
31
+ * eso cacheado el trap queda desincronizado apenas cambia el cuerpo.
32
+ */
33
+ const focusablesDe = (root: HTMLElement): HTMLElement[] =>
34
+ Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
35
+ (el) => !el.hasAttribute('hidden') && el.getAttribute('aria-hidden') !== 'true',
36
+ );
37
+
38
+ /**
39
+ * Pila de modales abiertos. Hace falta por el anidamiento real (un modal de
40
+ * confirmación abierto encima de un formulario): sólo el de más arriba
41
+ * atiende `Escape`, y el scroll del fondo se libera cuando se cierra el
42
+ * último, no el primero que se desmonte.
43
+ */
44
+ const pila: string[] = [];
45
+ let overflowPrevio: string | null = null;
46
+
47
+ const bloquearScroll = () => {
48
+ if (pila.length === 1 && typeof document !== 'undefined') {
49
+ overflowPrevio = document.body.style.overflow;
50
+ document.body.style.overflow = 'hidden';
51
+ }
52
+ };
53
+
54
+ const liberarScroll = () => {
55
+ if (pila.length === 0 && typeof document !== 'undefined') {
56
+ document.body.style.overflow = overflowPrevio ?? '';
57
+ overflowPrevio = null;
58
+ }
59
+ };
60
+
61
+ const Overlay = styled.div`
62
+ position: fixed;
63
+ inset: 0;
64
+ z-index: 2147483000;
65
+ display: flex;
66
+ align-items: center;
67
+ justify-content: center;
68
+ padding: ${space[4]};
69
+ background: ${scrim};
70
+ `;
71
+
72
+ const Panel = styled.div<{ $maxWidth: string }>`
73
+ position: relative;
74
+ display: flex;
75
+ flex-direction: column;
76
+ width: 100%;
77
+ max-width: ${({ $maxWidth }) => $maxWidth};
78
+ max-height: 90vh;
79
+ background: ${surface[3]};
80
+ color: ${fg.DEFAULT};
81
+ /* Portalea a document.body, fuera de cualquier contenedor con su propia
82
+ tipografía — sin esto hereda la fuente del body, no la del producto. */
83
+ font-family: ${font.family};
84
+ border-radius: ${radius.lg};
85
+ box-shadow: ${shadow.xl};
86
+ outline: none;
87
+ `;
88
+
89
+ const Header = styled.header<{ $conCierre: boolean }>`
90
+ display: flex;
91
+ align-items: flex-start;
92
+ padding: ${space[6]} ${space[6]} ${space[4]};
93
+ ${({ $conCierre }) => $conCierre && css`padding-right: ${space[10]};`}
94
+ `;
95
+
96
+ const Titulos = styled.div`
97
+ min-width: 0;
98
+ `;
99
+
100
+ const Titulo = styled.h2`
101
+ margin: 0;
102
+ font-size: ${font.lg};
103
+ font-weight: 600;
104
+ line-height: 1.3;
105
+ color: ${fg.DEFAULT};
106
+ `;
107
+
108
+ const Descripcion = styled.p`
109
+ margin: ${space[1]} 0 0;
110
+ font-size: ${font.sm};
111
+ color: ${fg.muted};
112
+ `;
113
+
114
+ const Body = styled.div<{ $flush: boolean }>`
115
+ flex: 1 1 auto;
116
+ min-height: 0;
117
+ overflow-y: auto;
118
+ padding: ${({ $flush }) => ($flush ? space[6] : `0 ${space[6]} ${space[2]}`)};
119
+ `;
120
+
121
+ const Footer = styled.footer`
122
+ flex: 0 0 auto;
123
+ display: flex;
124
+ align-items: center;
125
+ justify-content: flex-end;
126
+ gap: ${space[3]};
127
+ padding: ${space[4]} ${space[6]} ${space[6]};
128
+ `;
129
+
130
+ const Cerrar = styled.button`
131
+ position: absolute;
132
+ top: ${space[3]};
133
+ right: ${space[3]};
134
+ display: flex;
135
+ align-items: center;
136
+ justify-content: center;
137
+ width: 32px;
138
+ height: 32px;
139
+ padding: 0;
140
+ border: none;
141
+ border-radius: ${radius.sm};
142
+ background: none;
143
+ color: ${fg.muted};
144
+ font-size: 1.25rem;
145
+ line-height: 1;
146
+ cursor: pointer;
147
+
148
+ &:hover:not(:disabled) {
149
+ background: ${surface.hover};
150
+ color: ${fg.DEFAULT};
151
+ }
152
+
153
+ &:focus-visible {
154
+ outline: 2px solid ${ring};
155
+ outline-offset: 2px;
156
+ }
157
+
158
+ &:disabled {
159
+ opacity: 0.5;
160
+ cursor: not-allowed;
161
+ }
162
+ `;
163
+
164
+ type Props = {
165
+ open: boolean;
166
+ onClose: () => void;
167
+ /** Título visible, y nombre accesible cuando no se pasa `ariaLabel`. */
168
+ title?: ReactNode;
169
+ /** Subtítulo del encabezado. Sin `title` no se dibuja: es su acompañante. */
170
+ description?: ReactNode;
171
+ children: ReactNode;
172
+ /** Zona de acciones al pie. */
173
+ footer?: ReactNode;
174
+ size?: ModalSize;
175
+ /** Ancho máximo explícito (pisa `size`). */
176
+ maxWidth?: string;
177
+ /** Botón de cierre en la esquina. Default `true`. */
178
+ showClose?: boolean;
179
+ /** Click afuera del panel cierra. Default `true`. */
180
+ dismissOnOverlay?: boolean;
181
+ /** Operación en curso: ni la X, ni el overlay, ni Escape cierran. */
182
+ disableClose?: boolean;
183
+ /** Nombre accesible cuando no hay `title` visible. */
184
+ ariaLabel?: string;
185
+ className?: string;
186
+ };
187
+
188
+ /**
189
+ * Shell único de modal: portal a `document.body`, foco atrapado y devuelto,
190
+ * cierre por `Escape`/overlay/X, scroll de fondo bloqueado, pila para
191
+ * anidamiento real (un modal de confirmación sobre un formulario abierto).
192
+ *
193
+ * No escribas un overlay propio: si falta una variante, se agrega acá.
194
+ *
195
+ * ```tsx
196
+ * <Modal open={abierto} onClose={cerrar} title="Eliminar cepa" footer={<Acciones />}>
197
+ * <p>Esta acción no se puede deshacer.</p>
198
+ * </Modal>
199
+ * ```
200
+ */
201
+ export function Modal({
202
+ open,
203
+ onClose,
204
+ title,
205
+ description,
206
+ children,
207
+ footer,
208
+ size = 'md',
209
+ maxWidth,
210
+ showClose = true,
211
+ dismissOnOverlay = true,
212
+ disableClose = false,
213
+ ariaLabel,
214
+ className,
215
+ }: Props) {
216
+ const panelRef = useRef<HTMLDivElement>(null);
217
+ const devolverFocoA = useRef<HTMLElement | null>(null);
218
+ const empezoEnOverlay = useRef(false);
219
+ const [mounted, setMounted] = useState(false);
220
+ const id = useId();
221
+ const tituloId = `${id}-titulo`;
222
+
223
+ useEffect(() => {
224
+ setMounted(true);
225
+ }, []);
226
+
227
+ useEffect(() => {
228
+ if (!open) return undefined;
229
+
230
+ devolverFocoA.current = document.activeElement as HTMLElement | null;
231
+ pila.push(id);
232
+ bloquearScroll();
233
+
234
+ return () => {
235
+ const i = pila.lastIndexOf(id);
236
+ if (i >= 0) pila.splice(i, 1);
237
+ liberarScroll();
238
+ devolverFocoA.current?.focus?.();
239
+ };
240
+ }, [open, id]);
241
+
242
+ useEffect(() => {
243
+ if (!open || !mounted) return;
244
+ const panel = panelRef.current;
245
+ if (!panel) return;
246
+ const [primero] = focusablesDe(panel);
247
+ (primero ?? panel).focus?.();
248
+ }, [open, mounted]);
249
+
250
+ useEffect(() => {
251
+ if (!open) return undefined;
252
+ const alTeclear = (event: KeyboardEvent) => {
253
+ if (event.key !== 'Escape') return;
254
+ if (pila[pila.length - 1] !== id) return;
255
+ if (disableClose) return;
256
+ event.stopPropagation();
257
+ onClose();
258
+ };
259
+ document.addEventListener('keydown', alTeclear, true);
260
+ return () => document.removeEventListener('keydown', alTeclear, true);
261
+ }, [open, id, disableClose, onClose]);
262
+
263
+ const manejarTab = (event: ReactKeyboardEvent<HTMLDivElement>) => {
264
+ if (event.key !== 'Tab') return;
265
+ const panel = panelRef.current;
266
+ if (!panel) return;
267
+ const focusables = focusablesDe(panel);
268
+ if (focusables.length === 0) {
269
+ event.preventDefault();
270
+ panel.focus();
271
+ return;
272
+ }
273
+ const primero = focusables[0] as HTMLElement;
274
+ const ultimo = focusables[focusables.length - 1] as HTMLElement;
275
+ const activo = document.activeElement;
276
+
277
+ if (event.shiftKey && (activo === primero || activo === panel || !panel.contains(activo))) {
278
+ event.preventDefault();
279
+ ultimo.focus();
280
+ return;
281
+ }
282
+ if (!event.shiftKey && (activo === ultimo || !panel.contains(activo))) {
283
+ event.preventDefault();
284
+ primero.focus();
285
+ }
286
+ };
287
+
288
+ /**
289
+ * Un click cuyo mousedown ocurrió dentro del panel (seleccionar texto y
290
+ * soltar afuera) burbujea igual hasta el overlay. Sólo cierra si el gesto
291
+ * *empezó* en el overlay.
292
+ */
293
+ const manejarClickOverlay = (event: ReactMouseEvent<HTMLDivElement>) => {
294
+ const empezoAfuera = empezoEnOverlay.current;
295
+ empezoEnOverlay.current = false;
296
+ if (!empezoAfuera || event.target !== event.currentTarget) return;
297
+ if (dismissOnOverlay && !disableClose) onClose();
298
+ };
299
+
300
+ if (!open || !mounted) return null;
301
+
302
+ return createPortal(
303
+ <Overlay
304
+ onMouseDown={(event) => {
305
+ empezoEnOverlay.current = event.target === event.currentTarget;
306
+ }}
307
+ onClick={manejarClickOverlay}
308
+ >
309
+ <Panel
310
+ ref={panelRef}
311
+ className={className}
312
+ role="dialog"
313
+ aria-modal="true"
314
+ aria-labelledby={title ? tituloId : undefined}
315
+ aria-label={title ? undefined : ariaLabel}
316
+ tabIndex={-1}
317
+ $maxWidth={maxWidth ?? ANCHOS[size]}
318
+ onKeyDown={manejarTab}
319
+ >
320
+ {showClose ? (
321
+ <Cerrar type="button" onClick={onClose} disabled={disableClose} aria-label="Cerrar">
322
+ ×
323
+ </Cerrar>
324
+ ) : null}
325
+
326
+ {title ? (
327
+ <Header $conCierre={showClose}>
328
+ <Titulos>
329
+ <Titulo id={tituloId}>{title}</Titulo>
330
+ {description ? <Descripcion>{description}</Descripcion> : null}
331
+ </Titulos>
332
+ </Header>
333
+ ) : null}
334
+
335
+ <Body $flush={!title}>{children}</Body>
336
+
337
+ {footer ? <Footer>{footer}</Footer> : null}
338
+ </Panel>
339
+ </Overlay>,
340
+ document.body,
341
+ );
342
+ }
package/src/ui/index.ts CHANGED
@@ -7,3 +7,5 @@ export { ReactionButton } from './ReactionButton';
7
7
  export type { ReactionTone } from './ReactionButton';
8
8
  export { LangSelector } from './LangSelector';
9
9
  export type { OpcionDeIdioma } from './LangSelector';
10
+ export { Modal } from './Modal';
11
+ export type { ModalSize } from './Modal';