NavHistory
Back, forward, and recents for the app bar, backed by the useNavHistory store. It keeps two separate records of where you've been — and keeping them separate is the whole trick.
The buttons want a linear back/forward stack, just like the browser's: go somewhere new while you're not at the tip and the forward branch gets cut. Search wants the opposite — a visits record of every page you've seen, with how often and how recently, that doesn't forget a page just because you backed up and wandered off. Squash those into one list (the original client did) and the back stack turns into a lossy signal. So NavHistory keeps both: a entries stack and an ever-growing visits ledger.
Demo
Visit a few pages, then walk the back/forward buttons or pop open recents. The demo is fully self-contained — the buttons below feed the store, and "You are here" shows whatever NavHistory hands back; you never actually leave the page. Your history survives a refresh (go ahead, try it), and Clear history wipes the slate.
Two records, not one
The split is the whole idea. Each record answers a different question, so each one gets its own shape.
- entries + cursor
- The back/forward stack.
Linear, browser-style. A new navigation while not at the tip truncates the forward branch. Drives the buttons and the recents menu. Capped at 50, trimming the oldest.
- visits
- The visit ledger — { count, lastVisited } per path.
Monotonic: a page you saw an hour ago stays "visited" even after you branch away. This is the first-party signal search ranking reads to deprioritise what you have already seen (relevance still dominates). Recency + frequency, never a flat boolean. Capped at 200, evicting the least-recently-visited.
Props
- onNavigate
(pathname: string) => voidWire your router’s navigate here. The store moves its own cursor and hands you the target pathname; you do the navigation. This is how the library stays router-agnostic.
- icons
Record<string, ReactNode>Optional icon-key → glyph map. Each recorded entry carries an opaque icon string; rows whose key resolves here render the glyph. Omit for label-only rows.
- backOnly
booleanRender just the back button — for compact shells and mobile bars where the full cluster will not fit.
- onError
(error, info) => voidTelemetry reporter for the internal boundary (ADR-0030). On failure a degraded, inert back/forward cluster renders so the bar never blanks; this just surfaces the failure.
The store
NavHistory is the UI over a router-agnostic store you can read directly. The app records visits; search and per-item styling read them.
- recordNav(path, label, icon?)
- Record a navigation. The app calls this from a one-line effect on its route-change signal. De-dupes consecutive visits and bumps the ledger.
- useNavHistory()
- The reactive snapshot + actions (back / forward / goTo / clear) + derived canGoBack / canGoForward. What NavHistory itself consumes.
- useHasVisited(path)
- Reactive boolean that re-renders only when that one path’s visited status flips — cheap for dimming nav items you have already seen.
- getVisits()
- The full ledger, read non-reactively. The input to search ranking (#167).
Usage
import { NavHistory, recordNav } from '@halo-compliance/ui'
import { useRouterState, useNavigate } from '@tanstack/react-router'
// 1. Record every navigation — one effect on the router's path signal.
function useTrackNavigation() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
useEffect(() => {
const { label, icon } = resolveRoute(pathname)
recordNav(pathname, label, icon)
}, [pathname])
}
// 2. Render the cluster — wire the router's navigate into onNavigate.
const navigate = useNavigate()
<NavHistory onNavigate={(to) => navigate({ to })} icons={SECTION_ICONS} />import { getVisits } from '@halo-compliance/ui'
// Search ranking: relevance dominates, then demote what's already been seen.
function rank(result) {
const visit = getVisits()[result.pathname]
const seenPenalty = visit ? recencyDecay(visit.lastVisited) : 0
return result.relevance - seenPenalty
}Accessibility
The cluster is keyboard-first and self-isolating; nothing here rides on a mouse or on the rest of the bar staying up.
- Recents menu — role="menu" of role="menuitem" rows with roving tabindex: one Tab stop, ArrowUp/Down + Home/End move within, Escape closes and returns focus to the toggle. A long history is one tab stop, not fifty.
- Current page — the row you're on is marked aria-current="page" and gets the menu's single solid-gold highlight, so "here" is obvious at a glance.
- Clear is not buried — the destructive Clear history action is a labelled menu item that shows its danger color on hover and focus, not a hidden right-click trick the way the original client tucked it away.
- Self-isolating — an internal boundary (ADR-0030) swaps in an inert back/forward cluster on failure, so a fault here never blanks the app bar.
Related
- /reference-app-bar — the canonical Reference shell NavHistory rides in.
- /tokens — the gold-accent and silver-blue utility tokens the cluster resolves to.