Navigation
Summary
Wayfinding parts beyond the shell itself: breadcrumbs above page titles, pagination under collections, and the generic nav lists (vertical nav and the horizontal navgroup) for settings pages and content-area sub-navigation. Type-ahead search is Quicksearch; expandable hierarchies are the Tree. Sidebar nav groups — the shell sidebar's collapsible sections — are documented below, and sit inside a full dashboard on the Shell page.
When to use
| Component | Use it for |
|---|---|
| Breadcrumbs | Location in a hierarchy ≥2 levels deep; current page is text with aria-current="page". |
| Pagination | Chunked collections. Use the simple Newer/Older form for feeds. |
| Quicksearch | Type-ahead over a known set: pages, projects, people. Soma owns keys/open/close; you render items. |
| Tree | Expandable hierarchies: resources, files, component inventories. |
| Sidebar nav groups | Collapsible sections in the shell sidebar — a product area disclosing its pages ("DevOps Agent ▾"). This site's own sidebar is built from them. |
Examples
Breadcrumbs
A labelled <nav> with an ordered list:
every ancestor is a link, the current page is plain text with
aria-current="page" (never a link to itself).
Chevron separators are generated from the icon set and flip
direction under RTL; nothing separator-like belongs in the
markup:
<nav class="soma-breadcrumbs" aria-label="Breadcrumbs">
<ol>
<li><a href="/projects">Projects</a></li>
<li><a href="/projects/nware">Nware</a></li>
<li aria-current="page">Soma</li>
</ol>
</nav>
Long trails wrap onto further lines rather than truncating.
Where wrapping is unwanted (page headers, tight panels), opt
into the JS middle-collapse instead. Add
data-soma-collapse and the overflowing middle of
the trail folds into a "…" menu (see
Breadcrumb overflow under JavaScript below). Place
the trail directly above the .soma-page-title; a
single-level page earns no trail at all.
Pagination
Items are anchors (server-rendered paging), buttons (in-place
paging) or spans (the ellipsis); the styling is identical.
The current page carries aria-current="page"
(solid primary), unavailable ends carry
aria-disabled="true" (dimmed, mouse-inert), and
gaps are a .soma-pagination-ellipsis span.
Chevron-only items need an aria-label:
<nav class="soma-pagination" aria-label="Pagination">
<a href="?page=1" aria-disabled="true" aria-label="Previous page">
<span class="soma-icon soma-icon-chevron-left"></span></a>
<a href="?page=1" aria-current="page">1</a>
<a href="?page=2">2</a>
<span class="soma-pagination-ellipsis">…</span>
<a href="?page=12">12</a>
<a href="?page=2" aria-label="Next page">
<span class="soma-icon soma-icon-chevron-right"></span></a>
</nav>
The button form for app-driven paging — same classes, no
hrefs to dead-end:
<nav class="soma-pagination" aria-label="Results pagination">
<button type="button" aria-disabled="true" aria-label="Previous page">
<span class="soma-icon soma-icon-chevron-left"></span></button>
<button type="button" aria-current="page">1</button>
<button type="button">2</button>
<button type="button" aria-label="Next page">
<span class="soma-icon soma-icon-chevron-right"></span></button>
</nav>
Feeds and timelines take the simple two-item form (Newer / Older, no page numbers):
<nav class="soma-pagination" aria-label="Feed pagination">
<a href="?before=…"><span class="soma-icon soma-icon-chevron-left"></span>Newer</a>
<a href="?after=…">Older<span class="soma-icon soma-icon-chevron-right"></span></a>
</nav>
Nav lists
Two generic primitives share the .soma-nav-item
link style; neither is welded to the shell. The vertical list
stacks items with optional small-caps
.soma-nav-heading section labels, for settings
side-navs and in-widget navigation:
<nav class="soma-nav-vertical" aria-label="Settings">
<span class="soma-nav-heading">Account</span>
<a class="soma-nav-item" aria-current="page" href="/profile">Profile</a>
<a class="soma-nav-item" href="/security">Security</a>
<span class="soma-nav-heading">Workspace</span>
<a class="soma-nav-item" href="/members">Members</a>
</nav>
The navgroup is a tinted horizontal strip for content-area sub-navigation (the level between tabs and the app navbar) — primary links at the start, a secondary group flush at the end:
<nav class="soma-navgroup" aria-label="Application">
<div class="soma-navgroup-primary">
<a class="soma-nav-item" aria-current="page" href="/overview">Overview</a>
<a class="soma-nav-item" href="/deployments">Deployments</a>
</div>
<div class="soma-navgroup-secondary">
<a class="soma-nav-item" href="/settings">Settings</a>
</div>
</nav>
Mark the current item with aria-current="page"
(preferred: state and styling from one attribute) or
.soma-active; both render identically. Give every
<nav> an aria-label so multiple
landmarks on one page stay distinguishable.
Sidebar nav groups
The shell sidebar's collapsible sections: a toggle styled as a
sidebar link that discloses a set of child links. The toggle is
a real <button> carrying
aria-controls and aria-expanded; the
items panel mirrors the state on aria-hidden.
Declare the initial state on either side — auto-init normalises
the other. Child links must carry
.soma-sidebar-sublink: it provides the indent that
separates them from sibling toggles. The leading icon is
optional; the geometry works with or without it. The example
widget above wires exactly this markup:
<aside class="soma-sidebar" aria-label="Demo sidebar">
<nav class="soma-sidebar-nav" aria-label="Demo agents">
<span class="soma-sidebar-heading">Agents</span>
<div class="soma-sidebar-group">
<button class="soma-sidebar-link soma-sidebar-group-toggle"
aria-controls="navdemo-devops" aria-expanded="true">
<span class="soma-icon soma-icon-apps"></span>DevOps Agent
</button>
<div class="soma-sidebar-group-items" id="navdemo-devops" aria-hidden="false">
<a class="soma-sidebar-link soma-sidebar-sublink soma-active"
aria-current="page" href="#">Dashboard</a>
<a class="soma-sidebar-link soma-sidebar-sublink" href="#">Deployments</a>
<a class="soma-sidebar-link soma-sidebar-sublink" href="#">IaC Plans</a>
</div>
</div>
<div class="soma-sidebar-group">
<button class="soma-sidebar-link soma-sidebar-group-toggle"
aria-controls="navdemo-network" aria-expanded="false">
<span class="soma-icon soma-icon-refresh"></span>Network Agent
</button>
<div class="soma-sidebar-group-items" id="navdemo-network" aria-hidden="true">
<a class="soma-sidebar-link soma-sidebar-sublink" href="#">Topology</a>
<a class="soma-sidebar-link soma-sidebar-sublink" href="#">Policies</a>
</div>
</div>
</nav>
</aside>
Three wayfinding states come free, all CSS. An open group's
toggle lifts from muted to full text ink and a hairline tree
guide ties its children to it. The group whose child link
carries .soma-active paints its toggle in primary
ink — the section you are in stays visible however many groups
are open. Mark the current page's link with
.soma-active (plus aria-current="page"
for semantics); this site's own sidebar is the reference
consumer of all three states.
CSS classes
| Class | Effect |
|---|---|
.soma-breadcrumbs | The trail; chevron separators come from the icon set and mirror in RTL. Current page: li[aria-current="page"], darker and medium-weight. |
.soma-breadcrumbs-more | Generated by Soma.breadcrumbs: the trail item holding the "…" dropdown trigger. Not for authoring; open state gets the primary-subtle fill. |
.soma-pagination | Item row; anchors, buttons and spans style alike (32px targets). Current page: aria-current="page"; disabled ends: aria-disabled="true". |
.soma-pagination-ellipsis | The non-interactive gap marker between page numbers. |
.soma-nav-item | The shared link style of both nav lists: hover and current state get the primary-subtle fill; current also goes semibold. |
.soma-nav-vertical | Generic vertical nav list (not welded to the shell); holds .soma-nav-heading section labels + .soma-nav-item links. |
.soma-nav-heading | Small-caps section label between item groups in a vertical list; every heading after the first draws a hairline separator above itself. |
.soma-navgroup | Content-area sub-navigation bar (the level between tabs and the navbar): a tinted strip with -primary and -secondary groups of .soma-nav-item; the secondary group is pushed flush to the end. |
.soma-sidebar-group | Wrapper for one collapsible sidebar section: the toggle plus its items panel. The group whose items contain a .soma-active link paints its toggle in primary ink (the active trail). |
.soma-sidebar-group-toggle | The disclosure <button>, styled as a sidebar link with a trailing chevron; needs aria-controls pointing at the items panel's id. Open (aria-expanded="true") rotates the chevron and lifts the label to full text ink. |
.soma-sidebar-group-items | The child-link panel; aria-hidden="true" hides it. While open, a hairline tree guide runs under the toggle's icon column beside the children. |
.soma-sidebar-sublink | Required on the child links: slightly smaller size and the indent that separates children from sibling toggles. |
JavaScript
Pagination and the nav lists ship no JS — the state hooks are ARIA attributes. Four navigation behaviours ARE JS-driven: breadcrumb overflow and sidebar nav groups (both below), the navbar's overflow "More" menu (live demo and API on the Navbar page) and the resizable sidebar — demoed live on the Shell page's dashboard shell.
Breadcrumb overflow — Soma.breadcrumbs
Middle-collapse for long trails, opt-in per trail. When the
items exceed the container's width, the middle of the trail
moves into a generated "…" dropdown
placed after the first item. The first item and the current
page never collapse, and as many further trailing items as fit
stay visible. A ResizeObserver on the
<nav> re-computes on every container
resize. Items are moved, never cloned, so their
listeners and state survive the round-trip in and out of the
menu.
<nav class="soma-breadcrumbs" aria-label="Breadcrumbs" data-soma-collapse>
<ol>
<li><a href="/">Home</a></li>
<li><a href="/projects">Projects</a></li>
<li><a href="/projects/nware">Nware</a></li>
<li aria-current="page">Soma</li>
</ol>
</nav>
| Member | Description |
|---|---|
Soma.breadcrumbs(el) | Get or create the singleton for a .soma-breadcrumbs element / selector and collapse it to fit. Throws when nothing matches or the class is missing; a trail without an <ol>/<ul> is inert. |
data-soma-collapse | Auto-init hook: every .soma-breadcrumbs[data-soma-collapse] is bound on DOM ready. Trails without the attribute keep the default CSS wrapping. |
.refresh() | Re-measure and redistribute items between the trail and the menu. Runs automatically on container resize; call it after content changes inside items (e.g. a renamed project). Returns the instance (chainable). |
.destroy() | Restore the full trail in source order and remove the generated trigger and menu. The item set is snapshotted at init — after adding or removing trail items, destroy() and re-init. |
i18n breadcrumbs.more | The trigger's accessible name (the visible label is the "…" glyph), localised via Soma.i18n, translated in every built-in pack. |
Trails of fewer than three items never collapse (there would be nothing to put in the menu). The generated trigger is a standard dropdown2 trigger: keyboard and screen-reader behaviour (arrows, Home/End, Escape, menu roles) comes from that component unchanged.
// Explicit binding (or let data-soma-collapse auto-init it):
const bc = Soma.breadcrumbs('#trail');
bc.refresh(); // after changing text inside the items
bc.destroy(); // restore the full trail (re-init after items change)
The live overflow demo above, exactly as this page wires it:
Soma.breadcrumbs('#bc-collapse'); // idempotent alongside data-soma-collapse
Responsive navbar — Soma.responsiveNavbar
Overflow handling for the app navbar's primary link list. Items that don't fit collapse into a generated, localised "More" dropdown. The component, its slots and markup contract, a live overflow demo, and the full API live on the dedicated Navbar page.
Sidebar resize — Soma.sidebarResize
A drag handle for the shell sidebar. The chosen width is
applied to the enclosing .soma-shell grid template
(the sidebar is a grid column — resizing the element alone
would not move the content edge; without a shell ancestor the
element's own width is set instead) and persisted per
id in localStorage.
| Member | Description |
|---|---|
Soma.sidebarResize(el, options) | Get or create the singleton for the sidebar element / selector; appends the .soma-sidebar-handle and restores the persisted width (silently; no event, nothing written). Throws when nothing matches. |
options.id | Required: the persistence key; the stored entry is soma-sidebar-width:<id>. Omitting it throws. |
options.minWidth | Number or fn() => number. Default 180. |
options.maxWidth | Number or fn() => number. Default: a third of the viewport width, computed at each clamp; functions re-evaluate, so viewport-relative bounds track window resizes. |
options.onResize | fn(width), called on every applied resize (drag moves, keyboard steps, setWidth), not on the initial restore. |
.setWidth(px) | Set the width programmatically. Clamped to the bounds, persisted, onResize + event fired. Returns the instance (chainable). |
.destroy() | Remove the handle and reset the shell grid (or element width). The persisted value remains for the next init. |
Event soma-sidebar-resize | Bubbling CustomEvent on the sidebar element, detail { width }; fires with onResize, including continuously during a drag. |
The handle is a focusable role="separator"
(vertical orientation, localised sidebar.resize
label) whose aria-valuenow/-valuemin/-valuemax
track the width:
| Key | Action |
|---|---|
| ← / → | ±10px. Arrows are physical: the sidebar grows toward the content edge, so under RTL the roles swap. |
| Shift + ←/→ | ±50px. |
| Home / End | Jump to the minimum / maximum width. |
// Attach once; the width stored under "soma-sidebar-width:console"
// is restored silently on init.
const sb = Soma.sidebarResize('.soma-sidebar', {
id: 'console',
minWidth: 200,
maxWidth: () => Math.round(window.innerWidth / 3), // numbers or functions
onResize: (width) => console.log('sidebar', width),
});
sb.setWidth(280); // clamped, persisted, fires the event
Listen without holding the instance — the event bubbles:
document.querySelector('.soma-sidebar')
.addEventListener('soma-sidebar-resize', (e) => {
chart.reflow(); // e.detail.width is the new pixel width
});
Forget the operator's stored width:
localStorage.removeItem('soma-sidebar-width:console');
sb.destroy(); // removes the handle and restores the shell grid
Sidebar nav groups — auto-init
No constructor and no instance API — the markup plus ARIA is
the whole contract. Every
.soma-sidebar-group-toggle[aria-controls] is bound
on load; for markup injected later, bind the container with
Soma.scan(container). Each click flips
aria-expanded/aria-hidden and fires a
bubbling event:
| Event | Detail |
|---|---|
soma-sidebar-group-toggle | Bubbling CustomEvent on the toggle; detail.toggle (the button), detail.items (the panel), detail.open (boolean, the new state). |
Persistence is deliberately yours: listen once on the sidebar
and store what is open, then render the stored groups with
aria-expanded="true" on the next page. This site's
sidebar does exactly this:
document.querySelector('.soma-sidebar')
.addEventListener('soma-sidebar-group-toggle', () => {
const open = [...document.querySelectorAll('.soma-sidebar-group-toggle')]
.filter((t) => t.getAttribute('aria-expanded') === 'true')
.map((t) => t.getAttribute('aria-controls'));
localStorage.setItem('app-nav-open', JSON.stringify(open));
});
The live pagination example above, exactly as this page wires it:
const pg = document.querySelector('#pg-buttons');
const out = document.querySelector('#pg-out');
const pages = [...pg.querySelectorAll('button:not([aria-label])')];
const prev = pg.querySelector('[aria-label="Previous page"]');
const next = pg.querySelector('[aria-label="Next page"]');
const setPage = (n) => {
pages.forEach((b) => {
if (Number(b.textContent) === n) b.setAttribute('aria-current', 'page');
else b.removeAttribute('aria-current');
});
prev.setAttribute('aria-disabled', String(n === 1));
next.setAttribute('aria-disabled', String(n === pages.length));
out.textContent = String(n);
};
pg.addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (!btn || btn.getAttribute('aria-disabled') === 'true') return;
const current = Number(out.textContent);
if (btn === prev) setPage(current - 1);
else if (btn === next) setPage(current + 1);
else setPage(Number(btn.textContent));
});