uix(sidebar): F1.7 fase morfo+soma · contrato de 17 partes + shell headless

Primera tanda del último componente de F1, con el alcance firmado por el
usuario (17 partes · submenú flotante en modo icono · sema emerge · parada
tras morfo+soma). Suelo de paridad: dossier §P4, leído del `sidebar.tsx` real
de shadcn (23 partes); nadie headless lo tiene (Radix/Base/Ark/React-Aria).

Morfo (17 partes, 2 eventos)
- DOS ejes, no uno: `data-state` (expanded|collapsed) + `data-collapsible`
  (offcanvas|icon|none), este último SIEMPRE estampado (shadcn solo lo pone
  colapsado, lo que obliga a cada regla a guardar por estado). Más
  `data-side` y `data-mobile`.
- `panel` (no `sidebar`, que daría `data-sidebar-sidebar`) = `<aside>`
  NOMBRADO; `content` = `<nav>` NOMBRADO, con header y footer FUERA: así el
  landmark nombra la navegación y no el cromo. Ninguna referencia trae
  landmark.
- `rail` es una parte física y un `<button>` REAL en el orden de tabulación
  (shadcn lo deja en tabIndex=-1, inalcanzable por teclado).
- `aria-current="page"` sale del MISMO prop que `data-active`, en
  `menu-button` y en `menu-sub-button`.
- `menu-badge` va DENTRO del control de la fila (doctrina de nav-tree: su
  texto entra en el nombre accesible). `separator` e `input` NO son partes:
  se componen el `Separator` y el `Field` canónicos.

Soma
- Estado controlado/no controlado con las costuras exigidas por el dossier
  desde v1: `open` bindable, `defaultOpen`, `onOpenChange` y `toggle()`
  público — sin ellas, persistencia y atajo global en app-land serían
  rediseño posterior.
- `mobile` sale del breakpoint del SISTEMA (`uix.dom.isAtLeast`), nunca de un
  `matchMedia` propio; soma solo decide el hecho y estampa `data-mobile` — la
  presentación como `Drawer` la compone el eidos (y con ella su foco atrapado,
  bloqueo de scroll y descarte).
- Submenú que SOBREVIVE al modo icono: `menu-sub` se vuelve superficie
  flotante anclada al botón de su fila (`createFloatingShellRoot` + anchor +
  content), se abre por hover/foco y se cierra por salida, blur o Escape
  devolviendo el foco al botón. shadcn los OCULTA y deja ramas enteras
  inalcanzables.
- Dos `partRef` del morfo se resuelven last-write-wins entre instancias, así
  que soma da el id per-instancia (precedente `navigation-menu`):
  `aria-labelledby` del menú → la etiqueta de SU grupo; `aria-controls` del
  trigger/rail → el id del panel.

Sema: pack propio con `emerge.soft` / `emerge.exit.soft` sobre `panel`,
espejando nav-tree y collapsible. Langs: 4 textos (label, nav, trigger, rail).

Verificado: `morfo:check` carga el contrato sin invariantes rotas ·
`component:audit --only sidebar` **PASS** (0E/0W) · `svelte-check` 0 errores
en estos archivos · `contracts.test` solo con los 2 fallos ajenos conocidos
(menubar DOM-write, radio-group data-ready).

Pendiente de la siguiente tanda: eidos (raíl, anchos tokenizados, modo icono,
Drawer móvil), demo y review adversarial.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent ef6bd13be3
commit 2bc5fe6bd2

@ -88,6 +88,7 @@ import { rotateAlignLangs } from './rotate-align';
import { scrollAreaLangs } from './scroll-area';
import { searchFieldLangs } from './search-field';
import { selectLangs } from './select';
import { sidebarLangs } from './sidebar';
import { skeletonLangs } from './skeleton';
import { sliderLangs } from './slider';
import { splitButtonLangs } from './split-button';
@ -212,6 +213,7 @@ export const componentLangs = {
'scroll-area': scrollAreaLangs,
'search-field': searchFieldLangs,
select: selectLangs,
sidebar: sidebarLangs,
skeleton: skeletonLangs,
slider: sliderLangs,
'split-button': splitButtonLangs,

@ -0,0 +1,20 @@
import type { LangNode } from '$libs/langs';
export const sidebarLangs = {
label: {
es: 'Barra lateral',
en: 'Sidebar'
},
nav: {
es: 'Navegación principal',
en: 'Main navigation'
},
trigger: {
es: 'Alternar barra lateral',
en: 'Toggle sidebar'
},
rail: {
es: 'Redimensionar barra lateral',
en: 'Resize sidebar'
}
} satisfies LangNode;

@ -0,0 +1,424 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* Sidebar — the vertical navigation shell of an APPLICATION (app rail with
* groups, sections and a collapsed icon mode), F1.7 of the blocks tier. It is
* the chrome around a page: the `provider` lays out the panel next to the
* `inset` (the page's own region), and the panel collapses either off-canvas
* or down to an icon rail.
*
* NOT `nav-tree`. `nav-tree` (F1.8) is the DATA-DRIVEN documentation tree —
* arbitrary depth, active trail auto-expanded, one prop for the whole tree. The
* sidebar is COMPOSITIONAL app chrome with a fixed shallow anatomy (group →
* menu → item → sub). A docs shell composes `NavTree` INSIDE this sidebar's
* `content`; neither absorbs the other (E-1, scope-approval 2026-07-21).
*
* Reference floor: `RESEARCH-blocks-references.md` §P4, read from shadcn's real
* `sidebar.tsx` (23 parts + hook). Nobody headless ships this component
* (Radix / Base / Ark / React-Aria all confirmed absent) — shadcn is the de
* facto standard, so the floor is its anatomy, not a headless contract.
*
* TWO AXES, never one (the dossier's correction to our first sketch):
* - `data-state` — `expanded | collapsed`, the live state.
* - `data-collapsible` — `offcanvas | icon | none`, the MODE that decides
* what «collapsed» means. shadcn stamps this only
* while collapsed; we stamp it always, so the recipe
* can style the mode without a state guard.
* Plus `data-side` (`left | right`). `rail` is a physical PART (the thin
* grab-strip on the border), never a state.
*
* A11y leadership (dossier §P4 — every reference falls short): shadcn ships no
* landmark, no `aria-current`, and a rail unreachable by keyboard. Here the
* panel is a named `<aside>` (complementary), the scrolling body is a named
* `<nav>`, `aria-current="page"` comes from the SAME prop that stamps
* `data-active`, and the rail is a real `<button>` in the tab order.
*
* Membership: soma (open/collapsed state + the mobile Drawer swap + the
* floating sub-menu in icon mode) + sema (the collapse toggle is a canonical
* `emerge`, mirroring nav-tree / collapsible) + eidos (rail, widths, icon
* mode). Widths are TOKENS (`--sidebar-width`, `--sidebar-width-icon`), never
* TS constants. `apg: disclosure` — a navigation region behind a disclosure
* button; no WAI-ARIA widget pattern applies.
*/
export const sidebarMorfo = {
name: 'Sidebar',
kebab: 'sidebar',
scope: ['soma', 'sema', 'eidos'],
// Perceptual defaults resolved by the pack at
// `src/uix/sema/components/sidebar.ts` — soft emerge on expand, soft exit
// on collapse (the disclosure signature shared with nav-tree /
// collapsible). Navigating a menu item is native and unsonified.
expression: 'pack',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/ (Disclosure Navigation) — a named navigation region behind a disclosure button; no WAI-ARIA widget pattern applies',
texts: {
label: '#?components.sidebar.label|Sidebar',
nav: '#?components.sidebar.nav|Main navigation',
trigger: '#?components.sidebar.trigger|Toggle sidebar',
rail: '#?components.sidebar.rail|Resize sidebar'
},
events: [
{
// The panel reveals itself — book cap. 26 `emerge.expand`, the same
// signature as nav-tree's group and collapsible's content. Target is
// the panel (the surface that appears), so the `data-event-*` stamp
// and the pack selector land there.
name: 'emerge-expand',
semantic: {
family: 'emerge',
verb: 'expand',
target: v.partRef('panel'),
sequence: 'post'
}
},
{
// The panel folds away (off-canvas or down to the icon rail).
name: 'emerge-collapse',
semantic: {
family: 'emerge',
verb: 'collapse',
target: v.partRef('panel'),
sequence: 'post'
}
}
],
parts: [
{
// The shell. A REAL element (not virtual): the panel and the inset
// are siblings, so they need a common parent to lay out and a single
// place where the two axes live — the recipe positions the inset from
// `[data-sidebar][data-state]`, not from the panel.
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
states: ['expanded', 'collapsed'],
data: [
{
attr: 'data-state',
values: ['expanded', 'collapsed'],
value: v.stateRef('expanded')
},
{
// The MODE, always stamped (shadcn only stamps it while
// collapsed, which forces every rule to guard on state).
attr: 'data-collapsible',
values: ['offcanvas', 'icon', 'none'],
value: v.propRef('collapsible')
},
{ attr: 'data-side', values: ['left', 'right'], value: v.propRef('side') },
// Present while the panel is rendered as a Drawer (below the
// breakpoint). The recipe drops the desktop layout wholesale.
{ attr: 'data-mobile', value: v.propRef('mobile'), severity: 'optional' }
],
aria: []
},
{
// The panel itself — a named complementary landmark. `Panel`, not
// `Sidebar`, so the marker reads `data-sidebar-panel` instead of the
// stuttering `data-sidebar-sidebar`.
name: 'Panel',
kebab: 'panel',
kind: 'public',
defaultElement: 'aside',
optional: false,
states: ['expanded', 'collapsed'],
data: [
{
attr: 'data-state',
values: ['expanded', 'collapsed'],
value: v.stateRef('expanded')
}
],
aria: [
{
attr: 'aria-label',
value: v.propRef('ariaLabel'),
severity: 'recommended'
}
]
},
{
// The disclosure button (usually in a page header, outside the panel).
// Composes `Button` through the eidos child pattern.
name: 'Trigger',
kebab: 'trigger',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
states: ['expanded', 'collapsed'],
data: [
{
attr: 'data-state',
values: ['expanded', 'collapsed'],
value: v.stateRef('expanded')
}
],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-expanded', value: v.stateRef('expanded') },
{
attr: 'aria-controls',
value: v.partRef('panel'),
condition: { when: 'part-present', part: 'panel' },
severity: 'recommended'
},
{
attr: 'aria-label',
value: v.translationRef('#?components.sidebar.trigger|Toggle sidebar'),
severity: 'recommended'
}
]
},
{
// The grab-strip on the panel's border. shadcn makes it `tabIndex=-1`
// (unreachable by keyboard); here it is a REAL button in the tab
// order with its own name — the same toggle, reachable by everyone.
name: 'Rail',
kebab: 'rail',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
states: ['expanded', 'collapsed'],
data: [
{
attr: 'data-state',
values: ['expanded', 'collapsed'],
value: v.stateRef('expanded')
},
{ attr: 'data-side', values: ['left', 'right'], value: v.propRef('side') }
],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-expanded', value: v.stateRef('expanded') },
{
attr: 'aria-controls',
value: v.partRef('panel'),
condition: { when: 'part-present', part: 'panel' },
severity: 'recommended'
},
{
attr: 'aria-label',
value: v.translationRef('#?components.sidebar.rail|Resize sidebar'),
severity: 'recommended'
}
]
},
{
// The page's own region, sibling of the panel. `<main>` because the
// sidebar is chrome and this is the document's main content.
name: 'Inset',
kebab: 'inset',
kind: 'public',
defaultElement: 'main',
optional: true,
data: [],
aria: []
},
{
// Fixed top region of the panel (brand, workspace switcher).
name: 'Header',
kebab: 'header',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// The scrolling body — the second named landmark. Header and footer
// stay OUTSIDE it, so the `<nav>` names exactly the navigation.
name: 'Content',
kebab: 'content',
kind: 'public',
defaultElement: 'nav',
optional: true,
data: [],
aria: [
{
attr: 'aria-label',
value: v.propRef('navLabel'),
severity: 'recommended'
}
]
},
{
// Fixed bottom region (user menu, secondary links).
name: 'Footer',
kebab: 'footer',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// A titled section of the content.
name: 'Group',
kebab: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// The section's title. Not a heading element: it names the menu via
// `aria-labelledby`, and a heading here would inject a level into the
// page outline that the app does not control.
name: 'GroupLabel',
kebab: 'group-label',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
// The list of rows. Named by its GroupLabel when there is one.
name: 'Menu',
kebab: 'menu',
kind: 'public',
defaultElement: 'ul',
optional: true,
data: [],
aria: [
{
attr: 'aria-labelledby',
value: v.partRef('group-label'),
condition: { when: 'part-present', part: 'group-label' },
severity: 'recommended'
}
]
},
{
// The row's positioner. Separate from MenuButton on purpose (dossier
// §P4): without the split there is nowhere to hang the action, the
// badge or the sub-menu.
name: 'MenuItem',
kebab: 'menu-item',
kind: 'public',
defaultElement: 'li',
optional: true,
data: [],
aria: []
},
{
// The interactive row: a link when it navigates, a button when it
// only opens a sub-menu. `aria-current="page"` comes from the SAME
// prop that stamps `data-active` (no reference does this), so the
// visual and the announced state cannot drift. In icon mode it is the
// anchor of both the tooltip and the floating sub-menu.
name: 'MenuButton',
kebab: 'menu-button',
kind: 'public',
defaultElement: 'a',
optional: true,
data: [
{ attr: 'data-active', value: v.propRef('active'), severity: 'optional' },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }
],
aria: [
{
attr: 'aria-current',
value: v.literal('page'),
severity: 'recommended',
condition: { when: 'prop-truthy', prop: 'active' }
},
{
attr: 'aria-disabled',
value: v.literal('true'),
severity: 'recommended',
condition: { when: 'prop-truthy', prop: 'disabled' }
}
]
},
{
// Secondary control on a row (⋯ menu, add, pin). Its own button, so
// the row keeps ONE primary destination — nesting it inside the
// MenuButton would make an anchor with a button inside.
name: 'MenuAction',
kebab: 'menu-action',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [],
aria: [
{ attr: 'type', value: v.literal('button') },
{
attr: 'aria-label',
value: v.propRef('actionLabel'),
severity: 'recommended'
}
]
},
{
// Trailing count / status chip. Display part → NO archetype. It sits
// INSIDE the MenuButton so its text joins the row's accessible name
// («Inbox, 12, link») — the nav-tree badge doctrine.
name: 'MenuBadge',
kebab: 'menu-badge',
kind: 'public',
defaultElement: 'span',
optional: true,
data: [],
aria: []
},
{
// The nested list. Rendered in flow while the panel is expanded; in
// icon mode the soma floats it beside the rail anchored to its
// MenuButton (`data-floating`) — shadcn simply HIDES sub-menus there,
// which strands entire branches of the app.
name: 'MenuSub',
kebab: 'menu-sub',
kind: 'public',
defaultElement: 'ul',
optional: true,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{ attr: 'data-floating', value: v.propRef('floating'), severity: 'optional' }
],
aria: [],
keyboard: [
// Only meaningful while floating; in flow there is nothing to
// dismiss and the soma ignores it.
{ key: 'Escape', action: 'close-sub' }
]
},
{
// A row inside the sub-menu. Same active contract as MenuButton.
name: 'MenuSubButton',
kebab: 'menu-sub-button',
kind: 'public',
defaultElement: 'a',
optional: true,
data: [
{ attr: 'data-active', value: v.propRef('active'), severity: 'optional' },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }
],
aria: [
{
attr: 'aria-current',
value: v.literal('page'),
severity: 'recommended',
condition: { when: 'prop-truthy', prop: 'active' }
},
{
attr: 'aria-disabled',
value: v.literal('true'),
severity: 'recommended',
condition: { when: 'prop-truthy', prop: 'disabled' }
}
]
}
]
} as const satisfies Morfo;

@ -57,6 +57,7 @@ export { ratingGroupSema } from './rating-group';
export { rotateAlignSema } from './rotate-align';
export { searchFieldSema } from './search-field';
export { selectSema } from './select';
export { sidebarSema } from './sidebar';
export { sliderSema } from './slider';
export { splitterSema } from './splitter';
export { stepperSema } from './stepper';

@ -0,0 +1,37 @@
import { semaSelector } from '$uix/morfo';
import { sidebarMorfo } from '$uix/morfo/components/sidebar';
import { soundTuning } from '../sounds';
import type { Sema } from '../sema-map';
/**
* Sidebar perceptual defaults — SOFT EMERGE, the disclosure signature shared
* with nav-tree / collapsible / accordion.
*
* The sidebar's only sonified surface is the panel's disclosure (from the
* Trigger, from the Rail or from the app's own shortcut — all three go through
* `provider.toggle()`, so all three sound the same):
* - `emerge-expand` on `panel` — `emerge.soft` (the panel returns).
* - `emerge-collapse` on `panel` — `emerge.exit.soft`, the descending
* direction discipline shared with menus / dialogs / drawers.
*
* No haptic: collapsing the app chrome is a deliberate pointer or keyboard
* gesture on a trigger; tactile feedback on top would be noise. Navigating a
* row is native and carries no cue at all.
*/
const onPanel = (matchers?: Parameters<typeof semaSelector<typeof sidebarMorfo>>[2]) =>
semaSelector(sidebarMorfo, 'panel', matchers);
export const sidebarSema: Sema = {
name: 'sidebar',
cascade: [
{
selector: onPanel({ eventName: 'emerge-expand' }),
sound: soundTuning('emerge.soft')
},
{
selector: onPanel({ eventName: 'emerge-collapse' }),
sound: soundTuning('emerge.exit.soft')
}
]
};

@ -71,6 +71,7 @@ export * as RotateAlign from './rotate-align';
export * as ScrollArea from './scroll-area';
export * as SearchField from './search-field';
export * as Select from './select';
export * as Sidebar from './sidebar';
export * as Slider from './slider';
export * as Splitter from './splitter';
export * as Stepper from './stepper';

@ -0,0 +1,60 @@
# Sidebar (soma)
Headless application sidebar: the shell that lays a collapsible panel beside
the page's own region, with groups, rows, actions, badges and sub-menus. Built
as F1.7 of the blocks tier — the app chrome the app-shell blocks compose.
**Not `nav-tree`.** `nav-tree` is the data-driven documentation tree (arbitrary
depth, active trail auto-expanded, the whole tree in one prop). This is
compositional app chrome with a shallow fixed anatomy. A docs shell composes
`NavTree` INSIDE this sidebar's `Content`; neither absorbs the other.
## The contract
- **Two axes, never one** (the dossier's correction): `data-state`
(`expanded | collapsed`) is the live state, `data-collapsible`
(`offcanvas | icon | none`) is the MODE that decides what collapsing means.
Both live on the provider, plus `data-side` and `data-mobile`.
`data-collapsible` is stamped ALWAYS — shadcn stamps it only while collapsed,
which forces every rule to guard on state.
- **Parts** (17): `provider` (shell) + `panel` (`<aside>` landmark) + `trigger`
+ `rail` + `inset` (`<main>`) + `header` + `content` (`<nav>` landmark) +
`footer` + `group` + `group-label` + `menu` (`<ul>`) + `menu-item` (`<li>`) +
`menu-button` + `menu-action` + `menu-badge` + `menu-sub` +
`menu-sub-button`. `separator` and the search input are NOT parts: they
compose the canonical `Separator` and `Field`.
- **Two named landmarks**: the panel is a named `<aside>` (complementary) and
the scrolling body a named `<nav>`, so header and footer stay outside the
navigation. No reference ships either.
- **`aria-current="page"` from the same prop as `data-active`** on both
`menu-button` and `menu-sub-button` — what is seen and what is announced
cannot drift.
- **The rail is a real button** in the tab order with its own localized name
(shadcn's is `tabIndex=-1`, unreachable by keyboard).
- **Seams from v1**: `open` (bindable) / `defaultOpen` / `onOpenChange` and the
provider's `toggle()`. Persistence (a cookie read on the server) and a global
shortcut belong to app-land, and they are only possible because these exist.
- **Mobile is a system decision**: `mobile` derives from
`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.
- **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 —
which returns focus to the button. shadcn simply hides sub-menus there,
stranding whole branches of the app.
## Cross-instance ids
Two morfo `partRef`s resolve last-write-wins across repeated parts, so soma
supplies the per-instance value (the `navigation-menu` precedent):
- `menu`'s `aria-labelledby` → its OWN group's label id (via the group context).
- `trigger` / `rail`'s `aria-controls` → the panel id published by the panel.
## Sema events
`emerge-expand` / `emerge-collapse` on the `panel` part — the disclosure
signature shared with nav-tree and collapsible. Navigating a row is native and
unsonified.

@ -0,0 +1,42 @@
<script lang="ts">
/**
* The scrolling body — the second named landmark (`<nav aria-label>`).
* Header and footer stay OUTSIDE it on purpose, so the `<nav>` names
* exactly the navigation and not the panel's chrome.
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SidebarContentProvider } from '../sidebar-provider.svelte';
import type { SidebarContentProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sidebar-content'),
'aria-label': ariaLabel,
children,
child,
...restProps
}: SidebarContentProps = $props();
const provider = SidebarContentProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
ariaLabel: readableActive(() => ariaLabel)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<nav {...mergedProps}>
{@render children?.()}
</nav>
{/if}

@ -0,0 +1,36 @@
<script lang="ts">
/** Fixed bottom region of the panel (user menu, secondary links). */
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SidebarRegionProvider } from '../sidebar-provider.svelte';
import type { SidebarSectionProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sidebar-footer'),
children,
child,
...restProps
}: SidebarSectionProps = $props();
const provider = SidebarRegionProvider.create('footer', {
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,40 @@
<script lang="ts">
/**
* The section's title. Deliberately NOT a heading element: it names the
* group's menu through `aria-labelledby`, and a heading here would inject a
* level into a page outline the sidebar does not own.
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SidebarGroupLabelProvider } from '../sidebar-provider.svelte';
import type { SidebarSectionProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sidebar-group-label'),
children,
child,
...restProps
}: SidebarSectionProps = $props();
const provider = SidebarGroupLabelProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,40 @@
<script lang="ts">
/**
* A titled section of the content. Publishes itself on context so its own
* `GroupLabel` and `Menu` can wire `aria-labelledby` to EACH OTHER (a morfo
* partRef would resolve last-write-wins across sibling groups).
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SidebarGroupProvider } from '../sidebar-provider.svelte';
import type { SidebarSectionProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sidebar-group'),
children,
child,
...restProps
}: SidebarSectionProps = $props();
const provider = SidebarGroupProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,36 @@
<script lang="ts">
/** Fixed top region of the panel (brand, workspace switcher). */
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SidebarRegionProvider } from '../sidebar-provider.svelte';
import type { SidebarSectionProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sidebar-header'),
children,
child,
...restProps
}: SidebarSectionProps = $props();
const provider = SidebarRegionProvider.create('header', {
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,39 @@
<script lang="ts">
/**
* The page's own region, sibling of the panel. `<main>`: the sidebar is
* chrome, this is the document's main content.
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SidebarRegionProvider } from '../sidebar-provider.svelte';
import type { SidebarSectionProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sidebar-inset'),
children,
child,
...restProps
}: SidebarSectionProps = $props();
const provider = SidebarRegionProvider.create('inset', {
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<main {...mergedProps}>
{@render children?.()}
</main>
{/if}

@ -0,0 +1,42 @@
<script lang="ts">
/**
* Secondary control on a row (⋯ menu, add, pin). Its own button, sibling of
* the MenuButton — nesting it inside would put a button inside an anchor.
* It has no text of its own, so `aria-label` is required in practice.
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SidebarMenuActionProvider } from '../sidebar-provider.svelte';
import type { SidebarMenuActionProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sidebar-menu-action'),
'aria-label': ariaLabel,
children,
child,
...restProps
}: SidebarMenuActionProps = $props();
const provider = SidebarMenuActionProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
ariaLabel: readableActive(() => ariaLabel)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,40 @@
<script lang="ts">
/**
* Trailing count / status chip. It belongs INSIDE the MenuButton so its
* text joins the row's accessible name («Inbox, 12, link») instead of
* floating beside it as an unannounced decoration (nav-tree's doctrine).
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SidebarRegionProvider } from '../sidebar-provider.svelte';
import type { SidebarSectionProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sidebar-menu-badge'),
children,
child,
...restProps
}: SidebarSectionProps = $props();
const provider = SidebarRegionProvider.create('menu-badge', {
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.()}
</span>
{/if}

@ -0,0 +1,52 @@
<script lang="ts">
/**
* The interactive row: an `<a>` when it navigates, a `<button>` when it only
* opens a sub-menu. `aria-current="page"` and `data-active` come from the
* SAME `active` prop, so what is seen and what is announced cannot drift. A
* disabled row keeps its place but loses its `href` — nothing to follow by
* pointer, keyboard or context menu.
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SidebarMenuButtonProvider } from '../sidebar-provider.svelte';
import type { SidebarMenuButtonProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sidebar-menu-button'),
href,
active,
disabled,
children,
child,
...restProps
}: SidebarMenuButtonProps = $props();
const provider = SidebarMenuButtonProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
href: readableActive(() => href),
active: readableActive(() => active),
disabled: readableActive(() => disabled)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else if href !== undefined}
<a {...mergedProps}>
{@render children?.()}
</a>
{:else}
<button type="button" {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,40 @@
<script lang="ts">
/**
* A row's positioner AND the owner of its sub-menu presentation: it runs
* the floating shell that lets the sub-menu appear beside the icon rail
* when the panel is collapsed (in flow otherwise).
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SidebarMenuItemProvider } from '../sidebar-provider.svelte';
import type { SidebarSectionProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sidebar-menu-item'),
children,
child,
...restProps
}: SidebarSectionProps = $props();
const provider = SidebarMenuItemProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<li {...mergedProps}>
{@render children?.()}
</li>
{/if}

@ -0,0 +1,46 @@
<script lang="ts">
/** A row inside the sub-menu. Same active/disabled contract as MenuButton. */
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SidebarMenuSubButtonProvider } from '../sidebar-provider.svelte';
import type { SidebarMenuSubButtonProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sidebar-menu-sub-button'),
href,
active,
disabled,
children,
child,
...restProps
}: SidebarMenuSubButtonProps = $props();
const provider = SidebarMenuSubButtonProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
href: readableActive(() => href),
active: readableActive(() => active),
disabled: readableActive(() => disabled)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else if href !== undefined}
<a {...mergedProps}>
{@render children?.()}
</a>
{:else}
<button type="button" {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,42 @@
<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.
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SidebarMenuSubProvider } from '../sidebar-provider.svelte';
import type { SidebarMenuSubProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sidebar-menu-sub'),
children,
child,
...restProps
}: SidebarMenuSubProps = $props();
const provider = SidebarMenuSubProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<ul {...mergedProps}>
{@render children?.()}
</ul>
{/if}

@ -0,0 +1,36 @@
<script lang="ts">
/** The list of rows, named by its group's label when there is one. */
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SidebarMenuProvider } from '../sidebar-provider.svelte';
import type { SidebarSectionProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sidebar-menu'),
children,
child,
...restProps
}: SidebarSectionProps = $props();
const provider = SidebarMenuProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<ul {...mergedProps}>
{@render children?.()}
</ul>
{/if}

@ -0,0 +1,42 @@
<script lang="ts">
/**
* The panel — a NAMED complementary landmark (`<aside aria-label>`). No
* reference ships a landmark here; without one the whole app rail is an
* anonymous region a screen-reader user cannot jump to.
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SidebarPanelProvider } from '../sidebar-provider.svelte';
import type { SidebarPanelProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sidebar-panel'),
'aria-label': ariaLabel,
children,
child,
...restProps
}: SidebarPanelProps = $props();
const provider = SidebarPanelProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
ariaLabel: readableActive(() => ariaLabel)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<aside {...mergedProps}>
{@render children?.()}
</aside>
{/if}

@ -0,0 +1,40 @@
<script lang="ts">
/**
* The grab-strip on the panel's border — the same toggle as the Trigger, at
* the edge. A REAL button in the tab order with its own localized name;
* shadcn's is `tabIndex=-1`, so keyboard users simply cannot reach it.
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SidebarRailProvider } from '../sidebar-provider.svelte';
import type { SidebarRailProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sidebar-rail'),
children,
child,
...restProps
}: SidebarRailProps = $props();
const provider = SidebarRailProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,40 @@
<script lang="ts">
/**
* The disclosure button. Usually lives in the page header, OUTSIDE the
* panel it controls — hence `aria-controls` + `aria-expanded`. Inert while
* `collapsible='none'`.
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SidebarTriggerProvider } from '../sidebar-provider.svelte';
import type { SidebarTriggerProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sidebar-trigger'),
children,
child,
...restProps
}: SidebarTriggerProps = $props();
const provider = SidebarTriggerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,56 @@
<script lang="ts">
/**
* Headless `<Sidebar>` — the shell that lays the panel beside the inset and
* owns the two axes (`data-state` expanded/collapsed + `data-collapsible`
* offcanvas/icon/none) plus `data-side` and `data-mobile`. Everything below
* it reads this provider from context.
*/
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { SidebarProvider } from '../sidebar-provider.svelte';
import type { SidebarProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'sidebar'),
open = $bindable(undefined),
defaultOpen,
onOpenChange,
collapsible,
side,
mobileBreakpoint,
children,
child,
...restProps
}: SidebarProps = $props();
const provider = SidebarProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
open: writableActive(
() => open,
(v) => (open = v)
),
defaultOpen: readableActive(() => defaultOpen),
onOpenChange: readableActive(() => onOpenChange),
collapsible: readableActive(() => collapsible),
side: readableActive(() => side),
mobileBreakpoint: readableActive(() => mobileBreakpoint)
});
const mergedProps = $derived(mergeProps(restProps, provider.providerProps));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,33 @@
export { default as Provider } from './components/sidebar.svelte';
export { default as Panel } from './components/sidebar-panel.svelte';
export { default as Trigger } from './components/sidebar-trigger.svelte';
export { default as Rail } from './components/sidebar-rail.svelte';
export { default as Inset } from './components/sidebar-inset.svelte';
export { default as Header } from './components/sidebar-header.svelte';
export { default as Content } from './components/sidebar-content.svelte';
export { default as Footer } from './components/sidebar-footer.svelte';
export { default as Group } from './components/sidebar-group.svelte';
export { default as GroupLabel } from './components/sidebar-group-label.svelte';
export { default as Menu } from './components/sidebar-menu.svelte';
export { default as MenuItem } from './components/sidebar-menu-item.svelte';
export { default as MenuButton } from './components/sidebar-menu-button.svelte';
export { default as MenuAction } from './components/sidebar-menu-action.svelte';
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 type {
SidebarProps as ProviderProps,
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,
SidebarSnippetProps
} from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,7 @@
/** Idlangref constants for the Sidebar component. */
export const SIDEBAR_LANGS = {
LABEL: '#?components.sidebar.label|Sidebar',
NAV: '#?components.sidebar.nav|Main navigation',
TRIGGER: '#?components.sidebar.trigger|Toggle sidebar',
RAIL: '#?components.sidebar.rail|Resize sidebar'
} as const;

@ -0,0 +1,568 @@
import { context, type WithRefOpts } from '../../provider';
import { readableActive, state, type Active, type ActiveProps, type State } from '$libs/reactive';
import { untrack } from 'svelte';
import { Soma } from '../../core/soma.svelte';
import { Presence } from '../../layers/presence.svelte';
import {
FloatingProvider,
FloatingContent,
FloatingAnchor,
createFloatingShellRoot
} from '../../layers/floating';
import { sidebarMorfo } from '../../../morfo/components/sidebar';
import { SIDEBAR_LANGS } from './langs';
import type { SidebarCollapsible, SidebarSide } from './types';
import type { Breakpoint } from '$adom';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
interface SidebarOpts
extends
WithRefOpts,
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;
mobileBreakpoint: Breakpoint | undefined;
}> {}
/**
* Headless `Sidebar` provider — the shell that lays the panel next to the
* inset and owns the TWO axes the dossier demands: the live `expanded |
* collapsed` state and the `offcanvas | icon | none` MODE that decides what
* collapsing means.
*
* Controlled / uncontrolled: `open` wins when the consumer supplies it (bound
* or paired with `onOpenChange`); otherwise the provider holds the state,
* seeded from `defaultOpen`. `toggle()` / `setOpen()` are public because
* persistence (a cookie read on the server) and a global shortcut belong to
* app-land — the seams must exist from v1 or those become a redesign.
*
* Mobile: `mobile` is derived from the SYSTEM breakpoint tracker
* (`uix.dom.isAtLeast`), never a component-local `matchMedia`. Soma only
* decides the fact and stamps `data-mobile`; presenting the panel as a
* `Drawer` below that breakpoint is the eidos's composition (the Drawer's own
* behaviour — focus trap, scroll lock, dismiss — comes with it).
*/
export class SidebarProvider {
readonly opts: SidebarOpts;
readonly soma: Soma;
readonly runtime: SomaRuntime;
readonly providerPart: SomaRuntimePart;
static readonly ctx = context<SidebarProvider>('Sidebar');
static get(): SidebarProvider | undefined {
return this.ctx.getOr(undefined) as SidebarProvider | undefined;
}
static require(): SidebarProvider {
return this.ctx.get();
}
static create(opts: SidebarOpts) {
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('');
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: {
collapsible: () => this.collapsible,
side: () => this.side,
mobile: () => this.mobile
},
events: {
'emerge-expand': () => this.setOpen(true),
'emerge-collapse': () => this.setOpen(false)
}
});
this.providerPart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: SidebarProvider.ctx
});
}
/** Expanded — the consumer's value when controlled, ours otherwise. */
readonly open = $derived.by(() => this.opts.open.current ?? this.internalOpen.current);
readonly collapsible = $derived.by<SidebarCollapsible>(
() => this.opts.collapsible.current ?? 'offcanvas'
);
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')
);
/**
* Collapsed to the icon rail: only in `icon` mode, only while collapsed and
* only on the desktop presentation (on mobile the panel is a Drawer, which
* is either open or absent — there is no rail to collapse to).
*/
readonly iconMode = $derived.by(
() => this.collapsible === 'icon' && !this.open && !this.mobile
);
/** Landmark names — explicit props win, else the localized defaults. */
readonly resolvedLabel: Active<string | undefined> = readableActive(
() => this.soma.langs.ts(SIDEBAR_LANGS.LABEL) || undefined
);
readonly resolvedNavLabel: Active<string | undefined> = readableActive(
() => this.soma.langs.ts(SIDEBAR_LANGS.NAV) || undefined
);
/** Set the expanded state — fires the emerge cue and notifies the consumer. */
setOpen(open: boolean): void {
untrack(() => {
if (this.opts.open.current === undefined) this.internalOpen.current = open;
this.opts.onOpenChange.current?.(open);
});
}
/**
* Flip the panel. Public: the app's global shortcut and its persisted state
* drive the sidebar through here. Inert in `collapsible: 'none'`.
*/
toggle(): void {
if (this.collapsible === 'none') return;
void this.runtime.trigger(this.open ? 'emerge-collapse' : 'emerge-expand');
}
readonly providerProps = $derived.by(() =>
this.providerPart.assert({ ...this.providerPart.renderProps() } as const)
);
}
// ── Panel · Trigger · Rail ────────────────────────────────────────────────────
/** The complementary landmark. Publishes its id as the disclosure target. */
export class SidebarPanelProvider {
readonly opts: WithRefOpts & ActiveProps<{ ariaLabel: string | undefined }>;
readonly provider: SidebarProvider;
readonly runtimePart: SomaRuntimePart;
static create(opts: SidebarPanelProvider['opts']) {
return new SidebarPanelProvider(opts);
}
private constructor(opts: SidebarPanelProvider['opts']) {
this.opts = opts;
this.provider = SidebarProvider.require();
this.runtimePart = this.provider.runtime.part('panel', {
id: opts.id,
ref: opts.ref,
props: { ariaLabel: () => this.label }
});
// The Trigger's / Rail's `aria-controls` resolves from here, not from the
// morfo's partRef: with several panels a partRef is last-write-wins
// (navigation-menu's precedent for the same problem).
this.provider.panelId.current = opts.id.current;
}
readonly label = $derived.by(
() => this.opts.ariaLabel.current || this.provider.resolvedLabel.current
);
readonly props = $derived.by(() => this.runtimePart.renderProps());
}
/** Shared behaviour of the two disclosure controls (Trigger and Rail). */
class SidebarToggleProvider {
readonly opts: WithRefOpts;
readonly provider: SidebarProvider;
readonly runtimePart: SomaRuntimePart;
protected constructor(part: 'trigger' | 'rail', opts: WithRefOpts) {
this.opts = opts;
this.provider = SidebarProvider.require();
this.runtimePart = this.provider.runtime.part(part, { id: opts.id, ref: opts.ref });
}
readonly props = $derived.by(() => ({
...this.runtimePart.renderProps(),
// Per-instance target id — see the note in SidebarPanelProvider.
'aria-controls': this.provider.panelId.current || undefined,
disabled: this.provider.collapsible === 'none' || undefined,
onclick: () => this.provider.toggle()
}));
}
export class SidebarTriggerProvider extends SidebarToggleProvider {
static create(opts: WithRefOpts) {
return new SidebarTriggerProvider('trigger', opts);
}
}
export class SidebarRailProvider extends SidebarToggleProvider {
static create(opts: WithRefOpts) {
return new SidebarRailProvider('rail', opts);
}
}
// ── Structural regions ────────────────────────────────────────────────────────
/**
* The regions that carry no behaviour of their own — they exist so the recipe
* has a marker and the anatomy is declared. One class instead of four
* identical ones (four consumers, no branching).
*/
export class SidebarRegionProvider {
readonly provider: SidebarProvider;
readonly runtimePart: SomaRuntimePart;
static create(part: 'inset' | 'header' | 'footer' | 'menu-badge', opts: WithRefOpts) {
return new SidebarRegionProvider(part, opts);
}
private constructor(part: string, opts: WithRefOpts) {
this.provider = SidebarProvider.require();
this.runtimePart = this.provider.runtime.part(part, { id: opts.id, ref: opts.ref });
}
readonly props = $derived.by(() => this.runtimePart.renderProps());
}
/** The scrolling body — the `<nav>` landmark, named apart from the panel. */
export class SidebarContentProvider {
readonly opts: WithRefOpts & ActiveProps<{ ariaLabel: string | undefined }>;
readonly provider: SidebarProvider;
readonly runtimePart: SomaRuntimePart;
static create(opts: SidebarContentProvider['opts']) {
return new SidebarContentProvider(opts);
}
private constructor(opts: SidebarContentProvider['opts']) {
this.opts = opts;
this.provider = SidebarProvider.require();
this.runtimePart = this.provider.runtime.part('content', {
id: opts.id,
ref: opts.ref,
props: { navLabel: () => this.label }
});
}
readonly label = $derived.by(
() => this.opts.ariaLabel.current || this.provider.resolvedNavLabel.current
);
readonly props = $derived.by(() => this.runtimePart.renderProps());
}
// ── Group · GroupLabel · Menu ────────────────────────────────────────────────
/** A titled section. Holds the label's id so ITS menu can point at it. */
export class SidebarGroupProvider {
readonly provider: SidebarProvider;
readonly runtimePart: SomaRuntimePart;
/** Id of this group's label — empty while the group has none. */
readonly labelId: State<string> = state('');
static readonly ctx = context<SidebarGroupProvider>('SidebarGroup');
static get(): SidebarGroupProvider | undefined {
return this.ctx.getOr(undefined) as SidebarGroupProvider | undefined;
}
static create(opts: WithRefOpts) {
return new SidebarGroupProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.provider = SidebarProvider.require();
this.runtimePart = this.provider.runtime.part('group', {
id: opts.id,
ref: opts.ref,
owner: this,
context: SidebarGroupProvider.ctx
});
}
readonly props = $derived.by(() => this.runtimePart.renderProps());
}
export class SidebarGroupLabelProvider {
readonly provider: SidebarProvider;
readonly runtimePart: SomaRuntimePart;
static create(opts: WithRefOpts) {
return new SidebarGroupLabelProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.provider = SidebarProvider.require();
this.runtimePart = this.provider.runtime.part('group-label', { id: opts.id, ref: opts.ref });
const group = SidebarGroupProvider.get();
if (group) group.labelId.current = opts.id.current;
}
readonly props = $derived.by(() => this.runtimePart.renderProps());
}
export class SidebarMenuProvider {
readonly provider: SidebarProvider;
readonly group: SidebarGroupProvider | undefined;
readonly runtimePart: SomaRuntimePart;
static create(opts: WithRefOpts) {
return new SidebarMenuProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.provider = SidebarProvider.require();
this.group = SidebarGroupProvider.get();
this.runtimePart = this.provider.runtime.part('menu', { id: opts.id, ref: opts.ref });
}
readonly props = $derived.by(() => ({
...this.runtimePart.renderProps(),
// The morfo declares `aria-labelledby: partRef('group-label')`, but a
// partRef resolves last-write-wins across instances — with three groups
// every menu would name the LAST label. Soma supplies its own group's id.
'aria-labelledby': this.group?.labelId.current || undefined
}));
}
// ── MenuItem · MenuButton · MenuAction ───────────────────────────────────────
/**
* A row's positioner AND the owner of its sub-menu presentation. In icon mode
* the sub-menu cannot stay in flow (the panel is 3rem wide), so this provider
* runs a floating shell anchored to the row's button: the sub opens on hover /
* focus and closes on leave, blur or Escape. shadcn simply HIDES sub-menus in
* icon mode, stranding whole branches of the app — this is the dossier's
* «superación» for §P4.
*
* While the panel is expanded the shell stays idle and the sub renders in flow.
*/
export class SidebarMenuItemProvider {
readonly provider: SidebarProvider;
readonly soma: Soma;
readonly runtimePart: SomaRuntimePart;
readonly floatingProvider: FloatingProvider;
readonly contentPresence: Presence;
static readonly ctx = context<SidebarMenuItemProvider>('SidebarMenuItem');
static get(): SidebarMenuItemProvider | undefined {
return this.ctx.getOr(undefined) as SidebarMenuItemProvider | undefined;
}
static create(opts: WithRefOpts) {
return new SidebarMenuItemProvider(opts);
}
/** The row's button — Escape returns focus here. */
buttonRef: State<HTMLElement | null> = state(null);
subRef: State<HTMLElement | null> = state(null);
private readonly subOpenState: State<boolean> = state(false);
private constructor(opts: WithRefOpts) {
this.provider = SidebarProvider.require();
this.soma = this.provider.soma;
this.runtimePart = this.provider.runtime.part('menu-item', {
id: opts.id,
ref: opts.ref,
owner: this,
context: SidebarMenuItemProvider.ctx
});
const shell = createFloatingShellRoot({
dom: this.soma.dom,
open: readableActive(() => this.subOpen),
contentRef: this.subRef
});
this.floatingProvider = shell.floatingProvider;
this.contentPresence = shell.contentPresence;
}
/** The sub-menu floats only while the panel is an icon rail. */
readonly floating = $derived.by(() => this.provider.iconMode);
/** Open in flow whenever the panel is expanded; on demand while floating. */
readonly subOpen = $derived.by(() => (this.floating ? this.subOpenState.current : true));
openSub(): void {
if (this.floating) this.subOpenState.current = true;
}
closeSub(options?: { returnFocus?: boolean }): void {
if (!this.floating) return;
this.subOpenState.current = false;
if (options?.returnFocus) this.soma.dom.focus(this.buttonRef.current);
}
readonly props = $derived.by(() => this.runtimePart.renderProps());
}
interface SidebarMenuButtonOpts
extends
WithRefOpts,
ActiveProps<{
href: string | undefined;
active: boolean | undefined;
disabled: boolean | undefined;
}> {}
/**
* The interactive row. `aria-current="page"` and `data-active` come from the
* SAME prop, so what is seen and what is announced cannot drift (no reference
* ships `aria-current` here at all). A disabled row keeps its place but loses
* its `href`: nothing to follow by pointer, keyboard or context menu.
*/
export class SidebarMenuButtonProvider {
readonly opts: SidebarMenuButtonOpts;
readonly provider: SidebarProvider;
readonly item: SidebarMenuItemProvider | undefined;
readonly runtimePart: SomaRuntimePart;
static create(opts: SidebarMenuButtonOpts) {
return new SidebarMenuButtonProvider(opts);
}
private constructor(opts: SidebarMenuButtonOpts) {
this.opts = opts;
this.provider = SidebarProvider.require();
this.item = SidebarMenuItemProvider.get();
this.runtimePart = this.provider.runtime.part('menu-button', {
id: opts.id,
ref: opts.ref,
props: {
active: () => opts.active.current,
disabled: () => opts.disabled.current
},
onRefChange: (el) => {
if (this.item) this.item.buttonRef.current = el;
}
});
// Anchor of this item's floating sub-menu (icon mode).
if (this.item) {
FloatingAnchor.create({ id: opts.id, ref: readableActive(() => opts.ref.current) });
}
}
readonly disabled = $derived.by(() => !!this.opts.disabled.current && !!this.opts.href.current);
readonly props = $derived.by(() => ({
...this.runtimePart.renderProps(),
href: this.disabled ? undefined : this.opts.href.current,
onpointerenter: () => this.item?.openSub(),
onfocusin: () => this.item?.openSub()
}));
}
export class SidebarMenuActionProvider {
readonly opts: WithRefOpts & ActiveProps<{ ariaLabel: string | undefined }>;
readonly provider: SidebarProvider;
readonly runtimePart: SomaRuntimePart;
static create(opts: SidebarMenuActionProvider['opts']) {
return new SidebarMenuActionProvider(opts);
}
private constructor(opts: SidebarMenuActionProvider['opts']) {
this.opts = opts;
this.provider = SidebarProvider.require();
this.runtimePart = this.provider.runtime.part('menu-action', {
id: opts.id,
ref: opts.ref,
props: { actionLabel: () => opts.ariaLabel.current }
});
}
readonly props = $derived.by(() => this.runtimePart.renderProps());
}
// ── MenuSub · MenuSubButton ──────────────────────────────────────────────────
/**
* The nested list: in flow while the panel is expanded, a floating surface
* anchored to its row's button while the panel is an icon rail. Escape closes
* it and returns the focus to that button (APG disclosure).
*/
export class SidebarMenuSubProvider {
readonly provider: SidebarProvider;
readonly item: SidebarMenuItemProvider | undefined;
readonly runtimePart: SomaRuntimePart;
readonly floating: FloatingContent | undefined;
static create(opts: WithRefOpts) {
return new SidebarMenuSubProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.provider = SidebarProvider.require();
this.item = SidebarMenuItemProvider.get();
this.runtimePart = this.provider.runtime.part('menu-sub', {
id: opts.id,
ref: opts.ref,
states: { open: () => this.open },
props: { floating: () => this.isFloating }
});
if (this.item) {
this.floating = FloatingContent.create({
id: opts.id,
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;
}
}
readonly isFloating = $derived.by(() => !!this.item?.floating);
readonly open = $derived.by(() => this.item?.subOpen ?? true);
readonly props = $derived.by(() => ({
...this.runtimePart.renderProps(),
...(this.isFloating && this.floating ? this.floating.props : {}),
onpointerleave: () => this.item?.closeSub(),
// The morfo declares `Escape → close-sub` on this part. Runtime keyboard
// PLANS resolve their handler from the runtime-level `actions` map, which
// is shared by every sub in the sidebar — it cannot know WHICH sub fired.
// So the plan is honoured here, per instance, against this sub's own item.
onkeydown: (event: KeyboardEvent) => {
if (event.key !== 'Escape' || !this.isFloating) return;
event.stopPropagation();
this.item?.closeSub({ returnFocus: true });
}
}));
}
export class SidebarMenuSubButtonProvider {
readonly opts: SidebarMenuButtonOpts;
readonly provider: SidebarProvider;
readonly runtimePart: SomaRuntimePart;
static create(opts: SidebarMenuButtonOpts) {
return new SidebarMenuSubButtonProvider(opts);
}
private constructor(opts: SidebarMenuButtonOpts) {
this.opts = opts;
this.provider = SidebarProvider.require();
this.runtimePart = this.provider.runtime.part('menu-sub-button', {
id: opts.id,
ref: opts.ref,
props: {
active: () => opts.active.current,
disabled: () => opts.disabled.current
}
});
}
readonly disabled = $derived.by(() => !!this.opts.disabled.current && !!this.opts.href.current);
readonly props = $derived.by(() => ({
...this.runtimePart.renderProps(),
href: this.disabled ? undefined : this.opts.href.current
}));
}

@ -0,0 +1,98 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes, HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import type { Breakpoint } from '$adom';
/**
* What «collapsed» means for this sidebar (the MODE axis — the second of the
* two the dossier demands):
* - `offcanvas` — the panel slides out of the layout entirely.
* - `icon` — the panel narrows to an icon rail; labels hide, tooltips
* name the rows and sub-menus float beside them.
* - `none` — the panel never collapses (the trigger becomes inert).
*/
export type SidebarCollapsible = 'offcanvas' | 'icon' | 'none';
/** Which edge the panel lives on. */
export type SidebarSide = 'left' | 'right';
export interface SidebarSnippetProps {
props: Record<string, unknown>;
}
type Base = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
ref?: HTMLElement | null;
id?: string;
children?: Snippet;
child?: Snippet<[SidebarSnippetProps]>;
};
export type SidebarProps = Base & {
/**
* Expanded (bindable). Controlled when bound / paired with `onOpenChange`;
* otherwise the provider owns it from `defaultOpen`. This seam exists from
* v1 on purpose: persistence (a cookie) and a global shortcut live in
* app-land and need it (dossier §P4 — without the seam they are a later
* redesign, not an app concern).
*/
open?: boolean;
/** Initial expanded state for the uncontrolled case. Default `true`. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** What collapsing does. Default `'offcanvas'`. */
collapsible?: SidebarCollapsible;
/** Which edge the panel sits on. Default `'left'`. */
side?: SidebarSide;
/**
* Below this breakpoint the sidebar is «mobile»: the provider stamps
* `data-mobile` and the eidos presents the panel as a `Drawer`. Default
* `'md'`. The breakpoint comes from the system (`uix.dom.isAtLeast`) —
* never a component-local `matchMedia`.
*/
mobileBreakpoint?: Breakpoint;
};
export type SidebarPanelProps = Base & {
/** Accessible name of the complementary landmark. Falls back to «Sidebar». */
'aria-label'?: string;
};
export type SidebarContentProps = Base & {
/** Accessible name of the nav landmark. Falls back to «Main navigation». */
'aria-label'?: string;
};
export type SidebarTriggerProps = Omit<HTMLButtonAttributes, 'children'> & {
ref?: HTMLElement | null;
id?: string;
children?: Snippet;
child?: Snippet<[SidebarSnippetProps]>;
};
export type SidebarRailProps = SidebarTriggerProps;
export type SidebarSectionProps = Base;
/** A navigable row. `href` makes it an anchor; without one it is a button. */
export type SidebarMenuButtonProps = Omit<HTMLAnchorAttributes, 'children'> & {
ref?: HTMLElement | null;
id?: string;
href?: string;
/** The row points at the current page → `data-active` + `aria-current`. */
active?: boolean;
disabled?: boolean;
children?: Snippet;
child?: Snippet<[SidebarSnippetProps]>;
};
export type SidebarMenuSubButtonProps = SidebarMenuButtonProps;
export type SidebarMenuActionProps = Omit<HTMLButtonAttributes, 'children'> & {
ref?: HTMLElement | null;
id?: string;
/** Accessible name — the action has no text of its own (it is an icon). */
'aria-label'?: string;
children?: Snippet;
child?: Snippet<[SidebarSnippetProps]>;
};
export type SidebarMenuSubProps = Base;
Loading…
Cancel
Save

Powered by TurnKey Linux.