Skip to content

Commit f28f18c

Browse files
authored
Highlight the clicked sidebar item immediately (#62499)
1 parent 6a8580f commit f28f18c

3 files changed

Lines changed: 154 additions & 17 deletions

File tree

src/fixtures/tests/playwright-rendering.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,58 @@ test('use sidebar to go to Hello World page', async ({ page }) => {
5252
await expect(page).toHaveTitle(/Hello World - GitHub Docs/)
5353
})
5454

55+
test('sidebar highlights the clicked item optimistically while navigation is pending', async ({
56+
page,
57+
}) => {
58+
// Article pages are getServerSideProps routes, so router.asPath (and thus the real
59+
// aria-current) only updates after the destination loads. The sidebar marks the
60+
// clicked link with a visual-only `data-pending` accent so the click is acknowledged
61+
// immediately. Throttle the client-side data fetch so the navigation stays pending
62+
// long enough to observe that intermediate state.
63+
await page.goto('/get-started')
64+
await page.getByTestId('product-sidebar').getByText('Start your journey').click()
65+
66+
const sidebar = page.getByTestId('product-sidebar')
67+
const helloWorld = sidebar.getByRole('link', { name: 'Hello World' })
68+
const linkRewriting = sidebar.getByRole('link', { name: 'Link rewriting' })
69+
70+
// Hold the next data request open until we release it, so navigation stays pending.
71+
let releaseNavigation = () => {}
72+
const navigationHeld = new Promise<void>((resolve) => {
73+
releaseNavigation = resolve
74+
})
75+
await page.route('**/_next/data/**', async (route) => {
76+
await navigationHeld
77+
await route.continue()
78+
})
79+
80+
await helloWorld.click()
81+
82+
// While pending: the clicked link carries the optimistic visual marker, but the URL
83+
// and the semantic aria-current still reflect the (still-loaded) get-started page.
84+
await expect(helloWorld).toHaveAttribute('data-pending', '')
85+
await expect(helloWorld).not.toHaveAttribute('aria-current', 'page')
86+
await expect(page).not.toHaveURL(/hello-world/)
87+
88+
// Let the navigation finish: the marker gives way to a real aria-current.
89+
releaseNavigation()
90+
await expect(page).toHaveURL(/\/en\/get-started\/start-your-journey\/hello-world/)
91+
await expect(helloWorld).toHaveAttribute('aria-current', 'page')
92+
await expect(helloWorld).not.toHaveAttribute('data-pending', '')
93+
94+
// A modifier-click (open in new tab) must NOT move the optimistic selection.
95+
// handleNavClick bails on modifier clicks, so pendingHref is never set: the current
96+
// page keeps its URL, its aria-current, and the clicked link gets no data-pending.
97+
// Use ControlOrMeta so the real "open in new tab" modifier is sent per-platform
98+
// (Ctrl on Linux/Windows CI, Meta on macOS). The click opens a background tab we
99+
// don't need to assert on; catch any popup so it doesn't leak.
100+
page.on('popup', (popup) => popup.close())
101+
await linkRewriting.click({ modifiers: ['ControlOrMeta'] })
102+
await expect(linkRewriting).not.toHaveAttribute('data-pending', '')
103+
await expect(helloWorld).toHaveAttribute('aria-current', 'page')
104+
await expect(page).toHaveURL(/\/en\/get-started\/start-your-journey\/hello-world/)
105+
})
106+
55107
test('press "/" to open the search overlay', async ({ page }) => {
56108
await page.goto('/')
57109
await turnOffExperimentsInPage(page)

src/landings/components/SidebarProduct.module.scss

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@
77
// so a top-level article like "Quickstart" gets only the subtle background pill
88
// and no green bar. Restore the bar for active top-level leaves to match nested
99
// items. Brand's classes are hashed, so match on the stable substrings.
10+
//
11+
// This also covers `[data-pending]` — the optimistic marker on a just-clicked item
12+
// (see the pending rules below) — so a clicked top-level leaf gets the bar too.
1013
:global([class*="NavList__item--leaf"][class*="NavList__item--level-1"])
1114
:global([class*="NavList__link"])[aria-current]:not(
1215
[aria-current="false"]
13-
)::before {
16+
)::before,
17+
:global([class*="NavList__item--leaf"][class*="NavList__item--level-1"])
18+
:global([class*="NavList__link"])[data-pending]::before {
1419
content: "";
1520
position: absolute;
1621
// Level-1 links are shorter (~24px) than nested leaves, so use a small inset
@@ -25,4 +30,38 @@
2530
border-radius: var(--base-size-2);
2631
background-color: var(--brand-NavList-activeIndicator-color);
2732
}
33+
34+
// Optimistic "pending" highlight. Article pages are getServerSideProps routes and can
35+
// be slow to load, so router.asPath — and thus aria-current — only updates once the
36+
// page has loaded. We keep aria-current on the *loaded* page (assistive tech must not
37+
// be told a still-loading destination is the current page), and instead mark the
38+
// just-clicked link with `data-pending` for a VISUAL-ONLY accent. Brand couples its
39+
// active styling to `[aria-current]`, so mirror that treatment here for `[data-pending]`.
40+
// data-pending is only set while a *different* page is loading, so it never collides
41+
// with the real aria-current item.
42+
:global([class*="NavList__link"])[data-pending] {
43+
color: var(--brand-color-text-default);
44+
background-color: var(--brand-color-canvas-subtle);
45+
}
46+
47+
// Nested leaves: brand moves the background pill onto the label and adds a leading
48+
// accent bar (mirrors `.item--leaf:not(.item--level-1) .link[aria-current]`).
49+
:global([class*="NavList__item--leaf"]:not([class*="NavList__item--level-1"]))
50+
:global([class*="NavList__link"])[data-pending] {
51+
background-color: transparent;
52+
53+
:global([class*="NavList__labelArea"]) {
54+
background-color: var(--brand-color-canvas-subtle);
55+
}
56+
57+
&::before {
58+
content: "";
59+
position: absolute;
60+
inset-block: var(--base-size-8);
61+
inset-inline-start: 0;
62+
width: var(--base-size-4);
63+
border-radius: var(--base-size-2);
64+
background-color: var(--brand-NavList-activeIndicator-color);
65+
}
66+
}
2867
}

src/landings/components/SidebarProduct.tsx

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
5255
type 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

245290
const 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

335380
function 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

Comments
 (0)