diff --git a/docs/themes.md b/docs/themes.md index 6eb436bfd..d0187e31e 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -117,6 +117,10 @@ Display expand/collapse icons on page links in the sidebar. +
+ ```html @@ -128,6 +132,14 @@ Display expand/collapse icons on page links in the sidebar. ``` +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: + + +```html + +``` + To prevent chevrons from displaying for specific page links, add a `no-chevron` class as follows: ```md diff --git a/src/core/render/index.js b/src/core/render/index.js index ca09150b1..805747dcc 100644 --- a/src/core/render/index.js +++ b/src/core/render/index.js @@ -35,6 +35,32 @@ export function Render(Base) { }); } + /** + * Normalize links in loose Markdown lists from `
  • ` to + * `

  • ` 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') @@ -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())); @@ -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 => { diff --git a/src/themes/shared/_classes.css b/src/themes/shared/_classes.css index aab7f2c50..778197e38 100644 --- a/src/themes/shared/_classes.css +++ b/src/themes/shared/_classes.css @@ -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) { diff --git a/src/themes/shared/_sidebar.css b/src/themes/shared/_sidebar.css index 34cc7463d..0ccdc5e2a 100644 --- a/src/themes/shared/_sidebar.css +++ b/src/themes/shared/_sidebar.css @@ -99,7 +99,7 @@ } &.collapse { - > :not(a, p:has(> a.page-link)):not(.group-title) { + > :not(a, .group-title) { display: none; } } diff --git a/test/e2e/sidebar.test.js b/test/e2e/sidebar.test.js index 4ed35dc79..b79749940 100644 --- a/test/e2e/sidebar.test.js +++ b/test/e2e/sidebar.test.js @@ -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: ` + + + + +
    + + + `, + 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: ` + + + + +
    + + + `, + 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, }) => { @@ -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({ @@ -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();