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 <jibinjose884@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/4532/merge
Divyansh Singh 3 weeks ago
parent b18f30680b
commit ed2bfb266e

@ -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)
})
})

@ -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()
}
</script>
<template>
<component :is="sectionTag" class="VPSidebarItem" :class="classes">
<div
v-if="item.text"
class="item"
:role="itemRole"
v-on="
item.items
? { click: onItemInteraction, keydown: onItemInteraction }
: {}
"
:tabindex="item.items && 0"
>
<div v-if="item.text" class="item" @click="onItemClick">
<div class="indicator" />
<VPLink
@ -81,17 +61,16 @@ function onCaretClick() {
</VPLink>
<component v-else :is="textTag" class="text" v-html="item.text" />
<div
<button
v-if="item.collapsed != null && item.items && item.items.length"
type="button"
class="caret"
role="button"
aria-label="toggle section"
@click="onCaretClick"
@keydown.enter="onCaretClick"
tabindex="0"
:aria-expanded="!collapsed"
@click.stop="toggle"
>
<span class="vpi-chevron-right caret-icon" />
</div>
</button>
</div>
<ul v-if="item.items && item.items.length" class="items">

Loading…
Cancel
Save