Cuadro de diálogo

Descripción general

Usa el elemento nativo <dialog> para los diálogos modales y no modales. Proporciona la semántica de accesibilidad, el manejo del enfoque, la renderización de la capa superior y el manejo de Esc que las superposiciones personalizadas deben volver a compilar de forma manual.

Elige el tipo de superposición

Elige la primera regla que coincida. Si no lo sabes con certeza, usa la regla 1.

Regla 1: La superposición interrumpe al usuario y bloquea la página (predeterminada)

Úsala para confirmaciones, formularios, accesos, acciones destructivas y cualquier solicitud "modal", "de diálogo", "lightbox" o "emergente" que no indique lo contrario.

Usa <dialog> abierto como un modal, de forma declarativa con command="show-modal" (consulta Control declarativo de diálogos y ventanas emergentes) o desde una secuencia de comandos con showModal().

<button type="button" command="show-modal" commandfor="confirm">Delete</button>

<!-- MANDATORY: no role="dialog" or aria-modal; the native modal already provides them -->
<dialog id="confirm" aria-labelledby="confirm-title">
  <h2 id="confirm-title">Delete this item?</h2>
  <form method="dialog">
    <button value="cancel" autofocus>Cancel</button>
    <button value="delete">Delete</button>
  </form>
</dialog>

El navegador proporciona lo siguiente: página inerte, contención de enfoque, capa superior, ::backdrop, Esc y descarte de gestos de retroceso, y retorno del enfoque al invocador.

  • NO agregues role="dialog", aria-modal, una trampa de enfoque, un botón de activación inert ni un controlador Esc.
  • NO lo abras con show() ni con el atributo open.
  • SÍ detén el desplazamiento de la página detrás del anuncio con el CSS que se muestra a continuación. Consulta Cómo evitar que se desplace la página que se encuentra debajo de un diálogo modal para obtener más detalles.
  • NO bloquees el desplazamiento con overflow: hidden o position: fixed en html o body.
/* MANDATORY (both rules): without them, scrolling over the backdrop or inside the dialog scrolls the page behind it */
dialog:modal {
  overscroll-behavior: contain; /* stops scroll gestures in the dialog chaining to the page */
}
dialog::backdrop {
  overflow: hidden; /* ::backdrop is not a scroll container by default, so it would ignore overscroll-behavior */
  overscroll-behavior: contain;
}

No se usa para menús ni cuadros de información (consulta la regla 3).

Regla 2: El usuario sigue trabajando en la página mientras el panel está abierto

Se usa para barras de búsqueda, inspectores, paletas y paneles de redacción que, semánticamente, siguen siendo diálogos.

Usa <dialog popover> para un panel de capa superior con descarte de ventana emergente (auto descarta de forma ligera, manual no). Usa dialog.show() solo cuando el panel no deba estar en la capa superior.

<button type="button" popovertarget="find">Find</button>
<dialog id="find" popover="manual" aria-labelledby="find-title">
  <h2 id="find-title">Find</h2>
  <input type="search" aria-label="Search">
  <button type="button" popovertarget="find" popovertargetaction="hide">Close</button>
</dialog>

La página sigue siendo interactiva y no se vuelve inerte.

  • SÍ incluye un botón de cierre visible.
  • NO uses showModal(), que bloquea la página.

No se deben usar para bloquear confirmaciones (consulta la regla 1).

Regla 3: La superposición no es un diálogo

Se usa para menús, menús desplegables, cuadros de información, tarjetas emergentes y mensajes de aviso.

Usa un elemento semántico con el atributo popover, no <dialog>.

<button type="button" popovertarget="menu">Actions</button>
<ul id="menu" popover>
  <li><a href="/rename">Rename</a></li>
</ul>

Elige cómo se puede descartar

El valor predeterminado depende de cómo se abra el diálogo. Si closedby no está presente o no es válido, showModal() se comporta como closerequest y cualquier otra forma de apertura se comporta como none.

  • Solo gestos de Esc y Atrás (modal predeterminado): No configures nada o closedby="closerequest".
  • También haz clic fuera para cerrar: usa closedby="any".
  • Solo el desarrollador lo cierra (por ejemplo, un formulario con cambios sin guardar): Usa closedby="none" y agrega una ruta de confirmación. Consulta la sección Cierre que aparece más abajo.

Lineamientos

Nombre de accesibilidad y enfoque inicial

  • SÍ asigna un nombre a cada diálogo con aria-labelledby que apunte a su encabezado o aria-label cuando no haya un título visible.
  • SÍ coloca autofocus en el control principal o en el primer campo.
  • SÍ debes colocar autofocus en la acción segura (como Cancelar o Conservar) en una confirmación destructiva.
  • NO escribas código para devolver el foco al cerrar. El navegador lo devuelve al invocador.

Opening

Closing

  • SÍ cierres con botones <form method="dialog"> o command="close".
  • SÍ elige closedby de forma deliberada: any (descarte ligero y Esc), closerequest (solo Esc, el valor predeterminado modal) o none (solo cierre controlado por el desarrollador).
  • SÍ usa command="request-close" o requestClose() para los botones Cancelar que deben respetar la lógica de cambios no guardados. Activan cancel primero y, luego, ignoran closedby.
  • SÍ trata closedby como una mejora progresiva. Esc y el gesto de retroceso en dispositivos móviles ya cierran los modales sin él.

Cómo conectarse a close

  • SÍ lee dialog.returnValue en un objeto de escucha close para saber qué botón se usó y, luego, restablecerlo con dialog.returnValue = ''. Esc y otras solicitudes de cierre no establecen ningún valor, y los navegadores difieren en cuanto a si borran el anterior, por lo que, sin el restablecimiento, se puede filtrar un valor obsoleto. Trata '' como "descartado sin elegir".
  • SÍ uses cancel para interceptar Esc y request-close, pero no dependas de preventDefault() para presionar Esc de forma repetida sin la activación del usuario.
  • SÍ uses closedby="none" con un paso de confirmación explícito para los formularios modificados.
  • SÍ indícale al usuario por qué el diálogo permaneció abierto cuando se impidió cancel: muestra un mensaje visible y haz referencia a él con aria-describedby desde el control que recibe el enfoque (como Descartar), de modo que un lector de pantalla lo lea cuando se mueva el enfoque. Sin él, cuando el enfoque se mueve a un botón nuevo, solo se anuncia el nombre de ese botón.
  • NO permitas que la confirmación mueva los controles debajo del puntero. Reserva el espacio del mensaje (ocúltalo con visibility, no con display ni con hidden) y agrega el botón destructivo donde los botones existentes no se muevan (por ejemplo, primero en una fila alineada a la derecha), de modo que un segundo clic en el mismo lugar no pueda descartar los cambios del usuario.

Diálogos no modales

  • SÍ usa <dialog popover> (auto o manual) para un panel de capa superior y dialog.show() cuando no quieras la capa superior.
  • SÍ incluye un botón de cierre visible en cada diálogo no modal.

Fondo y estilo

Accesibilidad

  • NO agregues role="dialog", aria-modal, una trampa de enfoque manual ni un botón de activación de inert a un modal nativo.
  • SÍ usa un <button> nativo con un nombre de accesibilidad para los controles de cierre.
  • SÍ usa un nivel de encabezado coherente para los títulos de los diálogos.

Estrategias de resguardo

closedby (dialog-closedby)

Sin closedby, un modal se sigue cerrando con Esc y el gesto de retroceso; solo se pierde el clic fuera del modal, por lo que se trata de una degradación elegante. closedby="none" se ignora de la misma manera: Esc activa cancel y, luego, cierra el diálogo. Mantén la protección de cambios no guardados en un controlador cancel, no solo en closedby="none", para que también proteja donde falta closedby. Si tu destino de Baseline requiere un cierre rápido, agrega lo siguiente donde falta closedby:

if (!('closedBy' in HTMLDialogElement.prototype)) {
  // Test the pointer position, not event.target: the <dialog> is also the target for its own
  // padding and the gaps between its children, so a target check would close on those clicks
  const outside = (e, dialog) => {
    const r = dialog.getBoundingClientRect();
    return e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom;
  };
  for (const dialog of document.querySelectorAll('dialog[closedby="any"]')) {
    let pressedOutside = false;
    dialog.addEventListener('pointerdown', (e) => { pressedOutside = outside(e, dialog); });
    // Require the press and the release outside, so a text-selection drag that ends on the backdrop does not dismiss
    dialog.addEventListener('pointerup', (e) => { if (pressedOutside && outside(e, dialog)) dialog.close(); });
  }
}

overscroll-behavior

Trata la contención de desplazamiento como una mejora progresiva. No uses la detección de funciones: @supports informa que la propiedad es compatible, incluso cuando los contenedores que no se pueden desplazar la ignoran. Cuando no se aplica, el diálogo sigue conteniendo el desplazamiento cada vez que se desborda su propio contenido, y showModal() sigue haciendo que la página esté inerte. No recurras a overflow: hidden ni position: fixed en html ni body, ya que esto quita la barra de desplazamiento raíz y cambia el diseño.

Comandos del invocador (command/commandfor)

Si tu objetivo de Baseline no incluye comandos de invocador, mantén el mismo lenguaje de marcado y carga el polyfill de forma condicional (la misma experiencia, como mejora progresiva). Detecta en la interfaz y nunca la cargues de forma incondicional:

// MANDATORY: feature detect on HTMLButtonElement.prototype and load only when native support is missing
if (!('commandForElement' in HTMLButtonElement.prototype)) {
  import('invokers-polyfill'); // npm install invokers-polyfill
}

Solo las reglas 2 y 3 usan Popover.

Resguardos y compatibilidad de navegadores para Popover

La API de Popover es, en su mayor parte, una mejora progresiva, pero sus comportamientos definitorios (promoción de la capa superior, cierre rápido y la invocación de popovertarget) no tienen un equivalente solo de CSS. Los navegadores más antiguos necesitan un polyfill o un resguardo manual si prefieres no enviar uno.

Polyfill: Para admitir el atributo popover en navegadores más antiguos, carga @oddbird/popover-polyfill de forma condicional. OBLIGATORIO: Detecta la función verificando la propiedad popover en HTMLElement.prototype y carga el polyfill solo cuando no haya compatibilidad nativa. NO lo cargues de forma incondicional.

Con un empaquetador o un mapa de importación:

// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
  import("@oddbird/popover-polyfill");
}

Sin un empaquetador, importa desde una CDN dentro de un <script type="module">:

<script type="module">
  if (!("popover" in HTMLElement.prototype)) {
    import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
  }
</script>

Advertencia de diseño: El polyfill no puede definir la pseudoclase :popover-open real, por lo que aplica una clase .\:popover-open en su lugar. OBLIGATORIO: Combina las dos con :is() o :where(); de lo contrario, los navegadores que no tengan :popover-open descartarán toda la regla:

[popover]:is(:popover-open, .\:popover-open) {
  display: block;
}

Como alternativa, para un resguardo heredado sin un polyfill, usa position: fixed y calcula manualmente las coordenadas a través de getBoundingClientRect() o confía en el posicionamiento predeterminado con inset: auto si eso es aceptable para el caso de uso.