feat(sidebar): add collapsible root sidebar groups (#2784)

This commit is contained in:
Luffy
2026-08-20 11:36:20 +08:00
committed by GitHub
parent bd8f2ea121
commit 50992940f3
8 changed files with 392 additions and 9 deletions
+15
View File
@@ -306,6 +306,21 @@ window.$docsify = {
};
```
## collapseSidebarGroups
- Type: `Boolean`
- Default: `false`
Initially collapses all root sidebar groups. Visitors can still expand and
collapse each group by selecting its title. Their choices are preserved while
navigating between pages.
```js
window.$docsify = {
collapseSidebarGroups: true,
};
```
## sidebarPosition
- Type: `String`
+1
View File
@@ -11,6 +11,7 @@ const defaultDocsifyConfig = () => ({
autoHeader: false,
basePath: '',
catchPluginErrors: true,
collapseSidebarGroups: false,
cornerExternalLinkTarget:
/** @type {'_blank' | '_self' | '_parent' | '_top' | '_unfencedTop'} */ (
'_blank'
+43
View File
@@ -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));
}
/**
+61 -4
View File
@@ -294,7 +294,13 @@ export function Render(Base) {
}
_renderSidebar(text) {
const { maxLevel, subMaxLevel, loadSidebar, hideSidebar } = this.config;
const {
collapseSidebarGroups,
maxLevel,
subMaxLevel,
loadSidebar,
hideSidebar,
} = this.config;
const sidebarEl = dom.getNode('aside.sidebar');
const sidebarNavEl = dom.getNode('.sidebar-nav');
const sidebarToggleEl = dom.getNode('button.sidebar-toggle');
@@ -310,6 +316,18 @@ export function Render(Base) {
throw new Error('Compiler is not initialized');
}
const sidebarGroupStates = new Map(
dom
.findAll(
sidebarNavEl,
'li.group > .group-title[role="button"][data-group-id]',
)
.map(elm => [
elm.getAttribute('data-group-id'),
elm.closest('li')?.classList.contains('collapse'),
]),
);
dom.setHTML('.sidebar-nav', this.compiler.sidebar(text, maxLevel));
sidebarToggleEl.setAttribute('aria-expanded', String(!isMobile()));
@@ -360,9 +378,48 @@ 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 =
sidebarGroupStates.get(groupId) ?? collapseSidebarGroups;
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));
}
});
}
+39 -3
View File
@@ -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;
+13 -2
View File
@@ -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, p:has(> a.page-link)) {
> :not(a, p:has(> a.page-link)):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);
}
}
+1
View File
@@ -33,6 +33,7 @@ const d = new Docsify({
name: 'Vanilla ESM TypeScript Example',
themeColor: 'deeppink',
hideSidebar: false,
collapseSidebarGroups: true,
// @ts-expect-error invalid property to test that type checking works
blahblah: 123,
+219
View File
@@ -69,6 +69,225 @@ test.describe('Sidebar Tests', () => {
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('initially collapses root sidebar groups when configured', async ({
page,
}) => {
await docsifyInit({
config: {
collapseSidebarGroups: true,
},
styleURLs: ['/dist/themes/core.css'],
markdown: {
sidebar: `
- Getting started
- [Quick start](quickstart)
- Customization
- [Configuration](configuration)
- [Standalone](standalone)
`,
},
routes: {
'/quickstart.md': '# Quick start',
'/configuration.md': '# Configuration',
'/standalone.md': '# Standalone',
},
});
const groups = page.locator('.sidebar-nav > ul > li.group');
const firstGroup = groups.first();
const firstGroupTitle = firstGroup.locator(':scope > .group-title');
const firstGroupLink = firstGroup.locator(':scope > ul > li > a');
const secondGroup = groups.nth(1);
await expect(groups).toHaveCount(2);
await expect(firstGroup).toHaveClass(/collapse/);
await expect(secondGroup).toHaveClass(/collapse/);
await expect(firstGroupTitle).toHaveAttribute('aria-expanded', 'false');
await expect(firstGroupLink).toBeHidden();
await firstGroupTitle.click();
await expect(firstGroup).not.toHaveClass(/collapse/);
await firstGroupLink.click();
expect(page.url()).toMatch(/\/quickstart$/);
await expect(firstGroup).not.toHaveClass(/collapse/);
await expect(firstGroupTitle).toHaveAttribute('aria-expanded', 'true');
await expect(secondGroup).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: `
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8" /></head>
<body class="sidebar-chevron-right">
<div id="app"></div>
</body>
</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: `
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8" /></head>
<body class="sidebar-group-box">
<div id="app"></div>
</body>
</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('keeps a loose-list page link visible when collapsed', async ({
page,
}) => {