mirror of
https://github.com/docsifyjs/docsify.git
synced 2026-10-16 20:21:38 +08:00
feat(sidebar): add collapsible root sidebar groups (#2784)
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -11,6 +11,7 @@ const defaultDocsifyConfig = () => ({
|
||||
autoHeader: false,
|
||||
basePath: '',
|
||||
catchPluginErrors: true,
|
||||
collapseSidebarGroups: false,
|
||||
cornerExternalLinkTarget:
|
||||
/** @type {'_blank' | '_self' | '_parent' | '_top' | '_unfencedTop'} */ (
|
||||
'_blank'
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user