From c9e336d0686f3f423218dafccd083f0925954fc4 Mon Sep 17 00:00:00 2001 From: Luffy Date: Fri, 21 Aug 2026 14:16:14 +0800 Subject: [PATCH 1/2] feat(sidebar): normalize links and add root chevron control --- docs/themes.md | 12 +++ src/core/render/index.js | 33 ++++++- src/themes/shared/_classes.css | 9 ++ src/themes/shared/_sidebar.css | 2 +- test/e2e/sidebar.test.js | 153 ++++++++++++++++++++++++++++++++- 5 files changed, 203 insertions(+), 6 deletions(-) diff --git a/docs/themes.md b/docs/themes.md index 6eb436bfd1..d0187e31ee 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 ca09150b13..805747dccc 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 aab7f2c505..778197e384 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 34cc7463d1..0ccdc5e2aa 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 4ed35dc798..b797499400 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(); From 73b03c77edad00c6a30bee12b6512ae442e4e970 Mon Sep 17 00:00:00 2001 From: Luffy Date: Sat, 22 Aug 2026 22:16:47 +0800 Subject: [PATCH 2/2] feat(sidebar): replace group-title with group-toggle for improved accessibility fix https://github.com/docsifyjs/docsify/pull/2784#issuecomment-5371562687 --- src/core/event/index.js | 4 ++-- src/core/render/index.js | 9 +++++++-- src/themes/shared/_classes.css | 12 ++++++------ src/themes/shared/_sidebar.css | 16 ++++++++-------- test/e2e/sidebar.test.js | 31 +++++++++++++++++++++++++------ 5 files changed, 48 insertions(+), 24 deletions(-) diff --git a/src/core/event/index.js b/src/core/event/index.js index cc9f5e8216..d1f53b3bf7 100644 --- a/src/core/event/index.js +++ b/src/core/event/index.js @@ -254,7 +254,7 @@ export function Events(Base) { dom.on(sidebarElm, 'click', (/** @type {MouseEvent} */ { target }) => { const groupTitle = /** @type {HTMLElement | null} */ ( /** @type {HTMLElement} */ (target).closest( - '.group-title[role="button"]', + '.group-toggle[role="button"]', ) ); @@ -277,7 +277,7 @@ export function Events(Base) { dom.on(sidebarElm, 'keydown', (/** @type {KeyboardEvent} */ event) => { const groupTitle = /** @type {HTMLElement | null} */ ( /** @type {HTMLElement} */ (event.target).closest( - '.group-title[role="button"]', + '.group-toggle[role="button"]', ) ); diff --git a/src/core/render/index.js b/src/core/render/index.js index 805747dccc..10ff749771 100644 --- a/src/core/render/index.js +++ b/src/core/render/index.js @@ -346,7 +346,7 @@ export function Render(Base) { dom .findAll( sidebarNavEl, - 'li.group > .group-title[role="button"][data-group-id]', + 'li.group > .group-toggle[role="button"][data-group-id]', ) .map(elm => [ elm.getAttribute('data-group-id'), @@ -409,6 +409,10 @@ export function Render(Base) { let groupTitle = [...elm.children].find( child => child.tagName === 'P' && !child.querySelector('a'), ); + // Preserve the original styling behavior: only text-only paragraphs + // produced by Markdown receive the group-title class. + const styledGroupTitle = + groupTitle && !groupTitle.children.length ? groupTitle : null; if (!groupTitle) { const sublist = [...elm.children].find( @@ -432,7 +436,7 @@ export function Render(Base) { } } - groupTitle?.classList.add('group-title'); + styledGroupTitle?.classList.add('group-title'); const rootList = elm.parentElement; @@ -442,6 +446,7 @@ export function Render(Base) { sidebarGroupStates.get(groupId) ?? collapseSidebarGroups; elm.classList.toggle('collapse', isCollapsed); + groupTitle.classList.add('group-toggle'); groupTitle.setAttribute('data-group-id', groupId); groupTitle.setAttribute('role', 'button'); groupTitle.setAttribute('tabindex', '0'); diff --git a/src/themes/shared/_classes.css b/src/themes/shared/_classes.css index 778197e384..74776f05ce 100644 --- a/src/themes/shared/_classes.css +++ b/src/themes/shared/_classes.css @@ -81,11 +81,11 @@ } body[class*='sidebar-chevron'] { - .sidebar-nav :is(a.page-link, p.group-title[role='button']).no-chevron { + .sidebar-nav :is(a.page-link, p.group-toggle).no-chevron { background: none; } - .sidebar-nav p.group-title[role='button'] { + .sidebar-nav p.group-toggle { background: var(--sidebar-pagelink-bg); &[aria-expanded='true'] { @@ -97,7 +97,7 @@ body[class*='sidebar-chevron'] { body.sidebar-chevron-root-hidden { .sidebar-nav > ul > li { > a.page-link, - > p.group-title[role='button'][aria-expanded] { + > p.group-toggle[aria-expanded] { background: none; } } @@ -114,7 +114,7 @@ body.sidebar-chevron-left { --_inset: 18px; li { - :is(a.page-link, p.group-title[role='button']) { + :is(a.page-link, p.group-toggle) { padding-left: var(--_inset); } @@ -137,12 +137,12 @@ body.sidebar-chevron-left { body.sidebar-chevron-right { .sidebar-nav { - p.group-title[role='button'] { + p.group-toggle { margin-right: 0; } li { - :is(a, p.group-title[role='button']) { + :is(a, p.group-toggle) { padding-right: calc(var(--_sidebar-inset) + 15px); } } diff --git a/src/themes/shared/_sidebar.css b/src/themes/shared/_sidebar.css index 0ccdc5e2aa..f70f3d31d5 100644 --- a/src/themes/shared/_sidebar.css +++ b/src/themes/shared/_sidebar.css @@ -67,15 +67,15 @@ 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; + &.group-toggle { + cursor: pointer; + user-select: none; - &:focus-visible { - outline: 2px solid currentColor; - outline-offset: 2px; - } + &:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; } } } @@ -99,7 +99,7 @@ } &.collapse { - > :not(a, .group-title) { + > :not(a, .group-toggle) { display: none; } } diff --git a/test/e2e/sidebar.test.js b/test/e2e/sidebar.test.js index b797499400..f34dbe639e 100644 --- a/test/e2e/sidebar.test.js +++ b/test/e2e/sidebar.test.js @@ -92,7 +92,7 @@ test.describe('Sidebar Tests', () => { }); const group = page.locator('.sidebar-nav > ul > li').first(); - const groupTitle = group.locator(':scope > p.group-title'); + const groupTitle = group.locator(':scope > p.group-toggle'); const childLink = group.locator(':scope > ul > li > a'); await expect(groupTitle).toHaveAttribute('role', 'button'); @@ -158,7 +158,7 @@ test.describe('Sidebar Tests', () => { const groups = page.locator('.sidebar-nav > ul > li.group'); const firstGroup = groups.first(); - const firstGroupTitle = firstGroup.locator(':scope > .group-title'); + const firstGroupTitle = firstGroup.locator(':scope > .group-toggle'); const firstGroupLink = firstGroup.locator(':scope > ul > li > a'); const secondGroup = groups.nth(1); @@ -185,6 +185,7 @@ test.describe('Sidebar Tests', () => { :root:has(body[class*='sidebar-chevron']) { --sidebar-chevron-collapsed-color: rgb(1, 2, 3); --sidebar-chevron-expanded-color: rgb(4, 5, 6); + --sidebar-group-title-font-weight: 700; } `, html: ` @@ -201,15 +202,25 @@ test.describe('Sidebar Tests', () => { - Getting started - [Quick start](quickstart) - [Standalone](standalone) + + 1. Styled group + + - [Styled child](styled-child) `, }, routes: { '/quickstart.md': '# Quick start', + '/styled-child.md': '# Styled child', '/standalone.md': '# Standalone', }, }); - const groupTitle = page.locator('.group-title[role="button"]'); + const groupTitle = page + .locator('.group-toggle[role="button"]') + .filter({ hasText: 'Getting started' }); + const styledGroupTitle = page + .locator('.group-title.group-toggle') + .filter({ hasText: 'Styled group' }); const standaloneLink = page.locator('a[href="#/standalone"]'); const background = await groupTitle.evaluate( element => getComputedStyle(element).backgroundImage, @@ -218,10 +229,18 @@ test.describe('Sidebar Tests', () => { groupTitle.boundingBox(), standaloneLink.boundingBox(), ]); + const [groupTitleFontWeight, standaloneLinkFontWeight] = await Promise.all([ + groupTitle.evaluate(element => getComputedStyle(element).fontWeight), + standaloneLink.evaluate(element => getComputedStyle(element).fontWeight), + ]); expect(background).not.toBe('none'); expect(background).toMatch(/rgb\(1,\s*2,\s*3\)/); expect(background).not.toMatch(/rgb\(4,\s*5,\s*6\)/); + await expect(groupTitle).not.toHaveClass(/group-title/); + expect(groupTitleFontWeight).toBe(standaloneLinkFontWeight); + await expect(styledGroupTitle).not.toHaveCSS('background-image', 'none'); + await expect(styledGroupTitle).toHaveCSS('font-weight', '700'); expect(groupTitleBox?.x + groupTitleBox?.width).toBe( standaloneLinkBox?.x + standaloneLinkBox?.width, ); @@ -358,7 +377,7 @@ test.describe('Sidebar Tests', () => { 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 groupTitle = page.locator('.group-toggle[role="button"]'); const looseRootItem = page.locator( '.sidebar-nav li:has(> a[href="#/loose"])', ); @@ -416,12 +435,12 @@ test.describe('Sidebar Tests', () => { const upgradingGroup = page.locator( '.sidebar-nav > ul:first-of-type > li:last-child', ); - const groupTitle = upgradingGroup.locator(':scope > .group-title'); + const groupTitle = upgradingGroup.locator(':scope > .group-toggle'); await groupTitle.click(); const spacing = await page.evaluate(() => { - const title = document.querySelector('.group-title'); + const title = document.querySelector('.group-toggle'); const group = title.closest('li'); const awesome = document.querySelector('a[href="#/awesome"]'); const titleBox = title.getBoundingClientRect();