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>Como formulario
Sección titulada «Como formulario»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>Tres acciones
Sección titulada «Tres acciones»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>Tono, carga y error
Sección titulada «Tono, carga y error»<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.
Asistente por pasos
Sección titulada «Asistente por pasos»<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>Editor a pantalla casi completa
Sección titulada «Editor a pantalla casi completa»<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: xs…7xl, 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 |
ModalAction
Sección titulada «ModalAction»| 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 |
ModalStep
Sección titulada «ModalStep»{ key, label, description?, optional? }.
Composición
Sección titulada «Composición»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 |
ConfirmDialog
Sección titulada «ConfirmDialog»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>usePopup()
Sección titulada «usePopup()»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>;}Diálogos a medida
Sección titulada «Diálogos a medida»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.
PopupFrame
Sección titulada «PopupFrame»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 |
Tooltip
Sección titulada «Tooltip»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.
Accordion
Sección titulada «Accordion»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 |