Diálogos del SGD
Tercera familia del Design System. Usar DialogContent de src/components/ui/dialog.tsx para diálogos informativos y de formulario. Usar AlertDialogContent cuando una decisión deba confirmarse explícitamente. Ambos comparten superficie y fondo; AlertDialog conserva su comportamiento sin botón “X”. El catálogo /design-system/componentes muestra los tres estados del cierre y un diálogo operable.
Referencia y decisiones
Las tres capturas muestran un panel blanco de esquinas redondeadas, fondo oscuro atenuado, título destacado, texto gris, acciones al pie y un cierre con icono gris. La segunda muestra una superficie circular gris al pasar el cursor; la tercera, un anillo separado alrededor del botón. La referencia no fija medidas, tokens, modo oscuro ni reglas de cierre para otros diálogos. Los indicadores del tour y su casilla son contenido específico de esa pantalla y no forman parte de la primitiva.
El SGD define una superficie card (blanca en claro y neutra oscura en oscuro), radio --sgd-radius-dialog, sombra --sgd-shadow-dialog y fondo --sgd-dialog-overlay. DialogTitle usa la escala de sección (18 px) y DialogDescription el cuerpo gris de 14 px. No se agregan divisores ni botones genéricos.
Cabecera, cuerpo y pie
DialogContent y AlertDialogContent comparten sgd-dialog-frame: una columna flexible de altura limitada al viewport. DialogHeader queda arriba, DialogBody ocupa el espacio disponible y es la única zona que se desplaza, y DialogFooter queda visible al pie. Usar la misma secuencia dentro de un <form> cuando los botones deban conservar type="submit"; no sacar el botón del formulario. Los formularios existentes con una sola sección de campos se adaptan mediante la misma estructura de la primitiva.
<DialogContent>
<DialogHeader><DialogTitle>Título</DialogTitle></DialogHeader>
<DialogBody>Contenido que puede crecer</DialogBody>
<DialogFooter><Button type="button">Aceptar</Button></DialogFooter>
</DialogContent>
El token --sgd-dialog-footer-padding establece 32 px a izquierda, derecha y abajo en escritorio; en pantallas pequeñas pasa a 16 px. Las acciones se alinean a la derecha, usan gap de 8 px y el alto del botón compartido; en móvil se apilan con ancho completo. Altura adaptativa y prevención de scroll: los modales deben evitar scrollbars innecesarias; crecen verticalmente de forma natural hasta el límite del viewport (max-h-[calc(100dvh-2rem)] o max-h-[90vh]) según la cantidad de texto o campos del formulario. No usar restricciones artificiales fijas tipo h-72 o h-80 que fuercen scroll en modales con poco o mediano contenido. El desplazamiento vertical interno (overflow-y: auto) queda estrictamente reservado como mecanismo de respaldo si el contenido excede la altura real de pantalla. Solo los casos con diseño simétrico estricto de dos estados fijan altura: gestión de sesión usa --sgd-session-dialog-height para aviso y expiración. El catálogo muestra ejemplos cortos y largos con altura natural.
DialogContent layout="custom" se reserva para superficies con su propio patrón interno, como la paleta de comandos. No usarlo para formularios o confirmaciones normales.
Diálogo redimensionable y maximizable (opt-in)
src/components/ui/dialog-resizable.tsx añade, solo a quien lo pida, arrastre de bordes/esquina inferior derecha (también con flechas), botón Maximizar/Restaurar y límites de tamaño dentro de la pantalla. No modifica DialogContent: const resizable = useResizableDialog({ minWidth, minHeight }), <DialogContent style={resizable.style}>…<ResizeHandles resizable={resizable} /></DialogContent> y <DialogMaximizeButton resizable={resizable} /> en la cabecera. Los tiradores llevan data-slot="dialog-resize-handle" para quedar fuera de la regla .sgd-dialog-frame > :not([data-slot]) (que da padding y scroll a los hijos directos). Usar solo en ventanas de trabajo con un visor o contenido amplio; hoy: la ventana de firma digital (components/firma-digital.md). Dentro, preferir container queries (@container) a breakpoints de pantalla para que el diseño siga al ancho del diálogo.
Apertura y cierre
Los diálogos (DialogContent y AlertDialogContent) aparecen y se van solo con un desvanecimiento breve (200 ms); no se deslizan ni se acercan desde un borde. El movimiento sí se usa al maximizar y restaurar un diálogo redimensionable (ver arriba). Con «reducir movimiento» activo no hay animación.
Cierre compartido
DialogContent integra un solo DialogCloseButton mediante DialogPrimitive.Close asChild. Su nombre accesible predeterminado es “Cerrar”. El área interactiva mide 44 × 44 px y el icono Material Symbols Rounded 24 px. En reposo no tiene fondo ni borde; en hover, una superficie circular neutra de 36 px aparece dentro del área interactiva. El foco por teclado y la pulsación usan un único outline de 2 px en primary, separado 2 px del círculo interior. El mismo estilo se muestra en el catálogo sin crear otra variante funcional.
La X usa --sgd-close-icon en reposo y --sgd-close-icon-hover en hover, derivados del texto secundario y la superficie. Así queda visualmente subordinada al mensaje en las seis combinaciones de tema y modo. El anillo de foco conserva el color principal para identificar la interacción. El mismo par de tokens se usa en alertas flotantes y demás controles de cierre; no usar foreground ni destructive para oscurecer una X secundaria.
El botón se coloca con 14 px de separación respecto de los bordes del panel: el contorno exterior cabe dentro de la superficie incluso cuando el contenido desplaza verticalmente. El anillo es visible mientras se mantiene presionado el botón; al soltarlo, Radix cierra el diálogo inmediatamente, sin espera artificial. prefers-reduced-motion elimina la transición. Evitar clases locales de cierre y otros anillos (ring o box-shadow) superpuestos.
Paneles laterales (Sheet)
SheetContent (src/components/ui/sheet.tsx) no define su propia X: reutiliza DialogCloseButton de dialog.tsx (clase sgd-dialog-close, 44 × 44 px, 14 px de los bordes, icono de 24 px). Por tanto reposo gris sin fondo, hover circular suave (--sgd-surface-muted), anillo de foco con --primary y prefers-reduced-motion son idénticos a los diálogos, en los tres temas y en claro/oscuro. Esto afecta a todos los Sheet existentes (filtros avanzados y favoritos del usuario final, subnavegación móvil, menú lateral del admin), que ya mostraban una X; no se añade X a ventanas que no la tenían. Reservar espacio en el encabezado con pr-16 para que el título no se superponga. No crear botones de cierre locales ni usar SheetPrimitive.Close con estilos propios. Ejemplo operable: «Panel lateral · mismo cierre» en /design-system/componentes.
Guardar solo si hay cambios
En los formularios de edición, el botón «Guardar cambios» queda deshabilitado mientras no se haya modificado ningún campo, y se vuelve a deshabilitar si se devuelve el valor original. Se resuelve con useFormChanges (src/components/admin/use-form-changes.ts): se llama una vez con los valores de los campos y devuelve si difieren de los de apertura; el botón usa disabled={… || (isEdit && !changed)}. En creación no aplica (la regla es la validez de los campos). Los valores deben poder serializarse: un archivo se representa por nombre, tamaño y fecha. Los campos transitorios (cargando, borrador sin confirmar) no se incluyen.
Títulos, etiquetas y ayudas
Todos los diálogos comparten la misma tipografía y el mismo espacio:
- Título:
DialogTitleya trae su tamaño (sección, 18 px, semibold). No se sobrescribe context-xs,text-sm,text-baseotext-lg. Con icono se usaclassName="flex items-center gap-2"y el icono de 20 px (o una ficha de 32 px con el icono de 16 px).npm run dialog:checklo comprueba. - Descripción:
DialogDescription(14 px, gris), sin cambiar su tamaño. - Campo de formulario: un bloque
space-y-2conFieldLabel(etiqueta de 14 px, asterisco rojo si es obligatorio y, solo en los campos de texto largo (textarea), el contador de caracteres concounter={{ value, max, multiline: true }}(solo se muestra desde el 80 % del máximo); los campos de una línea no llevan contador) enlazada al control conhtmlForeid, el control y, debajo, la ayuda de 12 px (text-xs text-muted-foreground) enlazada conaria-describedby. Entre campos,space-y-4enDialogBody. Modelo:expedient-form-dialog.tsx. - Barras de filtros dentro de un modal (búsqueda de referencias): la etiqueta puede ser de 12 px gris porque es una fila compacta de filtros; el buscador usa
SearchInput. - Etiqueta y espacio:
Labelesinline-block. Coninline, el margen vertical despace-y-*se ignora y la etiqueta queda pegada al campo.
Contenido adicional en una confirmación
ConfirmDialog admite children: contenido bajo la descripción, dentro del encabezado, para detallar lo que se va a afectar (por ejemplo, la lista de anexos que se eliminarán, con un máximo de cuatro nombres y «y N más»). La descripción sigue siendo un texto corto; la lista no se mete en ella. Ejemplo: eliminación de varios anexos (anexos.md).
Confirmación de «Regresar a proyecto»
Ejemplo de confirmación (ConfirmDialog, título «¿Regresar a proyecto?», acción «Regresar a proyecto») cuyo texto es condicional y breve: dice solo lo que de verdad se pierde en ese documento. Lo genera revertConfirmText (app/src/app/dashboard/documentos/administrativos/nuevo/revert-confirm-text.ts) a partir de tres datos: si el documento está «Para despacho PIDE», cuántos vistos buenos tiene pedidos y si ya tiene firma.
Reglas:
- Frase base (siempre): «El documento volverá a ser editable.» En «Para despacho PIDE» la base es: «El documento volverá a ser editable y dejará de estar pendiente de envío por PIDE (saldrá de la Bandeja de Despacho de Interoperabilidad).»
- Frase de pérdida (solo si hay algo que perder), una sola oración: «Se perderá …» o «Se perderán …».
- Vistos buenos: «el visto bueno» si hay uno; «los vistos buenos» si hay más de uno. Sin vistos buenos pedidos, no se menciona.
- Firma: existe desde «Emitido» (también en «Para despacho PIDE» y «Observado»); en «Para despacho» todavía no. Sin firma, no se menciona.
- Verbo: singular («Se perderá») con «el visto bueno» solo o «la firma» sola; plural («Se perderán») con «los vistos buenos» o con dos elementos («el visto bueno y la firma»).
- Sin vistos buenos ni firma no se añade la frase de pérdida. No se repite que se conserva el PDF original sin firmar.
- Cerrar la confirmación equivale a cancelar (ver «Política de cierre»).
Combinaciones (texto completo = frase base + frase de pérdida):
| Estado del trámite | Vistos buenos | Firma | Frase de pérdida |
|---|---|---|---|
| Para despacho | 0 | No | (ninguna: solo la frase base) |
| Para despacho | 1 | No | Se perderá el visto bueno. |
| Para despacho | 3 | No | Se perderán los vistos buenos. |
| Emitido | 0 | Sí | Se perderá la firma. |
| Emitido | 1 | Sí | Se perderán el visto bueno y la firma. |
| Emitido | 3 | Sí | Se perderán los vistos buenos y la firma. |
| Para despacho PIDE (base PIDE) | 0 | Sí | Se perderá la firma. |
| Para despacho PIDE (base PIDE) | 1 | Sí | Se perderán el visto bueno y la firma. |
| Para despacho PIDE (base PIDE) | 3 | Sí | Se perderán los vistos buenos y la firma. |
En la página de componentes (/design-system/componentes, sección «Diálogos y cierre») el cuadro se genera con la propia función revertConfirmText, con un botón «Probar» por fila que abre la confirmación con ese texto. Para cualquier cantidad mayor que 1 el texto es el de «3». «Observado» se comporta como «Emitido». Si se cambian las reglas, actualizar la función y este cuadro a la vez.
Capa flotante sobre un diálogo (data-sgd-floating-layer)
Un panel global que debe verse nítido y con clics mientras hay un diálogo modal abierto (hoy, el panel de descarga del ZIP, ZipDownloadPanel) va por encima de la capa oscura (z-[70], mayor que el z-50 del diálogo), con pointer-events-auto (Radix apaga los eventos del resto de la página) y el atributo data-sgd-floating-layer. DialogContent lo reconoce: pulsar ahí no cierra el diálogo, aunque su closePolicy sea allowed. Debe ser una región aria-live (role="status") para anunciar sus cambios. Con el diálogo abierto el foco queda atrapado dentro, así que toda acción del panel debe poder hacerse también desde el diálogo (por ejemplo, el botón «Zip de todo el árbol» pasa a «Preparando ZIP…» y luego a «Descargar ZIP»).
Política de cierre
La raíz compartida Dialog acepta closePolicy="allowed" | "blocked". El valor predeterminado es allowed: muestra una sola X, y X, Escape y clic exterior solicitan el cierre. Para información, ayuda, detalles y formularios usar esta variante. La X conserva el hover, foco y nombre accesible descritos arriba.
En un formulario con cambios, DialogContent detecta entradas del usuario dentro de <form> y la raíz presenta «¿Descartar los cambios?» antes de cerrar por X, Escape, clic exterior o un botón «Cancelar» dentro de DialogFooter. Para formularios con editores o campos personalizados, pasar dirty={formIsDirty} a Dialog; ese valor controlado permite reconocer también cuando el usuario revierte todos los cambios. «Seguir editando» o Escape en esa confirmación vuelve al formulario; «Descartar cambios» ejecuta el cierre o el botón Cancelar original. Si el formulario tiene un botón de cancelación con otro texto, marcarlo data-dialog-cancel. Guardar mediante la acción propia del formulario no pide descarte. Un diálogo con dos modos (p. ej. «Ver» y «Editar» en la misma ventana, UserDetailDialog): el pie de «Editar» lleva «Cancelar» y «Guardar cambios», «Cancelar» vuelve a «Ver» y pasa dirty solo en modo edición para que pida el descarte cada vez que haya cambios; al cambiar de modo hay que mover el foco (al primer campo y, al volver, al botón que abrió el modo) porque el botón pulsado desaparece. La confirmación de descarte hecha por un botón «Cancelar» se consume al terminar (no queda activa mientras el diálogo siga abierto). En confirmaciones, ConfirmDialog trata Escape como «Cancelar»; Radix mantiene el clic exterior bloqueado y solo AlertDialogAction acepta. Nunca conectar un cierre pasivo a la acción de aceptación.
closePolicy="blocked" oculta la X e impide Escape, clic exterior y cualquier DialogClose. Solo una acción explícita puede cambiar el estado controlado del diálogo. Usar esta variante en «Sesión por expirar» y «Tu sesión ha expirado». No sustituirla por showCloseButton={false} más manejadores de teclado locales; esa prop solo sirve para composiciones que proporcionan otro cierre permitido. No añadir un segundo botón de cierre en el contenido. Conservar DialogHeader, DialogBody, DialogFooter, DialogTitle y DialogDescription para jerarquía y nombres accesibles.
Theme LATAM, Theme Adobe Spectrum y Theme Institucional comparten tamaños y comportamiento; los tokens primary, card, muted-foreground y la superficie neutra cambian con la paleta y el modo. El catálogo incluye ejemplos operables de cierre permitido con contenido corto y largo, descarte de un formulario y cierre bloqueado. Verificar hover y foco con teclado en las seis combinaciones antes de extender un modal.
Diálogo como hoja inferior en celular (mobileSheet)
DialogContent mobileSheet muestra el mismo diálogo, en pantallas de menos de 640 px, como una hoja inferior: pegado abajo y a todo el ancho, con las esquinas superiores redondeadas (--sgd-radius-dialog), alto máximo de 90 dvh, entrada desde abajo (220 ms; sin movimiento con «reducir movimiento»), área segura del equipo (env(safe-area-inset-bottom)), encabezado alineado a la izquierda y botones del pie apilados a todo el ancho. Arriba lleva el tirador de rayita (ui/sheet-grip.tsx, el mismo de Sheet side="bottom"): se arrastra hacia abajo para cerrar (más de 56 px o gesto rápido) y es un botón con foco (Enter, Espacio o flecha abajo cierran) para quien no arrastra (WCAG 2.5.7). Desde 640 px es el diálogo centrado de siempre y el tirador no se ve.
- Cuándo usarlo: formularios cortos, confirmaciones, selecciones y detalles que se leen mejor al alcance del pulgar. No para ventanas grandes o especializadas (firma,
dialog-resizable, Command), que siguen centradas. - Cierre: el gesto usa el botón de cierre del diálogo, así respetan lo mismo
confirmDiscardydirty. ConclosePolicy="blocked"no hay tirador ni gesto. - Estado actual (decisión de Jaime, 2026-10-18): se mantiene apagado (
mobileSheet, por defecto apagado) y ningún diálogo de las pantallas del SGD lo usa. Esta forma de hoja inferior se reserva para el visor de documentos (file-service: hojas Páginas y Más) y para un eventual desarrollo nativo en celular, que se evaluaría aparte. Ver «Diálogo como hoja en celular» en/design-system/componentes. - Verificado: Chrome con celular emulado de 390 × 800: el diálogo queda en
left 0, ancho completo y pegado abajo, esquinas superiores de 14 px, tirador centrado y arrastre hacia abajo que lo cierra; con 1200 px es el diálogo centrado de 448 px sin tirador. No probado: un teléfono real, áreas seguras reales ni lector de pantalla.