Modal
Diálogo que se superpone a todo, con el fondo desenfocado y un brillo del color del tono. Hecho sobre el
<dialog> nativo: de él vienen la capa por encima de cualquier z-index, el foco
atrapado, el Esc y el foco devuelto a quien lo abrió — el componente solo dibuja la caja y la anima.
Ejemplos
Todos los de abajo son el mismo Tucano.modal() con opciones distintas.
Tamaños
size define el ancho máximo de la caja; md es el predeterminado.
Tonos
tone cambia el brillo del fondo y el aura de la caja.
Confirmación con promesa
Tucano.confirm() resuelve true o false; cerrar desde fuera es un rechazo.
Hoja en el móvil
sheet: true sube desde abajo en pantallas estrechas. Estrecha la ventana para verlo.
Sin cerrar desde fuera
closeOnBackdrop: false ignora el clic en el fondo; closable: false quita la X y el Esc.
Motivo del cierre
onClose recibe por dónde salió la persona. Ciérralo de formas distintas.
Contenido libre
content(nodo) pone cualquier elemento en el cuerpo, que se desplaza cuando supera la altura.
Acción que no cierra
closes: false en una acción mantiene el modal abierto después del clic.
Cómo usar
Hay dos caminos. Desde JavaScript, el modal se monta en el momento, entra en el <body> al abrirse y sale
al cerrarse. Desde la plantilla, el <dialog> ya está en la página y el componente solo lo abre, lo cierra y lo anima.
En JavaScript
Tucano.modal({
title: 'Eliminar contrato',
text: 'Esta acción no se puede deshacer.',
tone: 'danger', // default | danger | success | warning
size: 'md', // sm | md | lg | full
sheet: true, // en el móvil sube desde abajo
actions: [
{ text: 'Cancelar', variant: 'outline' },
{ text: 'Eliminar', variant: 'danger', onClick: () => deleteContract() },
],
onClose: (reason, modal) => console.log(reason),
});
// Solo con texto, el atajo acepta una cadena
Tucano.modal('Archivo enviado.');El atajo Tucano.modal() crea y abre en un paso, y devuelve la instancia. Para montarlo antes y abrirlo después,
usa la clase:
const m = new Tucano.Modal({ title: 'Nuevo contacto', size: 'lg' });
m.content(form); // cualquier nodo, o un array de nodos
m.open();
m.close(); // anima la salida y llama a onClose(reason)Confirmación
El caso más común de modal en un CRUD es preguntar antes de borrar. Tucano.confirm() devuelve una promesa, y el
código sigue leyéndose de arriba abajo:
if (await Tucano.confirm({ title: '¿Eliminar contrato?', text: 'Esta acción no se puede deshacer.', confirm: 'Eliminar' })) {
deleteContract();
}| Opción | Predeterminado | Para qué |
|---|---|---|
confirm | 'Confirmar' | Texto del botón que resuelve true |
cancel | 'Cancelar' | Texto del botón que resuelve false |
tone | 'danger' | Con danger el botón de confirmar es rojo; con cualquier otro tono, primario |
Las demás opciones del modal también valen aquí, excepto actions, que confirm() monta por su cuenta. Un
onClose que pases se sigue llamando.
Cerrar desde fuera es un rechazo, no un limbo
Con la X, con Esc o con el fondo, la promesa resuelve false. Sin eso quedaría pendiente para siempre, y el await nunca volvería.
Acciones
Cada elemento de actions se convierte en un .tuc-btn en el pie, en el orden en que se escribió.
| Clave | Predeterminado | Para qué |
|---|---|---|
text | — | Texto del botón |
variant | 'outline' | Variante del botón: primary, outline, ghost, danger… |
onClick | — | Recibe la instancia del modal |
closes | true | false mantiene el modal abierto después del clic |
Escrito en la plantilla
Cuando el contenido viene renderizado por el servidor — un formulario de Django, con errores y csrf_token —,
el <dialog> vive en la plantilla. Todo dialog.tuc-modal se adopta al cargar y en cada
htmx:afterSwap; abrir no lo inserta y cerrar no lo quita, porque el nodo es de quien escribió el HTML.
<dialog class="tuc-modal is-md" id="delete" aria-labelledby="delete-title">
<form class="tuc-modal__panel" method="post" action="{% url 'contract-delete' contract.pk %}">
{% csrf_token %}
<div class="tuc-modal__top">
<div class="tuc-modal__header">
<h2 class="tuc-modal__title" id="delete-title">¿Eliminar contrato?</h2>
<p class="tuc-modal__text">Esta acción no se puede deshacer.</p>
</div>
<button type="button" class="tuc-btn is-ghost is-icon is-sm tuc-modal__close" aria-label="Cerrar" data-tuc-modal-close>…</button>
</div>
<div class="tuc-modal__footer">
<button type="button" class="tuc-btn is-outline" data-tuc-modal-close>Cancelar</button>
<button class="tuc-btn is-danger">Eliminar</button>
</div>
</form>
</dialog>
<button class="tuc-btn is-outline" data-tuc-modal="#delete">Eliminar</button>| Atributo | Dónde | Para qué |
|---|---|---|
data-tuc-modal="#id" | Disparador | Abre el <dialog> del selector al hacer clic |
data-tuc-modal-close | Botón dentro del diálogo | Cierra, con el motivo 'button' |
data-closable="false" | <dialog> | Esc deja de cerrar |
data-backdrop="false" | <dialog> | El clic en el fondo deja de cerrar |
Las clases son las mismas que monta el JavaScript: __panel es la caja, __top contiene el
__header (con __title y __text) y el __close, __body es el
cuerpo que se desplaza y __footer alinea las acciones a la derecha. El tamaño y el tono van como clase en el propio
<dialog>: is-lg, is-danger, is-sheet.
En la plantilla, la X y el nombre del diálogo son cosa tuya
Desde JavaScript el botón de cerrar y el aria-labelledby salen listos. En el <dialog> escrito a mano, escribe los dos: sin la X la persona depende del Esc, y sin el aria-labelledby el lector de pantalla anuncia solo "diálogo".
Confirmar antes de enviar un formulario
Para un formulario de eliminación que ya existe en la página, confirm() intercepta el envío y lo deja pasar solo con un sí:
document.querySelector('#delete-contract').addEventListener('submit', async (e) => {
e.preventDefault();
const ok = await Tucano.confirm({ title: '¿Eliminar contrato?', confirm: 'Eliminar' });
if (ok) e.target.submit();
});El <dialog> cerrado tiene que volver a display: none — y la biblioteca ya lo hace
La regla del modal declara display: grid, que gana a la hoja del navegador. Sin .tuc-modal:not([open]) { display: none }, un <dialog> quieto en la plantilla quedaría renderizado, fijo y transparente sobre toda la página: no se ve nada, y ningún clic funciona. Por eso no necesita hidden.
Tonos y brillo
El fondo no es solo oscuro: un brillo radial detrás de la caja separa el diálogo de lo que quedó debajo, y el desenfoque saca la página del foco. El tono cambia el color de ese brillo y del aura alrededor de la caja — no el color de los botones, que sigue siendo elección de cada acción.
tone | Clase | Color del brillo |
|---|---|---|
default | is-default | --tuc-accent — cambiar el acento cambia el brillo |
danger | is-danger | --tuc-danger-fill |
success | is-success | --tuc-success |
warning | is-warning | --tuc-warning |
Tamaños
La caja ocupa el ancho disponible hasta el límite del tamaño, y el cuerpo se desplaza cuando el contenido supera la altura de la pantalla.
size | Ancho máximo | Para qué |
|---|---|---|
sm | 22rem | Confirmación corta |
md | 30rem | El predeterminado: aviso, formulario de pocos campos |
lg | 44rem | Tabla o formulario ancho |
full | la pantalla, con un margen de 1rem | Ancho y alto completos: editor, vista previa de documento |
Por debajo de 40rem los botones del pie ocupan la fila, para que el área táctil crezca. Con sheet, la caja
pierde las esquinas de abajo, se apoya en el borde inferior y respeta el área segura del iPhone. Es opcional porque no todo modal quiere
convertirse en panel lateral en el móvil.
Teclado y accesibilidad
showModal() pone el diálogo en la top layer y deja inerte el resto de la página: el foco no escapa
detrás de él, y al cerrar vuelve a quien lo abrió. Nada de eso es JavaScript nuestro — es el elemento nativo.
| Tecla | Acción |
|---|---|
Tab Shift+Tab | Recorre los controles del diálogo, sin volver a la página |
Enter Espacio | Activa el botón con foco |
Esc | Cierra con animación y devuelve el foco; no hace nada con closable: false |
Por qué <dialog>, y no un div con z-index
La top layer queda por encima de cualquier z-index y es inmune a un ancestro con overflow: hidden o transform — los tres motivos por los que un modal artesanal aparece cortado o por debajo. El Esc nativo cierra al instante, sin animación; el componente intercepta el cancel para cerrar por el mismo camino que los demás, que anima e informa el motivo.
Con title, el diálogo recibe un aria-labelledby que apunta a él, y el lector de pantalla anuncia el título al
abrirse. La X es un .tuc-btn con aria-label="Fechar". Con prefers-reduced-motion, la entrada
dura 100 ms y no crece.
Motivo en onClose | Cuándo |
|---|---|
'button' | La X, o un data-tuc-modal-close en la plantilla |
'action' | Un botón de actions |
'escape' | La tecla Esc |
'backdrop' | Clic fuera de la caja |
'api' | close() llamado sin argumento |
El modal y el panel lateral comparten esta mecánica: una corrección hecha en uno vale para el otro.
API
Generada a partir del código en cada build — si algo no está aquí, no existe.
dialog.tuc-modal [data-tuc-modal-close] [data-tuc-modal]new Tucano.Modal(opcoes) Tucano.modal() Tucano.confirm()data-backdrop data-closable data-tuc-modalopen close contentOpciones
Las notas de esta tabla salen de los comentarios del código fuente, que están escritos en portugués.
| Opción | Por defecto | Para qué |
|---|---|---|
title | null | |
text | '' | |
size | 'md' | sm | md | lg | full |
tone | 'default' | default | danger | success | warning |
sheet | false | no celular sobe do rodape em vez de surgir no centro |
closable | true | botao X e Escape |
closeOnBackdrop | true | |
actions | null | [{ text, variant, onClick, closes }] — closes:false mantem aberto |
onClose | null | |
className | '' |