componente · @solu30/ui-kit/feedback
Modal
Overlay configurado por props, con encabezado, pie y cinco posiciones: centro, lateral, inferior o pantalla completa.
336 sitios de import en 10 de 15 verticales — un archivo que la importa cuenta una vez, monte una instancia o veinte.
- TheCharterPanel
- carlosmartinpavon
- cmp-packages
- dietaly
- easycharter
- elliot
- gestorsalon
- luigi
- raquetea
- zentrya
El componente, montado
Cargando el espécimen…
export function ModalMatrix() {
const [open, setOpen] = useState(false)
const [variant, setVariant] = useState<ModalVariant>('center')
const focus = useFocusReturnCheck()
const close = () => {
setOpen(false)
focus.notifyClosed()
}
return (
<Stage label="Modal · 5 variantes de layout, mismo contenido" tall wide>
<div className="flex flex-col items-center gap-3">
<Row>
{MODAL_VARIANTS.map((v) => (
<TriggerButton
key={v}
onClick={(e) =>
focus.openFrom(e, () => {
setVariant(v)
setOpen(true)
})
}
>
{v}
</TriggerButton>
))}
</Row>
<FocusReturnNote result={focus.result} />
</div>
{/* Mismo motivo que en ConfirmDialogMatrix: 157 sitios de import y el
panel vive en un portal, así que sin clic la ficha era cinco botones.
La réplica muestra la forma de `variant="center"`; las otras cuatro
cambian dónde se ancla el panel, no su contenido, y eso sólo se ve
abriéndolas de verdad. */}
<ReplicaContenida nota="réplica del panel center, no el portal">
<div className="flex flex-col">
<div className="flex items-center gap-2 border-b border-content/10 px-4 py-3">
<IconHome className="h-4 w-4 text-content/60" />
<div className="flex flex-col">
<span className="text-sm font-medium text-content">Ficha de franquicia</span>
<span className="text-[11px] text-content/60">Luigi · sucursal Alta Córdoba</span>
</div>
</div>
<div className="flex flex-col gap-1.5 px-4 py-3 text-xs text-content/70">
<span>Regalías del mes: 8.4% sobre ventas netas.</span>
<span>Stock crítico: 2 sabores por debajo del mínimo.</span>
</div>
<div className="flex justify-end border-t border-content/10 px-4 py-2.5">
<span className="rounded-md border border-content/10 bg-container px-3 py-1.5 text-xs text-content">
Cerrar
</span>
</div>
</div>
</ReplicaContenida>
<Modal
isOpen={open}
onClose={close}
variant={variant}
title="Ficha de franquicia"
subtitle="Luigi · sucursal Alta Córdoba"
icon={<IconHome className="h-4 w-4" />}
footer={
<div className="flex justify-end gap-2">
<TriggerButton onClick={close}>Cerrar</TriggerButton>
</div>
}
>
<div className="flex flex-col gap-1.5 text-sm text-content/70">
<p>Regalías del mes: 8.4% sobre ventas netas.</p>
<p>Stock crítico: 2 sabores por debajo del mínimo.</p>
</div>
</Modal>
</Stage>
)
}De specimens/feedback.tsx · ModalMatrix: el espécimen del Muestrario, no un ejemplo oficial del paquete. Muestra cómo se usa la pieza de verdad, montada contra los tipos instalados —eso es lo que vale— pero no es documentación del kit.
Cómo se usa
import { Modal } from '@solu30/ui-kit/feedback'Props
| Prop | Requerida | Tipo | Qué hace |
|---|---|---|---|
| isOpen | sí | boolean | — |
| onClose | sí | () => void | — |
| title | opcional | string | — |
| subtitle | opcional | string | — |
| description | opcional | string | — |
| icon | opcional | ReactNode | Optional icon rendered in a circle to the left of the title. Pair with `iconBgColor` to control the circle background. |
| iconBgColor | opcional | string | Tailwind background class for the icon circle. @default 'bg-primary-muted' |
| subtitleColor | opcional | string | Tailwind text color class for the subtitle. @default 'text-content/60' |
| size | opcional | ModalSize | — |
| children | sí | ReactNode | — |
| footer | opcional | ReactNode | — |
| className | opcional | string | — |
| style | opcional | CSSProperties | — |
| static | opcional | boolean | Prevent closing on backdrop click |
| dismissible | opcional | boolean | Cuando es `false`, el modal es genuinamente no-cerrable: bloquea Escape, click en el backdrop (independiente de `static`) Y el botón X (se oculta, sin importar `hideCloseButton`). `role` pasa de `dialog` a `alertdialog` — misma semántica ARIA que usa `AlertDialog` para "hay que responder sí o sí". El back button del navegador también queda tragado mientras `manageHistory` esté activo (mismo criterio que `AlertDialog`). Usalo SOLO cuando existe una salida alcanzable por teclado dentro del propio modal — típicamente un botón de aceptar en `footer` — porque sin eso es una trampa de teclado (WCAG 2.1.2). Pasá `initialFocusRef` apuntando a ese botón para que el foco llegue ahí al abrir. NO usar para confirmaciones de rutina, formularios o cualquier flujo con una vía de "cancelar" razonable — para eso `static` (bloquea solo el backdrop) alcanza y no genera este riesgo de a11y. Reservalo para avisos que de verdad exigen una respuesta explícita: cambio de precios, cambio de protocolo operativo, aviso de gerencia. @default true |
| initialFocusRef | opcional | RefObject<HTMLElement | null> | Elemento que recibe foco al abrir. Si no se pasa y `dismissible={false}`, el foco cae en el último elemento focuseable del modal (por convención del kit, la acción primaria del `footer` va al final — ver `ConfirmDialog`/`AlertDialogFooter`). Con `dismissible` en su default `true` no cambia nada: sigue yendo al primer elemento focuseable. |
| variant | opcional | ModalVariant | Visual variant |
| renderHeader | opcional | (props: { title?: string; onClose: () => void; }) => ReactNode | Override the entire header |
| hideCloseButton | opcional | boolean | Hide the close button |
| fillHeight | opcional | boolean | Side/left panels fill entire height |
| noContentPadding | opcional | boolean | Remove content padding (for maps, etc.) |
| manageHistory | opcional | boolean | Si este modal maneja su propia entrada de `window.history` (pushState al abrir, "atrás" lo cierra). Default `true` — sin cambios de comportamiento para consumidores existentes. Poné `false` cuando `isOpen`/`onClose` YA están sincronizados con la URL de la app (Next.js App Router: `?param=` + `router.push`/`searchParams`), o cuando el modal se abre ANIDADO dentro de un overlay URL-driven — en ambos casos el pushState propio del kit mete una entrada espuria en el historial y "atrás" necesita un press de más para cerrar lo de afuera (ADR-262). @default true |
Ejemplo del paquete
// Controlado siempre: el que lo abre es dueño de `isOpen`.
const [abierto, setAbierto] = useState(false)
<Modal
isOpen={abierto}
onClose={() => setAbierto(false)}
title="Nueva reserva"
size="lg"
footer={<Button onClick={guardar}>Guardar</Button>}
>
<FormularioReserva />
</Modal>Del @example del JSDoc de @solu30/ui-kit, sin editar: son 65 de los 541 símbolos. La sangría y los renglones en blanco son los del paquete.