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))