Skip to content

Commit 3e8c488

Browse files
theletterfcodexcursoragent
authored
Keep nav-v2 focused on the active section (#3354)
* Keep nav-v2 focused on the active section Same-section navigation could leave stale folders expanded, which made the sidebar show multiple open branches. Recomputing expansion from the active page path keeps one relevant branch open and preserves accordion behavior when folders are opened manually. Co-Authored-By: OpenAI GPT-5.4 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * Auto-scroll nav-v2 to the active branch Opening a lower section could still leave part of the active branch clipped below the sidebar viewport. Re-scrolling after the nav layout settles keeps the selected branch fully visible and locks in the behavior with a focused regression test. Co-Authored-By: OpenAI GPT-5.4 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * Re-scroll nav-v2 after sidebar chrome resizes The Jump to page web component can alter the sidebar chrome after the active branch scroll is first calculated. Observing the sidebar chrome and scroll container makes the active branch scroll correction respond to those late layout changes. Co-Authored-By: OpenAI GPT-5.4 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> * Format nav-v2 scroll observer Prettier wrapped the ResizeObserver map type so the npm formatting check passes in CI. Co-Authored-By: OpenAI GPT-5.4 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: OpenAI GPT-5.4 <noreply@openai.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2889d66 commit 3e8c488

2 files changed

Lines changed: 360 additions & 32 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { initNavV2 } from './pages-nav-v2'
2+
3+
jest.mock('tippy.js', () => ({
4+
__esModule: true,
5+
default: jest.fn(() => ({
6+
destroy: jest.fn(),
7+
setContent: jest.fn(),
8+
})),
9+
}))
10+
11+
function renderNav() {
12+
document.body.innerHTML = `
13+
<div class="pages-nav-menu">
14+
<nav class="docs-sidebar-nav-v2" data-nav-v2>
15+
<ul class="docs-sidebar-nav-v2__tree" id="nav-tree">
16+
<li class="relative">
17+
<span class="docs-sidebar-nav-v2__label docs-sidebar-nav-v2__label--top">Guides</span>
18+
<ul class="docs-sidebar-nav-v2__label-children w-full">
19+
<li class="flex flex-wrap group-navigation relative">
20+
<div class="peer nav-folder-peer grid w-full grid-cols-1">
21+
<input id="group-a" type="checkbox" checked />
22+
<a href="/guide/a" class="sidebar-link nav-v2-link">Group A</a>
23+
</div>
24+
<div class="docs-sidebar-nav-v2__folder-clip w-full">
25+
<div class="docs-sidebar-nav-v2__folder-clip-inner">
26+
<ul class="docs-sidebar-nav-v2__folder-children w-full relative">
27+
<li class="flex flex-wrap group-navigation relative">
28+
<div class="peer nav-folder-peer grid w-full grid-cols-1">
29+
<input id="group-a-1" type="checkbox" checked />
30+
<a href="/guide/a/topic-1" class="sidebar-link nav-v2-link">Topic 1</a>
31+
</div>
32+
<div class="docs-sidebar-nav-v2__folder-clip w-full">
33+
<div class="docs-sidebar-nav-v2__folder-clip-inner">
34+
<ul class="docs-sidebar-nav-v2__folder-children w-full relative">
35+
<li class="flex group/li">
36+
<a href="/guide/a/topic-1/current-page" class="sidebar-link nav-v2-link">Current page</a>
37+
</li>
38+
</ul>
39+
</div>
40+
</div>
41+
</li>
42+
<li class="flex flex-wrap group-navigation relative">
43+
<div class="peer nav-folder-peer grid w-full grid-cols-1">
44+
<input id="group-a-2" type="checkbox" checked />
45+
<a href="/guide/a/topic-2" class="sidebar-link nav-v2-link">Topic 2</a>
46+
</div>
47+
<div class="docs-sidebar-nav-v2__folder-clip w-full">
48+
<div class="docs-sidebar-nav-v2__folder-clip-inner">
49+
<ul class="docs-sidebar-nav-v2__folder-children w-full relative">
50+
<li class="flex group/li">
51+
<a href="/guide/a/topic-2/other-page" class="sidebar-link nav-v2-link">Other page</a>
52+
</li>
53+
</ul>
54+
</div>
55+
</div>
56+
</li>
57+
</ul>
58+
</div>
59+
</div>
60+
</li>
61+
<li class="flex flex-wrap group-navigation relative">
62+
<div class="peer nav-folder-peer grid w-full grid-cols-1">
63+
<input id="group-b" type="checkbox" checked />
64+
<a href="/guide/b" class="sidebar-link nav-v2-link">Group B</a>
65+
</div>
66+
<div class="docs-sidebar-nav-v2__folder-clip w-full">
67+
<div class="docs-sidebar-nav-v2__folder-clip-inner">
68+
<ul class="docs-sidebar-nav-v2__folder-children w-full relative">
69+
<li class="flex group/li">
70+
<a href="/guide/b/page" class="sidebar-link nav-v2-link">Group B page</a>
71+
</li>
72+
</ul>
73+
</div>
74+
</div>
75+
</li>
76+
</ul>
77+
</li>
78+
</ul>
79+
</nav>
80+
</div>
81+
`
82+
83+
return document.querySelector<HTMLElement>('[data-nav-v2]')!
84+
}
85+
86+
function checkbox(id: string): HTMLInputElement {
87+
return document.getElementById(id) as HTMLInputElement
88+
}
89+
90+
function groupRow(id: string): HTMLElement {
91+
return checkbox(id).closest('li.group-navigation') as HTMLElement
92+
}
93+
94+
describe('initNavV2', () => {
95+
const originalRequestAnimationFrame = window.requestAnimationFrame
96+
97+
beforeEach(() => {
98+
sessionStorage.clear()
99+
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
100+
cb(0)
101+
return 0
102+
}) as typeof window.requestAnimationFrame
103+
})
104+
105+
afterEach(() => {
106+
document.body.innerHTML = ''
107+
})
108+
109+
afterAll(() => {
110+
window.requestAnimationFrame = originalRequestAnimationFrame
111+
})
112+
113+
it('keeps only the active page branch expanded', () => {
114+
window.history.pushState({}, '', '/guide/a/topic-1/current-page')
115+
116+
const nav = renderNav()
117+
118+
initNavV2(nav)
119+
120+
expect(checkbox('group-a').checked).toBe(true)
121+
expect(checkbox('group-a-1').checked).toBe(true)
122+
expect(checkbox('group-a-2').checked).toBe(false)
123+
expect(checkbox('group-b').checked).toBe(false)
124+
})
125+
126+
it('collapses sibling folders when a new folder is opened manually', () => {
127+
window.history.pushState({}, '', '/guide/a/topic-1/current-page')
128+
129+
const nav = renderNav()
130+
131+
initNavV2(nav)
132+
133+
const groupA = checkbox('group-a')
134+
const groupB = checkbox('group-b')
135+
groupB.checked = true
136+
groupB.dispatchEvent(new Event('change', { bubbles: true }))
137+
138+
expect(groupB.checked).toBe(true)
139+
expect(groupA.checked).toBe(false)
140+
})
141+
142+
it('scrolls the active branch into view when it opens below the viewport', () => {
143+
window.history.pushState({}, '', '/guide/b/page')
144+
145+
const nav = renderNav()
146+
const container = document.querySelector(
147+
'.pages-nav-menu'
148+
) as HTMLElement
149+
const groupB = groupRow('group-b')
150+
151+
Object.defineProperty(container, 'scrollTop', {
152+
value: 0,
153+
writable: true,
154+
configurable: true,
155+
})
156+
Object.defineProperty(container, 'clientHeight', {
157+
value: 120,
158+
configurable: true,
159+
})
160+
container.getBoundingClientRect = jest.fn(() => ({
161+
x: 0,
162+
y: 0,
163+
top: 0,
164+
right: 280,
165+
bottom: 120,
166+
left: 0,
167+
width: 280,
168+
height: 120,
169+
toJSON: () => ({}),
170+
}))
171+
groupB.getBoundingClientRect = jest.fn(() => ({
172+
x: 0,
173+
y: 150,
174+
top: 150,
175+
right: 280,
176+
bottom: 250,
177+
left: 0,
178+
width: 280,
179+
height: 100,
180+
toJSON: () => ({}),
181+
}))
182+
183+
initNavV2(nav)
184+
185+
expect(container.scrollTop).toBe(142)
186+
})
187+
})

0 commit comments

Comments
 (0)