Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
12 changes: 12 additions & 0 deletions docs/themes.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ Display expand/collapse icons on page links in the sidebar.
<label>
<input class="toggle" type="checkbox" value="sidebar-chevron-left" data-class data-group="sidebar-chevron"> Preview <code>sidebar-chevron-left</code>
</label>
<br>
<label>
<input class="toggle" type="checkbox" value="sidebar-chevron-root-hidden" data-class> Hide root-level chevrons with <code>sidebar-chevron-root-hidden</code>
</label>

<!-- prettier-ignore -->
```html
Expand All @@ -128,6 +132,14 @@ Display expand/collapse icons on page links in the sidebar.
<body class="sidebar-chevron-left">
```

To hide chevrons on all root-level page links and group titles while retaining
chevrons on nested page links, add the `sidebar-chevron-root-hidden` class:

<!-- prettier-ignore -->
```html
<body class="sidebar-chevron-right sidebar-chevron-root-hidden">
```

To prevent chevrons from displaying for specific page links, add a `no-chevron` class as follows:

```md
Expand Down
33 changes: 30 additions & 3 deletions src/core/render/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,32 @@ export function Render(Base) {
});
}

/**
* Normalize links in loose Markdown lists from `<li><p><a>` to
* `<li><a>` so sidebar behavior and styling do not depend on list
* tightness.
*
* @param {Element} sidebarNavEl
*/
#normalizeSidebarPageLinks(sidebarNavEl) {
dom.findAll(sidebarNavEl, 'li > p').forEach(paragraph => {
const link = paragraph.firstElementChild;
const onlyContainsLink = [...paragraph.childNodes].every(
node =>
node === link || (node.nodeType === 3 && !node.textContent?.trim()),
);

if (
!paragraph.attributes.length &&
paragraph.children.length === 1 &&
link?.tagName === 'A' &&
onlyContainsLink
) {
paragraph.replaceWith(link);
}
});
}

#executeScript() {
const script = dom
.findAll('.markdown-section>script')
Expand Down Expand Up @@ -329,6 +355,7 @@ export function Render(Base) {
);

dom.setHTML('.sidebar-nav', this.compiler.sidebar(text, maxLevel));
this.#normalizeSidebarPageLinks(sidebarNavEl);

sidebarToggleEl.setAttribute('aria-expanded', String(!isMobile()));

Expand Down Expand Up @@ -358,18 +385,18 @@ export function Render(Base) {
// Mark page links and groups
const pageLinks = dom.findAll(
sidebarNavEl,
'a:is(li > a, li > p > a):not(.section-link, [target="_blank"])',
'li > a:not(.section-link, [target="_blank"])',
);
const pageLinkGroups = dom
// NOTE: Using filter() method as a replacement for :has() selector. It
// would be preferable to use only 'li:not(:has(> a, > p > a))' selector
// would be preferable to use only 'li:not(:has(> a))' selector
// but the :has() selector is not supported by our Jest test environment
// See: https://github.com/jsdom/jsdom/issues/3506#issuecomment-1769782333
.findAll(sidebarEl, 'li')
.filter(
elm =>
elm.querySelector(':scope > ul') &&
!elm.querySelectorAll(':scope > a, :scope > p > a').length,
!elm.querySelector(':scope > a'),
);

pageLinks.forEach(elm => {
Expand Down
9 changes: 9 additions & 0 deletions src/themes/shared/_classes.css
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ body[class*='sidebar-chevron'] {
}
}

body.sidebar-chevron-root-hidden {
.sidebar-nav > ul > li {
> a.page-link,
> p.group-title[role='button'][aria-expanded] {
background: none;
}
}
}

/* Left */
/* -------------------------------------------------------------------------- */
:root:has(body.sidebar-chevron-left) {
Expand Down
2 changes: 1 addition & 1 deletion src/themes/shared/_sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@
}

&.collapse {
> :not(a, p:has(> a.page-link)):not(.group-title) {
> :not(a, .group-title) {
display: none;
}
}
Expand Down
153 changes: 151 additions & 2 deletions test/e2e/sidebar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,155 @@ test.describe('Sidebar Tests', () => {
expect(collapsedBackground).not.toMatch(/rgb\(4,\s*5,\s*6\)/);
});

test('normalizes loose-list page links and shows expanded chevrons', async ({
page,
}) => {
await docsifyInit({
config: {
subMaxLevel: 2,
},
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);
--sidebar-link-color-active: rgb(7, 8, 9);
}
`,
html: `
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8" /></head>
<body class="sidebar-chevron-right">
<div id="app"></div>
</body>
</html>
`,
markdown: {
homepage: '# Home',
sidebar: `
* [Test](test.md)
+ [Quick start](quickstart.md)
- [Adding pages](adding-pages.md)

- Getting started

- [Cover page](cover.md)
`,
},
routes: {
'/test.md': '# Test',
'/quickstart.md': '# Quick start\n\n## Installation',
'/adding-pages.md': '# Adding pages\n\n## Sidebar',
'/cover.md': '# Cover page',
},
});

const quickStartLink = page.locator('a[href="#/quickstart"]');
const addingPagesLink = page.locator('a[href="#/adding-pages"]');
const quickStartItem = page.locator(
'.sidebar-nav li:has(> a[href="#/quickstart"])',
);
const addingPagesItem = page.locator(
'.sidebar-nav li:has(> a[href="#/adding-pages"])',
);

await expect(page.locator('.sidebar-nav li > p > a')).toHaveCount(0);

await quickStartLink.click();
await expect(
quickStartItem.locator(':scope > .app-sub-sidebar'),
).toBeVisible();
const quickStartBackground = await quickStartLink.evaluate(
element => getComputedStyle(element).backgroundImage,
);

await addingPagesLink.click();
await expect(
addingPagesItem.locator(':scope > .app-sub-sidebar'),
).toBeVisible();
const addingPagesBackground = await addingPagesLink.evaluate(
element => getComputedStyle(element).backgroundImage,
);

expect(addingPagesBackground).toBe(quickStartBackground);
expect(addingPagesBackground).toMatch(/rgb\(4,\s*5,\s*6\)/);
await expect(addingPagesLink).toHaveCSS('color', 'rgb(7, 8, 9)');

await addingPagesLink.click();
await expect(addingPagesItem).toHaveClass(/collapse/);
const collapsedBackground = await addingPagesLink.evaluate(
element => getComputedStyle(element).backgroundImage,
);

expect(collapsedBackground).not.toBe(addingPagesBackground);
});

test('hides root chevrons when configured by body class', async ({
page,
}) => {
await docsifyInit({
config: {
subMaxLevel: 2,
},
styleURLs: ['/dist/themes/core.css'],
html: `
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8" /></head>
<body class="sidebar-chevron-right sidebar-chevron-root-hidden">
<div id="app"></div>
</body>
</html>
`,
markdown: {
homepage: '# Home',
sidebar: `
+ [Direct root page](direct.md)
- [Loose root page](loose.md)

- Getting started

- [Nested page](nested.md)
`,
},
routes: {
'/direct.md': '# Direct root page',
'/loose.md': '# Loose root page\n\n## Child heading',
'/nested.md': '# Nested page',
},
});

const directRootLink = page.locator('a[href="#/direct"]');
const looseRootLink = page.locator('a[href="#/loose"]');
const nestedLink = page.locator('a[href="#/nested"]');
const groupTitle = page.locator('.group-title[role="button"]');
const looseRootItem = page.locator(
'.sidebar-nav li:has(> a[href="#/loose"])',
);

for (const rootLink of [directRootLink, looseRootLink]) {
await expect(rootLink).toHaveCSS('background-image', 'none');
}

await expect(nestedLink).not.toHaveCSS('background-image', 'none');
await expect(groupTitle).toHaveCSS('background-image', 'none');

await groupTitle.click();
await expect(groupTitle).toHaveAttribute('aria-expanded', 'false');
await expect(groupTitle).toHaveCSS('background-image', 'none');

await looseRootLink.click();
await expect(
looseRootItem.locator(':scope > .app-sub-sidebar'),
).toBeVisible();
await expect(looseRootLink).toHaveCSS('background-image', 'none');

await looseRootLink.click();
await expect(looseRootItem).toHaveClass(/collapse/);
await expect(looseRootLink).toHaveCSS('background-image', 'none');
});

test('keeps group border spacing when the last group collapses', async ({
page,
}) => {
Expand Down Expand Up @@ -288,7 +437,7 @@ test.describe('Sidebar Tests', () => {
expect(spacing.titleToBorder).toBeGreaterThan(spacing.borderToAwesome);
});

test('keeps a loose-list page link visible when collapsed', async ({
test('keeps a normalized loose-list page link visible when collapsed', async ({
page,
}) => {
await docsifyInit({
Expand Down Expand Up @@ -320,7 +469,7 @@ test.describe('Sidebar Tests', () => {
await quickStartLink.click();

const quickStartItem = page.locator(
'.sidebar-nav li:has(> p > a[href="#/quickstart"])',
'.sidebar-nav li:has(> a[href="#/quickstart"])',
);
const subSidebar = quickStartItem.locator(':scope > .app-sub-sidebar');
await expect(subSidebar).toBeVisible();
Expand Down