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.
<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.
<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.
<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.
<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
| Prop | Descripció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</. |
bind:open? boolean Por defecto false | Si 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"yaria-modal="true"), igual queDialog: 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
xestá 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
inertniaria-hidden(Bits UI no los pone ni ofrece ponerlos). La aíslanaria-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 enDialog. No se probó todavía con esos lectores; un lector más viejo que ignorearia-modalpodrí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 conaria-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.