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..9a37c5901
--- /dev/null
+++ b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.test.ts
@@ -0,0 +1,187 @@
+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
+}
+
+function groupRow(id: string): HTMLElement {
+ return checkbox(id).closest('li.group-navigation') as HTMLElement
+}
+
+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)
+ })
+
+ 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 83726c464..daa86d6f3 100644
--- a/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts
+++ b/src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts
@@ -8,6 +8,11 @@ let navV2FolderLinkToggleBound = false
let navV2OptimisticNavigateBound = false
let navV2TruncationTippyInstances: Instance[] = []
+const navV2ActiveBranchScrollTimeouts = new WeakMap()
+const navV2ActiveBranchScrollObservers = new WeakMap<
+ HTMLElement,
+ ResizeObserver
+>()
function readCollapsedFolderIds(): Set {
try {
@@ -227,43 +232,42 @@ function ensureNavV2OptimisticCurrentOnNavigate() {
markCurrentPageForPath(nav, path)
expandToCurrentPageForPath(nav, path)
applyActiveSubtreeHighlight(nav)
+ scheduleActiveBranchScroll(nav)
},
true
)
}
/**
- * 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 +285,142 @@ 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 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 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')) {
@@ -568,60 +708,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)
@@ -704,6 +843,8 @@ export function initNavV2(nav: HTMLElement) {
markCurrentPage(nav)
expandToCurrentPage(nav)
applyActiveSubtreeHighlight(nav)
+ initActiveBranchScrollObserver(nav)
+ scheduleActiveBranchScroll(nav)
initNavV2FolderLayoutWarmup(nav)
requestAnimationFrame(() => {
requestAnimationFrame(() => initNavV2TruncationTooltips(nav))