You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/components/sidebar/README.md

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, or collapsible='none' would leave a phone with no way to open the drawer at all. 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. A server render has no viewport, so mobile is FALSE there by definition — hydration does not diff attributes, and a data-mobile baked into the HTML would never be corrected.
  • The mobile panel has its OWN open state, starting closed: on the desktop open is a layout preference the app persists; on mobile it would mean an overlay covering the page. onOpenChange therefore fires only for the desktop state, 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-sub becomes a floating surface anchored to its row's button (data-floating). It opens on hover OR focus and closes on pointerleave, 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 the menu-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: dir sets the reading direction and is stamped RAW on the shell, because the recipe derives which way «out» is from data-side × :dir(rtl) — in RTL the row reverses, so a side='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'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

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 no regime: 'queue' is needed. calendar moved its own shift-navigate off the button for exactly this reason (the press-squeeze never painted), and field-langs stamps its regime change on the provider.
  • menu-button / menu-sub-button / menu-sub are REPEATED parts, so the provider emits through the anchored surface (runtimePart.trigger / runtime.partInstance(part, el).trigger), never a bare runtime.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.
  • trigger and menu-action are absent on purpose: the eidos composes the canon Button / IconButton there, and those own contact-activate. Declaring it here too would put one verb twice on one node (the navigation-menu rule). The rail IS 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).

Powered by TurnKey Linux.