8.2 KiB
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. The mode CLAMPS the state where they would contradict:'none'reports expanded on the desktop (no rule collapses the panel there, so announcing «collapsed» would lie about a visible panel, with no way back since the toggle is inert). On mobile the mode is irrelevant — and the toggle stays LIVE, orcollapsible='none'would leave a phone with no way to open the drawer at all. Both live on the provider, plusdata-sideanddata-mobile.data-collapsibleis stamped ALWAYS — shadcn stamps it only while collapsed, which forces every rule to guard on state. - Parts (17):
provider(shell) +panel(<aside>landmark) +triggerrail+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.separatorand the search input are NOT parts: they compose the canonicalSeparatorandField.
- 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 asdata-activeon bothmenu-buttonandmenu-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/onOpenChangeand the provider'stoggle(). 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:
mobilederives fromuix.dom.isAtLeast(mobileBreakpoint)(defaultmd) — never a component-localmatchMedia. Soma stampsdata-mobile; presenting the panel as aDrawerbelow that breakpoint is the eidos's composition, so the Drawer's own behaviour (focus trap, scroll lock, dismiss) comes with it. A server render has no viewport, somobileis FALSE there by definition — hydration does not diff attributes, and adata-mobilebaked into the HTML would never be corrected. - The mobile panel has its OWN open state, starting closed: on the desktop
openis a layout preference the app persists; on mobile it would mean an overlay covering the page.onOpenChangetherefore fires only for the desktop state, and the mobile flag is DISCARDED when the viewport leaves the mobile presentation — otherwise rotating a phone twice re-mounts the Drawer already open, with its overlay, focus trap and scroll lock engaged. useSidebar()/useSidebarItemOr(): read-only reactive views of the sidebar (open,collapsible,side,mobile,iconMode,toggle,setOpen) and of the enclosing row (hasSub,subOpen,floating). The dossier counts the hook as part of the reference floor; the eidos uses both to decide the Drawer presentation and whether a row still needs a tooltip.- Sub-menus survive icon mode: while the panel is an icon rail a
menu-subbecomes a floating surface anchored to its row's button (data-floating). It opens on hover OR focus and closes onpointerleave, on focus leaving the row and its sub, or on Escape — which returns focus to the button. The focus path matters as much as the pointer one: a keyboard user opens it by tabbing to the row, tabs THROUGH its links, and tabbing past them must not strand an open surface over the page. Both exits live on themenu-item, which contains the row AND the sub: the pointer can leave the row without ever crossing the flyout (straight into the page), Escape has to work from wherever focus IS (normally the row, not the sub), and travelling between row and flyout must NOT close it. The flag is also dropped whenever the presentation changes, or expanding and collapsing again would re-open a flyout nobody touched. shadcn simply hides sub-menus in icon mode, stranding whole branches of the app instead. - Direction reaches the DOM:
dirsets the reading direction and is stamped RAW on the shell, because the recipe derives which way «out» is fromdata-side×:dir(rtl)— in RTL the row reverses, so aside='left'panel already sits on the physical right and a fixed negative translate would sweep it ACROSS the page. The resolved value also mirrors the icon-mode flyout's placement, which needs a concrete side. Contract:docs/canon/direction-contract.md.
Cross-instance ids
Two morfo partRefs resolve last-write-wins across repeated parts, so soma
supplies the per-instance value (the navigation-menu precedent):
menu'saria-labelledby→ its OWN group's label id (via the group context).trigger/rail'saria-controls→ the panel id published by the panel.
Sema events
| Event | Family · verb | Target | Sequence | When |
|---|---|---|---|---|
emerge-expand |
emerge.expand |
panel |
post |
toggle() opens the panel — from the Trigger, the Rail or app-land. |
emerge-collapse |
emerge.collapse |
panel |
post |
…and folds it away. |
contact-activate |
contact.activate |
menu-button (+ menu-sub-button, rail) |
pre |
A row, a sub-row or the rail is pressed. Anchored on the pressed instance. |
shift-navigate |
shift.navigate |
provider |
post |
A row with an href is pressed — the shell crosses. |
emerge-open-sub |
emerge.open |
menu-sub |
post |
The icon-mode flyout appears (hover / focus of its row). Anchored by identity. |
emerge-close-sub |
emerge.close |
menu-sub |
pre |
…and retires (leave, blur, Escape, or the panel expanding). |
Why those targets, and not others:
- The row's press is stamped on the row; the crossing on the shell. Book
cap. 22 §9 composes a link as «contact.press seguido de shift.navigate: la
presión no es la navegación». The gesture belongs where the hand is, the
crossing to the surface that crosses as a unit — so the two never share the
one
data-event-*slot and noregime: 'queue'is needed.calendarmoved its ownshift-navigateoff the button for exactly this reason (the press-squeeze never painted), andfield-langsstamps its regime change on the provider. menu-button/menu-sub-button/menu-subare REPEATED parts, so the provider emits through the anchored surface (runtimePart.trigger/runtime.partInstance(part, el).trigger), never a bareruntime.trigger, which would land on the newest registered instance.- A row with no
href(the<button>form that only opens a sub-menu) emits contact and nothing else: the consequence belongs to whatever the consumer composes. A disabled row emits nothing at all. triggerandmenu-actionare absent on purpose: the eidos composes the canonButton/IconButtonthere, and those owncontact-activate. Declaring it here too would put one verb twice on one node (thenavigation-menurule). TherailIS declared: it is a strip of chrome, not a composed control.
Pack: src/uix/sema/components/sidebar.ts
— soft emerge for the panel, the family defaults for the row's pair (touch +
slide), and SILENT for the flyout, which opens on hover and would otherwise
chime once per row of the rail (book cap. 26 §7).