Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions src/Elastic.Documentation.Site/Assets/pages-nav-v2.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<nav class="docs-sidebar-nav-v2" data-nav-v2>
<ul class="docs-sidebar-nav-v2__tree" id="nav-tree">
<li class="relative">
<span class="docs-sidebar-nav-v2__label docs-sidebar-nav-v2__label--top">Guides</span>
<ul class="docs-sidebar-nav-v2__label-children w-full">
<li class="flex flex-wrap group-navigation relative">
<div class="peer nav-folder-peer grid w-full grid-cols-1">
<input id="group-a" type="checkbox" checked />
<a href="/guide/a" class="sidebar-link nav-v2-link">Group A</a>
</div>
<div class="docs-sidebar-nav-v2__folder-clip w-full">
<div class="docs-sidebar-nav-v2__folder-clip-inner">
<ul class="docs-sidebar-nav-v2__folder-children w-full relative">
<li class="flex flex-wrap group-navigation relative">
<div class="peer nav-folder-peer grid w-full grid-cols-1">
<input id="group-a-1" type="checkbox" checked />
<a href="/guide/a/topic-1" class="sidebar-link nav-v2-link">Topic 1</a>
</div>
<div class="docs-sidebar-nav-v2__folder-clip w-full">
<div class="docs-sidebar-nav-v2__folder-clip-inner">
<ul class="docs-sidebar-nav-v2__folder-children w-full relative">
<li class="flex group/li">
<a href="/guide/a/topic-1/current-page" class="sidebar-link nav-v2-link">Current page</a>
</li>
</ul>
</div>
</div>
</li>
<li class="flex flex-wrap group-navigation relative">
<div class="peer nav-folder-peer grid w-full grid-cols-1">
<input id="group-a-2" type="checkbox" checked />
<a href="/guide/a/topic-2" class="sidebar-link nav-v2-link">Topic 2</a>
</div>
<div class="docs-sidebar-nav-v2__folder-clip w-full">
<div class="docs-sidebar-nav-v2__folder-clip-inner">
<ul class="docs-sidebar-nav-v2__folder-children w-full relative">
<li class="flex group/li">
<a href="/guide/a/topic-2/other-page" class="sidebar-link nav-v2-link">Other page</a>
</li>
</ul>
</div>
</div>
</li>
</ul>
</div>
</div>
</li>
<li class="flex flex-wrap group-navigation relative">
<div class="peer nav-folder-peer grid w-full grid-cols-1">
<input id="group-b" type="checkbox" checked />
<a href="/guide/b" class="sidebar-link nav-v2-link">Group B</a>
</div>
<div class="docs-sidebar-nav-v2__folder-clip w-full">
<div class="docs-sidebar-nav-v2__folder-clip-inner">
<ul class="docs-sidebar-nav-v2__folder-children w-full relative">
<li class="flex group/li">
<a href="/guide/b/page" class="sidebar-link nav-v2-link">Group B page</a>
</li>
</ul>
</div>
</div>
</li>
</ul>
</li>
</ul>
</nav>
`

return document.querySelector<HTMLElement>('[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)
})
})
80 changes: 48 additions & 32 deletions src/Elastic.Documentation.Site/Assets/pages-nav-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>(
'[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<HTMLInputElement>(
'[data-v2-accordion] > .peer input[type=checkbox]'
'li.group-navigation > .nav-folder-peer input[type=checkbox]'
).forEach((cb) => {
if (cb.dataset.navV2AccordionBound === 'true') {
return
Expand All @@ -281,6 +279,25 @@ function initAccordion(nav: HTMLElement) {
})
}

function getAllFolderCheckboxes(nav: HTMLElement): HTMLInputElement[] {
return Array.from(
nav.querySelectorAll<HTMLInputElement>(
'li.group-navigation > .nav-folder-peer input[type=checkbox]'
)
)
}

function collapseInactiveFolders(
nav: HTMLElement,
activeCheckboxes: Set<HTMLInputElement>
) {
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')) {
Expand Down Expand Up @@ -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<HTMLInputElement>()
let wroteCollapsedIds = false

let el: Element | null = link.parentElement
while (el && el !== nav) {
if (el.matches('li')) {
const cb = el.querySelector<HTMLInputElement>(
':scope > .peer input[type=checkbox]'
':scope > .nav-folder-peer input[type=checkbox]'
)
if (cb && cb.id) {
const rowLink = el.querySelector<HTMLElement>(
':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<HTMLInputElement>()
const topLevelFolders = nav.querySelectorAll<HTMLInputElement>(
'#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)
Expand Down
Loading