9.6 KiB
Navigation component group — design
Date: 2026-08-07 Status: approved, not yet implemented
Problem
The navigation group has nine components. Five of them — Nav, MegaMenu,
Scrollspy, Pagination, Stepper — are byte-identical scaffold stubs that differ
only in one CSS class name. Each renders {#each items}<a href>{label}</a>{/each}
and nothing else. MegaMenu has no panel, Scrollspy never observes scroll,
Stepper has no steps or progress, Pagination has no pages.
Of the remaining four, Breadcrumb is genuinely built (323 lines, own style
block). Navbar and Sidebar render but have no keyboard handling and keep their
styles in ui.css. Tabs works but is off-pattern in three ways: it is the only
component in the library using Tailwind utility classes rather than wrn-* +
a local style block, it has no outputs block and fires raw CustomEvents via
dispatchEvent, and it sets a roving tabindex with no @keydown handler at
all — which is worse than having no keyboard support, because the roving
tabindex makes every inactive tab unreachable by Tab while arrows do nothing.
Scope
All nine components, delivered in three phases.
Decisions
Styling
Every navigation component gets a local style {} block using wrn-*
classes. This matches DataTable, Toaster, Modal, Drawer, ContextMenu and
Breadcrumb — the 22 of 108 components that carry local styles are exactly the
recently built ones. Navbar and Sidebar styles move out of ui.css as part of
this work.
Dropdowns: Nav vs MegaMenu
Both get dropdowns, of deliberately different kinds.
- Nav gets multi-level cascading submenus. It is the link bar, so nested submenus are its job.
- MegaMenu gets a single-level rich panel: columns of grouped links with headings, descriptions and icons. This is deliberate, not a shortcut. A mega menu exists to show breadth flat so everything is one click away; nesting inside the panel buries content behind hover-within-hover and is close to unusable by keyboard and touch.
Neither reuses the existing Dropdown component: Dropdown is click-triggered and has no nesting support.
Depth limit. There is no recursive-component precedent in this library, so
multi-level means fixed depth via nested {#each} loops — the pattern Navbar
(4 loops) and Sidebar (2 loops) already use. Depth is 3 levels. Unlimited
nesting would require proving out component self-reference, which is out of
scope here.
Roving focus lives in the runtime
Arrow-key roving focus is identical logic for Tabs, Nav, MegaMenu, Sidebar and Stepper. It goes in the reactive runtime as a declarative attribute rather than five near-identical client functions:
data-wrn-roving="horizontal|vertical|both"on the container[data-wrn-roving-item]on each focusable child
The runtime owns arrow keys, Home/End, wrap-around, skip-disabled, and
maintenance of the roving tabindex. Components declare intent only.
This follows the modal-dialog focus work already in the runtime, and for the same reason: a client function cannot hold focus state across callbacks, because state written after the function returns is dropped.
Scrollspy needs runtime support too
data-wrn-scrollspy backed by IntersectionObserver. It cannot be a client
function — the observer callback fires long after the function returns, and
that state write would be lost.
Runtime budget
Roving focus is roughly 3–4k, scrollspy roughly 1.5k. The runtime is at 167k against the 175k ceiling raised on 2026-08-07. This fits, but leaves little room. The group after this one forces the decision about splitting the runtime into loadable chunks so pages pay only for behaviour they use.
Tabs URL mode
mode="client" | "url". URL mode uses a query parameter (?tab=value) driven
by history.pushState, so content swaps with no page load and the back button
works.
Query parameter rather than hash: it is shareable, survives reload, and does not collide with in-page anchors or with Scrollspy, which wants the hash.
Tabs also gets a panel transition on change.
Sidebar composes Drawer
Sidebar does not reimplement off-canvas behaviour. Desktop renders a static rail; mobile renders inside Drawer. This inherits Drawer's focus trap and scroll lock rather than duplicating them.
Cross-cutting requirements
- Icons on every menu item, tab, and step
- Dropdown arrows that animate on open
- Responsive behaviour per component: Nav collapses to a toggle, MegaMenu stacks its panel, Tabs becomes a scrollable strip, Stepper goes vertical, Pagination goes compact
- Full ARIA Authoring Practices patterns for each role, including correct
aria-current/aria-selected/aria-expanded
Components
| Component | Work |
|---|---|
| Nav | Build. Flat or multi-level (3 deep), horizontal/vertical, items[{label,href,value,icon,badge,disabled,items}], aria-current="page", roving focus, animated arrows, collapses on mobile. outputs: select |
| Tabs | Rewrite off Tailwind onto wrn-* + local styles. Real outputs {change, select} replacing raw dispatchEvent. Roving + Home/End. mode="client"|"url". Panel transition. Fix the <slot> that renders regardless of active tab |
| Pagination | Build standalone. page/pageSize/total, compact + numbered styles, windowed page numbers. outputs: change, previous, next |
| Stepper | Build. <ol>/<li>, status derived from active index, horizontal/vertical, optionally clickable, aria-current="step". Indexed named slots (data-slot="step-0", step-1, …) for custom per-step content, falling back to built-in rendering |
| MegaMenu | Build. Trigger plus a single-level panel of link columns. Panel marked data-wrn-anchored so the existing clamp handles viewport containment. Hover and focus open, Escape closes, click-outside closes |
| Scrollspy | Build. Observes section ids, moves aria-current to the matching link |
| Navbar | Audit. Styles move to a local block, roving focus on the link group, keyboard for the mobile toggle |
| Sidebar | Audit and extend. Local styles, composes Drawer for mobile, single items / labelled groups / multi-level (3 deep), vertical roving |
| Breadcrumb | Audit only — already the strongest of the nine |
Component count stays at 108; all nine files already exist.
Data flow
Props in, outputs out, no global state. Every component takes its data as props
and reports interaction through declared outputs.
Error handling
items arrives as an HTML attribute and is frequently a JSON string, so:
- A non-array
itemsrenders empty rather than throwing - Out-of-range
active/pageclamps to bounds
Testing
- Per-component entries in
packages/ui/test/ui.test.tscovering names, declared outputs and props - Showcase profiles for each so every component gets live demos
- Regenerate component reference, catalog, showcase, and the UI visual contract
- Each phase ends with
bun run check:productiongreen
Phases
Each phase ends green, committed and pushed.
- Runtime and primitives — roving-focus runtime, Nav, Pagination, Stepper
- Composed — Tabs (rewrite, URL sync, animation), Sidebar (on Drawer), MegaMenu
- Audit and polish — Navbar, Breadcrumb, Scrollspy, responsive pass, showcase generation
Risks
- Scrollspy and MegaMenu are the only two needing new runtime behaviour.
MegaMenu leans on the anchored clamp, which could not be verified
interactively on 2026-08-07 because the browser pane was degraded
(screenshots timing out,
scrollIntoViewinert). Verify the pane works before starting phase 2. - Tabs is a breaking change. Replacing raw
CustomEvents with declared outputs changes its public contract; anything listening for the old events breaks. 0.8.5 shipped on 2026-08-07, so this needs a migration entry inpackages/cli/src/update.ts.
Codebase constraints to respect
Hazards this codebase has already hit:
- No apostrophes in
.wrncomments — the brace scanner breaks on them /* */only inside style blocks;//is not a CSS comment and silently eats the following rule- No deferred state writes in client functions; state written after the function returns is dropped
- Never call a peer function after an application callback — the wrapper flushes the entry-time snapshot
- No boolean attributes bound to loop variables
- Package components need explicit imports