Ir al contenido

Componentes · Ola 2 · Superposiciones

Sheet

Panel modal que entra desde un borde de la ventana, para tareas que necesitan más lugar. Entra por la derecha (por defecto), por la izquierda o desde abajo. Tiene una cabecera, un cuerpo con desplazamiento propio y acciones al pie, y se comporta como un Dialog: atrapa el foco y lo devuelve al cerrar.

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

Ejemplos

Filtros a la derecha

Por defecto entra por la derecha con talla md. Con closedBy="closerequest" un clic en el velo no descarta los filtros sin aplicar.

Svelte
<script lang="ts">
	import { Button, Checkbox, Field, Select, Sheet } from '@archeblack/ui';
	import { IconFilter } from '@archeblack/ui/icons';

	const statuses = ['En curso', 'Listo', 'Con error', 'Cancelado', 'En cola'];

	let open = $state(false);
	let selected = $state<string[]>(['En curso', 'Con error']);
	let branch = $state('all');
	let applied = $state('');

	function toggle(status: string, checked: boolean) {
		selected = checked ? [...selected, status] : selected.filter((item) => item !== status);
	}

	function apply() {
		applied = `Filtros aplicados: ${selected.length} estados.`;
		open = false;
	}
</script>

<div style="display: grid; justify-items: center; gap: var(--arche-spacing-3)">
	<Sheet
		bind:open
		title="Filtros"
		description="Elige qué despliegues ver en la lista."
		closedBy="closerequest"
	>
		{#snippet trigger(props)}
			<Button {...props} iconStart={IconFilter}>Filtros</Button>
		{/snippet}
		<div style="display: grid; gap: var(--arche-spacing-6)">
			<fieldset
				style="display: grid; gap: var(--arche-spacing-1); margin: 0; padding: 0; border: 0"
			>
				<legend
					style="padding: 0 0 var(--arche-spacing-2); font-weight: var(--arche-font-weight-medium); color: var(--arche-color-text-strong)"
				>
					Estado
				</legend>
				{#each statuses as status (status)}
					<Checkbox
						label={status}
						checked={selected.includes(status)}
						onchange={(event) => toggle(status, event.currentTarget.checked)}
					/>
				{/each}
			</fieldset>
			<Field label="Rama">
				<Select bind:value={branch}>
					<option value="all">Todas las ramas</option>
					<option value="main">main</option>
					<option value="preview">Ramas de vista previa</option>
				</Select>
			</Field>
		</div>
		{#snippet actions()}
			<Button variant="ghost" onclick={() => (selected = [])}>Limpiar</Button>
			<Button variant="primary" onclick={apply}>Aplicar</Button>
		{/snippet}
	</Sheet>
	<p role="status" style="margin: 0; color: var(--arche-color-text-muted)">{applied}</p>
</div>

Navegación a la izquierda

side="left" y talla sm, para la navegación en pantallas angostas.

Svelte
<script lang="ts">
	import { resolve } from '$app/paths';
	import { Button, Sheet } from '@archeblack/ui';
	import { IconMenu2 } from '@archeblack/ui/icons';

	const links = [
		{ href: resolve('/components/button'), label: 'Button' },
		{ href: resolve('/components/card'), label: 'Card' },
		{ href: resolve('/components/dialog'), label: 'Dialog' },
		{ href: resolve('/components/field'), label: 'Field' }
	];
</script>

<Sheet side="left" size="sm" title="Componentes" description="Ir a la página de un componente.">
	{#snippet trigger(props)}
		<Button {...props} icon={IconMenu2} aria-label="Abrir la navegación" />
	{/snippet}
	<nav aria-label="Componentes">
		<!-- Filas de 40 px: objetivos cómodos para el dedo. -->
		<ul style="display: grid; margin: 0; padding: 0; list-style: none">
			{#each links as link (link.href)}
				<li>
					<a href={link.href} style="display: block; padding: var(--arche-spacing-2) 0">
						{link.label}
					</a>
				</li>
			{/each}
		</ul>
	</nav>
</Sheet>

Desde abajo

side="bottom" ocupa todo el ancho y crece con el contenido hasta 3 rem del borde superior.

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

	let open = $state(false);
	let publicLink = $state(true);
</script>

<!-- Desde abajo: el panel que conviene en pantallas angostas. -->
<Sheet
	bind:open
	side="bottom"
	title="Compartir «Portal de clientes»"
	description="Quien tenga el enlace ve el proyecto, pero no puede cambiarlo."
>
	{#snippet trigger(props)}
		<Button {...props} iconStart={IconShare}>Compartir</Button>
	{/snippet}
	<div style="display: grid; gap: var(--arche-spacing-5); max-width: 36rem">
		<Field label="Enlace">
			<Input value="https://portal.arche.dev/p/7f3a" readonly />
		</Field>
		<Switch bind:checked={publicLink} label="Cualquiera con el enlace puede verlo" />
	</div>
	{#snippet actions()}
		<Button variant="primary" onclick={() => (open = false)}>Listo</Button>
	{/snippet}
</Sheet>

Tallas

sm, md (por defecto) y lg cambian el ancho de los laterales.

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

	const sizes: [SheetSize, string][] = [
		['sm', 'Navegación y listas cortas: 20 rem.'],
		['md', 'Filtros, detalles y formularios breves: 26 rem.'],
		['lg', 'Formularios largos y vistas de detalle: 36 rem.']
	];
</script>

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

Props

Props de Sheet
PropDescripción
title string Título de la cabecera, en 17 px semibold. Es el nombre accesible del panel (aria-labelledby).
description? string Una o dos oraciones debajo del título, en text-muted. Es la descripción accesible (aria-describedby).
children? Snippet El cuerpo. Tiene su propio desplazamiento: la cabecera y el pie quedan fijos, separados del cuerpo por una línea de 1 px en color-border.
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 panel. Recibe los atributos del disparador, que se esparcen en un Button: {#snippet trigger(props)}<Button {...props}>Filtros</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.
side? 'right' | 'left' | 'bottom' Por defecto 'right'Borde desde el que entra. right y left ocupan todo el alto; bottom todo el ancho, hasta 3 rem del borde superior, y es el que conviene en pantallas angostas.
size? 'sm' | 'md' | 'lg' Por defecto 'md'Ancho de los laterales: 20, 26 o 36 rem, y siempre deja 3 rem del velo a la vista. bottom no la usa.
closedBy? 'any' | 'closerequest' | 'none' Por defecto 'any'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.
closeLabel? string Por defecto 'Cerrar'Nombre accesible del botón de cerrar, para productos en otro idioma.
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-sheet, el panel.
...rest HTMLAttributes<HTMLDivElement> Cualquier otro atributo va al panel. El rol, aria-modal, aria-labelledby y aria-describedby los pone el panel.

Accesibilidad

  • Es un diálogo modal (role="dialog" y aria-modal="true"), igual que Dialog: el título lo nombra y la descripción lo describe.
  • 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. Tab y Mayús+Tab quedan dentro del panel y, al cerrar, el foco vuelve al elemento que lo abrió.
  • Escape y un clic en el velo lo cierran. Con un formulario, closedBy="closerequest" evita perder los cambios por un clic afuera.
  • 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, igual que en Dialog. 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.
  • 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 deslizándose desde su borde, sin rebote, y el velo se funde. Con movimiento reducido no se desliza: aparece y desaparece sin transición.
  • Los laterales dejan siempre 3 rem del velo a la vista: se ve que es una capa sobre la página y hay dónde tocar para cerrarla.

Qué evitar

  • Una Sheet para una pregunta corta o una confirmación. Un Dialog, o role="alertdialog" si la acción no se puede deshacer.
  • La navegación principal de un producto de escritorio dentro de una Sheet. Una barra lateral fija; la Sheet desde la izquierda, solo en pantallas angostas.
  • Paneles laterales en un teléfono que tapan todo el ancho. side="bottom", o el lateral, que ya deja 3 rem del velo a la vista.
  • Abrir una Sheet desde otra Sheet o desde un Dialog. Un solo panel con secciones, o una página propia para la tarea.
  • Un formulario largo que se cierra con un clic afuera y pierde lo escrito. closedBy="closerequest" y una acción de cancelar al pie.