From ed2bfb266ef788e86d1a73fe3cd7708a4cd5e260 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:24:45 +0530 Subject: [PATCH] fix(theme): render sidebar group toggles as native buttons The sidebar item row was a `role="button"` wrapping both the group heading and a second `role="button"` caret, which is invalid HTML and nests interactive controls. The caret is now the only control (a native button with `aria-expanded`), the row keeps its click handler as a mouse-only affordance, and groups without a heading render a `div` instead of a `section`. fixes #5366 closes #5371 Co-authored-by: Jibin7Jose Co-Authored-By: Claude Fable 5 --- __tests__/e2e/sidebar.test.ts | 36 ++++++++++++++ .../components/VPSidebarItem.vue | 49 ++++++------------- 2 files changed, 50 insertions(+), 35 deletions(-) create mode 100644 __tests__/e2e/sidebar.test.ts diff --git a/__tests__/e2e/sidebar.test.ts b/__tests__/e2e/sidebar.test.ts new file mode 100644 index 00000000..288b15c8 --- /dev/null +++ b/__tests__/e2e/sidebar.test.ts @@ -0,0 +1,36 @@ +describe('sidebar', () => { + beforeAll(async () => { + await goto('/frontmatter/multiple-levels-outline') + }) + + test('collapsible group renders a heading and a single toggle button', async () => { + const group = page.locator('.VPSidebarItem.level-0.collapsible').first() + const caret = group.locator('.caret').first() + + expect(await page.locator('.VPSidebarItem [role="button"]').count()).toBe(0) + expect(await caret.evaluate((el) => el.tagName)).toBe('BUTTON') + expect(await caret.getAttribute('aria-expanded')).toBe('true') + }) + + test('group toggles with keyboard, caret and heading', async () => { + const group = page.locator('.VPSidebarItem.level-0.collapsible').first() + const caret = group.locator('.caret').first() + const isCollapsed = () => + group.evaluate((el) => el.classList.contains('collapsed')) + + await caret.focus() + await page.keyboard.press('Enter') + expect(await isCollapsed()).toBe(true) + expect(await caret.getAttribute('aria-expanded')).toBe('false') + + await page.keyboard.press('Space') + expect(await isCollapsed()).toBe(false) + expect(await caret.getAttribute('aria-expanded')).toBe('true') + + await caret.click() + expect(await isCollapsed()).toBe(true) + + await group.locator('.text').first().click() + expect(await isCollapsed()).toBe(false) + }) +}) diff --git a/src/client/theme-default/components/VPSidebarItem.vue b/src/client/theme-default/components/VPSidebarItem.vue index 6dad9f58..fe268a63 100644 --- a/src/client/theme-default/components/VPSidebarItem.vue +++ b/src/client/theme-default/components/VPSidebarItem.vue @@ -19,19 +19,16 @@ const { toggle } = useSidebarItemControl(computed(() => props.item)) -const sectionTag = computed(() => (hasChildren.value ? 'section' : `div`)) - const linkTag = computed(() => (isLink.value ? 'a' : 'div')) -const textTag = computed(() => { - return !hasChildren.value - ? 'p' - : props.depth + 2 === 7 - ? 'p' - : `h${props.depth + 2}` -}) +const textTag = computed(() => + hasChildren.value && props.depth < 5 ? `h${props.depth + 2}` : 'p' +) -const itemRole = computed(() => (isLink.value ? undefined : 'button')) +// a section needs a heading +const sectionTag = computed(() => + props.item.text && textTag.value !== 'p' ? 'section' : 'div' +) const classes = computed(() => [ [`level-${props.depth}`], @@ -42,31 +39,14 @@ const classes = computed(() => [ { 'has-active': hasActiveLink.value } ]) -function onItemInteraction(e: MouseEvent | Event) { - if ('key' in e && e.key !== 'Enter') { - return - } +function onItemClick() { !props.item.link && toggle() } - -function onCaretClick() { - props.item.link && toggle() -}