Ir al contenido

Overlays

Lo que se abre por encima de lo demás: diálogos, paneles laterales, confirmaciones, pistas y paneles plegables.

El diálogo completo: cabecera con tono e icono, cuerpo con scroll propio, pie con hasta tres acciones, modo formulario, estados de carga y error, y asistente por pasos.

import { Modal, Button, Input, Textarea } from '@openfactu/ui';
import { Plus } from 'lucide-react';
<Modal
isOpen={abierto}
onClose={cerrar}
icon={<Plus size={18} />}
title="Nueva tarea"
subtitle="Se asignará al proyecto activo."
size="lg"
primaryAction={{ label: 'Crear', onClick: crear }}
>
<Input label="Título" />
<Textarea label="Descripción" rows={3} />
</Modal>

onSubmit envuelve el cuerpo en un <form>: Intro envía y la acción primaria pasa a ser un submit. isBusy inhabilita las acciones e impide cerrar mientras se guarda.

<Modal
isOpen={abierto}
onClose={cerrar}
title="Nueva tarea"
isBusy={guardando}
onSubmit={async (e) => {
e.preventDefault();
setGuardando(true);
await crear();
setGuardando(false);
cerrar();
}}
primaryAction={{ label: 'Crear', type: 'submit', isLoading: guardando }}
>
<Input label="Título" required />
</Modal>

tertiaryAction se alinea a la izquierda del pie, que es donde va lo destructivo o lo secundario que no compite con el botón principal.

<Modal
isOpen={abierto}
onClose={cerrar}
title="Factura FAC/2026/0042"
tertiaryAction={{ label: 'Eliminar', variant: 'danger', icon: <Trash2 className="h-3.5 w-3.5" />, onClick: eliminar }}
secondaryAction={{ label: 'Descargar PDF', icon: <FileDown className="h-3.5 w-3.5" />, onClick: descargar }}
primaryAction={{ label: 'Enviar por correo', onClick: enviar }}
>
</Modal>
<Modal isOpen={abierto} onClose={cerrar} tone="danger" title="Anular factura" …>…</Modal>
<Modal isOpen={abierto} onClose={cerrar} isLoading loadingLabel="Cargando la factura…"></Modal>
<Modal isOpen={abierto} onClose={cerrar} error="La AEAT ha rechazado el envío: código 3002."></Modal>

isLoading pone una capa de carga sobre el cuerpo —para cuando aún estás trayendo los datos del diálogo—; isBusy es para la operación en curso, que además bloquea el cierre.

<Modal
isOpen={abierto}
onClose={cerrar}
title="Importar artículos"
steps={[
{ key: 'archivo', label: 'Archivo' },
{ key: 'columnas', label: 'Columnas' },
{ key: 'revision', label: 'Revisión', optional: true },
]}
currentStep={paso}
onStepChange={setPaso}
>
</Modal>
<Modal isOpen={abierto} onClose={cerrar} size="screen" fullHeight noBodyPadding scrollBehavior="container">
<EditorDeDocumento />
</Modal>

size="screen" es el ancho de editor y fullHeight la altura fija; juntos dan el diálogo grande de trabajo.

Prop Tipo Por defecto Descripción
isOpen boolean Abierto
onClose () => void Cerrar
children ReactNode Cuerpo
title ReactNode Título
subtitle ReactNode Subtítulo
eyebrow ReactNode Antetítulo en versales pequeñas
icon ReactNode Icono en un chip a la izquierda del título
tone 'default' | 'info' | 'success' | 'warning' | 'danger' 'default' Color de la cabecera
headerActions ReactNode Nodos extra junto al botón de cerrar
size ModalSize 'md' Ancho: xs7xl, full o screen
maxWidth ModalSize Obsoleta: usa size. Si se pasa, tiene prioridad
fullHeight boolean false Altura fija tipo editor
noBodyPadding boolean false Quita el relleno del cuerpo
scrollBehavior 'body' | 'container' 'body' body fija cabecera y pie; container scrollea el panel entero
footer ReactNode Pie a medida. Tiene prioridad sobre las acciones
primaryAction ModalAction Botón principal
secondaryAction ModalAction Botón secundario
tertiaryAction ModalAction Acción alineada a la izquierda del pie
hideCancel boolean false Oculta el botón de cancelar
cancelLabel string 'Cancelar' Texto del botón de cancelar
onSubmit (e: FormEvent) => void | Promise<void> Envuelve el cuerpo en un <form>
isLoading boolean false Capa de carga sobre el cuerpo
loadingLabel string 'Cargando…' Texto de la capa de carga
isBusy boolean false Operación en curso: inhabilita acciones e impide cerrar
error ReactNode Banda de error encima del pie
steps ModalStep[] Asistente por pasos
currentStep number 0 Paso actual, 0-based
onStepChange (index: number) => void Cambio de paso
dismissible boolean true Muestra la X y permite cerrar con Escape
closeOnOverlayClick boolean false Cerrar al pulsar fuera
closeOnEscape boolean Cerrar con Escape
initialFocusRef RefObject<HTMLElement> Qué se enfoca al abrir
zIndex number | string Capa
container HTMLElement | null document.body Dónde se monta el portal
lockScroll boolean Bloquea el scroll del fondo
className, overlayClassName, headerClassName, bodyClassName, footerClassName string Clases
Prop Tipo Descripción
label ReactNode Texto del botón
onClick () => void | Promise<void> Acción
variant ButtonProps['variant'] Estilo del botón
icon ReactNode Icono
disabled boolean Desactivado
isLoading boolean Spinner
type 'button' | 'submit' submit envía el <form> interno

{ key, label, description?, optional? }.

Si el pie o la cabecera se te quedan cortos, monta las piezas a mano:

import { Modal, ModalHeader, ModalBody, ModalFooter } from '@openfactu/ui';
<Modal isOpen={abierto} onClose={cerrar}>
<ModalHeader title="Detalle" subtitle="Composición manual" onClose={cerrar} />
<ModalBody noPadding>
<Table columns={columnas} data={lineas} />
</ModalBody>
<ModalFooter align="between">
<Button variant="ghost">Ayuda</Button>
<Button variant="accent" onClick={cerrar}>Hecho</Button>
</ModalFooter>
</Modal>

ModalFooter acepta align con 'end' (por defecto), 'between' o 'start'.

Panel lateral. En móvil ocupa siempre el ancho completo.

import { Drawer, Button } from '@openfactu/ui';
<Drawer
open={abierto}
onClose={cerrar}
title="Filtros avanzados"
side="right"
size="md"
footer={
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={limpiar}>Limpiar</Button>
<Button variant="accent" onClick={aplicar}>Aplicar</Button>
</div>
}
>
</Drawer>
Prop Tipo Por defecto Descripción
open boolean Abierto
onClose () => void Cerrar
children ReactNode Contenido
side 'left' | 'right' 'right' Por dónde entra
size 'sm' | 'md' | 'lg' | 'full' 'md' 320 · 420 · 560 px, o completo
title ReactNode Título
footer ReactNode Pie
dismissible boolean true El fondo y Escape cierran
className string Clases

La confirmación de siempre, controlada con props.

import { ConfirmDialog } from '@openfactu/ui';
<ConfirmDialog
open={confirmando}
tone="danger"
title="Anular factura"
message="La factura FAC/2026/0042 quedará anulada y se emitirá una rectificativa. No se puede deshacer."
confirmLabel="Anular"
loading={anulando}
onConfirm={anular}
onCancel={() => setConfirmando(false)}
/>
Prop Tipo Por defecto Descripción
open boolean Abierto
message ReactNode Cuerpo del mensaje
title string 'Confirmar' Título
tone 'info' | 'success' | 'warning' | 'danger' Color
confirmLabel string 'Confirmar' Texto del botón de confirmar
cancelLabel string 'Cancelar' Texto del botón de cancelar
loading boolean false Deshabilita los botones y pone spinner en confirmar
onConfirm () => void Confirmación
onCancel () => void Cancelación

Diálogos por promesa: los pides desde el código, sin montar componentes ni llevar estado. Es la forma más cómoda de preguntar algo en mitad de una función.

Monta el proveedor una vez, en la raíz:

import { PopupProvider } from '@openfactu/ui';
<PopupProvider>
<App />
</PopupProvider>
import { usePopup } from '@openfactu/ui';
function BotonAnular({ factura }) {
const popup = usePopup();
const anular = async () => {
const seguro = await popup.confirm({
title: 'Anular factura',
message: `Se anulará ${factura.numero} y se emitirá una rectificativa.`,
tone: 'danger',
confirmLabel: 'Anular',
});
if (!seguro) return;
await anularFactura(factura.id);
await popup.alert({ message: 'Factura anulada.', tone: 'success' });
};
return <Button variant="danger" onClick={anular}>Anular</Button>;
}

show() pinta lo que quieras y resuelve con lo que le pases a close:

const motivo = await popup.show<string>({
title: 'Motivo de la anulación',
maxWidth: 'md',
render: (close) => <FormularioDeMotivo onCancelar={() => close()} onAceptar={(m) => close(m)} />,
});
if (motivo) await anularFactura(factura.id, motivo);

Si el usuario cierra sin elegir, la promesa resuelve undefined.

Método Firma Descripción
alert (opts) => Promise<void> Aviso con un solo botón
confirm (opts) => Promise<boolean> Confirmación; resuelve true o false
show <T>(opts) => Promise<T | undefined> Diálogo a medida
closeAll () => void Cierra todos los abiertos

alert acepta { title?, message, tone?, confirmLabel? }; confirm añade cancelLabel; show toma { title?, subtitle?, tone?, maxWidth?, dismissible?, render }.

Los popups se apilan: puedes abrir uno desde dentro de otro.

El marco que usan los popups por dentro. Se exporta por si quieres el mismo aspecto en un diálogo que montes tú.

Prop Tipo Por defecto Descripción
onClose () => void Cerrar
children ReactNode Cuerpo
title string Título
subtitle string Subtítulo
tone 'info' | 'success' | 'warning' | 'danger' Color
maxWidth 'sm'…'7xl' | 'full' 'lg' Ancho
dismissible boolean true Muestra la X
footer ReactNode Barra inferior de acciones, a la derecha

La pista al pasar el ratón.

import { Tooltip } from '@openfactu/ui';
<Tooltip content="Descargar el PDF de la factura">
<Button variant="ghost"><FileDown className="h-4 w-4" /></Button>
</Tooltip>
<Tooltip content="Solo para administradores" side="right" delay={0}>
<span></span>
</Tooltip>
Prop Tipo Por defecto Descripción
content ReactNode Contenido de la pista
children ReactNode El elemento que la dispara
side 'top' | 'bottom' | 'left' | 'right' 'top' Lado preferido
delay number 300 Retardo de apertura en milisegundos
disabled boolean false Desactiva la pista
className string Clases

side es una preferencia: si no cabe, el tooltip se coloca donde quepa.

Paneles plegables.

import { Accordion } from '@openfactu/ui';
<Accordion
items={[
{ key: 'fiscal', title: 'Datos fiscales', content: <DatosFiscales /> },
{ key: 'pago', title: 'Condiciones de pago', content: <CondicionesDePago /> },
{ key: 'auditoria', title: 'Auditoría', content: <Auditoria />, disabled: true },
]}
/>

Por defecto solo hay un panel abierto a la vez. Con type="multiple" se pueden abrir varios.

<Accordion type="multiple" defaultOpenKeys={['fiscal', 'pago']} items={SECCIONES} />
{/* controlado */}
<Accordion openKeys={abiertos} onOpenChange={setAbiertos} items={SECCIONES} />
Prop Tipo Por defecto Descripción
items AccordionItem[] Paneles: { key, title, content, disabled? }
type 'single' | 'multiple' 'single' Cuántos pueden estar abiertos
defaultOpenKeys string[] [] Estado inicial, no controlado
openKeys string[] Estado controlado
onOpenChange (keys: string[]) => void Cambio de paneles abiertos
className string Clases