|
|
<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><main></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><section></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><div></Code> fijo, así que <Code><main></Code> y
|
|
|
<Code><aside></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>
|