diff --git a/src/core/event/index.js b/src/core/event/index.js index ad194647c..cc9f5e821 100644 --- a/src/core/event/index.js +++ b/src/core/event/index.js @@ -252,6 +252,17 @@ export function Events(Base) { // Collapse toggle dom.on(sidebarElm, 'click', (/** @type {MouseEvent} */ { target }) => { + const groupTitle = /** @type {HTMLElement | null} */ ( + /** @type {HTMLElement} */ (target).closest( + '.group-title[role="button"]', + ) + ); + + if (groupTitle) { + this.#toggleSidebarGroup(groupTitle); + return; + } + const linkElm = /** @type {HTMLElement} */ (target).closest('a'); const linkParent = /** @type {HTMLLIElement} */ ( linkElm?.closest('li') @@ -262,6 +273,38 @@ export function Events(Base) { linkParent.classList.toggle('collapse'); } }); + + dom.on(sidebarElm, 'keydown', (/** @type {KeyboardEvent} */ event) => { + const groupTitle = /** @type {HTMLElement | null} */ ( + /** @type {HTMLElement} */ (event.target).closest( + '.group-title[role="button"]', + ) + ); + + if (groupTitle && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + this.#toggleSidebarGroup(groupTitle); + } + }); + } + + /** + * Toggle a root sidebar group and keep its accessible state in sync. + * + * @param {HTMLElement} groupTitle + * @void + */ + #toggleSidebarGroup(groupTitle) { + const group = /** @type {HTMLLIElement | null} */ ( + groupTitle.closest('li') + ); + + if (!group) { + return; + } + + const isCollapsed = group.classList.toggle('collapse'); + groupTitle.setAttribute('aria-expanded', String(!isCollapsed)); } /** diff --git a/src/core/render/index.js b/src/core/render/index.js index f4db19375..210f2fe2f 100644 --- a/src/core/render/index.js +++ b/src/core/render/index.js @@ -310,6 +310,16 @@ export function Render(Base) { throw new Error('Compiler is not initialized'); } + const collapsedGroupIds = new Set( + dom + .findAll( + sidebarNavEl, + 'li.group.collapse > .group-title[role="button"][data-group-id]', + ) + .map(elm => elm.getAttribute('data-group-id')) + .filter(Boolean), + ); + dom.setHTML('.sidebar-nav', this.compiler.sidebar(text, maxLevel)); sidebarToggleEl.setAttribute('aria-expanded', String(!isMobile())); @@ -356,9 +366,47 @@ export function Render(Base) { pageLinkGroups.forEach(elm => { elm.classList.add('group'); - elm - .querySelector(':scope > p:not(:has(> *))') - ?.classList.add('group-title'); + + let groupTitle = [...elm.children].find( + child => child.tagName === 'P' && !child.querySelector('a'), + ); + + if (!groupTitle) { + const sublist = [...elm.children].find( + child => child.tagName === 'UL', + ); + const titleNodes = []; + + for (const child of elm.childNodes) { + if (child === sublist) { + break; + } + + titleNodes.push(child); + } + + if (sublist && titleNodes.some(node => node.textContent?.trim())) { + const newGroupTitle = document.createElement('p'); + titleNodes.forEach(node => newGroupTitle.append(node)); + groupTitle = newGroupTitle; + elm.insertBefore(newGroupTitle, sublist); + } + } + + groupTitle?.classList.add('group-title'); + + const rootList = elm.parentElement; + + if (groupTitle && rootList?.parentElement === sidebarNavEl) { + const groupId = `${[...sidebarNavEl.children].indexOf(rootList)}:${[...rootList.children].indexOf(elm)}`; + const isCollapsed = collapsedGroupIds.has(groupId); + + elm.classList.toggle('collapse', isCollapsed); + groupTitle.setAttribute('data-group-id', groupId); + groupTitle.setAttribute('role', 'button'); + groupTitle.setAttribute('tabindex', '0'); + groupTitle.setAttribute('aria-expanded', String(!isCollapsed)); + } }); } diff --git a/src/themes/shared/_classes.css b/src/themes/shared/_classes.css index 9662854c8..aab7f2c50 100644 --- a/src/themes/shared/_classes.css +++ b/src/themes/shared/_classes.css @@ -53,6 +53,22 @@ var(--sidebar-chevron-expanded-color) 2.75px 4.25px, transparent 4.25px ); + /* Chevron down (Mono) */ + --sidebar-group-title-bg-expanded: no-repeat + calc(var(--_sidebar_pagelink-bg-left) - 2px) center / 5px 6px + linear-gradient( + 225deg, + transparent 2.75px, + var(--sidebar-chevron-collapsed-color) 2.75px 4.25px, + transparent 4.25px + ), + no-repeat calc(var(--_sidebar_pagelink-bg-left) + 3px) center / 5px 6px + linear-gradient( + 135deg, + transparent 2.75px, + var(--sidebar-chevron-collapsed-color) 2.75px 4.25px, + transparent 4.25px + ); /* Dot (active without children) */ --sidebar-pagelink-bg-empty: no-repeat var(--_sidebar_pagelink-bg-left) center / 7px 7px @@ -65,9 +81,17 @@ } body[class*='sidebar-chevron'] { - .sidebar-nav a.page-link.no-chevron { + .sidebar-nav :is(a.page-link, p.group-title[role='button']).no-chevron { background: none; } + + .sidebar-nav p.group-title[role='button'] { + background: var(--sidebar-pagelink-bg); + + &[aria-expanded='true'] { + background: var(--sidebar-group-title-bg-expanded); + } + } } /* Left */ @@ -81,7 +105,7 @@ body.sidebar-chevron-left { --_inset: 18px; li { - a.page-link { + :is(a.page-link, p.group-title[role='button']) { padding-left: var(--_inset); } @@ -104,8 +128,12 @@ body.sidebar-chevron-left { body.sidebar-chevron-right { .sidebar-nav { + p.group-title[role='button'] { + margin-right: 0; + } + li { - a { + :is(a, p.group-title[role='button']) { padding-right: calc(var(--_sidebar-inset) + 15px); } } @@ -120,6 +148,14 @@ body.sidebar-chevron-right { --sidebar-group-spacing: 1em; } +body.sidebar-group-box { + :is(.app-nav-merged, .sidebar-nav) + > ul + > li.group:is(:has(+ :not(.group)), :last-child).collapse { + padding-bottom: var(--sidebar-group-spacing); + } +} + /* prettier-ignore */ :root:has(body.sidebar-group-underline) { --sidebar-group-spacing : 0.5em; diff --git a/src/themes/shared/_sidebar.css b/src/themes/shared/_sidebar.css index 60444045a..e0e1b63a8 100644 --- a/src/themes/shared/_sidebar.css +++ b/src/themes/shared/_sidebar.css @@ -67,6 +67,16 @@ color: var(--sidebar-group-title-color); font-size: var(--sidebar-group-title-font-size); font-weight: var(--sidebar-group-title-font-weight); + + &[role='button'] { + cursor: pointer; + user-select: none; + + &:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } + } } } @@ -89,7 +99,7 @@ } &.collapse { - > :not(a) { + > :not(a):not(.group-title) { display: none; } } @@ -175,6 +185,7 @@ body.sticky & { position: fixed; overflow-y: auto; + scrollbar-gutter: stable; } } @@ -249,7 +260,7 @@ } /* Increase tap target size on touch-only devices */ - @media screen and not (any-hover) { + @media screen and (any-hover: none) { width: calc(var(--content-margin-inline) - 10px); } } diff --git a/test/e2e/sidebar.test.js b/test/e2e/sidebar.test.js index ff4526601..575d9edd1 100644 --- a/test/e2e/sidebar.test.js +++ b/test/e2e/sidebar.test.js @@ -68,6 +68,179 @@ test.describe('Sidebar Tests', () => { await expect(activeLinkElm).toHaveText('Test >'); expect(page.url()).toMatch(/\/test%3Efoo$/); }); + + test('collapses root sidebar groups', async ({ page }) => { + await docsifyInit({ + styleURLs: ['/dist/themes/core.css'], + markdown: { + sidebar: ` + - Getting started + - [Quick start](quickstart) + - Customization + - [Configuration](configuration) + - Standalone + - [Linked group](linked) + - [Linked child](linked-child) + `, + }, + routes: { + '/quickstart.md': '# Quick start', + '/configuration.md': '# Configuration', + '/linked.md': '# Linked group', + '/linked-child.md': '# Linked child', + }, + }); + + const group = page.locator('.sidebar-nav > ul > li').first(); + const groupTitle = group.locator(':scope > p.group-title'); + const childLink = group.locator(':scope > ul > li > a'); + + await expect(groupTitle).toHaveAttribute('role', 'button'); + await expect(groupTitle).toHaveAttribute('tabindex', '0'); + await expect(groupTitle).toHaveAttribute('aria-expanded', 'true'); + await expect(childLink).toBeVisible(); + + const standalone = page.locator('.sidebar-nav > ul > li').nth(2); + await expect(standalone).not.toHaveClass(/group/); + await expect(standalone.locator('[role="button"]')).toHaveCount(0); + + const linkedGroup = page.locator('.sidebar-nav > ul > li').nth(3); + const linkedGroupLink = linkedGroup.locator('a').first(); + await expect(linkedGroupLink).toHaveAttribute('href', '#/linked'); + + await groupTitle.click(); + + await expect(group).toHaveClass(/collapse/); + await expect(groupTitle).toHaveAttribute('aria-expanded', 'false'); + await expect(childLink).toBeHidden(); + + await linkedGroupLink.click(); + expect(page.url()).toMatch(/\/linked$/); + await expect(group).toHaveClass(/collapse/); + await expect(groupTitle).toHaveAttribute('aria-expanded', 'false'); + await expect(childLink).toBeHidden(); + + await groupTitle.press('Enter'); + + await expect(group).not.toHaveClass(/collapse/); + await expect(groupTitle).toHaveAttribute('aria-expanded', 'true'); + await expect(childLink).toBeVisible(); + + await groupTitle.press('Space'); + await expect(group).toHaveClass(/collapse/); + await groupTitle.press('Space'); + await expect(group).not.toHaveClass(/collapse/); + }); + + test('supports chevrons on collapsible root groups', async ({ page }) => { + await docsifyInit({ + styleURLs: ['/dist/themes/core.css'], + style: ` + :root:has(body[class*='sidebar-chevron']) { + --sidebar-chevron-collapsed-color: rgb(1, 2, 3); + --sidebar-chevron-expanded-color: rgb(4, 5, 6); + } + `, + html: ` + + +
+ + + + + `, + markdown: { + sidebar: ` + - Getting started + - [Quick start](quickstart) + - [Standalone](standalone) + `, + }, + routes: { + '/quickstart.md': '# Quick start', + '/standalone.md': '# Standalone', + }, + }); + + const groupTitle = page.locator('.group-title[role="button"]'); + const standaloneLink = page.locator('a[href="#/standalone"]'); + const background = await groupTitle.evaluate( + element => getComputedStyle(element).backgroundImage, + ); + const [groupTitleBox, standaloneLinkBox] = await Promise.all([ + groupTitle.boundingBox(), + standaloneLink.boundingBox(), + ]); + + expect(background).not.toBe('none'); + expect(background).toMatch(/rgb\(1,\s*2,\s*3\)/); + expect(background).not.toMatch(/rgb\(4,\s*5,\s*6\)/); + expect(groupTitleBox?.x + groupTitleBox?.width).toBe( + standaloneLinkBox?.x + standaloneLinkBox?.width, + ); + await groupTitle.click(); + + await expect(groupTitle).toHaveAttribute('aria-expanded', 'false'); + const collapsedBackground = await groupTitle.evaluate( + element => getComputedStyle(element).backgroundImage, + ); + expect(collapsedBackground).not.toBe(background); + expect(collapsedBackground).toMatch(/rgb\(1,\s*2,\s*3\)/); + expect(collapsedBackground).not.toMatch(/rgb\(4,\s*5,\s*6\)/); + }); + + test('keeps group border spacing when the last group collapses', async ({ + page, + }) => { + await docsifyInit({ + styleURLs: ['/dist/themes/core.css'], + html: ` + + + + + + + + `, + markdown: { + sidebar: ` + - Upgrading + - [v4 to v5](upgrade) + + * [Awesome docsify](awesome) + `, + }, + routes: { + '/upgrade.md': '# v4 to v5', + '/awesome.md': '# Awesome docsify', + }, + }); + + const upgradingGroup = page.locator( + '.sidebar-nav > ul:first-of-type > li:last-child', + ); + const groupTitle = upgradingGroup.locator(':scope > .group-title'); + + await groupTitle.click(); + + const spacing = await page.evaluate(() => { + const title = document.querySelector('.group-title'); + const group = title.closest('li'); + const awesome = document.querySelector('a[href="#/awesome"]'); + const titleBox = title.getBoundingClientRect(); + const groupBox = group.getBoundingClientRect(); + const awesomeBox = awesome.getBoundingClientRect(); + + return { + borderToAwesome: awesomeBox.top - groupBox.bottom, + titleToBorder: groupBox.bottom - titleBox.bottom, + }; + }); + + expect(spacing.titleToBorder).toBeGreaterThan(spacing.borderToAwesome); + }); }); test.describe('Configuration: autoHeader', () => {