You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/web/routes/blocks/app-shell/+page.svelte

296 lines
12 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script lang="ts">
/**
* AppShell demo. Full-bleed on the page; device widths from `./preview`.
*
* The shell is the one block whose full-bleed rule is not a nicety: it owns
* the page's height, so a frame with padding would give it a viewport that
* is not the viewport and every measurement taken here would be a
* measurement of the frame.
*/
import { Stack } from '$uix/eidos/components/stack';
import { Group } from '$uix/eidos/components/group';
import { Wrap } from '$uix/eidos/components/wrap';
import { Text } from '$uix/eidos/components/text';
import { Code } from '$uix/eidos/components/code';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import BlockDemo from '../_lib/BlockDemo.svelte';
import DocRow from '../_lib/DocRow.svelte';
import AppShellSite from './AppShellSite.svelte';
import type { AppShellScroll } from '$blocks/app-shell';
import type { SidebarCollapsible } from '$uix/eidos/components/sidebar';
let scroll = $state<AppShellScroll>('main');
let collapsible = $state<SidebarCollapsible>('icon');
let rail = $state(true);
let aside = $state(true);
let footer = $state(true);
let banner = $state(false);
const previewSrc = $derived(
`/blocks/app-shell/preview?scroll=${scroll}&collapsible=${collapsible}&rail=${rail}&aside=${aside}&footer=${footer}&banner=${banner}`
);
</script>
<BlockDemo
name="AppShell"
slug="$blocks/app-shell"
{previewSrc}
previewHeight="760px"
meta={[
{ key: 'compone', value: 'Sidebar · SkipLink · Sticky · Grid · Flex' },
{ key: 'landmark', value: 'banner · main · navigation · complementary ×2 · contentinfo' },
{ key: 'forma', value: 'slots de snippet + contexto' },
{ key: 'coordina', value: 'navOpen · asideOpen · mobile' }
]}
>
{#snippet preview()}
<AppShellSite {scroll} {collapsible} {rail} {aside} {footer} {banner} />
{/snippet}
{#snippet lede()}
El esqueleto de una página de aplicación, y el block que <strong>posee las regiones</strong> de
la página: sus landmarks, sus enlaces de salto y sus alturas. Esa última es la que faltaba en el
tier — un aviso fijado y una cabecera pegada se anclan los dos al viewport sin verse, y la tira
tapaba la cabecera entera (49px medidos, fila <Code>A-95</Code>). Aquí no se compensa con un
número: bajo
<Code>scroll="main"</Code> no hay nada fijo. El aviso es una fila, el cromo es una fila, y la página
es la fila que se desplaza.
{/snippet}
{#snippet controls()}
<Wrap gap={5}>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">scroll</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[scroll]}
onValueChange={(v) => (scroll = (v[0] ?? scroll) as AppShellScroll)}
aria-label="quién scrollea"
>
<ToggleGroup.Item value="main">main</ToggleGroup.Item>
<ToggleGroup.Item value="body">body</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">collapsible</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[collapsible]}
onValueChange={(v) => (collapsible = (v[0] ?? collapsible) as SidebarCollapsible)}
aria-label="qué hace el colapso del raíl"
>
<ToggleGroup.Item value="icon">icon</ToggleGroup.Item>
<ToggleGroup.Item value="offcanvas">offcanvas</ToggleGroup.Item>
<ToggleGroup.Item value="none">none</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">raíl</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[rail ? 'sí' : 'no']}
onValueChange={(v) => (rail = (v[0] ?? 'sí') === 'sí')}
aria-label="con raíl o shell apilado"
>
<ToggleGroup.Item value="sí">con raíl</ToggleGroup.Item>
<ToggleGroup.Item value="no">apilado</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">aside</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[aside ? 'sí' : 'no']}
onValueChange={(v) => (aside = (v[0] ?? 'sí') === 'sí')}
aria-label="panel complementario"
>
<ToggleGroup.Item value="sí">sí</ToggleGroup.Item>
<ToggleGroup.Item value="no">no</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">footer</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[footer ? 'sí' : 'no']}
onValueChange={(v) => (footer = (v[0] ?? 'sí') === 'sí')}
aria-label="pie de página"
>
<ToggleGroup.Item value="sí">sí</ToggleGroup.Item>
<ToggleGroup.Item value="no">no</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">aviso</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[banner ? 'sí' : 'no']}
onValueChange={(v) => (banner = (v[0] ?? 'no') === 'sí')}
aria-label="aviso de página"
>
<ToggleGroup.Item value="sí">sí</ToggleGroup.Item>
<ToggleGroup.Item value="no">no</ToggleGroup.Item>
</ToggleGroup>
</Group>
</Wrap>
{/snippet}
{#snippet composition()}
<Stack gap={4}>
<Text color="muted">
Todo lo que se ve es canon: el block coloca, nombra y coordina. No trae ni un
<Code>.css</Code>.
</Text>
<Stack gap={3}>
<DocRow term="el raíl">
El <Code>Sidebar</Code> del canon entero, con sus tres modos de colapso, su drawer móvil por
el breakpoint del SISTEMA y sus dos landmarks nombrados. El block no reimplementa nada de eso:
le pasa la configuración y le presta su <Code>navOpen</Code>.
</DocRow>
<DocRow term="la rejilla">
<Code>Grid</Code> con <Code>templateRows</Code>. Dos filas arriba (aviso · resto) y tres
dentro del inset (cabecera · página · pie). El <Code>Grid</Code> reenvía sus rest props al
<Code>Box</Code>, así que el inset del <Code>Sidebar</Code> y la rejilla del shell son
<strong>un solo elemento</strong>: sin div intermedio.
</DocRow>
<DocRow term="la cabecera">
Va DENTRO del inset, no por encima del raíl, y no es estética: el provider del
<Code>Sidebar</Code> ES la fila flex, así que su <Code>Trigger</Code> sólo vive dentro. Es la
disposición de shadcn y del <Code>layout="alt"</Code> de Mantine. La de Mantine por defecto
pide partir ese provider — gap de canon anotado para v2.
</DocRow>
<DocRow term="los enlaces de salto">
Uno por región que EXISTE, generados de las regiones montadas. Monta un aside y aparece el
suyo. Es la técnica G124 de la WCAG, y la mitad que ni Polaris ni Atlassian traen: los dos
piden al consumidor un <Code>id</Code> y una etiqueta a mano.
</DocRow>
<DocRow term="el aviso">
En flujo, con su altura intrínseca. Es la respuesta a <Code>A-95</Code>: cualquier número
acordado se rompe con un aviso más alto, con dos avisos, o con un mensaje que envuelve en
un móvil.
</DocRow>
</Stack>
</Stack>
{/snippet}
{#snippet api()}
<Stack gap={3}>
<DocRow term="scroll">
<Code>'main'</Code> (por defecto): el shell llena el viewport (<Code>100dvh</Code>) y el
<Code>&lt;main&gt;</Code> tiene su propio scroll — nada fijo, ningún offset que mantener.
<Code>'body'</Code>: scrollea el documento y la cabecera se pega con <Code>Sticky</Code>. Es
el eje que Mantine expone como <Code>mode</Code> y Atlassian como <Code>isFixed</Code> por slot.
</DocRow>
<DocRow term="nav · aside">
<Code>nav={'{ collapsible, side, mobileBreakpoint }'}</Code> se reenvía al
<Code>Sidebar</Code>; <Code>aside={'{ breakpoint }'}</Code> decide dónde se pliega el panel.
</DocRow>
<DocRow term="navOpen · asideOpen">
Bindables. El shell los COORDINA (la cabecera, el raíl y el drawer leen uno solo); la
persistencia es del app — una cookie leída en el servidor para no parpadear al hidratar, la
misma línea que trazó el <Code>Sidebar</Code> (D-BLK.6).
</DocRow>
<DocRow term="banner · navContent · header · children · asideContent · footer">
Slots de snippet. El block pone los elementos de landmark alrededor; el contenido es del app
(B-7). Omite <Code>navContent</Code> y sale el shell apilado, el primer fork de todas las referencias.
</DocRow>
<DocRow term="useAppShell()">
<Code>scroll · navOpen · asideOpen · mobile · toggleNav() · toggleAside()</Code>. Para lo
que vive FUERA del raíl; lo de dentro lee <Code>useSidebar()</Code>, que sabe más.
</DocRow>
</Stack>
{/snippet}
{#snippet a11y()}
<Stack gap={3}>
<DocRow term="Los landmarks salen por construcción">
Un shell completo publica <strong>un</strong>
<Code>banner</Code>, <strong>un</strong>
<Code>main</Code>, <strong>una</strong>
<Code>navigation</Code>, <strong>dos</strong>
<Code>complementary</Code> NOMBRADOS (el panel del raíl y el aside) y <strong>un</strong>
<Code>contentinfo</Code>. La regla de la APG —el rol que se repite lleva nombre— se cumple
sin que el app haga nada.
</DocRow>
<DocRow term="Los enlaces de salto son el primer foco">
WCAG 2.4.1. Con sólo el main es la técnica G1; con las tres regiones, G124. Las palabras no
son del block: las posee el canon <Code>SkipLink</Code>, por rol de landmark, porque «Ir al
contenido principal» es el nombre de un contrato y no copy.
</DocRow>
<DocRow term="El salto mueve el FOCO">
Un <Code>#fragment</Code> desplaza la vista y deja el foco donde estaba; el
<Code>SkipLink</Code> enfoca el destino y le presta un <Code>tabindex="-1"</Code> mientras dura
el salto. Sin eso, el siguiente Tab vuelve al cromo que el lector pidió saltar.
</DocRow>
<DocRow term="El raíl en móvil devuelve el foco">
Porque es un <Code>Drawer</Code> compuesto, no una reimplementación: la trampa de foco, el bloqueo
de scroll y el descarte vienen con él.
</DocRow>
<DocRow term="El aviso es una región, no un segundo `banner`">
<Code>A-109</Code>, cerrada: el canon <Code>Banner</Code> renderiza un
<Code>&lt;section&gt;</Code> y ya no reclama <Code>role="banner"</Code>, que ARIA reserva
para la cabecera del sitio. Nombrarlo con <Code>aria-label</Code> lo expone como
<Code>region</Code>; no nombrarlo lo deja como contenido.
</DocRow>
</Stack>
{/snippet}
{#snippet gaps()}
<Stack gap={3}>
<DocRow term="Los primitivos de layout no cambian de elemento (F21)">
<Code>Box</Code> —y con él <Code>Grid</Code>/<Code>Flex</Code>— renderiza un
<Code>&lt;div&gt;</Code> fijo, así que <Code>&lt;main&gt;</Code> y
<Code>&lt;aside&gt;</Code> no pueden SER una caja del sistema y llevan dos declaraciones en línea.
Es el elemento propio del block, no internals de un compuesto (B-3), pero es una carencia del
canon y está registrada.
</DocRow>
<DocRow term="Topbar a todo lo ancho">
Pide partir el provider del <Code>Sidebar</Code> (que hoy ES la fila flex). Canon, v2 — lo pedirá
<Code>docs-shell</Code>.
</DocRow>
<DocRow term="El Sidebar no publica su ancho actual">
La reserva es flexbox y aquí basta, pero un app que quiera anclar algo al borde del raíl no
tiene a qué. Mantine expone ocho variables <Code>--app-shell-*</Code>.
</DocRow>
<DocRow term="Sin superficie online/offline">
No existe en el ecosistema. Un ribbon de «sin conexión» en <Code>banner</Code> lo alimenta el
app desde <Code>Connections.anyReconnecting</Code>.
</DocRow>
</Stack>
{/snippet}
{#snippet notes()}
<Stack gap={3}>
<DocRow term="Skeleton retiró su AppShell en v3">
«The number of issues introduced by it far outweigh its potential gains» — a11y en
navegadores móviles con <Code>100vh</Code> y una historia de sticky header confusa. Las dos averías
vienen de un shell que asume UN modelo. Por eso aquí el modelo se elige, la unidad es
<Code>100dvh</Code> y bajo <Code>body</Code> nada capa el documento a una pantalla.
</DocRow>
<DocRow term="Sin ScrollArea">
La ficha del plan lo listaba. Se deja fuera: montar barras propias sobre la superficie que
scrollea toda la aplicación es una decisión visual que nadie pidió, y con coste — esconde
las nativas. Un app que las quiera compone <Code>ScrollArea</Code> dentro de
<Code>children</Code>.
</DocRow>
</Stack>
{/snippet}
</BlockDemo>

Powered by TurnKey Linux.