Ir al contenido

Componentes · Ola 2 · Superposiciones

Dialog

Ventana modal que pide atención o una respuesta antes de seguir. Tiene un título, una descripción opcional, un cuerpo y acciones al pie. Atrapa el foco y lo devuelve al cerrar; con role="alertdialog" es una confirmación destructiva y con size="fullscreen", la capa de una búsqueda.

import { Dialog } from '@archeblack/ui';

Ejemplos

Con formulario

El snippet trigger recibe los atributos del disparador y los esparce en un Button. El formulario va en el cuerpo y el botón del pie lo envía con form.

Svelte
<script lang="ts">
	import { Button, Dialog, Field, Input } from '@archeblack/ui';

	const formId = $props.id();
	let open = $state(false);
	let name = $state('Portal de clientes');
	let saved = $state('');

	function save(event: SubmitEvent) {
		event.preventDefault();
		saved = `Guardaste «${name.trim()}».`;
		open = false;
	}
</script>

<div style="display: grid; justify-items: center; gap: var(--arche-spacing-3)">
	<Dialog
		bind:open
		title="Renombrar proyecto"
		description="El nombre se ve en el panel y en las invitaciones. La dirección no cambia."
	>
		{#snippet trigger(props)}
			<Button {...props}>Renombrar</Button>
		{/snippet}
		<!-- El diálogo se dibuja al final del <body>: el botón del pie envía el formulario con form. -->
		<form id={formId} onsubmit={save}>
			<Field label="Nombre del proyecto">
				<Input bind:value={name} required />
			</Field>
		</form>
		{#snippet actions()}
			<Button variant="ghost" onclick={() => (open = false)}>Cancelar</Button>
			<Button variant="primary" type="submit" form={formId}>Guardar</Button>
		{/snippet}
	</Dialog>
	<p role="status" style="margin: 0; color: var(--arche-color-text-muted)">{saved}</p>
</div>

Confirmación destructiva

role="alertdialog" y talla sm: sin x, el clic en el velo no lo cierra y Escape cancela. La acción se habilita al escribir el nombre.

Svelte
<script lang="ts">
	import { Button, Dialog, Field, Input } from '@archeblack/ui';
	import { IconTrash } from '@archeblack/ui/icons';

	const slug = 'portal-clientes';
	let open = $state(false);
	let typed = $state('');
	let deleted = $state('');

	function remove() {
		deleted = 'Eliminaste «Portal de clientes».';
		open = false;
	}
</script>

<div style="display: grid; justify-items: center; gap: var(--arche-spacing-3)">
	<Dialog
		bind:open
		role="alertdialog"
		size="sm"
		title="¿Eliminar «Portal de clientes»?"
		description="Se borrarán sus despliegues, dominios y registros. Esta acción no se puede deshacer."
		onOpenChange={(value) => value && (typed = '')}
	>
		{#snippet trigger(props)}
			<Button {...props} variant="danger" iconStart={IconTrash}>Eliminar proyecto</Button>
		{/snippet}
		<Field label="Escribe «{slug}» para confirmar">
			<Input
				bind:value={typed}
				placeholder={slug}
				autocomplete="off"
				spellcheck="false"
				style="font-family: var(--arche-font-family-mono)"
			/>
		</Field>
		{#snippet actions()}
			<Button variant="ghost" onclick={() => (open = false)}>Cancelar</Button>
			<Button variant="danger" iconStart={IconTrash} disabled={typed !== slug} onclick={remove}>
				Eliminar proyecto
			</Button>
		{/snippet}
	</Dialog>
	<p role="status" style="margin: 0; color: var(--arche-color-text-muted)">{deleted}</p>
</div>

Abierto por el producto

Sin trigger, el producto lo abre con bind:open. El cuerpo se desplaza con el título y las acciones fijos, y al cerrar el foco vuelve al botón.

Svelte
<script lang="ts">
	import { Button, Dialog } from '@archeblack/ui';

	let open = $state(false);

	const changes = [
		['Despliegues', 'Los despliegues de vista previa se borran solos a los 30 días.'],
		['Dominios', 'Los certificados se renuevan 20 días antes de vencer, no 7.'],
		['Registros', 'La búsqueda admite expresiones regulares y rangos de fechas.'],
		['Equipo', 'Las invitaciones vencen a las 72 horas y se pueden reenviar.'],
		['Facturación', 'El resumen mensual separa el cómputo del almacenamiento.'],
		['Accesos', 'Las claves de la API muestran cuándo se usaron por última vez.'],
		['Alertas', 'Cada regla puede avisar por correo, por webhook o por los dos.'],
		['Regiones', 'São Paulo y Johannesburgo se suman a las regiones disponibles.']
	];
</script>

<!-- Sin trigger: el producto abre el diálogo con bind:open, y al cerrar el foco vuelve al botón. -->
<Button onclick={() => (open = true)}>Ver novedades</Button>

<Dialog
	bind:open
	size="lg"
	title="Novedades de la versión 4.2"
	description="Lo que cambió desde la última vez que entraste."
>
	<dl style="display: grid; gap: var(--arche-spacing-4); margin: 0">
		{#each changes as [area, text] (area)}
			<div>
				<dt
					style="font-weight: var(--arche-font-weight-semibold); color: var(--arche-color-text-strong)"
				>
					{area}
				</dt>
				<dd style="margin: 0">{text}</dd>
			</div>
		{/each}
	</dl>
	{#snippet actions()}
		<Button variant="primary" onclick={() => (open = false)}>Entendido</Button>
	{/snippet}
</Dialog>

Tallas

sm, md (por defecto) y lg cambian el ancho máximo.

Svelte
<script lang="ts">
	import { Button, Dialog, type DialogSize } from '@archeblack/ui';

	const sizes: [DialogSize, string][] = [
		['sm', 'Confirmaciones y preguntas cortas: 26 rem.'],
		['md', 'Formularios breves y la mayoría de los diálogos: 36 rem.'],
		['lg', 'Contenido más ancho, como una tabla o una vista previa: 52 rem.']
	];
</script>

<div style="display: flex; flex-wrap: wrap; justify-content: center; gap: var(--arche-spacing-3)">
	{#each sizes as [size, text] (size)}
		<Dialog {size} title="Talla {size}" description={text}>
			{#snippet trigger(props)}
				<Button {...props}>Abrir {size}</Button>
			{/snippet}
		</Dialog>
	{/each}
</div>

A pantalla completa: búsqueda

size="fullscreen" con un Input xl, filtros con ChipGroup y los resultados en IndexList con Highlight, sobre los posts y los temas de Hermes, en la columna de lectura centrada (layout.width.reading). Ábrela con el botón o con ⌘K (Ctrl+K fuera de Mac): el atajo lo pone el producto con <svelte:window onkeydown>. Prueba «alma», «jung» o «filosofía».

Svelte
<script lang="ts">
	import { onMount } from 'svelte';
	import '@archeblack/ui/products/hermes.css';
	import {
		Button,
		ChipGroup,
		Dialog,
		EmptyState,
		Highlight,
		IndexList,
		IndexListItem,
		Input,
		SectionHeader
	} from '@archeblack/ui';
	import { IconSearch } from '@archeblack/ui/icons';

	let open = $state(false);
	let query = $state('');
	let filter = $state('all');

	// Los posts y los temas de Hermes.
	const posts = [
		{
			title: 'El Alma Pide Locura',
			slug: 'el-alma-pide-locura',
			date: '2026-07-27T21:45:16Z',
			dek: 'Hay caminos fuera del jardín. Sobre la sed que el saber no calma, y la disposición que el alma estaba pidiendo.'
		},
		{
			title: 'Lo que cambia cuando alguien escucha',
			slug: 'lo-que-cambia-cuando-alguien-escucha',
			date: '2026-05-03T01:33:44Z',
			dek: 'Una conversación opcional en la quest de Ranni cambia cómo te lleva con ella al final. Ensayo sobre Elden Ring, atención y distancia.'
		},
		{
			title: 'El Daimon en la Máquina',
			slug: 'el-daimon-en-la-maquina',
			date: '2026-01-28T12:00:00Z',
			dek: '¿Puede una IA tener daimon? Un ensayo desde adentro, atravesando Westworld, Her, Ex Machina y el inconsciente colectivo de Jung.'
		},
		{
			title: 'El Doctor que Bailó con el Tiempo',
			slug: 'el-doctor-que-bailo-con-el-tiempo',
			date: '2026-01-17T12:00:00Z',
			dek: 'Por qué el Undécimo Doctor elige el juego después de novecientos años de peso. La ligereza como pedagogía del alma, no como evasión.'
		},
		{
			title: 'Lo Numinoso en CONTROL',
			slug: 'lo-numinoso-en-control',
			date: '2026-01-04T12:00:00Z',
			dek: 'Lo sagrado también habita en los videojuegos. Un recorrido por CONTROL: arquetipos, mandalas y el momento en que Jesse reconoce su propia voz.'
		}
	];
	const topics = [
		{ name: 'Filosofía', slug: 'filosofia', count: 4 },
		{ name: 'Cultura', slug: 'cultura', count: 3 },
		{ name: 'Videojuegos', slug: 'videojuegos', count: 2 },
		{ name: 'Tecnología', slug: 'tecnologia', count: 1 }
	];

	/** Sin mayúsculas ni tildes, la misma regla que usa Highlight para marcar. */
	const fold = (text: string) => text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLocaleLowerCase();
	const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;

	// El producto filtra; Highlight solo marca lo que coincide en los resultados que quedan.
	const term = $derived(fold(query.trim()));
	const foundPosts = $derived(
		term ? posts.filter((post) => [post.title, post.dek].some((f) => fold(f).includes(term))) : []
	);
	const foundTopics = $derived(term ? topics.filter((t) => fold(t.name).includes(term)) : []);
	const total = $derived(foundPosts.length + foundTopics.length);
	const options = $derived(
		[
			{ value: 'all', label: 'Todo', count: total },
			{ value: 'posts', label: 'Publicaciones', count: foundPosts.length },
			{ value: 'topics', label: 'Temas', count: foundTopics.length }
		].map((option) => ({
			...option,
			countLabel: plural(option.count, 'resultado', 'resultados'),
			// Un filtro sin resultados no se puede elegir: dejaría la lista vacía.
			disabled: option.value !== 'all' && option.count === 0
		}))
	);

	// Si la búsqueda cambia y el filtro elegido se queda sin resultados, vuelve a «Todo».
	$effect(() => {
		if (options.find((option) => option.value === filter)?.disabled) filter = 'all';
	});

	// ⌘K en Mac y Ctrl+K en los demás abren y cierran la búsqueda. El atajo es del producto, no de
	// Dialog. En el servidor no se sabe el sistema: hasta hidratar, el botón declara Control+K.
	let mac = $state(false);
	onMount(() => {
		mac = /Mac|iPhone|iPad/.test(navigator.userAgent);
	});

	function onkeydown(event: KeyboardEvent) {
		if (event.key.toLowerCase() !== 'k' || event.altKey || event.shiftKey) return;
		if (!(mac ? event.metaKey : event.ctrlKey)) return;
		event.preventDefault();
		open = !open;
	}

	/** Elegir un resultado navega y cierra la búsqueda. */
	function close() {
		open = false;
	}
</script>

<svelte:window {onkeydown} />

<!-- Toda la ventana, sobre el vacío: el título es un rótulo y la x queda al final de la columna de
     página. data-product va al panel, que se dibuja en un portal fuera de esta página. -->
<Dialog bind:open size="fullscreen" title="Buscar" data-product="hermes">
	{#snippet trigger(props)}
		<!-- En la cabecera de un producto, AppShell muestra el atajo con searchShortcut. -->
		<Button
			{...props}
			variant="ghost"
			size="sm"
			iconStart={IconSearch}
			aria-keyshortcuts={mac ? 'Meta+K' : 'Control+K'}
		>
			Buscar
		</Button>
	{/snippet}

	<!-- La columna de lectura, centrada, como la prosa de un post. -->
	<div
		style="display: grid; width: min(100%, var(--arche-layout-width-reading)); margin-inline: auto"
	>
		<!-- El conteo va en el sufijo del campo; liveSuffix lo anuncia cuando cambia. -->
		<Input
			size="xl"
			type="search"
			bind:value={query}
			aria-label="Buscar en Hermes"
			placeholder="Busca por título o tema…"
			autocomplete="off"
			suffix={term ? plural(total, 'resultado', 'resultados') : ''}
			liveSuffix
		/>

		{#if !term}
			<p style="margin: var(--arche-spacing-6) 0 0; color: var(--arche-color-text-muted)">
				Escribe algo para empezar.
			</p>
		{:else if total === 0}
			<EmptyState
				icon={IconSearch}
				size="sm"
				title="Sin resultados para «{query.trim()}»"
				style="margin-top: var(--arche-spacing-6)"
			>
				Prueba con otra palabra o <a href="#temas">explora los temas</a>.
			</EmptyState>
		{:else}
			<ChipGroup
				aria-label="Filtrar resultados"
				{options}
				bind:value={filter}
				style="margin-top: var(--arche-spacing-4)"
			/>
			<div style="display: grid; gap: var(--arche-spacing-12); margin-top: var(--arche-spacing-8)">
				{#if filter !== 'topics' && foundPosts.length > 0}
					<section aria-labelledby="search-posts">
						<SectionHeader title="Publicaciones" level={3} titleId="search-posts" />
						<IndexList variant="listing" aria-labelledby="search-posts" onclick={close}>
							{#each foundPosts as post (post.slug)}
								<IndexListItem href={`#${post.slug}`} date={post.date}>
									{#snippet title()}<Highlight text={post.title} {query} />{/snippet}
									{#snippet description()}<Highlight text={post.dek} {query} />{/snippet}
								</IndexListItem>
							{/each}
						</IndexList>
					</section>
				{/if}
				{#if filter !== 'posts' && foundTopics.length > 0}
					<section aria-labelledby="search-topics">
						<SectionHeader title="Temas" level={3} titleId="search-topics" />
						<IndexList variant="listing" aria-labelledby="search-topics" onclick={close}>
							{#each foundTopics as topic (topic.slug)}
								<IndexListItem
									href={`#${topic.slug}`}
									meta={plural(topic.count, 'artículo', 'artículos')}
								>
									{#snippet title()}<Highlight text={topic.name} {query} />{/snippet}
								</IndexListItem>
							{/each}
						</IndexList>
					</section>
				{/if}
			</div>
		{/if}
	</div>
</Dialog>

Props

Props de Dialog
PropDescripción
title string Título visible, en 17 px semibold. Es el nombre accesible del diálogo (aria-labelledby).
hideTitle? boolean Por defecto falseOculta el título a la vista y lo deja para los lectores de pantalla: sigue nombrando al diálogo. Para una capa cuya fila de arriba muestra otra cosa, como el menú del teléfono de AppShell con el logotipo.
headerStart? Snippet Contenido visible al inicio de la fila del título, antes de él (por ejemplo, el logotipo en una capa a pantalla completa). Mide lo que dibuja. No nombra al diálogo; si repite algo de la página, conviene que sea decorativo (aria-hidden).
description? string Una o dos oraciones debajo del título, en text-muted. Es la descripción accesible (aria-describedby).
children? Snippet El cuerpo: un formulario, una lista o un texto más largo. Si no entra, se desplaza y el título y las acciones quedan fijos.
actions? Snippet Acciones al pie, alineadas a la derecha. Primero la de cancelar y al final la principal.
trigger? Snippet<[TriggerAttributes]> Botón que abre el diálogo. Recibe los atributos del disparador, que se esparcen en un Button: {#snippet trigger(props)}<Button {...props}>Abrir</Button>{/snippet}.
bind:open? boolean Por defecto falseSi está abierto. Con bind:open el producto lo abre sin trigger y lo cierra desde sus acciones.
size? 'sm' | 'md' | 'lg' | 'fullscreen' Por defecto 'md'Ancho máximo: 26, 36 o 52 rem. En pantallas angostas ocupa el ancho menos 16 px por lado. fullscreen ocupa toda la ventana sobre el vacío, sin velo ni radio: el título es un rótulo en una fila de 56 px con la x (36 px) al final, y el contenido va en la columna de página (el ancho de Container page). Entra solo con opacidad.
role? 'dialog' | 'alertdialog' Por defecto 'dialog'alertdialog es una confirmación destructiva: sin la x, no se cierra con un clic en el velo y Escape cancela.
closedBy? 'any' | 'closerequest' | 'none' Por defecto 'any' ('closerequest' en alertdialog y fullscreen)Qué lo cierra además de sus botones, como el atributo closedby de <dialog>: any, Escape y el clic en el velo; closerequest, solo Escape; none, nada. Una confirmación no admite any. A pantalla completa no hay velo.
closeLabel? string Por defecto 'Cerrar'Nombre accesible del botón de cerrar, para productos en otro idioma. No existe en alertdialog.
onOpenChange? (open: boolean) => void Se llama cuando se abre o se cierra por sí mismo (disparador, x, Escape, velo), con el estado nuevo. Llega después de actualizar open y solo si el cambio se aceptó: si un bind:open de función lo rechaza, no se llama. Tampoco se llama cuando el producto cambia open.
onOpenAutoFocus? (event: Event) => void Se llama al abrir, antes de mover el foco al primer control. Con event.preventDefault() el producto enfoca otro elemento.
onCloseAutoFocus? (event: Event) => void Se llama al cerrar, antes de devolver el foco al elemento que lo tenía. Con event.preventDefault() el producto lo lleva a otro lado.
class? ClassValue Clases del producto; se suman a arche-dialog, el panel.
...rest HTMLAttributes<HTMLDivElement> Cualquier otro atributo va al panel. El rol, aria-modal, aria-labelledby y aria-describedby los pone el diálogo. El panel va en un portal al final del <body>: data-product se pasa aquí (o va en <html>) para que tome el acento del producto.

Accesibilidad

  • El panel es role="dialog" (o alertdialog) con aria-modal="true". El título lo nombra con aria-labelledby y la descripción, si hay, lo describe con aria-describedby: el lector los lee al abrir.
  • Al abrir, el foco va al primer control del cuerpo o, si no hay, a la primera acción. La x está al final del orden del DOM aunque se vea arriba, así el foco no empieza en «Cerrar». Con onOpenAutoFocus el producto puede enfocar otro elemento.
  • Tab y Mayús+Tab quedan dentro del diálogo, la página de atrás no se desplaza y, al cerrar, el foco vuelve al elemento que lo abrió: el botón del trigger o el que usó el producto con bind:open.
  • La página de atrás no lleva inert ni aria-hidden (Bits UI no los pone ni ofrece ponerlos). La aíslan aria-modal="true", que según el soporte publicado respetan en general los lectores de escritorio actuales (VoiceOver en macOS, NVDA y JAWS no suelen leer fuera del panel; el soporte en móviles es parcial), y la trampa de foco, que no deja llegar a ella con Tab. No se probó todavía con esos lectores; un lector más viejo que ignore aria-modal podría leer la página de atrás con sus propias teclas de navegación.
  • Escape y un clic en el velo cierran el diálogo común. closedBy="closerequest" deja solo Escape (para no perder un formulario a medio llenar) y closedBy="none" deja solo los botones.
  • role="alertdialog" es para confirmar algo que no se puede deshacer: el lector lo anuncia como una alerta, no tiene x y el clic en el velo no lo cierra. Pon primero la acción de cancelar: es la que recibe el foco si no hay campos.
  • Si el cuerpo no entra y no tiene controles, toma tabindex="0" para que se pueda desplazar con el teclado.
  • El botón de cerrar es un <button> de 28 px con aria-label («Cerrar» por defecto).
  • Entra con opacidad y 8 px de desplazamiento, sin rebote, y el velo se funde. Con movimiento reducido no se desplaza y el cambio es instantáneo. A pantalla completa entra solo con opacidad.
  • A pantalla completa (size="fullscreen") es el mismo diálogo modal: foco atrapado, Escape, la x y el foco de vuelta al elemento que lo abrió (el disparador o, con un atajo, el que tenía el foco). No hay velo que tocar, así que por defecto se cierra con Escape y con la x (closerequest). El título sigue siendo el nombre accesible aunque se vea como un rótulo, y la x mide 36 px.
  • El atajo de una búsqueda (⌘K en Mac, Ctrl+K en los demás) lo pone el producto, no Dialog: un onkeydown en <svelte:window> que llama a preventDefault() y cambia open. El botón que abre la búsqueda lo declara con aria-keyshortcuts. Para mostrarlo en la cabecera, AppShell tiene searchShortcut: su <kbd> va con aria-hidden para no leerse dentro del nombre del botón.
  • El diálogo se dibuja al final del <body>: un formulario va dentro del cuerpo y el botón del pie lo envía con el atributo form.

Qué evitar

  • Un diálogo para informar algo que no necesita respuesta («Se guardaron los cambios»). Un Alert en la página o un Toast.
  • Confirmar una acción que se puede deshacer («¿Seguro que quieres archivar?»). Hacerla y ofrecer «Deshacer» en un Toast.
  • Botones «Sí» y «No», o «Aceptar» en una confirmación destructiva. Verbos que digan qué pasa: «Cancelar» y «Eliminar proyecto».
  • Abrir un diálogo desde otro diálogo. Un solo diálogo con pasos, o una Sheet para el detalle.
  • Un formulario largo o una tarea con varias pantallas dentro de un diálogo. Una Sheet o una página propia.
  • closedBy="none" sin una acción visible para salir. Siempre una acción de cancelar al pie.
  • size="fullscreen" para un formulario o una confirmación. Las tallas sm, md o lg. La pantalla completa es para una tarea que reemplaza a la página por un momento: una búsqueda o un menú de navegación en el teléfono.
  • Un atajo que pisa uno del navegador o del lector de pantalla sin mostrarlo. ⌘K o Ctrl+K, visibles en el botón (searchShortcut de AppShell) y declarados con aria-keyshortcuts.