uix(sidebar): F1.7 eidos + demo · raíl, modo icono y submenú flotante

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
dev 3 months ago
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;
};

@ -3228,6 +3228,18 @@
--anchor-nav-indent: var(--space-3);
--anchor-nav-rail-width: 2px;
--nav-tree-indent: var(--space-4);
--sidebar-width: 16rem;
--sidebar-width-icon: 3rem;
--sidebar-width-mobile: 18rem;
--sidebar-rail-width: var(--space-4);
--sidebar-gap: var(--space-1);
--sidebar-padding: var(--space-2);
--sidebar-row-height: var(--space-8);
--sidebar-row-radius: var(--radius-md);
--sidebar-row-gap: var(--space-2);
--sidebar-sub-indent: var(--space-4);
--sidebar-bg: var(--color-surface-muted);
--sidebar-border-color: var(--color-border-subtle);
--callout-gap: var(--space-3);
--callout-padding: var(--space-4);
--callout-accent-width: 3px;

@ -4488,6 +4488,29 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
indent: 'var(--space-4)'
},
// ─────────────────────────────────────────────────────────────────────
// Sidebar — the app's vertical chrome. Widths are TOKENS, never TS
// constants (the dossier's note on shadcn shipping them as consts): the
// three of them are the whole layout contract — expanded, icon rail and
// the mobile Drawer. The panel animates `transform` when it goes
// off-canvas and `width` only in icon mode (animating width every frame
// is layout work; icon mode is the one case where the inset must follow).
// ─────────────────────────────────────────────────────────────────────
sidebar: {
width: '16rem',
'width-icon': '3rem',
'width-mobile': '18rem',
'rail-width': 'var(--space-4)',
gap: 'var(--space-1)',
padding: 'var(--space-2)',
'row-height': 'var(--space-8)',
'row-radius': 'var(--radius-md)',
'row-gap': 'var(--space-2)',
'sub-indent': 'var(--space-4)',
bg: 'var(--color-surface-muted)',
'border-color': 'var(--color-border-subtle)'
},
// ─────────────────────────────────────────────────────────────────────
// Callout — inline admonition. Per-color forwarders (track/text/solid)
// + the canonical `_palette-*` slots so the THM-2 shared layer routes

@ -38,7 +38,19 @@ compositional app chrome with a shallow fixed anatomy. A docs shell composes
`uix.dom.isAtLeast(mobileBreakpoint)` (default `md`) — never a
component-local `matchMedia`. Soma stamps `data-mobile`; presenting the panel
as a `Drawer` below that breakpoint is the eidos's composition, so the
Drawer's own behaviour (focus trap, scroll lock, dismiss) comes with it.
Drawer's own behaviour (focus trap, scroll lock, dismiss) comes with it. A
server render has no viewport, so `mobile` is FALSE there by definition —
hydration does not diff attributes, and a `data-mobile` baked into the HTML
would never be corrected.
- **The mobile panel has its OWN open state**, starting closed: on the desktop
`open` is a layout preference the app persists; on mobile it would mean an
overlay covering the page. `onOpenChange` therefore fires only for the
desktop state.
- **`useSidebar()` / `useSidebarItemOr()`**: read-only reactive views of the
sidebar (`open`, `collapsible`, `side`, `mobile`, `iconMode`, `toggle`,
`setOpen`) and of the enclosing row (`hasSub`, `subOpen`, `floating`). The
dossier counts the hook as part of the reference floor; the eidos uses both
to decide the Drawer presentation and whether a row still needs a tooltip.
- **Sub-menus survive icon mode**: while the panel is an icon rail a
`menu-sub` becomes a floating surface anchored to its row's button
(`data-floating`), opened on hover / focus, closed on leave, blur or Escape —

@ -1,13 +1,15 @@
<script lang="ts">
/**
* The nested list. In flow while the panel is expanded; while the panel is
* an icon rail it becomes a floating surface anchored to its row's button
* (`data-floating`), opened by hover / focus and closed by leave, blur or
* Escape — which returns the focus to that button. shadcn simply HIDES
* sub-menus in icon mode, stranding whole branches of the app.
* The nested list. In flow while the panel is expanded — a plain `<ul>`
* nested under its row. While the panel is an icon rail it becomes a
* floating surface anchored to that row's button: the positioning wrapper
* only exists in that mode, so the flow case stays structurally clean.
* Opened by hover / focus, closed by leave, blur or Escape (which returns
* focus to the button). shadcn simply HIDES sub-menus in icon mode.
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { styleToString } from '../../../css';
import { createId } from '$active-uix/id';
import { SidebarMenuSubProvider } from '../sidebar-provider.svelte';
import type { SidebarMenuSubProps } from '../types';
@ -31,12 +33,28 @@
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
const wrapperProps = $derived(provider.wrapperProps);
const wrapperStyle = $derived(
typeof wrapperProps?.style === 'string'
? wrapperProps.style
: styleToString((wrapperProps?.style as Record<string, unknown>) ?? {})
);
</script>
{#if child}
{@render child({ props: mergedProps })}
{#snippet list()}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<ul {...mergedProps}>
{@render children?.()}
</ul>
{/if}
{/snippet}
{#if provider.isFloating && wrapperProps}
<div {...wrapperProps} style={wrapperStyle}>
{@render list()}
</div>
{:else}
<ul {...mergedProps}>
{@render children?.()}
</ul>
{@render list()}
{/if}

@ -16,8 +16,11 @@
let {
ref = $bindable(null),
id = createId(uid, 'sidebar'),
open = $bindable(undefined),
defaultOpen,
defaultOpen = true,
// The bindable holds the state when the consumer does not bind it (the
// house pattern), seeded from `defaultOpen` — which is what an app reads
// from its cookie on the server to avoid a hydration flash.
open = $bindable(defaultOpen),
onOpenChange,
collapsible,
side,
@ -37,7 +40,6 @@
() => open,
(v) => (open = v)
),
defaultOpen: readableActive(() => defaultOpen),
onOpenChange: readableActive(() => onOpenChange),
collapsible: readableActive(() => collapsible),
side: readableActive(() => side),

@ -16,6 +16,14 @@ export { default as MenuBadge } from './components/sidebar-menu-badge.svelte';
export { default as MenuSub } from './components/sidebar-menu-sub.svelte';
export { default as MenuSubButton } from './components/sidebar-menu-sub-button.svelte';
export {
useSidebar,
useSidebarOr,
useSidebarItemOr,
type SidebarState,
type SidebarItemState
} from './use-sidebar';
export type {
SidebarProps as ProviderProps,
SidebarPanelProps as PanelProps,

@ -1,13 +1,20 @@
import { context, type WithRefOpts } from '../../provider';
import { readableActive, state, type Active, type ActiveProps, type State } from '$libs/reactive';
import {
readableActive,
state,
type Active,
type ActiveProps,
type State,
type StateProps
} from '$libs/reactive';
import { untrack } from 'svelte';
import { Soma } from '../../core/soma.svelte';
import { Presence } from '../../layers/presence.svelte';
import {
FloatingProvider,
FloatingContent,
FloatingAnchor,
createFloatingShellRoot
createFloatingShellRoot,
buildFloatingShellWrapperProps
} from '../../layers/floating';
import { sidebarMorfo } from '../../../morfo/components/sidebar';
import { SIDEBAR_LANGS } from './langs';
@ -18,10 +25,13 @@ import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
interface SidebarOpts
extends
WithRefOpts,
/**
* Expanded — WRITABLE. The component's `$bindable` holds it when the
* consumer does not bind (the house pattern: collapsible / popover), so
* the provider writes through in both cases.
*/
StateProps<{ open: boolean }>,
ActiveProps<{
/** Expanded. Controlled when the consumer binds it, else provider-owned. */
open: boolean | undefined;
defaultOpen: boolean | undefined;
onOpenChange: ((open: boolean) => void) | undefined;
collapsible: SidebarCollapsible | undefined;
side: SidebarSide | undefined;
@ -63,16 +73,21 @@ export class SidebarProvider {
return new SidebarProvider(opts);
}
/** Uncontrolled state. Ignored while the consumer drives `open`. */
private readonly internalOpen: State<boolean>;
/** Id of the panel — the Trigger's and Rail's `aria-controls` target. */
readonly panelId: State<string> = state('');
/**
* The mobile presentation has its OWN open state, starting CLOSED. The two
* are not the same fact: on the desktop `open` means «the rail is expanded»
* (a layout preference the app persists), while on mobile it would mean «an
* overlay is covering the page», which must never be the initial state. The
* references keep them separate for the same reason.
*/
private readonly mobileOpenState: State<boolean> = state(false);
private constructor(opts: SidebarOpts) {
this.opts = opts;
this.soma = Soma.require();
this.internalOpen = state(opts.defaultOpen.current ?? true);
this.runtime = this.soma.runtime(sidebarMorfo, {
states: { expanded: () => this.open },
props: {
@ -93,8 +108,10 @@ export class SidebarProvider {
});
}
/** Expanded — the consumer's value when controlled, ours otherwise. */
readonly open = $derived.by(() => this.opts.open.current ?? this.internalOpen.current);
/** Expanded — the drawer's state on mobile, the rail's on the desktop. */
readonly open = $derived.by(() =>
this.mobile ? this.mobileOpenState.current : this.opts.open.current
);
readonly collapsible = $derived.by<SidebarCollapsible>(
() => this.opts.collapsible.current ?? 'offcanvas'
@ -102,9 +119,17 @@ export class SidebarProvider {
readonly side = $derived.by<SidebarSide>(() => this.opts.side.current ?? 'left');
/** Below the breakpoint the panel is a Drawer (the eidos composes it). */
readonly mobile = $derived.by(
() => !this.soma.dom.isAtLeast(this.opts.mobileBreakpoint.current ?? 'md')
);
readonly mobile = $derived.by(() => {
// A server render has NO viewport (width 0), and `isAtLeast` would then
// answer «below every breakpoint» — stamping `data-mobile` into the HTML
// and locking the mobile presentation in, because hydration does not
// diff attributes and the value never changes afterwards. Unknown
// viewport therefore means DESKTOP: the Drawer presentation needs JS to
// be useful anyway, so it can only ever be an enhancement.
const width = this.soma.dom.viewport.width;
if (width <= 0) return false;
return !this.soma.dom.isAtLeast(this.opts.mobileBreakpoint.current ?? 'md');
});
/**
* Collapsed to the icon rail: only in `icon` mode, only while collapsed and
@ -123,10 +148,18 @@ export class SidebarProvider {
() => this.soma.langs.ts(SIDEBAR_LANGS.NAV) || undefined
);
/** Set the expanded state — fires the emerge cue and notifies the consumer. */
/**
* Set the expanded state. On mobile that is the drawer's own state, which
* is deliberately NOT the persisted layout preference — `onOpenChange` (the
* app's persistence hook) only fires for the desktop state.
*/
setOpen(open: boolean): void {
untrack(() => {
if (this.opts.open.current === undefined) this.internalOpen.current = open;
if (this.mobile) {
this.mobileOpenState.current = open;
return;
}
this.opts.open.current = open;
this.opts.onOpenChange.current?.(open);
});
}
@ -363,6 +396,8 @@ export class SidebarMenuItemProvider {
/** The row's button — Escape returns focus here. */
buttonRef: State<HTMLElement | null> = state(null);
subRef: State<HTMLElement | null> = state(null);
/** A `MenuSub` registered under this row (set by its provider). */
hasSub: State<boolean> = state(false);
private readonly subOpenState: State<boolean> = state(false);
private constructor(opts: WithRefOpts) {
@ -442,9 +477,15 @@ export class SidebarMenuButtonProvider {
if (this.item) this.item.buttonRef.current = el;
}
});
// Anchor of this item's floating sub-menu (icon mode).
// Anchor of this item's floating sub-menu (icon mode). Wired DIRECTLY to
// the item's floating provider instead of through `FloatingAnchor`,
// which resolves its provider from context: the eidos wraps this row in
// a `Tooltip` while the rail is collapsed, and the tooltip publishes its
// own floating context — an anchor registered by context would land on
// the tooltip and leave the sub-menu with no reference to position
// against (it parks off-screen at translate(0, -200%)).
if (this.item) {
FloatingAnchor.create({ id: opts.id, ref: readableActive(() => opts.ref.current) });
this.item.floatingProvider.triggerNode = readableActive(() => opts.ref.current);
}
}
@ -500,24 +541,50 @@ export class SidebarMenuSubProvider {
private constructor(opts: WithRefOpts) {
this.provider = SidebarProvider.require();
this.item = SidebarMenuItemProvider.get();
if (this.item) this.item.hasSub.current = true;
this.runtimePart = this.provider.runtime.part('menu-sub', {
id: opts.id,
ref: opts.ref,
states: { open: () => this.open },
props: { floating: () => this.isFloating }
props: { floating: () => this.isFloating },
onRefChange: (el) => {
if (this.item) this.item.subRef.current = el;
}
});
if (this.item) {
// The floating layer needs its full option surface (every field is
// read as an Active). Defaults mirror a menu panel: the sub sits on
// the rail's outer edge, aligned to its row, flipping if it would
// collide, and it only ENGAGES while the rail is collapsed.
this.floating = FloatingContent.create({
id: opts.id,
wrapperId: readableActive(() => `${opts.id.current}-wrapper`),
side: readableActive(() => (this.provider.side === 'left' ? 'right' : 'left')),
align: readableActive(() => 'start' as const),
sideOffset: readableActive(() => 4),
enabled: readableActive(() => this.isFloating)
} as Parameters<typeof FloatingContent.create>[0]);
this.item.subRef = opts.ref;
align: readableActive(() => 'start'),
alignOffset: readableActive(() => 0),
arrowPadding: readableActive(() => 0),
avoidCollisions: readableActive(() => true),
collisionBoundary: readableActive(() => []),
collisionPadding: readableActive(() => 0),
sticky: readableActive(() => 'partial'),
hideWhenDetached: readableActive(() => true),
updatePositionStrategy: readableActive(() => 'optimized'),
strategy: readableActive(() => 'fixed'),
onPlaced: readableActive(() => () => {}),
dir: readableActive(() => this.provider.soma.prefs?.getDir() ?? 'ltr'),
style: readableActive(() => null),
enabled: readableActive(() => this.isFloating),
customAnchor: readableActive(() => null)
});
}
}
/** Positioning wrapper — only rendered while the sub floats. */
readonly wrapperProps = $derived.by(() =>
this.floating ? buildFloatingShellWrapperProps(this.floating) : undefined
);
readonly isFloating = $derived.by(() => !!this.item?.floating);
readonly open = $derived.by(() => this.item?.subOpen ?? true);

@ -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;
}
};
}

@ -21,6 +21,7 @@
import { knobSema } from '$uix/sema/components/knob';
import { maskFieldSema } from '$uix/sema/components/mask-field';
import { navTreeSema } from '$uix/sema/components/nav-tree';
import { sidebarSema } from '$uix/sema/components/sidebar';
import { numberFieldSema } from '$uix/sema/components/number-field';
import { paginationSema } from '$uix/sema/components/pagination';
import { passwordFieldSema } from '$uix/sema/components/password-field';
@ -109,6 +110,7 @@
knobSema,
maskFieldSema,
navTreeSema,
sidebarSema,
numberFieldSema,
paginationSema,
passwordFieldSema,
@ -235,7 +237,8 @@
{ slug: '/uix/components/breadcrumb', label: 'Breadcrumb' },
{ slug: '/uix/components/pagination', label: 'Pagination' },
{ slug: '/uix/components/navigation-menu', label: 'Navigation menu' },
{ slug: '/uix/components/nav-tree', label: 'Nav tree' }
{ slug: '/uix/components/nav-tree', label: 'Nav tree' },
{ slug: '/uix/components/sidebar', label: 'Sidebar' }
]
},
{

@ -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>&lt;main&gt;</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">&lt;{part.defaultElement}&gt;</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>&lt;aside&gt;</code> (complementary) and the scrolling
body a named <code>&lt;nav&gt;</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…
Cancel
Save

Powered by TurnKey Linux.