Segunda tanda del sidebar: la capa visual, la demo canónica de 9 pestañas y la verificación en navegador real. Con esto el componente está vivo; queda el review adversarial. Eidos - Recipe con los dos ejes: off-canvas por `transform` (trabajo de compositor, el inset no reflowea) y `width` animado SOLO en modo icono, donde el inset sí tiene que seguir al raíl. El raíl NO lleva `will-change` (una franja fina tiembla a DPR fraccionario — memoria NavMenu). - Anchos como TOKENS: `--sidebar-width` 16rem · `--sidebar-width-icon` 3rem · `--sidebar-width-mobile` 18rem, más raíl, gaps, altura de fila y colores. - Tres composiciones que la capa headless no puede hacer: `Drawer` para la presentación móvil, `Tooltip` para nombrar filas en modo icono y `Badge` para los contadores. El trigger compone `Button` por el patrón `child`. - Convención de icono documentada: sobrevive el PRIMER elemento de la fila. Arreglos que destapó el navegador - **`mobile` en SSR**: sin viewport, `isAtLeast` responde «por debajo de todo» → el HTML salía con `data-mobile` y la hidratación NO lo corrige (no diffea atributos y el valor ya no cambia). Viewport desconocido = ESCRITORIO. - **Estado móvil propio** (arranca cerrado): en escritorio `open` es preferencia de layout persistida; en móvil sería un overlay tapando la página, que jamás puede ser el estado inicial. `onOpenChange` solo se dispara con el estado de escritorio. - **`open` controlado/no controlado**: se adopta el patrón de la casa (`$bindable` con default, el provider escribe siempre) — mi versión previa con estado interno no propagaba `bind:open`. - **El submenú flotante no se posicionaba**: `FloatingAnchor` resuelve su provider por CONTEXTO y el eidos envuelve la fila en un `Tooltip`, que publica el suyo → el ancla caía en el tooltip y el submenú se quedaba en `translate(0,-200%)`. Ahora el ancla se cablea directamente al provider flotante del item. Además el `<ul>` ya no se posiciona a sí mismo (colapsaba el wrapper a tamaño cero) y toma elevación del plano `overlay` por `data-depth`, como cualquier panel flotante del ecosistema. - **Tooltip vs submenú**: una fila con submenú no lleva tooltip en modo icono (dos superficies sobre el mismo ancla se pisan) — nuevo `useSidebarItemOr()`. - El item envuelve (`flex-wrap`) para que el submenú caiga en su propia línea en vez de ponerse al lado de la fila. Demo: shell de app real (grupos, badge, acción, submenú, fila deshabilitada), controles vivos de los tres props y las 9 pestañas del canon. Verificado en navegador real (Playwright): dos landmarks nombrados en el idioma activo · `aria-current="page"` en la fila activa · cada menú etiquetado por SU grupo · raíl enfocable que alterna con Enter · colapso 256→48px · off-canvas por transform · submenú flotante anclado a su fila y Escape que devuelve el foco · sema `emerge-expand/collapse` estampando en `panel` · móvil 600px = Drawer cerrado con el landmark dentro · RTL espeja el shell entero · oscuro · 0 errores de consola. Gates: `component:audit --only sidebar` PASS 0E/0W · eidos-lint 45 morfo-backed / 0 invalid / 0 class-hooks · `svelte-check` 0 errores propios · `vitest src/uix/eidos` 353/353 · `contracts.test` solo con fallos ajenos (menubar, radio-group y las claves camelCase de aura). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>alpha-0.1-sec-dom
parent
e28df1817f
commit
21bbf9f506
@ -0,0 +1,96 @@
|
||||
# Sidebar
|
||||
|
||||
The application's vertical chrome: a collapsible panel beside the page, with
|
||||
groups, rows, actions, badges and sub-menus. Built 2026-07-22 as F1.7 of the
|
||||
blocks initiative (`docs/process/PLAN-blocks.md`). Reference floor: the research
|
||||
dossier (`docs/process/RESEARCH-blocks-references.md` §P4, read from shadcn's
|
||||
real `sidebar.tsx`). Headless behavior:
|
||||
[`soma/components/sidebar`](../../../soma/components/sidebar/README.md).
|
||||
|
||||
## Baseline
|
||||
|
||||
- **Classification**: behavioral (soma + sema + eidos), **compositional**.
|
||||
`apg: disclosure` — a named navigation region behind a disclosure button; no
|
||||
WAI-ARIA widget pattern applies.
|
||||
- **NOT `nav-tree`**. `nav-tree` is the data-driven docs tree (arbitrary depth,
|
||||
auto-expanded active trail, the whole tree in one prop). This is app chrome
|
||||
with a shallow fixed anatomy. A docs shell composes `NavTree` INSIDE this
|
||||
sidebar's `Content`.
|
||||
- **Anatomy** (17 parts): `provider` + `panel` (`<aside>` landmark) + `trigger`
|
||||
+ `rail` + `inset` (`<main>`) + `header` + `content` (`<nav>` landmark) +
|
||||
`footer` + `group` + `group-label` + `menu` + `menu-item` + `menu-button` +
|
||||
`menu-action` + `menu-badge` + `menu-sub` + `menu-sub-button`.
|
||||
- **Two axes**: `data-state` (`expanded | collapsed`) says WHETHER,
|
||||
`data-collapsible` (`offcanvas | icon | none`) says WHAT collapsing does.
|
||||
Both on the provider, always stamped, plus `data-side` and `data-mobile`.
|
||||
- **Nobody headless ships this** (Radix / Base / Ark / React-Aria all confirmed
|
||||
absent): the canonical sidebar is an invention of the styled tier, with
|
||||
shadcn as the de facto standard.
|
||||
|
||||
## Comparativa
|
||||
|
||||
| Ref | Qué adoptamos | Qué no |
|
||||
| --- | --- | --- |
|
||||
| shadcn `Sidebar` (benchmark) | anatomía (con el corte `menu-item` / `menu-button`), dos ejes, `rail` como parte física, anchos como tokens, costuras `open`/`onOpenChange`/`toggle` | sus 23 partes enteras (skeleton, group-action y `separator`/`input` propios: componemos `Separator` y `Field`); su falta de landmark y de `aria-current`; su `rail` con `tabIndex=-1`; su ocultación de submenús en modo icono |
|
||||
| Mantine `AppShell.Navbar` | la idea de `inset` como región hermana | su modelo de breakpoints por prop (el nuestro sale del sistema) |
|
||||
| AntD `Menu` (inline-collapsed) | submenú FLOTANTE al colapsar — el mejor comportamiento del mercado | su acoplamiento a un modelo de datos propio |
|
||||
| Radix / Base / Ark | nada: no lo tienen | — |
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **`panel`, no `sidebar`**, para el nombre de la parte: `data-sidebar-sidebar`
|
||||
sería un tartamudeo.
|
||||
- **Dos landmarks nombrados**: `panel` = `<aside>` (complementary) y `content` =
|
||||
`<nav>`, con header y footer FUERA del nav — así el landmark nombra la
|
||||
navegación y no el cromo. Ninguna referencia trae ninguno de los dos.
|
||||
- **`data-collapsible` siempre estampado** (shadcn solo lo pone colapsado, lo
|
||||
que obliga a cada regla del recipe a guardar por estado).
|
||||
- **`rail` alcanzable por teclado**: es un `<button>` real con nombre
|
||||
localizado, no el `tabIndex=-1` de la referencia.
|
||||
- **`aria-current="page"` del MISMO prop que `data-active`**, en fila y subfila.
|
||||
- **Submenú flotante en modo icono** (superación): al colapsar, `menu-sub` se
|
||||
ancla al botón de su fila y se abre por hover/foco; Escape cierra y devuelve
|
||||
el foco. shadcn los oculta y deja ramas enteras inalcanzables.
|
||||
- **Móvil = `Drawer` compuesto**, no reimplementado: el foco atrapado, el
|
||||
bloqueo de scroll y el descarte vienen con él. La decisión `mobile` sale del
|
||||
breakpoint del SISTEMA (`uix.dom.isAtLeast`), nunca de un `matchMedia` propio.
|
||||
**Un render de servidor no tiene viewport** (ancho 0): ahí `mobile` es
|
||||
FALSE por definición — si no, el HTML sale con `data-mobile` y la hidratación
|
||||
no lo corrige (no diffea atributos y el valor ya no cambia), dejando la
|
||||
presentación móvil clavada en escritorio.
|
||||
- **El estado móvil es SUYO** (arranca cerrado): en escritorio `open` significa
|
||||
«el raíl está desplegado» —preferencia de layout que el app persiste—; en
|
||||
móvil significaría «hay un overlay tapando la página», que jamás puede ser el
|
||||
estado inicial. Por eso `onOpenChange` (el gancho de persistencia) solo se
|
||||
dispara con el estado de escritorio.
|
||||
- **Tooltip y submenú no compiten**: una fila CON submenú no lleva tooltip en
|
||||
modo icono — el flyout que abre ese mismo hover ya la nombra, y dos
|
||||
superficies sobre el mismo ancla se pisan.
|
||||
- **Movimiento**: off-canvas por `transform` (trabajo de compositor); `width`
|
||||
solo se anima en modo icono, donde el inset tiene que seguir al raíl.
|
||||
**Nunca `will-change: transform` en el raíl** — una franja fina tiembla a DPR
|
||||
fraccionario (memoria NavMenu).
|
||||
- **Convención de icono**: en modo icono sobrevive el PRIMER elemento de la
|
||||
fila. Envuelve la etiqueta en un elemento si usas ese modo (igual que las
|
||||
referencias); el nombre no se pierde, `tooltip` compone el `Tooltip` canónico.
|
||||
|
||||
## Sema events
|
||||
|
||||
| Event | Family · verb | Target | Signature |
|
||||
| --- | --- | --- | --- |
|
||||
| `emerge-expand` | `emerge.expand` | `panel` | `emerge.soft` |
|
||||
| `emerge-collapse` | `emerge.collapse` | `panel` | `emerge.exit.soft` |
|
||||
|
||||
Los tres caminos de colapso (Trigger, Rail y el `toggle()` de app-land) pasan
|
||||
por el mismo evento, así que suenan igual. Navegar una fila es nativo y no
|
||||
suena. Sin haptic. Pack: `src/uix/sema/components/sidebar.ts`.
|
||||
|
||||
## Gaps
|
||||
|
||||
| Gap | Disposition |
|
||||
| --- | --- |
|
||||
| Atajo global (⌘/Ctrl+B) y persistencia del estado | **fuera por diseño** — viven en app-land a través de las costuras `open` / `onOpenChange` / `toggle()` (D-BLK.6). El atajo además secuestraría la negrita de cualquier editor embebido |
|
||||
| `variant` `floating` / `inset` | **diferir** — v1 trae una sola presentación; son tratamientos de superficie sobre el mismo contrato |
|
||||
| `menu-skeleton` (fila de carga) | **diferir** — se compone `Skeleton` dentro de una fila; no necesita parte propia |
|
||||
| `group-action` (acción a nivel de grupo) | **diferir** — sin consumidor todavía; el `menu-action` cubre el caso frecuente |
|
||||
| Redimensionar arrastrando el raíl | **descartar v1** — el raíl alterna; un ancho continuo pide persistencia y un contrato de tamaño que nadie ha pedido |
|
||||
@ -0,0 +1,104 @@
|
||||
// Sidebar — eidos compound API (the app's vertical chrome).
|
||||
//
|
||||
// import { Sidebar } from '$uix/eidos/components/sidebar';
|
||||
//
|
||||
// <Sidebar bind:open collapsible="icon">
|
||||
// <Sidebar.Panel>
|
||||
// <Sidebar.Header>…brand…</Sidebar.Header>
|
||||
// <Sidebar.Content>
|
||||
// <Sidebar.Group>
|
||||
// <Sidebar.GroupLabel>Workspace</Sidebar.GroupLabel>
|
||||
// <Sidebar.Menu>
|
||||
// <Sidebar.MenuItem>
|
||||
// <Sidebar.MenuButton href="/inbox" active tooltip="Inbox">
|
||||
// Inbox <Sidebar.MenuBadge>12</Sidebar.MenuBadge>
|
||||
// </Sidebar.MenuButton>
|
||||
// <Sidebar.MenuAction aria-label="Inbox options">⋯</Sidebar.MenuAction>
|
||||
// </Sidebar.MenuItem>
|
||||
// </Sidebar.Menu>
|
||||
// </Sidebar.Group>
|
||||
// </Sidebar.Content>
|
||||
// <Sidebar.Footer>…user…</Sidebar.Footer>
|
||||
// </Sidebar.Panel>
|
||||
// <Sidebar.Rail />
|
||||
// <Sidebar.Inset>…the page…</Sidebar.Inset>
|
||||
// </Sidebar>
|
||||
//
|
||||
// Soma owns the two axes (state + mode), the mobile decision and the floating
|
||||
// sub-menu; eidos paints the rail, the widths and the icon mode, and makes the
|
||||
// three compositions the headless layer cannot: `Drawer` (mobile presentation),
|
||||
// `Tooltip` (row names in icon mode) and `Badge` (counts). `useSidebar()` reads
|
||||
// the state from anywhere inside — that is the dossier's `useSidebar` hook.
|
||||
import SidebarRoot from './sidebar.svelte';
|
||||
import Panel from './sidebar-panel.svelte';
|
||||
import Trigger from './sidebar-trigger.svelte';
|
||||
import Rail from './sidebar-rail.svelte';
|
||||
import Inset from './sidebar-inset.svelte';
|
||||
import Header from './sidebar-header.svelte';
|
||||
import Content from './sidebar-content.svelte';
|
||||
import Footer from './sidebar-footer.svelte';
|
||||
import Group from './sidebar-group.svelte';
|
||||
import GroupLabel from './sidebar-group-label.svelte';
|
||||
import Menu from './sidebar-menu.svelte';
|
||||
import MenuItem from './sidebar-menu-item.svelte';
|
||||
import MenuButton from './sidebar-menu-button.svelte';
|
||||
import MenuAction from './sidebar-menu-action.svelte';
|
||||
import MenuBadge from './sidebar-menu-badge.svelte';
|
||||
import MenuSub from './sidebar-menu-sub.svelte';
|
||||
import MenuSubButton from './sidebar-menu-sub-button.svelte';
|
||||
|
||||
type SidebarNamespace = typeof SidebarRoot & {
|
||||
Panel: typeof Panel;
|
||||
Trigger: typeof Trigger;
|
||||
Rail: typeof Rail;
|
||||
Inset: typeof Inset;
|
||||
Header: typeof Header;
|
||||
Content: typeof Content;
|
||||
Footer: typeof Footer;
|
||||
Group: typeof Group;
|
||||
GroupLabel: typeof GroupLabel;
|
||||
Menu: typeof Menu;
|
||||
MenuItem: typeof MenuItem;
|
||||
MenuButton: typeof MenuButton;
|
||||
MenuAction: typeof MenuAction;
|
||||
MenuBadge: typeof MenuBadge;
|
||||
MenuSub: typeof MenuSub;
|
||||
MenuSubButton: typeof MenuSubButton;
|
||||
};
|
||||
|
||||
const Sidebar = SidebarRoot as SidebarNamespace;
|
||||
Sidebar.Panel = Panel;
|
||||
Sidebar.Trigger = Trigger;
|
||||
Sidebar.Rail = Rail;
|
||||
Sidebar.Inset = Inset;
|
||||
Sidebar.Header = Header;
|
||||
Sidebar.Content = Content;
|
||||
Sidebar.Footer = Footer;
|
||||
Sidebar.Group = Group;
|
||||
Sidebar.GroupLabel = GroupLabel;
|
||||
Sidebar.Menu = Menu;
|
||||
Sidebar.MenuItem = MenuItem;
|
||||
Sidebar.MenuButton = MenuButton;
|
||||
Sidebar.MenuAction = MenuAction;
|
||||
Sidebar.MenuBadge = MenuBadge;
|
||||
Sidebar.MenuSub = MenuSub;
|
||||
Sidebar.MenuSubButton = MenuSubButton;
|
||||
|
||||
export { Sidebar };
|
||||
export default Sidebar;
|
||||
export { useSidebar, type SidebarState } from '$soma/components/sidebar';
|
||||
|
||||
export type {
|
||||
SidebarProps,
|
||||
SidebarPanelProps as PanelProps,
|
||||
SidebarTriggerProps as TriggerProps,
|
||||
SidebarRailProps as RailProps,
|
||||
SidebarContentProps as ContentProps,
|
||||
SidebarSectionProps as SectionProps,
|
||||
SidebarMenuButtonProps as MenuButtonProps,
|
||||
SidebarMenuSubButtonProps as MenuSubButtonProps,
|
||||
SidebarMenuActionProps as MenuActionProps,
|
||||
SidebarMenuSubProps as MenuSubProps,
|
||||
SidebarCollapsible,
|
||||
SidebarSide
|
||||
} from './types';
|
||||
@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '$soma/components/sidebar';
|
||||
import type { SidebarContentProps } from './types';
|
||||
|
||||
let { children, ...rest }: SidebarContentProps = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.Content {...rest}>{@render children?.()}</Sidebar.Content>
|
||||
@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '$soma/components/sidebar';
|
||||
import type { SidebarSectionProps } from './types';
|
||||
|
||||
let { children, ...rest }: SidebarSectionProps = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.Footer {...rest}>{@render children?.()}</Sidebar.Footer>
|
||||
@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '$soma/components/sidebar';
|
||||
import type { SidebarSectionProps } from './types';
|
||||
|
||||
let { children, ...rest }: SidebarSectionProps = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.GroupLabel {...rest}>{@render children?.()}</Sidebar.GroupLabel>
|
||||
@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '$soma/components/sidebar';
|
||||
import type { SidebarSectionProps } from './types';
|
||||
|
||||
let { children, ...rest }: SidebarSectionProps = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.Group {...rest}>{@render children?.()}</Sidebar.Group>
|
||||
@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '$soma/components/sidebar';
|
||||
import type { SidebarSectionProps } from './types';
|
||||
|
||||
let { children, ...rest }: SidebarSectionProps = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.Header {...rest}>{@render children?.()}</Sidebar.Header>
|
||||
@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '$soma/components/sidebar';
|
||||
import type { SidebarSectionProps } from './types';
|
||||
|
||||
let { children, ...rest }: SidebarSectionProps = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.Inset {...rest}>{@render children?.()}</Sidebar.Inset>
|
||||
@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The row's secondary control. Native button styled by the recipe (a
|
||||
* compact square inside a row), not the `Button` component — its chrome
|
||||
* belongs to the row, exactly like the rail.
|
||||
*/
|
||||
import * as Sidebar from '$soma/components/sidebar';
|
||||
import type { SidebarMenuActionProps } from './types';
|
||||
|
||||
let { children, ...rest }: SidebarMenuActionProps = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.MenuAction {...rest}>{@render children?.()}</Sidebar.MenuAction>
|
||||
@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The row's trailing chip. Composes the canonical `Badge` inside the morfo
|
||||
* part, so a count in the sidebar is the same object the rest of the
|
||||
* ecosystem shows — and it sits inside the row's control, so its text joins
|
||||
* the accessible name («Inbox, 12, link»).
|
||||
*/
|
||||
import * as Sidebar from '$soma/components/sidebar';
|
||||
import { Badge } from '../badge';
|
||||
import type { SidebarSectionProps } from './types';
|
||||
|
||||
let { children, ...rest }: SidebarSectionProps = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.MenuBadge {...rest}>
|
||||
<Badge size="xs" variant="soft">{@render children?.()}</Badge>
|
||||
</Sidebar.MenuBadge>
|
||||
@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The row. While the panel is collapsed to the icon rail the label is
|
||||
* hidden by the recipe, so the row would lose its name for sighted users —
|
||||
* that is what `tooltip` is for: it composes the canonical `Tooltip`, only
|
||||
* in icon mode (mounting one per row otherwise would be noise).
|
||||
*/
|
||||
import * as Sidebar from '$soma/components/sidebar';
|
||||
import { useSidebar, useSidebarItemOr } from '$soma/components/sidebar';
|
||||
import { Tooltip } from '../tooltip';
|
||||
import type { SidebarMenuButtonProps } from './types';
|
||||
|
||||
let { children, tooltip, ...rest }: SidebarMenuButtonProps = $props();
|
||||
|
||||
const sidebar = useSidebar();
|
||||
const item = useSidebarItemOr();
|
||||
// A row with a sub-menu already names itself through the flyout that opens
|
||||
// on the same hover — two surfaces on one anchor would overlap.
|
||||
const tooltipped = $derived(!!tooltip && sidebar.iconMode && !item?.hasSub);
|
||||
</script>
|
||||
|
||||
{#if tooltipped}
|
||||
<Tooltip>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton {...rest} {...props}>{@render children?.()}</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side={sidebar.side === 'left' ? 'right' : 'left'}>{tooltip}</Tooltip.Content>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
<Sidebar.MenuButton {...rest}>{@render children?.()}</Sidebar.MenuButton>
|
||||
{/if}
|
||||
@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '$soma/components/sidebar';
|
||||
import type { SidebarSectionProps } from './types';
|
||||
|
||||
let { children, ...rest }: SidebarSectionProps = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.MenuItem {...rest}>{@render children?.()}</Sidebar.MenuItem>
|
||||
@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
/** A row inside the sub-menu — same treatment as the main row. */
|
||||
import * as Sidebar from '$soma/components/sidebar';
|
||||
import type { SidebarMenuSubButtonProps } from './types';
|
||||
|
||||
let { children, ...rest }: SidebarMenuSubButtonProps = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.MenuSubButton {...rest}>{@render children?.()}</Sidebar.MenuSubButton>
|
||||
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The nested list. In flow it is a plain indented list; while the panel is
|
||||
* an icon rail the soma turns it into a floating surface, and THEN it takes
|
||||
* the `overlay` depth plane — the same elevation language every floating
|
||||
* panel in the ecosystem uses (surface · border · shadow from `data-depth`,
|
||||
* never hand-rolled in the recipe).
|
||||
*/
|
||||
import * as Sidebar from '$soma/components/sidebar';
|
||||
import { useSidebarItemOr } from '$soma/components/sidebar';
|
||||
import type { SidebarMenuSubProps } from './types';
|
||||
|
||||
let { children, ...rest }: SidebarMenuSubProps = $props();
|
||||
|
||||
const item = useSidebarItemOr();
|
||||
</script>
|
||||
|
||||
<Sidebar.MenuSub data-depth={item?.floating ? 'overlay' : undefined} {...rest}>
|
||||
{@render children?.()}
|
||||
</Sidebar.MenuSub>
|
||||
@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '$soma/components/sidebar';
|
||||
import type { SidebarSectionProps } from './types';
|
||||
|
||||
let { children, ...rest }: SidebarSectionProps = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu {...rest}>{@render children?.()}</Sidebar.Menu>
|
||||
@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The panel. Below the mobile breakpoint it is presented inside a `Drawer`
|
||||
* — composed, never re-implemented, so the focus trap, the scroll lock and
|
||||
* the dismissal come with it. On the desktop it is the plain landmark and
|
||||
* the recipe animates its width / transform.
|
||||
*
|
||||
* The soma provider decides `mobile` from the SYSTEM breakpoint; this layer
|
||||
* only reads it (`useSidebar`) and picks the presentation. The `<aside>`
|
||||
* landmark and its name travel into the Drawer either way, so the a11y
|
||||
* contract does not change with the viewport.
|
||||
*/
|
||||
import * as Sidebar from '$soma/components/sidebar';
|
||||
import { useSidebar } from '$soma/components/sidebar';
|
||||
import { Drawer } from '../drawer';
|
||||
import type { SidebarPanelProps } from './types';
|
||||
|
||||
let { children, ...rest }: SidebarPanelProps = $props();
|
||||
|
||||
const sidebar = useSidebar();
|
||||
</script>
|
||||
|
||||
{#if sidebar.mobile}
|
||||
<Drawer
|
||||
open={sidebar.open}
|
||||
onOpenChange={(open) => sidebar.setOpen(open)}
|
||||
direction={sidebar.side}
|
||||
>
|
||||
<Drawer.Overlay />
|
||||
<Drawer.Content size="full">
|
||||
<Sidebar.Panel {...rest}>{@render children?.()}</Sidebar.Panel>
|
||||
</Drawer.Content>
|
||||
</Drawer>
|
||||
{:else}
|
||||
<Sidebar.Panel {...rest}>{@render children?.()}</Sidebar.Panel>
|
||||
{/if}
|
||||
@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The grab-strip on the panel's border — a real, focusable toggle. Native
|
||||
* button (not `Button`): it is a 16px strip of chrome with directional
|
||||
* cursors, not an action control, so the Button recipe would fight it.
|
||||
*/
|
||||
import * as Sidebar from '$soma/components/sidebar';
|
||||
import type { SidebarRailProps } from './types';
|
||||
|
||||
let { children, ...rest }: SidebarRailProps = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.Rail {...rest}>{@render children?.()}</Sidebar.Rail>
|
||||
@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The disclosure button. Composes the canonical `Button` through soma's
|
||||
* `child` seam (the house pattern) so it inherits the ecosystem's chrome,
|
||||
* focus ring and sizes instead of re-implementing a bare button.
|
||||
*/
|
||||
import * as Sidebar from '$soma/components/sidebar';
|
||||
import { Button } from '../button';
|
||||
import type { SidebarTriggerProps } from './types';
|
||||
|
||||
let { children, ...rest }: SidebarTriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.Trigger {...rest}>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="sm">{@render children?.()}</Button>
|
||||
{/snippet}
|
||||
</Sidebar.Trigger>
|
||||
@ -0,0 +1,313 @@
|
||||
/*
|
||||
* Sidebar recipe — the app's vertical chrome. Two axes drive everything:
|
||||
* `data-state` (expanded | collapsed) says WHETHER, `data-collapsible`
|
||||
* (offcanvas | icon | none) says WHAT collapsing does. Both live on the
|
||||
* provider, so the inset and the rail react from one place. `data-side`
|
||||
* mirrors the whole layout, `data-mobile` drops the desktop layout entirely
|
||||
* (there the panel is a Drawer).
|
||||
*
|
||||
* Motion discipline: off-canvas moves by `transform` (compositor work, the
|
||||
* inset does not reflow); `width` animates ONLY in icon mode, where the inset
|
||||
* genuinely has to follow the rail. Never `will-change: transform` on the
|
||||
* rail — a thin strip jitters at fractional DPR (NavMenu memory).
|
||||
*
|
||||
* Public tokens: `--sidebar-width`, `--sidebar-width-icon`,
|
||||
* `--sidebar-width-mobile`, `--sidebar-rail-width`, `--sidebar-gap`,
|
||||
* `--sidebar-padding`, `--sidebar-row-height`, `--sidebar-row-radius`,
|
||||
* `--sidebar-row-gap`, `--sidebar-sub-indent`, `--sidebar-bg`,
|
||||
* `--sidebar-border-color`.
|
||||
*/
|
||||
|
||||
[data-sidebar] {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
inline-size: 100%;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
[data-sidebar][data-side='right'] {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
/* ── Panel ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
[data-sidebar-panel] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: none;
|
||||
inline-size: var(--sidebar-width);
|
||||
min-inline-size: 0;
|
||||
block-size: 100%;
|
||||
background: var(--sidebar-bg);
|
||||
border-inline-end: var(--border-width) solid var(--sidebar-border-color);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
inline-size var(--duration-normal) var(--ease-default),
|
||||
transform var(--duration-normal) var(--ease-default);
|
||||
}
|
||||
|
||||
[data-sidebar][data-side='right'] [data-sidebar-panel] {
|
||||
border-inline-end: 0;
|
||||
border-inline-start: var(--border-width) solid var(--sidebar-border-color);
|
||||
}
|
||||
|
||||
/* Off-canvas: the panel slides out and stops taking room. Two properties
|
||||
because a transform alone would leave a 16rem hole in the layout. */
|
||||
[data-sidebar][data-collapsible='offcanvas'][data-state='collapsed'] [data-sidebar-panel] {
|
||||
inline-size: 0;
|
||||
transform: translateX(calc(var(--sidebar-width) * -1));
|
||||
}
|
||||
[data-sidebar][data-collapsible='offcanvas'][data-state='collapsed'][data-side='right']
|
||||
[data-sidebar-panel] {
|
||||
transform: translateX(var(--sidebar-width));
|
||||
}
|
||||
|
||||
/* Icon rail: the panel narrows; labels hide, glyphs stay. */
|
||||
[data-sidebar][data-collapsible='icon'][data-state='collapsed'] [data-sidebar-panel] {
|
||||
inline-size: var(--sidebar-width-icon);
|
||||
}
|
||||
|
||||
/* ── Inset (the page) ──────────────────────────────────────────────────── */
|
||||
|
||||
[data-sidebar-inset] {
|
||||
flex: 1 1 auto;
|
||||
min-inline-size: 0;
|
||||
min-block-size: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* ── Rail (the grab strip) ─────────────────────────────────────────────── */
|
||||
|
||||
[data-sidebar-rail] {
|
||||
flex: none;
|
||||
inline-size: var(--sidebar-rail-width);
|
||||
align-self: stretch;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: ew-resize;
|
||||
/* Deliberately NO `will-change: transform`: a thin rail jitters at
|
||||
fractional DPR when it gets its own layer. */
|
||||
transition: background var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
|
||||
[data-sidebar-rail]:hover {
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
[data-sidebar-rail]:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
|
||||
/* ── Header · Content · Footer ─────────────────────────────────────────── */
|
||||
|
||||
[data-sidebar-header],
|
||||
[data-sidebar-footer] {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sidebar-row-gap);
|
||||
padding: var(--sidebar-padding);
|
||||
}
|
||||
|
||||
[data-sidebar-content] {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
overflow-y: auto;
|
||||
/* A scroll chain into the page behind is the classic app-rail annoyance. */
|
||||
overscroll-behavior: contain;
|
||||
padding: var(--sidebar-padding);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
/* ── Group ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
[data-sidebar-group] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sidebar-gap);
|
||||
}
|
||||
|
||||
[data-sidebar-group-label] {
|
||||
padding-inline: var(--space-2);
|
||||
padding-block: var(--space-1);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--font-line-height-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-content-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Menu ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
[data-sidebar-menu],
|
||||
[data-sidebar-menu-sub] {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sidebar-gap);
|
||||
}
|
||||
|
||||
/* The row is a flex line (button + action); a sub-menu is a SECOND line, so
|
||||
the item wraps rather than nesting another wrapper part just for layout. */
|
||||
[data-sidebar-menu-item] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
[data-sidebar-menu-item] > [data-sidebar-menu-sub]:not([data-floating]) {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
[data-sidebar-menu-button],
|
||||
[data-sidebar-menu-sub-button] {
|
||||
flex: 1 1 auto;
|
||||
min-inline-size: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sidebar-row-gap);
|
||||
min-block-size: var(--sidebar-row-height);
|
||||
padding-inline: var(--space-2);
|
||||
border-radius: var(--sidebar-row-radius);
|
||||
color: var(--color-content-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--font-line-height-sm);
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
transition:
|
||||
background var(--duration-fast) var(--ease-default),
|
||||
color var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
|
||||
[data-sidebar-menu-button]:hover,
|
||||
[data-sidebar-menu-sub-button]:hover {
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-content-primary);
|
||||
}
|
||||
|
||||
[data-sidebar-menu-button][data-active],
|
||||
[data-sidebar-menu-sub-button][data-active] {
|
||||
background: var(--color-primary-track);
|
||||
color: var(--color-primary-text);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
[data-sidebar-menu-button][data-disabled],
|
||||
[data-sidebar-menu-sub-button][data-disabled] {
|
||||
color: var(--color-content-disabled);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-sidebar-menu-button]:focus-visible,
|
||||
[data-sidebar-menu-sub-button]:focus-visible,
|
||||
[data-sidebar-menu-action]:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
|
||||
/* Row action — a compact square that keeps the row's space for the link. */
|
||||
[data-sidebar-menu-action] {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--space-6);
|
||||
block-size: var(--space-6);
|
||||
border-radius: var(--sidebar-row-radius);
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-content-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-sidebar-menu-action]:hover {
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-content-primary);
|
||||
}
|
||||
|
||||
/* Badge — pushed to the row's end, inside the control so its text joins the
|
||||
accessible name. */
|
||||
[data-sidebar-menu-badge] {
|
||||
flex: none;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
/* Sub-menu in flow: indented under its row. */
|
||||
[data-sidebar-menu-sub] {
|
||||
padding-inline-start: var(--sidebar-sub-indent);
|
||||
}
|
||||
|
||||
/* ── Icon mode ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Everything but the first glyph hides; the row becomes a square. */
|
||||
[data-sidebar][data-collapsible='icon'][data-state='collapsed'] [data-sidebar-group-label],
|
||||
[data-sidebar][data-collapsible='icon'][data-state='collapsed'] [data-sidebar-menu-badge],
|
||||
[data-sidebar][data-collapsible='icon'][data-state='collapsed'] [data-sidebar-menu-action] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-sidebar][data-collapsible='icon'][data-state='collapsed'] [data-sidebar-menu-button] {
|
||||
justify-content: center;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
/* Only the row's FIRST element (its glyph) survives the icon rail. This is the
|
||||
documented convention — wrap the label in an element when you use icon mode,
|
||||
exactly as the references do; a bare text node cannot be hidden by CSS. The
|
||||
name is not lost: `tooltip` on MenuButton composes the canonical Tooltip. */
|
||||
[data-sidebar][data-collapsible='icon'][data-state='collapsed']
|
||||
[data-sidebar-menu-button]
|
||||
> *:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The floating sub-menu: same surface language as a menu panel. It is only
|
||||
`data-floating` while the rail is collapsed — in flow it stays a plain list. */
|
||||
/* Surface only — placement belongs to the floating wrapper the soma renders
|
||||
around it. Positioning the list itself would collapse that wrapper to zero
|
||||
size and the engine would have nothing to measure. */
|
||||
[data-sidebar-menu-sub][data-floating] {
|
||||
min-inline-size: var(--sidebar-width);
|
||||
padding: var(--sidebar-padding);
|
||||
padding-inline-start: var(--sidebar-padding);
|
||||
border-radius: var(--radius-lg);
|
||||
/* Elevation (surface · border · shadow) comes from the `overlay` depth
|
||||
plane the eidos stamps — the ecosystem's single floating language. */
|
||||
}
|
||||
|
||||
[data-sidebar-menu-sub][data-floating][data-state='closed'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Mobile ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* The panel is a Drawer down here: it must not also hold layout width. */
|
||||
[data-sidebar][data-mobile] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-sidebar][data-mobile] [data-sidebar-panel] {
|
||||
inline-size: var(--sidebar-width-mobile);
|
||||
max-inline-size: 100%;
|
||||
border: 0;
|
||||
transform: none;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
[data-sidebar][data-mobile] [data-sidebar-rail] {
|
||||
display: none;
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Eidos `<Sidebar>` — the app's vertical chrome over Soma's headless shell.
|
||||
* It brings the CSS (widths, rail, icon mode, row treatment) and the three
|
||||
* compositions the headless layer cannot make: `Drawer` on mobile,
|
||||
* `Tooltip` on collapsed rows, `Badge` for counts.
|
||||
*
|
||||
* <Sidebar bind:open collapsible="icon">
|
||||
* <Sidebar.Panel>…</Sidebar.Panel>
|
||||
* <Sidebar.Rail />
|
||||
* <Sidebar.Inset>…the page…</Sidebar.Inset>
|
||||
* </Sidebar>
|
||||
*/
|
||||
import './sidebar.css';
|
||||
import * as Sidebar from '$soma/components/sidebar';
|
||||
import type { SidebarProps } from './types';
|
||||
|
||||
// `open` is forwarded as a BINDING, not a value: the expanded state is the
|
||||
// component's headline seam (a persisted cookie or a global shortcut drives
|
||||
// it from app-land), so `bind:open` has to survive the eidos wrapper.
|
||||
let { defaultOpen = true, open = $bindable(defaultOpen), children, ...rest }: SidebarProps = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.Provider bind:open {...rest}>{@render children?.()}</Sidebar.Provider>
|
||||
@ -0,0 +1,42 @@
|
||||
import type {
|
||||
ProviderProps,
|
||||
PanelProps,
|
||||
TriggerProps,
|
||||
RailProps,
|
||||
ContentProps,
|
||||
SectionProps,
|
||||
MenuButtonProps,
|
||||
MenuSubButtonProps,
|
||||
MenuActionProps,
|
||||
MenuSubProps,
|
||||
SidebarCollapsible,
|
||||
SidebarSide
|
||||
} from '$soma/components/sidebar';
|
||||
|
||||
export type { SidebarCollapsible, SidebarSide };
|
||||
|
||||
/**
|
||||
* Sidebar is behavioral: the eidos layer adds the chrome (widths, rail, icon
|
||||
* mode, row treatment) and the three compositions the headless layer cannot
|
||||
* make on its own — `Drawer` for the mobile presentation, `Tooltip` to name
|
||||
* the rows while collapsed to icons, and the canonical `Badge` for counts.
|
||||
* Everything else passes through to the soma providers.
|
||||
*/
|
||||
export type SidebarProps = ProviderProps;
|
||||
export type SidebarPanelProps = PanelProps;
|
||||
export type SidebarTriggerProps = TriggerProps;
|
||||
export type SidebarRailProps = RailProps;
|
||||
export type SidebarContentProps = ContentProps;
|
||||
export type SidebarSectionProps = SectionProps;
|
||||
export type SidebarMenuSubProps = MenuSubProps;
|
||||
export type SidebarMenuActionProps = MenuActionProps;
|
||||
export type SidebarMenuSubButtonProps = MenuSubButtonProps;
|
||||
|
||||
export type SidebarMenuButtonProps = MenuButtonProps & {
|
||||
/**
|
||||
* Text shown in a `Tooltip` while the panel is collapsed to the icon rail
|
||||
* (there the row has only its glyph). Defaults to nothing — pass it for any
|
||||
* row that can be reached in icon mode.
|
||||
*/
|
||||
tooltip?: string;
|
||||
};
|
||||
@ -0,0 +1,93 @@
|
||||
import { SidebarProvider, SidebarMenuItemProvider } from './sidebar-provider.svelte';
|
||||
import type { SidebarCollapsible, SidebarSide } from './types';
|
||||
|
||||
/**
|
||||
* Read-only view of the sidebar's state for whoever renders inside it — the
|
||||
* eidos layer (which decides to present the panel as a `Drawer` on mobile and
|
||||
* to tooltip the rows in icon mode) and app-land (a custom header that mirrors
|
||||
* the toggle, a shortcut handler, a persisted cookie).
|
||||
*
|
||||
* This is the dossier's `useSidebar` hook (§P4 counts it as part of shadcn's
|
||||
* floor: «23 parts + hook»), narrowed to what a consumer legitimately needs:
|
||||
* the two axes, the derived presentation facts and the toggle. The provider
|
||||
* class itself stays internal — the state is exposed, not the implementation.
|
||||
*
|
||||
* Reactive: every field is a getter over the provider's `$derived` state, so
|
||||
* reading one inside an effect / template subscribes to it.
|
||||
*/
|
||||
export interface SidebarState {
|
||||
/** The panel is expanded. */
|
||||
readonly open: boolean;
|
||||
/** What collapsing means for this sidebar. */
|
||||
readonly collapsible: SidebarCollapsible;
|
||||
/** Which edge the panel sits on. */
|
||||
readonly side: SidebarSide;
|
||||
/** Below the mobile breakpoint (the panel is presented as a Drawer). */
|
||||
readonly mobile: boolean;
|
||||
/** Collapsed down to the icon rail (`icon` mode, closed, desktop). */
|
||||
readonly iconMode: boolean;
|
||||
/** Flip the panel — the same path the Trigger and the Rail take. */
|
||||
toggle(): void;
|
||||
setOpen(open: boolean): void;
|
||||
}
|
||||
|
||||
/** Read the sidebar's state from context. Throws outside a `<Sidebar>`. */
|
||||
export function useSidebar(): SidebarState {
|
||||
const provider = SidebarProvider.require();
|
||||
return {
|
||||
get open() {
|
||||
return provider.open;
|
||||
},
|
||||
get collapsible() {
|
||||
return provider.collapsible;
|
||||
},
|
||||
get side() {
|
||||
return provider.side;
|
||||
},
|
||||
get mobile() {
|
||||
return provider.mobile;
|
||||
},
|
||||
get iconMode() {
|
||||
return provider.iconMode;
|
||||
},
|
||||
toggle: () => provider.toggle(),
|
||||
setOpen: (open: boolean) => provider.setOpen(open)
|
||||
};
|
||||
}
|
||||
|
||||
/** Same as `useSidebar`, but `undefined` outside a `<Sidebar>`. */
|
||||
export function useSidebarOr(): SidebarState | undefined {
|
||||
return SidebarProvider.get() ? useSidebar() : undefined;
|
||||
}
|
||||
|
||||
/** What a row needs to know about ITS OWN item. */
|
||||
export interface SidebarItemState {
|
||||
/** A `MenuSub` is declared under this row. */
|
||||
readonly hasSub: boolean;
|
||||
/** That sub is currently shown (always true while the panel is expanded). */
|
||||
readonly subOpen: boolean;
|
||||
/** The sub is presented as a floating surface (icon rail). */
|
||||
readonly floating: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the enclosing `MenuItem`'s state — `undefined` outside one. The eidos
|
||||
* uses it to decide whether a row still needs a tooltip in icon mode: a row
|
||||
* with a sub-menu already names itself through the flyout, and showing both
|
||||
* would stack two surfaces on the same anchor.
|
||||
*/
|
||||
export function useSidebarItemOr(): SidebarItemState | undefined {
|
||||
const item = SidebarMenuItemProvider.get();
|
||||
if (!item) return undefined;
|
||||
return {
|
||||
get hasSub() {
|
||||
return item.hasSub.current;
|
||||
},
|
||||
get subOpen() {
|
||||
return item.subOpen;
|
||||
},
|
||||
get floating() {
|
||||
return item.floating;
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,622 @@
|
||||
<script lang="ts">
|
||||
import { Sidebar, type SidebarCollapsible, type SidebarSide } from '$uix/eidos/components/sidebar';
|
||||
import * as Icon from '$uix/eidos/components/icon';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { sidebarMorfo } from '@/uix/morfo/components/sidebar';
|
||||
import { getActiveUix } from '$active-uix';
|
||||
|
||||
import SystemAxes from '../../lib/SystemAxes.svelte';
|
||||
import MotionPanel from '../../lib/MotionPanel.svelte';
|
||||
import SemaPanel from '../../lib/SemaPanel.svelte';
|
||||
import { DemoTrace } from '../../lib/harness.svelte';
|
||||
|
||||
const uix = getActiveUix();
|
||||
|
||||
// prettier-ignore
|
||||
type Tab = 'live' | 'system' | 'motion' | 'sema' | 'services' | 'api' | 'morfo' | 'recipe' | 'a11y';
|
||||
let tab = $state<Tab>('live');
|
||||
|
||||
const trace = new DemoTrace();
|
||||
let stageRef = $state<HTMLElement | null>(null);
|
||||
$effect(() => {
|
||||
if (stageRef) return trace.observe(stageRef);
|
||||
});
|
||||
let density = $state('comfortable');
|
||||
let scaling = $state(1);
|
||||
let mode = $state<'inherit' | 'light' | 'dark'>('inherit');
|
||||
let dir = $state<'ltr' | 'rtl'>('ltr');
|
||||
let borderWidth = $state(1);
|
||||
|
||||
// The component's own props — every one of them a live control.
|
||||
let open = $state(true);
|
||||
let collapsible = $state<SidebarCollapsible>('icon');
|
||||
let side = $state<SidebarSide>('left');
|
||||
|
||||
const COLLAPSIBLES: SidebarCollapsible[] = ['offcanvas', 'icon', 'none'];
|
||||
const SIDES: SidebarSide[] = ['left', 'right'];
|
||||
|
||||
// A small app map — the sidebar is compositional, so this is markup, not data.
|
||||
let activeHref = $state('/app/inbox');
|
||||
|
||||
const compiled = compileMorfo(sidebarMorfo);
|
||||
const partsList = [...compiled.parts.byKebab.values()];
|
||||
const events = [...compiled.actions.byName.values()];
|
||||
|
||||
function navigate(event: MouseEvent) {
|
||||
const link = (event.target as HTMLElement | null)?.closest?.('a[href]');
|
||||
if (!link) return;
|
||||
event.preventDefault();
|
||||
activeHref = link.getAttribute('href') ?? activeHref;
|
||||
}
|
||||
|
||||
const eidosSnippet = $derived(
|
||||
[
|
||||
'<Sidebar',
|
||||
' bind:open',
|
||||
collapsible !== 'offcanvas' && ` collapsible="${collapsible}"`,
|
||||
side !== 'left' && ` side="${side}"`,
|
||||
'>',
|
||||
' <Sidebar.Panel>',
|
||||
' <Sidebar.Header>…brand…</Sidebar.Header>',
|
||||
' <Sidebar.Content>',
|
||||
' <Sidebar.Group>',
|
||||
' <Sidebar.GroupLabel>Workspace</Sidebar.GroupLabel>',
|
||||
' <Sidebar.Menu>',
|
||||
' <Sidebar.MenuItem>',
|
||||
` <Sidebar.MenuButton href="/app/inbox" active tooltip="Inbox">`,
|
||||
' <Icon.Inbox /><span>Inbox</span>',
|
||||
' <Sidebar.MenuBadge>12</Sidebar.MenuBadge>',
|
||||
' </Sidebar.MenuButton>',
|
||||
' <Sidebar.MenuAction aria-label="Inbox options">⋯</Sidebar.MenuAction>',
|
||||
' </Sidebar.MenuItem>',
|
||||
' </Sidebar.Menu>',
|
||||
' </Sidebar.Group>',
|
||||
' </Sidebar.Content>',
|
||||
' <Sidebar.Footer>…user…</Sidebar.Footer>',
|
||||
' </Sidebar.Panel>',
|
||||
' <Sidebar.Rail />',
|
||||
' <Sidebar.Inset>…the page…</Sidebar.Inset>',
|
||||
'</Sidebar>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Navigation · Sidebar</div>
|
||||
<h1 data-uix-page-title>Sidebar</h1>
|
||||
<p data-uix-page-lede>
|
||||
The application's vertical chrome: a collapsible panel beside the page, with groups, rows,
|
||||
actions, badges and sub-menus. Two axes drive it — <code>data-state</code>
|
||||
says whether it is expanded, <code>collapsible</code> says what collapsing DOES (slide away,
|
||||
shrink to an icon rail, or nothing). Nobody headless ships this component; the floor is
|
||||
shadcn's, and this goes past it: two named landmarks, <code>aria-current="page"</code> from the
|
||||
same prop as <code>data-active</code>, a rail you can reach with Tab, and sub-menus that survive
|
||||
icon mode instead of disappearing.
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
|
||||
</span>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>events</span>{events.length}
|
||||
</span>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>scope</span>{sidebarMorfo.scope.join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div data-uix-stage>
|
||||
<div
|
||||
data-uix-stage-area
|
||||
bind:this={stageRef}
|
||||
data-density={density}
|
||||
data-theme={mode === 'inherit' ? undefined : mode}
|
||||
data-mode={mode === 'inherit' ? undefined : mode}
|
||||
{dir}
|
||||
style={`--scaling: ${scaling}; --border-width: ${borderWidth}px; display: block;`}
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div
|
||||
onclick={navigate}
|
||||
style="block-size: 420px; border: var(--border-width) solid var(--color-border-subtle); border-radius: var(--radius-lg); overflow: hidden;"
|
||||
>
|
||||
<Sidebar bind:open {collapsible} {side}>
|
||||
<Sidebar.Panel>
|
||||
<Sidebar.Header>
|
||||
<strong style="font-size: var(--font-size-sm);">Acme</strong>
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Content>
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel>Workspace</Sidebar.GroupLabel>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
href="/app/inbox"
|
||||
active={activeHref === '/app/inbox'}
|
||||
tooltip="Inbox"
|
||||
>
|
||||
<Icon.Inbox size="sm" />
|
||||
<span>Inbox</span>
|
||||
<Sidebar.MenuBadge>12</Sidebar.MenuBadge>
|
||||
</Sidebar.MenuButton>
|
||||
<Sidebar.MenuAction aria-label="Inbox options">⋯</Sidebar.MenuAction>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
href="/app/drafts"
|
||||
active={activeHref === '/app/drafts'}
|
||||
tooltip="Drafts"
|
||||
>
|
||||
<Icon.File size="sm" />
|
||||
<span>Drafts</span>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
href="/app/projects"
|
||||
active={activeHref.startsWith('/app/projects')}
|
||||
tooltip="Projects"
|
||||
>
|
||||
<Icon.Folder size="sm" />
|
||||
<span>Projects</span>
|
||||
</Sidebar.MenuButton>
|
||||
<Sidebar.MenuSub>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuSubButton
|
||||
href="/app/projects/apollo"
|
||||
active={activeHref === '/app/projects/apollo'}
|
||||
>
|
||||
Apollo
|
||||
</Sidebar.MenuSubButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuSubButton
|
||||
href="/app/projects/borealis"
|
||||
active={activeHref === '/app/projects/borealis'}
|
||||
>
|
||||
Borealis
|
||||
</Sidebar.MenuSubButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.MenuSub>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel>Account</Sidebar.GroupLabel>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
href="/app/settings"
|
||||
active={activeHref === '/app/settings'}
|
||||
tooltip="Settings"
|
||||
>
|
||||
<Icon.Settings size="sm" />
|
||||
<span>Settings</span>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton href="/app/billing" disabled tooltip="Billing">
|
||||
<Icon.CreditCard size="sm" />
|
||||
<span>Billing</span>
|
||||
<Sidebar.MenuBadge>Soon</Sidebar.MenuBadge>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
</Sidebar.Content>
|
||||
<Sidebar.Footer>
|
||||
<span style="font-size: var(--font-size-xs); color: var(--color-content-muted);">
|
||||
ada@acme.dev
|
||||
</span>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar.Panel>
|
||||
<Sidebar.Rail />
|
||||
<Sidebar.Inset>
|
||||
<div style="padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3);">
|
||||
<Sidebar.Trigger>{open ? 'Collapse' : 'Expand'}</Sidebar.Trigger>
|
||||
<h2 style="margin: 0; font-size: var(--font-size-lg); font-weight: var(--font-weight-semibold);">
|
||||
{activeHref}
|
||||
</h2>
|
||||
<p style="margin: 0; color: var(--color-content-muted);">
|
||||
This is the <code>Inset</code> — the page's own <code><main></code>, sibling of
|
||||
the panel. Collapse the sidebar from the button, from the rail at its edge, or from
|
||||
<code>useSidebar().toggle()</code> in your own header: all three go through the same
|
||||
event, so they sound the same.
|
||||
</p>
|
||||
</div>
|
||||
</Sidebar.Inset>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>state</span>
|
||||
<span>{open ? 'expanded' : 'collapsed'}</span>
|
||||
<span style="color: var(--uix-text-faint)">·</span>
|
||||
<span data-uix-stage-trace-key>collapsible</span>
|
||||
<span>{collapsible}</span>
|
||||
<span style="color: var(--uix-text-faint)">·</span>
|
||||
<span data-uix-stage-trace-key>active</span>
|
||||
<span>{activeHref}</span>
|
||||
<span style="margin-inline-start: auto;">collapse in icon mode → hover a row</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-uix-tabs role="tablist">
|
||||
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
||||
<button data-uix-tab data-active={tab === 'system'} onclick={() => (tab = 'system')}>
|
||||
System
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'motion'} onclick={() => (tab = 'motion')}>
|
||||
Motion
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
|
||||
<span data-uix-layer-badge="sema">sema</span>
|
||||
<span data-uix-tab-count>{events.length}</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'services'} onclick={() => (tab = 'services')}>
|
||||
Services
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
|
||||
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
||||
<span data-uix-layer-badge="morfo">morfo</span>
|
||||
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>
|
||||
Recipe
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'live'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Controls</h2>
|
||||
<p data-uix-section-desc>
|
||||
The two axes are separate props on purpose: <code>open</code> is the live state (bindable,
|
||||
so app-land can persist it), <code>collapsible</code> decides what collapsing means. Try
|
||||
<code>icon</code> and then hover «Projects»: its sub-menu floats beside the rail instead of
|
||||
disappearing, which is what every reference does.
|
||||
</p>
|
||||
<div data-uix-controls>
|
||||
<div data-uix-control>
|
||||
<span data-uix-control-label>open (state)</span>
|
||||
<div data-uix-chips>
|
||||
<button data-uix-chip data-active={open} onclick={() => (open = true)}>expanded</button>
|
||||
<button data-uix-chip data-active={!open} onclick={() => (open = false)}>
|
||||
collapsed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-uix-control>
|
||||
<span data-uix-control-label>collapsible (mode)</span>
|
||||
<div data-uix-chips>
|
||||
{#each COLLAPSIBLES as value (value)}
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={collapsible === value}
|
||||
onclick={() => (collapsible = value)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div data-uix-control>
|
||||
<span data-uix-control-label>side</span>
|
||||
<div data-uix-chips>
|
||||
{#each SIDES as value (value)}
|
||||
<button data-uix-chip data-active={side === value} onclick={() => (side = value)}>
|
||||
{value}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>compositional anatomy</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{eidosSnippet}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'system'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>System axes</h2>
|
||||
<p data-uix-section-desc>
|
||||
The recipe is logical throughout (<code>border-inline-end</code>,
|
||||
<code>padding-inline</code>), so RTL mirrors the whole shell — including which edge the rail
|
||||
sits on. Widths are tokens (<code>--sidebar-width</code>,
|
||||
<code>--sidebar-width-icon</code>), so density and scaling reach them too.
|
||||
</p>
|
||||
<SystemAxes bind:density bind:scaling bind:mode bind:dir bind:borderWidth />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'motion'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Motion</h2>
|
||||
<MotionPanel
|
||||
note="Off-canvas moves by transform (compositor work — the inset never reflows); width animates ONLY in icon mode, where the inset genuinely has to follow the rail. The rail itself deliberately has no will-change: a thin strip jitters at fractional DPR when it gets its own layer (the NavMenu indicator memory)."
|
||||
/>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'sema'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>
|
||||
<span data-uix-layer-badge="sema">sema</span> · events
|
||||
</h2>
|
||||
<p data-uix-section-desc>
|
||||
One sonified surface: the panel's disclosure. The Trigger, the Rail and an app's own
|
||||
shortcut all go through <code>toggle()</code>, so the three sound identical —
|
||||
<code>emerge.soft</code> on expand, <code>emerge.exit.soft</code> on collapse, the same
|
||||
signature as nav-tree and collapsible. Navigating a row is native and carries no cue.
|
||||
</p>
|
||||
<SemaPanel
|
||||
actions={events}
|
||||
{uix}
|
||||
getTarget={() => stageRef?.querySelector('[data-sidebar-panel]') ?? stageRef}
|
||||
/>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'services'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Services</h2>
|
||||
<p data-uix-section-desc>
|
||||
<strong>langs</strong> names both landmarks and the two controls — «{uix.langs.ts(
|
||||
'#?components.sidebar.label|Sidebar'
|
||||
)}», «{uix.langs.ts('#?components.sidebar.nav|Main navigation')}», «{uix.langs.ts(
|
||||
'#?components.sidebar.trigger|Toggle sidebar'
|
||||
)}», «{uix.langs.ts('#?components.sidebar.rail|Resize sidebar')}» — so a rail with no visible
|
||||
text is still announced in the reader's language. <strong>adom</strong> supplies the
|
||||
breakpoint tracker that decides <code>data-mobile</code> (<code>uix.dom.isAtLeast</code>) and
|
||||
the floating engine that anchors a sub-menu to its row in icon mode. <strong>sema</strong>
|
||||
plays the disclosure cue.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API reference</h2>
|
||||
<div data-uix-subsection-head>Sidebar props</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="name">open</td>
|
||||
<td class="type">boolean (bindable)</td>
|
||||
<td>
|
||||
Expanded. Bind it (or pair it with <code>onOpenChange</code>) and app-land owns the
|
||||
state — that is how a cookie or a global shortcut drives the sidebar.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">defaultOpen</td>
|
||||
<td class="type">boolean</td>
|
||||
<td>Initial state when uncontrolled. Default <code>true</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">collapsible</td>
|
||||
<td class="type">'offcanvas' | 'icon' | 'none'</td>
|
||||
<td>What collapsing does. Default <code>'offcanvas'</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">side</td>
|
||||
<td class="type">'left' | 'right'</td>
|
||||
<td>Which edge the panel sits on. Default <code>'left'</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">mobileBreakpoint</td>
|
||||
<td class="type">Breakpoint</td>
|
||||
<td>
|
||||
Below it the panel is presented as a <code>Drawer</code>. Default <code>'md'</code>.
|
||||
Resolved by the system tracker, never a local <code>matchMedia</code>.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div data-uix-subsection-head>Sidebar.MenuButton props</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="name">href</td>
|
||||
<td class="type">string</td>
|
||||
<td>Makes the row an anchor; without one it renders a button.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">active</td>
|
||||
<td class="type">boolean</td>
|
||||
<td>
|
||||
Stamps <code>data-active</code> AND <code>aria-current="page"</code> — one prop, so
|
||||
the two cannot drift.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">disabled</td>
|
||||
<td class="type">boolean</td>
|
||||
<td>Drops the <code>href</code> and announces <code>aria-disabled</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">tooltip</td>
|
||||
<td class="type">string</td>
|
||||
<td>
|
||||
Name shown in a composed <code>Tooltip</code> while the panel is an icon rail (where
|
||||
the label is hidden). Eidos-only.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div data-uix-subsection-head>useSidebar()</div>
|
||||
<p data-uix-section-desc>
|
||||
<code>{'{ open, collapsible, side, mobile, iconMode, toggle, setOpen }'}</code> — a read-only
|
||||
reactive view for anything rendered inside the sidebar (a custom header that mirrors the
|
||||
toggle, a shortcut handler). The dossier counts this hook as part of the reference floor.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Morfo contract</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Field</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">name</td><td>{sidebarMorfo.name}</td></tr>
|
||||
<tr><td class="name">kebab</td><td><code>{sidebarMorfo.kebab}</code></td></tr>
|
||||
<tr><td class="name">scope</td><td>{sidebarMorfo.scope.join(', ')}</td></tr>
|
||||
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
|
||||
<tr><td class="name">events</td><td>{events.length}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div data-uix-subsection-head>Parts</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead>
|
||||
<tr><th>kebab</th><th>marker</th><th>element</th><th>optional</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each partsList as part (part.kebab)}
|
||||
<tr>
|
||||
<td class="name">{part.kebab}</td>
|
||||
<td><code data-uix-part-marker>[{part.marker}]</code></td>
|
||||
<td class="type"><{part.defaultElement}></td>
|
||||
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
|
||||
<code>separator</code> and the search input are deliberately NOT parts: they compose the
|
||||
canonical <code>Separator</code> and <code>Field</code>. Two <code>partRef</code>s
|
||||
(<code>aria-labelledby</code> on the menu, <code>aria-controls</code> on the trigger and the
|
||||
rail) resolve last-write-wins across repeated instances, so soma supplies the per-instance id
|
||||
— the same correction <code>navigation-menu</code> makes.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>
|
||||
Recipe in <code>src/uix/eidos/components/sidebar/sidebar.css</code>. Public tokens:
|
||||
<code>--sidebar-width</code> (16rem), <code>--sidebar-width-icon</code> (3rem),
|
||||
<code>--sidebar-width-mobile</code> (18rem), <code>--sidebar-rail-width</code>,
|
||||
<code>--sidebar-gap</code>, <code>--sidebar-padding</code>,
|
||||
<code>--sidebar-row-height</code>, <code>--sidebar-row-radius</code>,
|
||||
<code>--sidebar-row-gap</code>, <code>--sidebar-sub-indent</code>,
|
||||
<code>--sidebar-bg</code>, <code>--sidebar-border-color</code>.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="name"><code>[data-sidebar][data-collapsible='offcanvas'][data-state='collapsed']</code></td>
|
||||
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
|
||||
<td>Panel slides out by transform and stops taking width.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name"><code>[data-sidebar][data-collapsible='icon'][data-state='collapsed']</code></td>
|
||||
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
|
||||
<td>Panel narrows to the icon rail; labels, badges and actions hide.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name"><code>[data-sidebar-menu-button][data-active]</code></td>
|
||||
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
|
||||
<td>The current page — accent track + text.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name"><code>[data-sidebar-menu-sub][data-floating]</code></td>
|
||||
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
|
||||
<td>The sub-menu as a floating surface beside the rail.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name"><code>[data-sidebar][data-mobile]</code></td>
|
||||
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
|
||||
<td>Desktop layout off — the panel lives inside a Drawer.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="name">Landmarks</td>
|
||||
<td>
|
||||
The panel is a named <code><aside></code> (complementary) and the scrolling
|
||||
body a named <code><nav></code>, with header and footer OUTSIDE it so the nav
|
||||
names the navigation and not the chrome. shadcn ships neither.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">aria-current</td>
|
||||
<td>
|
||||
<code>aria-current="page"</code> on the active row and sub-row, from the SAME prop
|
||||
that stamps <code>data-active</code> — the visual and the announced state cannot
|
||||
drift.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">Disclosure</td>
|
||||
<td>
|
||||
Trigger and Rail are real buttons with <code>aria-expanded</code> +
|
||||
<code>aria-controls</code> pointing at the panel. The rail is IN the tab order with
|
||||
its own localized name; shadcn's is <code>tabIndex=-1</code>.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">Icon mode</td>
|
||||
<td>
|
||||
The label is hidden visually, so the row's name comes from the composed
|
||||
<code>Tooltip</code> (<code>tooltip</code> prop). Sub-menus float beside the rail
|
||||
instead of disappearing, and Escape closes one returning focus to its row.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">Disabled row</td>
|
||||
<td>
|
||||
Keeps its place but loses the <code>href</code>: nothing to activate by pointer,
|
||||
keyboard or context menu, plus <code>aria-disabled</code>.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">Mobile</td>
|
||||
<td>
|
||||
Below the breakpoint the panel is a composed <code>Drawer</code>, so focus trap,
|
||||
scroll lock and dismissal come with it — the landmark and its name travel inside.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
Loading…
Reference in new issue