From 5ab233bd210bdd7afdd71ebbb3fd53a061e90751 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Tue, 19 May 2026 16:19:28 +0200 Subject: [PATCH 1/4] 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 Co-authored-by: Cursor --- .../Assets/pages-nav-v2.test.ts | 135 ++++++++++++++++++ .../Assets/pages-nav-v2.ts | 80 ++++++----- 2 files changed, 183 insertions(+), 32 deletions(-) create mode 100644 src/Elastic.Documentation.Site/Assets/pages-nav-v2.test.ts diff --git a/src/Elastic.Documentation.Site/Assets/pages-nav-v2.test.ts b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.test.ts new file mode 100644 index 000000000..e1f07d4bb --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.test.ts @@ -0,0 +1,135 @@ +import { initNavV2 } from './pages-nav-v2' + +jest.mock('tippy.js', () => ({ + __esModule: true, + default: jest.fn(() => ({ + destroy: jest.fn(), + setContent: jest.fn(), + })), +})) + +function renderNav() { + document.body.innerHTML = ` + + ` + + return document.querySelector('[data-nav-v2]')! +} + +function checkbox(id: string): HTMLInputElement { + return document.getElementById(id) as HTMLInputElement +} + +describe('initNavV2', () => { + const originalRequestAnimationFrame = window.requestAnimationFrame + + beforeEach(() => { + sessionStorage.clear() + window.requestAnimationFrame = ((cb: FrameRequestCallback) => { + cb(0) + return 0 + }) as typeof window.requestAnimationFrame + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + afterAll(() => { + window.requestAnimationFrame = originalRequestAnimationFrame + }) + + it('keeps only the active page branch expanded', () => { + window.history.pushState({}, '', '/guide/a/topic-1/current-page') + + const nav = renderNav() + + initNavV2(nav) + + expect(checkbox('group-a').checked).toBe(true) + expect(checkbox('group-a-1').checked).toBe(true) + expect(checkbox('group-a-2').checked).toBe(false) + expect(checkbox('group-b').checked).toBe(false) + }) + + it('collapses sibling folders when a new folder is opened manually', () => { + window.history.pushState({}, '', '/guide/a/topic-1/current-page') + + const nav = renderNav() + + initNavV2(nav) + + const groupA = checkbox('group-a') + const groupB = checkbox('group-b') + groupB.checked = true + groupB.dispatchEvent(new Event('change', { bubbles: true })) + + expect(groupB.checked).toBe(true) + expect(groupA.checked).toBe(false) + }) +}) diff --git a/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts index 83726c464..221b4c9cc 100644 --- a/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts +++ b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts @@ -233,37 +233,35 @@ function ensureNavV2OptimisticCurrentOnNavigate() { } /** - * Returns all sibling top-level accordion checkboxes for a given checkbox. - * Siblings are other checkboxes inside [data-v2-accordion] elements at the - * same nesting level as the given checkbox's ancestor accordion. + * Returns all sibling folder checkboxes at the same nesting level. */ function getSiblingAccordionCheckboxes( checkbox: HTMLInputElement ): HTMLInputElement[] { - const accordion = checkbox.closest('[data-v2-accordion]') - if (!accordion) { + const group = checkbox.closest('li.group-navigation') + if (!group) { return [] } - const parent = accordion.parentElement + const parent = group.parentElement if (!parent) { return [] } return Array.from( parent.querySelectorAll( - '[data-v2-accordion] > .peer input[type=checkbox]' + ':scope > li.group-navigation > .nav-folder-peer input[type=checkbox]' ) ).filter((c) => c !== checkbox) } /** - * Accordion behaviour: when a top-level section is opened, - * collapse all its siblings so only one section is expanded at a time. + * Accordion behaviour: when a folder is opened, collapse its siblings so + * only one branch stays expanded at that nesting level. */ function initAccordion(nav: HTMLElement) { nav.querySelectorAll( - '[data-v2-accordion] > .peer input[type=checkbox]' + 'li.group-navigation > .nav-folder-peer input[type=checkbox]' ).forEach((cb) => { if (cb.dataset.navV2AccordionBound === 'true') { return @@ -281,6 +279,25 @@ function initAccordion(nav: HTMLElement) { }) } +function getAllFolderCheckboxes(nav: HTMLElement): HTMLInputElement[] { + return Array.from( + nav.querySelectorAll( + 'li.group-navigation > .nav-folder-peer input[type=checkbox]' + ) + ) +} + +function collapseInactiveFolders( + nav: HTMLElement, + activeCheckboxes: Set +) { + getAllFolderCheckboxes(nav).forEach((cb) => { + if (!activeCheckboxes.has(cb)) { + cb.checked = false + } + }) +} + function warmFolderSubtreeLayoutFromPeer(peer: HTMLElement) { const li = peer.parentElement if (!li?.matches('li.group-navigation')) { @@ -568,60 +585,59 @@ function pickDeepestAnchorMatchingPath( function expandToCurrentPageForPath(nav: HTMLElement, pathnameRaw: string) { const link = pickDeepestAnchorMatchingPath(nav, pathnameRaw) if (!link) { + collapseInactiveFolders(nav, new Set()) return } const collapsedIds = readCollapsedFolderIds() + const activeCheckboxes = new Set() + let wroteCollapsedIds = false let el: Element | null = link.parentElement while (el && el !== nav) { if (el.matches('li')) { const cb = el.querySelector( - ':scope > .peer input[type=checkbox]' + ':scope > .nav-folder-peer input[type=checkbox]' ) if (cb && cb.id) { - const rowLink = el.querySelector( - ':scope > .nav-folder-peer > a.sidebar-link' - ) - const currentIsThisFolderRow = - rowLink !== null && rowLink === link - + activeCheckboxes.add(cb) if (collapsedIds.has(cb.id)) { - if (currentIsThisFolderRow) { - // User collapsed this folder while its index is current; HTML swap often - // re-checks the input — force closed so a second click can stay collapsed. - cb.checked = false - } else { - collapsedIds.delete(cb.id) - writeCollapsedFolderIds(collapsedIds) - cb.checked = true - } - } else { - cb.checked = true + collapsedIds.delete(cb.id) + wroteCollapsedIds = true } + cb.checked = true } else if (cb) { + activeCheckboxes.add(cb) cb.checked = true } } el = el.parentElement } + + if (wroteCollapsedIds) { + writeCollapsedFolderIds(collapsedIds) + } + + collapseInactiveFolders(nav, activeCheckboxes) } /** * Expand all ancestor collapsible sections that contain the current page link, - * so that navigating directly to a URL reveals its location in the sidebar. - * Does not re-open a folder row that the user collapsed while that folder index - * is the current page (see session storage + folder row link match). + * so that navigating directly to a URL reveals its location in the sidebar, + * while collapsing folders that are outside the active branch. */ function expandToCurrentPage(nav: HTMLElement) { if (isOnSectionRootPage(nav)) { + const activeCheckboxes = new Set() const topLevelFolders = nav.querySelectorAll( - '#nav-tree > li > .peer > input[type="checkbox"]' + '#nav-tree > li.group-navigation > .nav-folder-peer > input[type="checkbox"]' ) if (topLevelFolders.length === 1) { topLevelFolders[0].checked = true + activeCheckboxes.add(topLevelFolders[0]) } + collapseInactiveFolders(nav, activeCheckboxes) return } expandToCurrentPageForPath(nav, window.location.pathname) From acdfe6a212a9b9ff81c1724f1d70d893aa15bc83 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Tue, 19 May 2026 19:03:19 +0200 Subject: [PATCH 2/4] 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 Co-authored-by: Cursor --- .../Assets/pages-nav-v2.test.ts | 176 ++++++++++++------ .../Assets/pages-nav-v2.ts | 87 +++++++++ 2 files changed, 201 insertions(+), 62 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/pages-nav-v2.test.ts b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.test.ts index e1f07d4bb..9a37c5901 100644 --- a/src/Elastic.Documentation.Site/Assets/pages-nav-v2.test.ts +++ b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.test.ts @@ -10,72 +10,74 @@ jest.mock('tippy.js', () => ({ function renderNav() { document.body.innerHTML = ` - + + + + + + ` return document.querySelector('[data-nav-v2]')! @@ -85,6 +87,10 @@ function checkbox(id: string): HTMLInputElement { return document.getElementById(id) as HTMLInputElement } +function groupRow(id: string): HTMLElement { + return checkbox(id).closest('li.group-navigation') as HTMLElement +} + describe('initNavV2', () => { const originalRequestAnimationFrame = window.requestAnimationFrame @@ -132,4 +138,50 @@ describe('initNavV2', () => { expect(groupB.checked).toBe(true) expect(groupA.checked).toBe(false) }) + + it('scrolls the active branch into view when it opens below the viewport', () => { + window.history.pushState({}, '', '/guide/b/page') + + const nav = renderNav() + const container = document.querySelector( + '.pages-nav-menu' + ) as HTMLElement + const groupB = groupRow('group-b') + + Object.defineProperty(container, 'scrollTop', { + value: 0, + writable: true, + configurable: true, + }) + Object.defineProperty(container, 'clientHeight', { + value: 120, + configurable: true, + }) + container.getBoundingClientRect = jest.fn(() => ({ + x: 0, + y: 0, + top: 0, + right: 280, + bottom: 120, + left: 0, + width: 280, + height: 120, + toJSON: () => ({}), + })) + groupB.getBoundingClientRect = jest.fn(() => ({ + x: 0, + y: 150, + top: 150, + right: 280, + bottom: 250, + left: 0, + width: 280, + height: 100, + toJSON: () => ({}), + })) + + initNavV2(nav) + + expect(container.scrollTop).toBe(142) + }) }) diff --git a/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts index 221b4c9cc..5f246e578 100644 --- a/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts +++ b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts @@ -8,6 +8,7 @@ let navV2FolderLinkToggleBound = false let navV2OptimisticNavigateBound = false let navV2TruncationTippyInstances: Instance[] = [] +const navV2ActiveBranchScrollTimeouts = new WeakMap() function readCollapsedFolderIds(): Set { try { @@ -227,6 +228,7 @@ function ensureNavV2OptimisticCurrentOnNavigate() { markCurrentPageForPath(nav, path) expandToCurrentPageForPath(nav, path) applyActiveSubtreeHighlight(nav) + scheduleActiveBranchScroll(nav) }, true ) @@ -298,6 +300,90 @@ function collapseInactiveFolders( }) } +function findNavV2ScrollContainer(nav: HTMLElement): HTMLElement | null { + return nav.closest('.pages-nav-menu') +} + +function pickActiveBranchScrollTarget( + nav: HTMLElement, + current: HTMLAnchorElement +): HTMLElement | null { + let target = current.closest('li') + let walk = target + while (walk && walk !== nav) { + if (walk.matches('li.group-navigation')) { + target = walk + } + + walk = walk.parentElement?.closest('li') ?? null + } + + return target +} + +function scrollActiveBranchIntoView(nav: HTMLElement) { + const container = findNavV2ScrollContainer(nav) + if (!container) { + return + } + + const current = deepestCurrentSidebarLink(nav) + if (!current) { + return + } + + const target = pickActiveBranchScrollTarget(nav, current) + if (!target) { + return + } + + const containerRect = container.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + const topPadding = 8 + const bottomPadding = 16 + const visibleTop = containerRect.top + topPadding + const visibleBottom = containerRect.bottom - bottomPadding + + if (targetRect.top >= visibleTop && targetRect.bottom <= visibleBottom) { + return + } + + const targetTop = + targetRect.top - containerRect.top + container.scrollTop - topPadding + container.scrollTop = Math.max(0, targetTop) +} + +function scheduleActiveBranchScroll(nav: HTMLElement) { + const existingTimeouts = navV2ActiveBranchScrollTimeouts.get(nav) + if (existingTimeouts !== undefined) { + existingTimeouts.forEach((timeoutId) => window.clearTimeout(timeoutId)) + } + + const run = () => { + if (!nav.isConnected) { + return + } + + scrollActiveBranchIntoView(nav) + } + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + run() + }) + }) + + const timeoutIds = [260, 520].map((delay, index) => + window.setTimeout(() => { + run() + if (index === 1) { + navV2ActiveBranchScrollTimeouts.delete(nav) + } + }, delay) + ) + navV2ActiveBranchScrollTimeouts.set(nav, timeoutIds) +} + function warmFolderSubtreeLayoutFromPeer(peer: HTMLElement) { const li = peer.parentElement if (!li?.matches('li.group-navigation')) { @@ -720,6 +806,7 @@ export function initNavV2(nav: HTMLElement) { markCurrentPage(nav) expandToCurrentPage(nav) applyActiveSubtreeHighlight(nav) + scheduleActiveBranchScroll(nav) initNavV2FolderLayoutWarmup(nav) requestAnimationFrame(() => { requestAnimationFrame(() => initNavV2TruncationTooltips(nav)) From 174e219001777618b81283e6a8280d6c70f30fcb Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Tue, 19 May 2026 19:06:31 +0200 Subject: [PATCH 3/4] 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 Co-authored-by: Cursor --- .../Assets/pages-nav-v2.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts index 5f246e578..845146c7e 100644 --- a/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts +++ b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts @@ -9,6 +9,7 @@ let navV2OptimisticNavigateBound = false let navV2TruncationTippyInstances: Instance[] = [] const navV2ActiveBranchScrollTimeouts = new WeakMap() +const navV2ActiveBranchScrollObservers = new WeakMap() function readCollapsedFolderIds(): Set { try { @@ -384,6 +385,39 @@ function scheduleActiveBranchScroll(nav: HTMLElement) { navV2ActiveBranchScrollTimeouts.set(nav, timeoutIds) } +function initActiveBranchScrollObserver(nav: HTMLElement) { + if ( + typeof ResizeObserver === 'undefined' || + navV2ActiveBranchScrollObservers.has(nav) + ) { + return + } + + const observer = new ResizeObserver(() => { + if (!nav.isConnected) { + observer.disconnect() + navV2ActiveBranchScrollObservers.delete(nav) + return + } + + scheduleActiveBranchScroll(nav) + }) + navV2ActiveBranchScrollObservers.set(nav, observer) + + const container = findNavV2ScrollContainer(nav) + const stickyChrome = + container?.previousElementSibling instanceof HTMLElement + ? container.previousElementSibling + : null + + if (container) { + observer.observe(container) + } + if (stickyChrome) { + observer.observe(stickyChrome) + } +} + function warmFolderSubtreeLayoutFromPeer(peer: HTMLElement) { const li = peer.parentElement if (!li?.matches('li.group-navigation')) { @@ -806,6 +840,7 @@ export function initNavV2(nav: HTMLElement) { markCurrentPage(nav) expandToCurrentPage(nav) applyActiveSubtreeHighlight(nav) + initActiveBranchScrollObserver(nav) scheduleActiveBranchScroll(nav) initNavV2FolderLayoutWarmup(nav) requestAnimationFrame(() => { From cf3175eb09e1b47a4f0c30111bb0355aeeaf561e Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Tue, 19 May 2026 19:15:11 +0200 Subject: [PATCH 4/4] 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 Co-authored-by: Cursor --- src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts index 845146c7e..daa86d6f3 100644 --- a/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts +++ b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts @@ -9,7 +9,10 @@ let navV2OptimisticNavigateBound = false let navV2TruncationTippyInstances: Instance[] = [] const navV2ActiveBranchScrollTimeouts = new WeakMap() -const navV2ActiveBranchScrollObservers = new WeakMap() +const navV2ActiveBranchScrollObservers = new WeakMap< + HTMLElement, + ResizeObserver +>() function readCollapsedFolderIds(): Set { try {