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
parent
ef6bd13be3
commit
2bc5fe6bd2
@ -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;
|
||||
@ -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')
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -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…
Reference in new issue