@@ -27,7 +27,9 @@ type Router = ReturnType<typeof useRouter>
2727// not next/link), so intercept clicks to restore next/link-style client-side
2828// navigation. Modifier/middle clicks fall through to the browser so open-in-new-tab
2929// still works, and the <a href> keeps links crawlable for SSR. Mirrors Breadcrumbs.tsx.
30- function handleNavClick ( router : Router , event : MouseEvent < HTMLElement > , href : string ) {
30+ // Returns true when it performed a client-side navigation (so the caller can move the
31+ // optimistic selection), false when the click was left to the browser.
32+ function handleNavClick ( router : Router , event : MouseEvent < HTMLElement > , href : string ) : boolean {
3133 if (
3234 event . defaultPrevented ||
3335 event . button !== 0 ||
@@ -37,20 +39,28 @@ function handleNavClick(router: Router, event: MouseEvent<HTMLElement>, href: st
3739 event . altKey ||
3840 ! href . startsWith ( '/' )
3941 ) {
40- return
42+ return false
4143 }
4244 event . preventDefault ( )
4345 // hrefs already include the locale prefix (e.g. /en/...), so disable Next.js
4446 // locale handling to avoid double-prefixing.
4547 router . push ( href , undefined , { locale : false } )
48+ return true
4649}
4750
4851// The sidebar renders the full product tree (hundreds of nodes) and fully remounts
4952// on every navigation (key={asPath} in SidebarNav). To keep per-item cost down we
5053// subscribe to the router ONCE here and hand items a stable routePath plus stable
5154// navigate/prefetch callbacks, instead of every item calling useRouter itself.
5255type SidebarNavValue = {
56+ // The real loaded route. Drives aria-current (the semantic "current page") and the
57+ // auto-expanded active ancestor chain — both must reflect the page actually loaded.
5358 routePath : string
59+ // The in-flight click target, or null. Drives a VISUAL-ONLY optimistic accent bar
60+ // (via data-pending) so the click feels acknowledged before the slow
61+ // getServerSideProps page loads — without lying to assistive tech about the current
62+ // page. Once navigation completes, the keyed remount clears it and routePath catches up.
63+ pendingHref : string | null
5464 navigate : ( event : MouseEvent < HTMLElement > , href : string ) => void
5565 prefetch : ( href : string ) => void
5666}
@@ -64,6 +74,17 @@ function useSidebarNav(): SidebarNavValue {
6474 return value
6575}
6676
77+ // Props for a leaf link's <a>: aria-current tracks the loaded page (semantics), while
78+ // data-pending marks the in-flight click so CSS can move the accent bar optimistically
79+ // without changing what screen readers announce as current. data-pending is only set
80+ // while a *different* page is loading, so it never double-marks the already-current item.
81+ function leafLinkProps ( nav : SidebarNavValue , href : string ) {
82+ return {
83+ 'aria-current' : ( nav . routePath === href ? 'page' : false ) as 'page' | false ,
84+ 'data-pending' : nav . pendingHref === href && nav . routePath !== href ? '' : undefined ,
85+ }
86+ }
87+
6788// Separate context for the REST-only scroll-spy state (full asPath with query+hash,
6889// and query). Kept out of SidebarNavValue so its per-navigation identity churn
6990// doesn't invalidate the memoized common items — only RestNavListItem consumes it.
@@ -103,19 +124,43 @@ export const SidebarProduct = () => {
103124 const { asPath, locale, query } = router
104125 const routePath = `/${ locale } ${ asPath . split ( '?' ) [ 0 ] . split ( '#' ) [ 0 ] } `
105126
127+ // Optimistic selection: the href of an in-flight click. Used to move the accent bar
128+ // visually (data-pending) the instant a link is clicked, even while the destination
129+ // page is still loading. This SidebarProduct instance persists during the pending
130+ // fetch (SidebarNav keys it on asPath, which only changes once navigation completes),
131+ // so the state survives the wait and is discarded by the keyed remount when the new
132+ // route lands. aria-current is NOT derived from this — it stays on the loaded route.
133+ const [ pendingHref , setPendingHref ] = useState < string | null > ( null )
134+
106135 const prefetchHref = usePrefetchOnInteraction ( )
107136 // Stable callbacks so memoized items don't re-render on unrelated changes.
108137 const navigate = useCallback (
109- ( event : MouseEvent < HTMLElement > , href : string ) => handleNavClick ( router , event , href ) ,
138+ ( event : MouseEvent < HTMLElement > , href : string ) => {
139+ // Only move the optimistic highlight on a real client-side nav, not on a
140+ // modifier/middle click that opens a new tab (the current page stays put).
141+ if ( handleNavClick ( router , event , href ) ) setPendingHref ( href )
142+ } ,
110143 [ router ] ,
111144 )
112145 const prefetch = useCallback ( ( href : string ) => prefetchHref ( router , href ) , [ router , prefetchHref ] )
113146 const navValue = useMemo < SidebarNavValue > (
114- ( ) => ( { routePath, navigate, prefetch } ) ,
115- [ routePath , navigate , prefetch ] ,
147+ ( ) => ( { routePath, pendingHref , navigate, prefetch } ) ,
148+ [ routePath , pendingHref , navigate , prefetch ] ,
116149 )
117150 const restNavValue = useMemo < RestNavValue > ( ( ) => ( { asPath, query } ) , [ asPath , query ] )
118151
152+ useEffect ( ( ) => {
153+ // Clear the optimistic highlight if a navigation genuinely fails, so it doesn't
154+ // stick on a page that never loaded. Skip cancellations (err.cancelled) — those
155+ // fire when a second click supersedes the first, and pendingHref already points at
156+ // that newer target, which we want to keep highlighted.
157+ const clearPending = ( err : { cancelled ?: boolean } ) => {
158+ if ( ! err ?. cancelled ) setPendingHref ( null )
159+ }
160+ router . events . on ( 'routeChangeError' , clearPending )
161+ return ( ) => router . events . off ( 'routeChangeError' , clearPending )
162+ } , [ router . events ] )
163+
119164 useEffect ( ( ) => {
120165 // Brand NavList auto-expands the whole ancestor chain of the active item, so
121166 // scroll to the item marked aria-current="page" (the active article) rather
@@ -243,14 +288,14 @@ function navListLevelSentinel() {
243288}
244289
245290const LeafLink = memo ( function LeafLink ( { node } : { node : ProductTreeNode } ) {
246- const { routePath , navigate , prefetch } = useSidebarNav ( )
291+ const nav = useSidebarNav ( )
247292 return (
248293 < NavList . Item
249294 as = "a"
250295 href = { node . href }
251- aria-current = { routePath === node . href ? 'page' : false }
252- onClick = { ( event : MouseEvent < HTMLElement > ) => navigate ( event , node . href ) }
253- { ...prefetchHandlers ( prefetch , node . href ) }
296+ { ... leafLinkProps ( nav , node . href ) }
297+ onClick = { ( event : MouseEvent < HTMLElement > ) => nav . navigate ( event , node . href ) }
298+ { ...prefetchHandlers ( nav . prefetch , node . href ) }
254299 >
255300 { node . title }
256301 </ NavList . Item >
@@ -264,9 +309,9 @@ const NavListItem = memo(function NavListItem({
264309 childPage : ProductTreeNode
265310 level ?: number
266311} ) {
267- const { routePath, navigate, prefetch } = useSidebarNav ( )
312+ const nav = useSidebarNav ( )
313+ const { routePath, navigate, prefetch } = nav
268314 const locale = routePath . split ( '/' ) [ 1 ]
269- const isActive = routePath === childPage . href
270315 const hasChildren = childPage . childPages . length > 0
271316 const specialCategory = childPage . layout === 'category-landing'
272317 const canNest = level < MAX_NAVLIST_LEVEL
@@ -307,7 +352,7 @@ const NavListItem = memo(function NavListItem({
307352 < NavList . Item
308353 as = "a"
309354 href = { sidebarLinkHref }
310- aria-current = { routePath === sidebarLinkHref ? 'page' : false }
355+ { ... leafLinkProps ( nav , sidebarLinkHref ) }
311356 onClick = { ( event : MouseEvent < HTMLElement > ) => navigate ( event , sidebarLinkHref ) }
312357 { ...prefetchHandlers ( prefetch , sidebarLinkHref ) }
313358 >
@@ -318,7 +363,7 @@ const NavListItem = memo(function NavListItem({
318363 < NavList . Item
319364 as = "a"
320365 href = { childPage . href }
321- aria-current = { isActive ? 'page' : false }
366+ { ... leafLinkProps ( nav , childPage . href ) }
322367 onClick = { ( event : MouseEvent < HTMLElement > ) => navigate ( event , childPage . href ) }
323368 { ...prefetchHandlers ( prefetch , childPage . href ) }
324369 >
@@ -333,7 +378,8 @@ const NavListItem = memo(function NavListItem({
333378} )
334379
335380function RestNavListItem ( { category } : { category : ProductTreeNode } ) {
336- const { routePath, navigate, prefetch } = useSidebarNav ( )
381+ const nav = useSidebarNav ( )
382+ const { routePath, navigate, prefetch } = nav
337383 const { asPath, query } = useRestNav ( )
338384 const [ visibleAnchor , setVisibleAnchor ] = useState ( '' )
339385 const miniTocItems =
@@ -378,7 +424,7 @@ function RestNavListItem({ category }: { category: ProductTreeNode }) {
378424 < NavList . Item
379425 as = "a"
380426 href = { category . href }
381- aria-current = { routePath === category . href ? 'page' : false }
427+ { ... leafLinkProps ( nav , category . href ) }
382428 onClick = { ( event : MouseEvent < HTMLElement > ) => navigate ( event , category . href ) }
383429 { ...prefetchHandlers ( prefetch , category . href ) }
384430 >
@@ -429,7 +475,7 @@ function RestNavListItem({ category }: { category: ProductTreeNode }) {
429475 key = { childPage . href }
430476 as = "a"
431477 href = { childPage . href }
432- aria-current = { routePath === childPage . href ? 'page' : false }
478+ { ... leafLinkProps ( nav , childPage . href ) }
433479 onClick = { ( event : MouseEvent < HTMLElement > ) => navigate ( event , childPage . href ) }
434480 { ...prefetchHandlers ( prefetch , childPage . href ) }
435481 >
0 commit comments