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.

You are here: nowhere yet — visit a page
Visit a demo page

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) => void

Wire 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
boolean

Render just the back button — for compact shells and mobile bars where the full cluster will not fit.

onError
(error, info) => void

Telemetry 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

Record navigations + render the clustertsx
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} />
Read the visit signal for rankingtsx
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.
  • /reference-app-bar — the canonical Reference shell NavHistory rides in.
  • /tokens — the gold-accent and silver-blue utility tokens the cluster resolves to.