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 +32 -1
- package/package.json +1 -1
- package/src/tokens/defaults.css +7 -0
- package/src/tokens/index.ts +3 -0
- package/src/ui/Modal.tsx +342 -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`) | 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
package/src/tokens/defaults.css
CHANGED
|
@@ -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
|
/*
|
package/src/tokens/index.ts
CHANGED
|
@@ -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`.
|
package/src/ui/Modal.tsx
ADDED
|
@@ -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';
|