blocks(app-shell): la pieza que posee las alturas de la página, y por eso el aviso deja de tapar la cabecera
F3.1 del tier, con su fase 0 firmada delante. El block que faltaba: el que POSEE las regiones de una página de aplicación — sus landmarks, sus enlaces de salto y sus alturas. **A-95 se cierra por construcción, no compensando.** Bajo `scroll="main"` no hay nada fijo: el aviso es una fila, el cromo es una fila y la página es la fila que se desplaza. Medido con la tira puesta: aviso [0,54], cabecera [54,85], **solape 0**, y el hit-test en el centro de la cabecera cae en la cabecera (antes: 49px de solape y el clic en la tira). El eje `scroll` es el que Mantine expone como `mode` y Atlassian como `isFixed` por slot; `body` existe porque `docs-shell` lo necesitará para anclas y TOC. Compone `Sidebar` (sus tres colapsos, su drawer móvil, sus dos landmarks), `SkipLink`, `Sticky`, `Grid`/`Flex`/`Box`. Sin `.css`. Coordina tres booleanos (`navOpen · asideOpen · mobile`) por contexto, sin máquina y sin palabras: nada en un shell puede bloquearse, así que no hay frase que decir. Medido en navegador (Playwright; 1280 y 375, claro y oscuro, LTR y RTL): - landmarks por construcción — 1 banner · 1 main · 2 navigation NOMBRADOS · 2 complementary NOMBRADOS · 1 contentinfo; encabezados 1-2-2-2-2 sin saltos. - `scroll="main"`: documento 720 = viewport, el main scrollea dentro y la cabecera se queda en 0 tras desplazar 900px. `scroll="body"`: el documento scrollea y la cabecera SE PEGA (top 0 a 900px de scroll). - teclado: los tres enlaces de salto son las tres primeras paradas y Enter deja el foco en el `<main>`. - 375: el raíl es drawer, devuelve el foco al trigger con Escape, el aside se pliega y el documento no crece. RTL: sin desbordamiento horizontal, raíl a la derecha, aside a la izquierda. Cuatro averías que sólo aparecieron midiendo, y las cuatro son doctrina ahora: 1. `minHeight` es un SUELO, no un techo: con `100dvh` ahí el documento crecía con el contenido. Es `height`. 2. Un grid que sólo declara filas tiene UNA columna implícita `auto`, que se encoge a su contenido: la página salía en una tira de 32px al lado del raíl. 3. `Box` declara `flex-grow/shrink/basis`, así que pisa el `flex: 1 1 auto` de la receta del inset. Quien está más abajo en el árbol tiene que PEDIR crecer. 4. Un ancestro con `overflow` se lleva el `position: sticky` de dentro. El `overflow: auto` de `Sidebar.Inset` dejaba la barra sin pegar bajo `body` mientras `data-stuck` decía que sí — el atributo dando la razón a una página que no la tenía. Por eso el shell pone su propia caja: gap de canon anotado (el inset debería dejar elegir si es contenedor de scroll). **Y una avería del ARNÉS que este block destapó**: los 19 previews del tier llevaban los 8px de margen por defecto del navegador, así que ningún block se enseñaba a sangre — contra la regla dura del propio tier. Medido: `x: 8, y: 8, w: 1264` en 1280, la página compuesta incluida, cuyo README cita cifras a sangre. Invisible porque todos scrollean; deja de serlo con un shell que llena el viewport. Arreglado en el reset del arnés. De paso, en el canon `skip-link`: el enlace del panel complementario decía «Ir a la barra lateral», que es como todo el mundo llama al raíl de NAVEGACIÓN. En una página que tiene los dos, nombraba al que no era. Ahora «Ir al panel lateral». Guards: `blocks:check` verde con 19 blocks · `docs:check` 0/0 (638) · `svelte-check` 74, ninguno aquí. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>alpha-0.1-background
parent
f3bf6a0299
commit
6a53c3dc99
@ -0,0 +1,278 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* AppShell — the geometry of an application page: a rail, a top bar, the
|
||||
* content, an optional complementary panel and an optional footer.
|
||||
*
|
||||
* What it is FOR, since a shell can look like a `Grid` with opinions: it is
|
||||
* the piece that OWNS the page's regions — their landmarks, their bypass
|
||||
* links, and their heights. That last one is why the tier needed it. An
|
||||
* affixed notice and a pinned header both anchor to the viewport and neither
|
||||
* of them can know about the other, so the strip covered the header by its
|
||||
* whole height (49px, ledger A-95) and no fix inside either block could be
|
||||
* more than a number agreed by hand — which breaks the day the notice wraps
|
||||
* to two lines, or there are two of them. The shell answers by removing the
|
||||
* question: under `scroll="main"` nothing is fixed at all. The notice is a
|
||||
* row, the chrome is a row, the page is the row that scrolls.
|
||||
*
|
||||
* B contract: composes canon only, ships no `.css`, and every visible word
|
||||
* arrives from the app. The one thing it says on its own is the bypass
|
||||
* links, and it does not say those either — the canon `SkipLink` owns those
|
||||
* words, because «Skip to main content» is the name of an accessibility
|
||||
* contract and not copy (B-7 / D-BLK.5 point at exactly that door).
|
||||
*
|
||||
* B-10 exception: shells compose other blocks by design (`SHELL_ALLOWLIST`
|
||||
* in `blocks-check.ts`). This one composes none yet — the app drops a
|
||||
* `banner` or a `site-footer` into its slots.
|
||||
*/
|
||||
import { Box } from '$uix/eidos/components/box';
|
||||
import { Grid } from '$uix/eidos/components/grid';
|
||||
import { Flex } from '$uix/eidos/components/flex';
|
||||
import { Sidebar } from '$uix/eidos/components/sidebar';
|
||||
import { SkipLink } from '$uix/eidos/components/skip-link';
|
||||
import { Sticky } from '$uix/eidos/components/sticky';
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { setAppShellContext } from './context';
|
||||
import type { AppShellProps } from './types';
|
||||
|
||||
let {
|
||||
scroll = 'main',
|
||||
nav,
|
||||
aside,
|
||||
navOpen = $bindable(true),
|
||||
asideOpen = $bindable(true),
|
||||
navLabel,
|
||||
asideLabel,
|
||||
banner,
|
||||
navContent,
|
||||
header,
|
||||
children,
|
||||
asideContent,
|
||||
footer,
|
||||
...rest
|
||||
}: AppShellProps = $props();
|
||||
|
||||
const uid = $props.id();
|
||||
const mainId = `${uid}-main`;
|
||||
const navId = `${uid}-nav`;
|
||||
const asideId = `${uid}-aside`;
|
||||
|
||||
const dom = ActiveEidos.require().dom;
|
||||
|
||||
const collapsible = $derived(nav?.collapsible ?? 'icon');
|
||||
const mobileBreakpoint = $derived(nav?.mobileBreakpoint ?? 'md');
|
||||
const asideBreakpoint = $derived(aside?.breakpoint ?? 'lg');
|
||||
|
||||
/**
|
||||
* The rail is a drawer below its breakpoint. Read from the framework's own
|
||||
* reactive breakpoint, never a `matchMedia` of our own (B-6) — and the same
|
||||
* source the `Sidebar` reads, so the two can never disagree about which
|
||||
* presentation is on screen.
|
||||
*/
|
||||
const mobile = $derived(!dom.isAtLeast(mobileBreakpoint));
|
||||
/** The aside folds away where there is no room for it. Same source, same reason. */
|
||||
const asideFits = $derived(dom.isAtLeast(asideBreakpoint));
|
||||
const asideVisible = $derived(!!asideContent && asideOpen && asideFits);
|
||||
|
||||
// The page fills the viewport ONLY when the main region owns the scroll.
|
||||
// Under `scroll="body"` a `100dvh` root would cap the document at one screen
|
||||
// and hand the overflow to an inner box — the failure Skeleton cited when it
|
||||
// retired its own AppShell in v3.
|
||||
const fillsViewport = $derived(scroll === 'main');
|
||||
|
||||
setAppShellContext({
|
||||
get scroll() {
|
||||
return scroll;
|
||||
},
|
||||
get navOpen() {
|
||||
return navOpen;
|
||||
},
|
||||
get asideOpen() {
|
||||
return asideOpen;
|
||||
},
|
||||
get mobile() {
|
||||
return mobile;
|
||||
},
|
||||
toggleNav() {
|
||||
navOpen = !navOpen;
|
||||
},
|
||||
toggleAside() {
|
||||
asideOpen = !asideOpen;
|
||||
}
|
||||
});
|
||||
|
||||
/** The scroll host the header pins against — the main box, or the viewport. */
|
||||
let mainEl = $state<HTMLElement | null>(null);
|
||||
</script>
|
||||
|
||||
<!--
|
||||
The bypass links, and they are FIRST because that is the entire mechanism:
|
||||
a skip link reached after the chrome has skipped nothing. One per region
|
||||
that actually exists, which is WCAG technique G124 — and generating them
|
||||
from the mounted regions instead of a written list is the half neither
|
||||
Polaris nor Atlassian ships (both make the consumer supply an id and a
|
||||
label by hand).
|
||||
-->
|
||||
{#snippet skipLinks()}
|
||||
<SkipLink to={mainId} region="main" />
|
||||
{#if navContent}
|
||||
<SkipLink to={navId} region="navigation" />
|
||||
{/if}
|
||||
{#if asideVisible}
|
||||
<SkipLink to={asideId} region="complementary" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!--
|
||||
The inset's contents: the top bar, then the page beside its panel, then the
|
||||
footer. `<header>` is the page's banner landmark, so it CANNOT be inside the
|
||||
`<main>` the sidebar renders by default — hence the `child` on `Sidebar.Inset`
|
||||
below, which hands us its props and lets the region be a plain box.
|
||||
-->
|
||||
{#snippet insetBody()}
|
||||
{#if header}
|
||||
{#if scroll === 'body'}
|
||||
<!-- The document scrolls, so the bar has to pin. `Sticky` with no
|
||||
`root` observes the viewport, which is the scroll host here. -->
|
||||
<Sticky offset={0}>
|
||||
<header>{@render header()}</header>
|
||||
</Sticky>
|
||||
{:else}
|
||||
<!-- The main region scrolls underneath; the bar simply stays where it
|
||||
is. No `position`, no z-index, no height to declare — the reason
|
||||
`scroll="main"` closes A-95 instead of compensating for it. -->
|
||||
<header>{@render header()}</header>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Flex align="stretch" minHeight={0}>
|
||||
<!--
|
||||
`min-inline-size: 0` because a grid/flex item's floor is its CONTENT,
|
||||
so one wide table would push the whole page instead of scrolling
|
||||
inside it. `overflow` makes this box the scroll host under
|
||||
`scroll="main"`.
|
||||
|
||||
⚠️ Inline style, and it is the block's own landmark element — not a
|
||||
composed component's internals (B-3). The reason is a registered canon
|
||||
gap: the layout primitives cannot change element (F21 in
|
||||
`PLAN-blocks-quality.md`), so `<main>` cannot BE a `Box` and there is
|
||||
nowhere else to put these two declarations. Recorded in the README.
|
||||
-->
|
||||
<main
|
||||
id={mainId}
|
||||
bind:this={mainEl}
|
||||
style={scroll === 'main'
|
||||
? 'flex: 1 1 auto; min-inline-size: 0; min-block-size: 0; overflow: auto;'
|
||||
: 'flex: 1 1 auto; min-inline-size: 0;'}
|
||||
>
|
||||
{@render children?.()}
|
||||
</main>
|
||||
|
||||
{#if asideVisible}
|
||||
<aside id={asideId} aria-label={asideLabel} style="flex: 0 0 auto; overflow: auto;">
|
||||
{@render asideContent?.()}
|
||||
</aside>
|
||||
{/if}
|
||||
</Flex>
|
||||
|
||||
{#if footer}
|
||||
<footer>{@render footer()}</footer>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!--
|
||||
The root grid: the notice, then everything else. Two rows and no more,
|
||||
because the shell only ever stacks those two things — the rail and the page
|
||||
share the second row through the sidebar, which already knows how to lay a
|
||||
panel beside an inset and how to become a drawer.
|
||||
-->
|
||||
<Grid
|
||||
{...rest}
|
||||
data-app-shell=""
|
||||
data-app-shell-scroll={scroll}
|
||||
templateRows={banner ? 'auto minmax(0, 1fr)' : 'minmax(0, 1fr)'}
|
||||
templateColumns="minmax(0, 1fr)"
|
||||
height={fillsViewport ? '100dvh' : undefined}
|
||||
>
|
||||
{@render skipLinks()}
|
||||
|
||||
{#if banner}
|
||||
{@render banner()}
|
||||
{/if}
|
||||
|
||||
{#if navContent}
|
||||
<Sidebar bind:open={navOpen} {collapsible} side={nav?.side} {mobileBreakpoint}>
|
||||
<!--
|
||||
The block owns the Panel, not the app: it is the landmark the
|
||||
navigation skip link jumps to, so it needs the id, and a region
|
||||
the app could forget to render is a bypass link pointing at
|
||||
nothing. The app fills it with `Sidebar.Header` / `.Content` /
|
||||
`.Footer` — never another `Panel`.
|
||||
|
||||
`navLabel` names THIS element (the `complementary` region), which
|
||||
is what the skip link lands on. Undefined leaves the canon's own
|
||||
localized name: for `aria-label` the consumer wins when it says
|
||||
something, and says nothing by staying quiet.
|
||||
-->
|
||||
<Sidebar.Panel id={navId} aria-label={navLabel}>
|
||||
{@render navContent()}
|
||||
</Sidebar.Panel>
|
||||
{#if collapsible !== 'none' && !mobile}
|
||||
<Sidebar.Rail />
|
||||
{/if}
|
||||
{@render pageBox()}
|
||||
</Sidebar>
|
||||
{:else}
|
||||
<!--
|
||||
The stacked shell — no rail. It is the first fork every reference
|
||||
offers (Tailwind Plus ships nine of them), and here it costs nothing:
|
||||
the same page box without the sidebar around it.
|
||||
-->
|
||||
{@render pageBox()}
|
||||
{/if}
|
||||
</Grid>
|
||||
|
||||
{#snippet pageBox()}
|
||||
<!--
|
||||
The page's box, and the shell renders it ITSELF rather than taking
|
||||
`Sidebar.Inset`.
|
||||
|
||||
The inset would be the obvious choice — it is the sidebar's own name for
|
||||
«the region beside the panel» — and it was, until `scroll="body"` was
|
||||
measured: its recipe declares `overflow: auto`, which is right for a rail
|
||||
that fills a viewport and wrong for a document that scrolls, because an
|
||||
ancestor with `overflow` becomes the containing block of anything
|
||||
`position: sticky` inside it. The header stopped pinning and slid away at
|
||||
-195px while `data-stuck` cheerfully said yes: the attribute agreeing with
|
||||
a page that disagreed. And the prop could not win it back — `[data-box]`
|
||||
and `[data-sidebar-inset]` have the same specificity, so the later rule
|
||||
takes it.
|
||||
|
||||
What the inset actually contributes is four declarations (`flex`,
|
||||
`min-inline-size`, `min-block-size`, `overflow`), and the first three are
|
||||
props of `Box`. So the shell asks for them by name. Registered as a canon
|
||||
gap: `Sidebar.Inset` should let its consumer say whether it is a scroll
|
||||
container.
|
||||
-->
|
||||
{#if fillsViewport}
|
||||
<Grid
|
||||
flex="1 1 auto"
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
height="100%"
|
||||
templateRows="auto minmax(0, 1fr) auto"
|
||||
templateColumns="minmax(0, 1fr)"
|
||||
>
|
||||
{@render insetBody()}
|
||||
</Grid>
|
||||
{:else}
|
||||
<!--
|
||||
Block flow, NOT a grid: `position: sticky` is confined to its
|
||||
containing block, and a grid item's containing block is its AREA — an
|
||||
`auto` row is exactly as tall as the header, so the bar had nowhere to
|
||||
travel.
|
||||
-->
|
||||
<Box flex="1 1 auto" minWidth={0}>
|
||||
{@render insetBody()}
|
||||
</Box>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@ -0,0 +1,33 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type { AppShellState } from './types';
|
||||
|
||||
/**
|
||||
* The shell's coordination, published for whatever the app puts in its regions.
|
||||
*
|
||||
* Why it exists at all, since a shell is mostly layout: the pieces that live in
|
||||
* a shell's chrome need to agree about ONE thing — is the rail open, is the
|
||||
* aside showing, are we on a phone — and if the answer lives outside, every
|
||||
* consumer re-derives it at the point of use and the answers drift. That is the
|
||||
* exact failure the tier's coordination doctrine names (`blocks.md`
|
||||
* §Coordination): the state of a section belongs to the section.
|
||||
*
|
||||
* It is deliberately SMALL. No words, no machine, no `disabled`: nothing in a
|
||||
* shell can be blocked, so there is nothing to explain. Compare `contact`,
|
||||
* which owns five states and a sentence for each because its send CAN be
|
||||
* blocked. Inventing that here would be the opposite mistake.
|
||||
*
|
||||
* The canon `Sidebar` publishes its own state through `useSidebar()`; this does
|
||||
* not replace it. Anything INSIDE the rail reads the sidebar's context, which is
|
||||
* richer (it knows `iconMode`). This one answers the pieces OUTSIDE it — a
|
||||
* toggle in the header, a panel that wants to know whether the aside is up.
|
||||
*/
|
||||
const KEY = Symbol('uix.app-shell');
|
||||
|
||||
export function setAppShellContext(state: AppShellState): void {
|
||||
setContext(KEY, state);
|
||||
}
|
||||
|
||||
/** Read the shell's state. `undefined` outside a shell — callers degrade. */
|
||||
export function useAppShell(): AppShellState | undefined {
|
||||
return getContext<AppShellState | undefined>(KEY);
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
// AppShell — the geometry of an application page: rail · top bar · content
|
||||
// (+ complementary panel, + footer).
|
||||
//
|
||||
// import { AppShell, useAppShell } from '$blocks/app-shell';
|
||||
//
|
||||
// <AppShell bind:navOpen navLabel="Main" {navContent} {header}>
|
||||
// …the page…
|
||||
// </AppShell>
|
||||
//
|
||||
// Content arrives by snippet and the shell owns the landmarks — which is the
|
||||
// point: an app cannot forget the `<main>` that its skip link jumps to.
|
||||
import AppShell from './app-shell.svelte';
|
||||
|
||||
export { AppShell };
|
||||
export default AppShell;
|
||||
export { useAppShell } from './context';
|
||||
export type {
|
||||
AppShellProps,
|
||||
AppShellScroll,
|
||||
AppShellNav,
|
||||
AppShellAside,
|
||||
AppShellState
|
||||
} from './types';
|
||||
@ -0,0 +1,106 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { Breakpoint } from '$adom';
|
||||
import type { SidebarCollapsible, SidebarSide } from '$uix/eidos/components/sidebar';
|
||||
|
||||
/**
|
||||
* Who scrolls. The axis every reference exposes and the one that decides the
|
||||
* whole geometry — Mantine calls it `mode`, Atlassian sets it per slot with
|
||||
* `isFixed`.
|
||||
*
|
||||
* - `main` — the shell fills the viewport (`100dvh`) and the main region has
|
||||
* its own scroll container. The chrome does not move because it never
|
||||
* scrolls: no `position: fixed`, no declared heights, no offsets to keep in
|
||||
* sync. This is what closes ledger row A-95 by construction.
|
||||
* - `body` — the document scrolls, the way a marketing or docs page does.
|
||||
* Anchors, a table of contents and the mobile URL bar all behave natively;
|
||||
* the header pins with `Sticky` instead of standing still.
|
||||
*/
|
||||
export type AppShellScroll = 'main' | 'body';
|
||||
|
||||
/** Navigation rail configuration — forwarded to the canon `Sidebar`. */
|
||||
export type AppShellNav = {
|
||||
/** `offcanvas` slides out · `icon` narrows to a rail · `none` is fixed. @default 'icon' */
|
||||
collapsible?: SidebarCollapsible;
|
||||
/** Which side the rail sits on. @default 'left' */
|
||||
side?: SidebarSide;
|
||||
/** Below this breakpoint the rail becomes a drawer. @default 'md' */
|
||||
mobileBreakpoint?: Breakpoint;
|
||||
};
|
||||
|
||||
/** Complementary panel configuration. */
|
||||
export type AppShellAside = {
|
||||
/**
|
||||
* Below this breakpoint the aside folds away. A right panel is the first
|
||||
* thing a narrow viewport cannot afford, and folding it is not a decision
|
||||
* the app should have to re-derive at every width.
|
||||
* @default 'lg'
|
||||
*/
|
||||
breakpoint?: Breakpoint;
|
||||
};
|
||||
|
||||
// `class` and `style` are omitted from the element attributes and re-declared
|
||||
// as plain strings: the shell's root IS a `Grid`, and Svelte's
|
||||
// `ClassValue | null` / `string | null` do not fit `Box`'s `string`. Narrowing
|
||||
// here keeps the spread honest instead of casting it at the call site.
|
||||
export type AppShellProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'class' | 'style'> & {
|
||||
/** Extra class names on the shell's root. */
|
||||
class?: string;
|
||||
/** Extra inline style on the shell's root. */
|
||||
style?: string;
|
||||
/** Who scrolls — see {@link AppShellScroll}. @default 'main' */
|
||||
scroll?: AppShellScroll;
|
||||
/** Rail configuration. Omit the `nav` snippet entirely for a stacked shell. */
|
||||
nav?: AppShellNav;
|
||||
/** Aside configuration. */
|
||||
aside?: AppShellAside;
|
||||
/**
|
||||
* Whether the rail is expanded. Bindable — the shell coordinates it so the
|
||||
* header's trigger, the rail and the drawer all read one value; PERSISTING
|
||||
* it (a cookie read on the server to avoid a hydration flash) is the app's,
|
||||
* like every other service.
|
||||
* @default true
|
||||
*/
|
||||
navOpen?: boolean;
|
||||
/** Whether the aside is showing. Bindable. @default true */
|
||||
asideOpen?: boolean;
|
||||
/**
|
||||
* Accessible name for the whole navigation region. Passed to the canon
|
||||
* `Sidebar`, which names its `<aside>` and its `<nav>` with it.
|
||||
*/
|
||||
navLabel?: string;
|
||||
/** Accessible name for the complementary panel. Required when `asideContent` is used. */
|
||||
asideLabel?: string;
|
||||
/**
|
||||
* A page-wide notice, IN FLOW above everything. In flow is the decision:
|
||||
* an affixed strip reserves no space, so it covers the chrome underneath —
|
||||
* measured at 49px, the whole height of a header (ledger A-95). Here the
|
||||
* notice pushes, and the geometry stays true whatever its height, whether
|
||||
* there are two of them, or whether it wraps to three lines on a phone.
|
||||
*/
|
||||
banner?: Snippet;
|
||||
/**
|
||||
* The rail's content — the app composes `Sidebar.Header` / `.Content` /
|
||||
* `.Group` / `.Menu` inside it. Omit for a shell with no rail.
|
||||
*/
|
||||
navContent?: Snippet;
|
||||
/** The top bar: the rail trigger, a breadcrumb, a search trigger, the user menu. */
|
||||
header?: Snippet;
|
||||
/** The page. */
|
||||
children?: Snippet;
|
||||
/** The complementary panel beside the page. */
|
||||
asideContent?: Snippet;
|
||||
/** The page footer. */
|
||||
footer?: Snippet;
|
||||
};
|
||||
|
||||
/** What `useAppShell()` hands to anything that needs the shell's state. */
|
||||
export type AppShellState = {
|
||||
readonly scroll: AppShellScroll;
|
||||
readonly navOpen: boolean;
|
||||
readonly asideOpen: boolean;
|
||||
/** True while the rail is a drawer — the system breakpoint decided it, never a `matchMedia`. */
|
||||
readonly mobile: boolean;
|
||||
toggleNav(): void;
|
||||
toggleAside(): void;
|
||||
};
|
||||
@ -0,0 +1,288 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A real application inside the shell — the only way to see what a shell
|
||||
* does. A frame with lorem in it would show the grid and hide the point:
|
||||
* the rail that collapses, the bar that stays put while the page scrolls,
|
||||
* the aside that folds when the width runs out, and the bypass links that
|
||||
* appear on the first Tab.
|
||||
*/
|
||||
import { AppShell, type AppShellScroll } from '$blocks/app-shell';
|
||||
import { Sidebar } from '$uix/eidos/components/sidebar';
|
||||
import { Banner } from '$uix/eidos/components/banner';
|
||||
import { Breadcrumb } from '$uix/eidos/components/breadcrumb';
|
||||
import { Avatar } from '$uix/eidos/components/avatar';
|
||||
import { Badge } from '$uix/eidos/components/badge';
|
||||
import { Button } from '$uix/eidos/components/button';
|
||||
import { Card } from '$uix/eidos/components/card';
|
||||
import { Box } from '$uix/eidos/components/box';
|
||||
import { Container } from '$uix/eidos/components/container';
|
||||
import { Grid } from '$uix/eidos/components/grid';
|
||||
import { Group } from '$uix/eidos/components/group';
|
||||
import { Heading } from '$uix/eidos/components/heading';
|
||||
import * as Icon from '$uix/eidos/components/icon';
|
||||
import { Separator } from '$uix/eidos/components/separator';
|
||||
import { Stack } from '$uix/eidos/components/stack';
|
||||
import { Text } from '$uix/eidos/components/text';
|
||||
import type { SidebarCollapsible } from '$uix/eidos/components/sidebar';
|
||||
|
||||
let {
|
||||
scroll = 'main' as AppShellScroll,
|
||||
collapsible = 'icon' as SidebarCollapsible,
|
||||
rail = true,
|
||||
aside = true,
|
||||
footer = true,
|
||||
banner = false
|
||||
}: {
|
||||
scroll?: AppShellScroll;
|
||||
collapsible?: SidebarCollapsible;
|
||||
rail?: boolean;
|
||||
aside?: boolean;
|
||||
footer?: boolean;
|
||||
banner?: boolean;
|
||||
} = $props();
|
||||
|
||||
let navOpen = $state(true);
|
||||
let active = $state('/app/inbox');
|
||||
let noticeUp = $state(true);
|
||||
|
||||
const rows = [
|
||||
{ from: 'Ada Lovelace', subject: 'Analytical engine — v2 notes', when: '09:41' },
|
||||
{ from: 'Grace Hopper', subject: 'Compiler pass is green', when: '08:12' },
|
||||
{ from: 'Karen Spärck Jones', subject: 'Ranking weights for the index', when: 'Yesterday' },
|
||||
{ from: 'Radia Perlman', subject: 'Spanning tree, revisited', when: 'Yesterday' },
|
||||
{ from: 'Barbara Liskov', subject: 'Substitution in the new API', when: 'Monday' }
|
||||
];
|
||||
</script>
|
||||
|
||||
{#snippet notice()}
|
||||
{#if banner && noticeUp}
|
||||
<Banner intent="primary" variant="solid" aria-label="Aviso de producto">
|
||||
<Container width="100%" paddingX={0}>
|
||||
<Group gap={3} align="center" justify="space-between">
|
||||
<Text size="sm">La facturación cambia el 1 de septiembre.</Text>
|
||||
<Banner.Close onclick={() => (noticeUp = false)} />
|
||||
</Group>
|
||||
</Container>
|
||||
</Banner>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet navContent()}
|
||||
<!-- No `Sidebar.Panel` here: the shell owns it (it is the landmark its skip
|
||||
link jumps to). The app fills the panel, it does not re-open it. -->
|
||||
<Sidebar.Header>
|
||||
<Group gap={2} align="center">
|
||||
<Icon.Sparkles size="sm" />
|
||||
<strong style="font-size: var(--font-size-sm);">Acme</strong>
|
||||
</Group>
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Content>
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel>Bandeja</Sidebar.GroupLabel>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
href="#/app/inbox"
|
||||
active={active === '/app/inbox'}
|
||||
tooltip="Entrada"
|
||||
onclick={(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
active = '/app/inbox';
|
||||
}}
|
||||
>
|
||||
<Icon.Inbox size="sm" />
|
||||
<span>Entrada</span>
|
||||
<Sidebar.MenuBadge>5</Sidebar.MenuBadge>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
href="#/app/drafts"
|
||||
active={active === '/app/drafts'}
|
||||
tooltip="Borradores"
|
||||
onclick={(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
active = '/app/drafts';
|
||||
}}
|
||||
>
|
||||
<Icon.File size="sm" />
|
||||
<span>Borradores</span>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel>Trabajo</Sidebar.GroupLabel>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
href="#/app/projects"
|
||||
active={active === '/app/projects'}
|
||||
tooltip="Proyectos"
|
||||
onclick={(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
active = '/app/projects';
|
||||
}}
|
||||
>
|
||||
<Icon.Folder size="sm" />
|
||||
<span>Proyectos</span>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
href="#/app/reports"
|
||||
active={active === '/app/reports'}
|
||||
tooltip="Informes"
|
||||
onclick={(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
active = '/app/reports';
|
||||
}}
|
||||
>
|
||||
<Icon.ChartBar size="sm" />
|
||||
<span>Informes</span>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
</Sidebar.Content>
|
||||
<Sidebar.Footer>
|
||||
<Group gap={2} align="center">
|
||||
<Avatar size="xs"><Avatar.Fallback>JV</Avatar.Fallback></Avatar>
|
||||
<Text size="sm" color="muted">Juan V.</Text>
|
||||
</Group>
|
||||
</Sidebar.Footer>
|
||||
{/snippet}
|
||||
|
||||
{#snippet header()}
|
||||
<Box
|
||||
paddingX={4}
|
||||
paddingY={3}
|
||||
style="border-block-end: var(--border-width) solid var(--color-border-subtle); background: var(--color-surface-default);"
|
||||
>
|
||||
<Group gap={3} align="center" justify="space-between">
|
||||
<Group gap={3} align="center">
|
||||
{#if rail}
|
||||
<Sidebar.Trigger><Icon.PanelLeft size="sm" /></Sidebar.Trigger>
|
||||
{/if}
|
||||
<Breadcrumb>
|
||||
<Breadcrumb.List>
|
||||
<Breadcrumb.Item><Breadcrumb.Link href="#/app">Acme</Breadcrumb.Link></Breadcrumb.Item>
|
||||
<Breadcrumb.Separator />
|
||||
<Breadcrumb.Item
|
||||
><Breadcrumb.Link href="#/app/inbox">Entrada</Breadcrumb.Link></Breadcrumb.Item
|
||||
>
|
||||
</Breadcrumb.List>
|
||||
</Breadcrumb>
|
||||
</Group>
|
||||
<Group gap={2} align="center">
|
||||
<Badge size="sm">Beta</Badge>
|
||||
<Button variant="ghost" size="sm">Nuevo</Button>
|
||||
<Avatar size="xs"><Avatar.Fallback>JV</Avatar.Fallback></Avatar>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
{/snippet}
|
||||
|
||||
{#snippet asideContent()}
|
||||
<Box
|
||||
padding={4}
|
||||
height="100%"
|
||||
style="inline-size: 16rem; border-inline-start: var(--border-width) solid var(--color-border-subtle); background: var(--color-surface-muted);"
|
||||
>
|
||||
<Stack gap={3}>
|
||||
<Heading level={2} size="sm">Detalle</Heading>
|
||||
<Text size="sm" color="muted">
|
||||
El panel que se pliega cuando la anchura no da. A 1280 está; a 375 no.
|
||||
</Text>
|
||||
<Separator />
|
||||
<Stack gap={2}>
|
||||
<Text size="sm">Estado: <strong>abierto</strong></Text>
|
||||
<Text size="sm">Asignado: Grace H.</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
{/snippet}
|
||||
|
||||
{#snippet pageFooter()}
|
||||
<Box
|
||||
paddingX={4}
|
||||
paddingY={3}
|
||||
style="border-block-start: var(--border-width) solid var(--color-border-subtle);"
|
||||
>
|
||||
<Group gap={3} align="center" justify="space-between">
|
||||
<Text size="sm" color="muted">© 2026 Acme</Text>
|
||||
<Text size="sm" color="muted">v0.1</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
{/snippet}
|
||||
|
||||
<AppShell
|
||||
{scroll}
|
||||
bind:navOpen
|
||||
nav={{ collapsible, mobileBreakpoint: 'md' }}
|
||||
aside={{ breakpoint: 'lg' }}
|
||||
navLabel="Producto"
|
||||
asideLabel="Detalle del elemento"
|
||||
banner={banner ? notice : undefined}
|
||||
navContent={rail ? navContent : undefined}
|
||||
{header}
|
||||
asideContent={aside ? asideContent : undefined}
|
||||
footer={footer ? pageFooter : undefined}
|
||||
>
|
||||
<Box padding={5}>
|
||||
<Stack gap={5}>
|
||||
<Group gap={3} align="center" justify="space-between">
|
||||
<Heading level={1} size="lg">Entrada</Heading>
|
||||
<Text size="sm" color="muted">5 sin leer</Text>
|
||||
</Group>
|
||||
|
||||
<Grid columns={{ base: 1, md: 3 }} gap={4}>
|
||||
<Card
|
||||
><Stack gap={1}
|
||||
><Text size="sm" color="muted">Abiertos</Text><Heading level={2} size="md">128</Heading
|
||||
></Stack
|
||||
></Card
|
||||
>
|
||||
<Card
|
||||
><Stack gap={1}
|
||||
><Text size="sm" color="muted">Sin asignar</Text><Heading level={2} size="md"
|
||||
>17</Heading
|
||||
></Stack
|
||||
></Card
|
||||
>
|
||||
<Card
|
||||
><Stack gap={1}
|
||||
><Text size="sm" color="muted">Cerrados hoy</Text><Heading level={2} size="md"
|
||||
>42</Heading
|
||||
></Stack
|
||||
></Card
|
||||
>
|
||||
</Grid>
|
||||
|
||||
<Stack gap={0}>
|
||||
{#each rows as row (row.subject)}
|
||||
<div
|
||||
style="padding-block: var(--space-3); border-block-end: var(--border-width) solid var(--color-border-subtle);"
|
||||
>
|
||||
<Group gap={3} align="center" justify="space-between">
|
||||
<Stack gap={1}>
|
||||
<Text size="sm"><strong>{row.from}</strong></Text>
|
||||
<Text size="sm" color="muted">{row.subject}</Text>
|
||||
</Stack>
|
||||
<Text size="sm" color="muted">{row.when}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
{/each}
|
||||
</Stack>
|
||||
|
||||
<!-- Enough page to make the scroll model visible: with `scroll="main"`
|
||||
the bar above stays while this moves; with `body` the whole document
|
||||
goes and the bar pins. -->
|
||||
{#each Array(8) as _, i (i)}
|
||||
<Text color="muted">
|
||||
Bloque de contenido {i + 1} — desplaza para ver quién scrollea.
|
||||
</Text>
|
||||
{/each}
|
||||
</Stack>
|
||||
</Box>
|
||||
</AppShell>
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The preview is its OWN page — `@` resets the layout. Axes arrive via the URL
|
||||
* so the demo's iframe can drive them.
|
||||
*
|
||||
* The wrapper below does NOT set `min-block-size: 100dvh` the way the other
|
||||
* previews do: this block owns the page's height under `scroll="main"` (that
|
||||
* is the whole point of it), and a second owner would give the shell a floor
|
||||
* it did not ask for and a scrollbar nobody can explain.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import BootUix, { type BootLanguage } from '../../_lib/BootUix.svelte';
|
||||
import '@/uix/eidos/index.css';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const mode = $derived(page.url.searchParams.get('mode') === 'dark' ? 'dark' : 'light');
|
||||
const dir = $derived(page.url.searchParams.get('dir') === 'rtl' ? 'rtl' : 'ltr');
|
||||
const language = $derived((page.url.searchParams.get('lang') ?? 'es') as BootLanguage);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
</svelte:head>
|
||||
|
||||
<BootUix {mode} {dir} {language}>
|
||||
<div
|
||||
data-theme={mode}
|
||||
data-mode={mode}
|
||||
{dir}
|
||||
style="background: var(--color-surface-default); color: var(--color-content-primary);"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</BootUix>
|
||||
@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Standalone page for the app-shell mini-site — the same component the demo
|
||||
* renders inline, served as its OWN document for the device-width frame.
|
||||
*
|
||||
* A shell needs this more than any other block: `100dvh` means the frame's
|
||||
* viewport, and the rail's drawer measures the same viewport to decide
|
||||
* whether it is on a phone. Inside a scroll box, both would read the page.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import AppShellSite from '../AppShellSite.svelte';
|
||||
import type { AppShellScroll } from '$blocks/app-shell';
|
||||
import type { SidebarCollapsible } from '$uix/eidos/components/sidebar';
|
||||
|
||||
const params = $derived(page.url.searchParams);
|
||||
const scroll = $derived((params.get('scroll') ?? 'main') as AppShellScroll);
|
||||
const collapsible = $derived((params.get('collapsible') ?? 'icon') as SidebarCollapsible);
|
||||
const rail = $derived(params.get('rail') !== 'false');
|
||||
const aside = $derived(params.get('aside') !== 'false');
|
||||
const footer = $derived(params.get('footer') !== 'false');
|
||||
const banner = $derived(params.get('banner') === 'true');
|
||||
</script>
|
||||
|
||||
<AppShellSite {scroll} {collapsible} {rail} {aside} {footer} {banner} />
|
||||
Loading…
Reference in new issue