fix(theme): mark current navigation links (#5395)

Co-authored-by: Divyansh Singh <40380293+brc-dd@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
pull/4957/merge
yiheng-kkk 2 weeks ago committed by GitHub
parent 40380c3085
commit 0f0fe13576
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -65,6 +65,11 @@ const nav: DefaultTheme.Config['nav'] = [
]
}
]
},
{
text: 'Active Match',
link: '/markdown-extensions/',
activeMatch: '^/home'
}
]
@ -153,6 +158,23 @@ const sidebar: DefaultTheme.Config['sidebar'] = {
link: '/team-and-sponsors/home-no-markdown-styles'
}
]
},
{
text: 'Sidebar Hash',
items: [
{
text: 'Overview',
link: '/sidebar-hash/'
},
{
text: 'Section One',
link: '/sidebar-hash/#section-one'
},
{
text: 'Section Two',
link: '/sidebar-hash/#section-two'
}
]
}
],
'/multi-sidebar/': [

@ -16,7 +16,8 @@ describe('test multi sidebar sort root', () => {
'Multi Sidebar Test',
'Dynamic Routes',
'Markdown Extensions',
'Team & Sponsors'
'Team & Sponsors',
'Sidebar Hash'
])
})
})

@ -0,0 +1,88 @@
const ariaCurrent = (selector: string) =>
page.locator(selector).getAttribute('aria-current')
describe('navigation accessibility', () => {
beforeEach(async () => {
await page.setViewportSize({ width: 1280, height: 720 })
})
test('marks direct nav links to the current page', async () => {
await goto('/')
expect(await ariaCurrent('.VPNavBarMenuLink[href="/"]')).toBe('page')
await page.setViewportSize({ width: 375, height: 667 })
await page.locator('.VPNavBarHamburger').click()
expect(await ariaCurrent('.VPNavScreenMenuLink[href="/"]')).toBe('page')
})
test('marks nested nav links to the current page', async () => {
await goto('/home')
expect(await ariaCurrent('.VPMenuLink a[href="/home.html"]')).toBe('page')
expect(await ariaCurrent('.VPNavBarMenuLink[href="/"]')).toBeNull()
await page.setViewportSize({ width: 375, height: 667 })
await page.locator('.VPNavBarHamburger').click()
expect(
await ariaCurrent('.VPNavScreenMenuGroupLink[href="/home.html"]')
).toBe('page')
})
test('does not mark broad activeMatch links as current', async () => {
await goto('/home')
const sectionLink = page.locator(
'.VPNavBarMenuLink[href="/markdown-extensions/"]'
)
expect(await sectionLink.getAttribute('class')).toContain('active')
expect(await sectionLink.getAttribute('aria-current')).toBeNull()
})
test('marks only exact sidebar links, including fragments', async () => {
const overview = '.VPSidebarItem .link[href="/sidebar-hash/"]'
const sectionOne = '.VPSidebarItem .link[href="/sidebar-hash/#section-one"]'
const sectionTwo = '.VPSidebarItem .link[href="/sidebar-hash/#section-two"]'
await goto('/sidebar-hash/')
// wait for hydration to replace the hash-agnostic server-rendered state
await page.waitForFunction(
() => document.querySelectorAll('.VPSidebarItem.is-active').length === 1
)
expect(await ariaCurrent(overview)).toBe('page')
expect(await ariaCurrent(sectionOne)).toBeNull()
expect(await ariaCurrent(sectionTwo)).toBeNull()
await page.locator(sectionTwo).click()
await page.waitForSelector(`${sectionTwo}[aria-current="page"]`)
expect(await ariaCurrent(sectionOne)).toBeNull()
await page.locator(sectionOne).click()
await page.waitForSelector(`${sectionOne}[aria-current="page"]`)
expect(await ariaCurrent(sectionTwo)).toBeNull()
})
test.runIf(process.env.VITE_TEST_BUILD)(
'omits aria-current for fragment links in server-rendered HTML',
async () => {
const response = await page.request.get(
`http://localhost:${process.env['PORT']}/sidebar-hash/`
)
const anchors = (
(await response.text()).match(/<a\b[^>]*>/g) ?? []
).filter((anchor) => anchor.includes('/sidebar-hash/'))
expect(
anchors.filter((anchor) => anchor.includes('#section-')).length
).toBeGreaterThanOrEqual(2)
expect(
anchors.filter((anchor) => anchor.includes('aria-current'))
).toEqual([expect.stringContaining('href="/sidebar-hash/"')])
}
)
})

@ -0,0 +1,11 @@
# Sidebar Hash
A page whose sidebar entries point at fragments of the same page.
## Section One
Content for the first section.
## Section Two
Content for the second section.

@ -1,9 +1,7 @@
<script lang="ts" setup generic="T extends DefaultTheme.NavItemWithLink">
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { computed } from 'vue'
import { isActive } from '../../shared'
import { useNavItemLink } from '../composables/nav'
import VPLink from './VPLink.vue'
const props = defineProps<{
@ -11,22 +9,7 @@ const props = defineProps<{
rel?: string
}>()
const route = useRoute()
const href = computed(() =>
typeof props.item.link === 'function'
? props.item.link(route.data)
: props.item.link
)
const isActiveLink = computed(() => {
return isActive(
route.data.relativePath,
route.hash,
props.item.activeMatch || href.value,
!!props.item.activeMatch
)
})
const { href, isActiveLink, isCurrentLink } = useNavItemLink(() => props.item)
defineOptions({ inheritAttrs: false })
</script>
@ -36,6 +19,7 @@ defineOptions({ inheritAttrs: false })
<VPLink
v-bind="$attrs"
:class="{ active: isActiveLink }"
:aria-current="isCurrentLink ? 'page' : undefined"
:href
:target="item.target"
:rel="props.rel ?? item.rel"

@ -1,36 +1,20 @@
<script lang="ts" setup>
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { computed } from 'vue'
import { isActive } from '../../shared'
import { useNavItemLink } from '../composables/nav'
import VPLink from './VPLink.vue'
const props = defineProps<{
item: DefaultTheme.NavItemWithLink
}>()
const route = useRoute()
const href = computed(() =>
typeof props.item.link === 'function'
? props.item.link(route.data)
: props.item.link
)
const isActiveLink = computed(() => {
return isActive(
route.data.relativePath,
route.hash,
props.item.activeMatch || href.value,
!!props.item.activeMatch
)
})
const { href, isActiveLink, isCurrentLink } = useNavItemLink(() => props.item)
</script>
<template>
<VPLink
:class="{ VPNavBarMenuLink: true, active: isActiveLink }"
:aria-current="isCurrentLink ? 'page' : undefined"
:href
:target="item.target"
:rel="item.rel"

@ -1,32 +1,15 @@
<script lang="ts" setup>
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { computed, inject } from 'vue'
import { inject } from 'vue'
import { isActive } from '../../shared'
import { navInjectionKey } from '../composables/nav'
import { navInjectionKey, useNavItemLink } from '../composables/nav'
import VPLink from './VPLink.vue'
const props = defineProps<{
item: DefaultTheme.NavItemWithLink
}>()
const route = useRoute()
const href = computed(() =>
typeof props.item.link === 'function'
? props.item.link(route.data)
: props.item.link
)
const isActiveLink = computed(() => {
return isActive(
route.data.relativePath,
route.hash,
props.item.activeMatch || href.value,
!!props.item.activeMatch
)
})
const { href, isActiveLink, isCurrentLink } = useNavItemLink(() => props.item)
const { closeScreen } = inject(navInjectionKey)!
</script>
@ -34,6 +17,7 @@ const { closeScreen } = inject(navInjectionKey)!
<template>
<VPLink
:class="{ VPNavScreenMenuGroupLink: true, active: isActiveLink }"
:aria-current="isCurrentLink ? 'page' : undefined"
:href
:target="item.target"
:rel="item.rel"

@ -1,32 +1,15 @@
<script lang="ts" setup>
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { computed, inject } from 'vue'
import { inject } from 'vue'
import { isActive } from '../../shared'
import { navInjectionKey } from '../composables/nav'
import { navInjectionKey, useNavItemLink } from '../composables/nav'
import VPLink from './VPLink.vue'
const props = defineProps<{
item: DefaultTheme.NavItemWithLink
}>()
const route = useRoute()
const href = computed(() =>
typeof props.item.link === 'function'
? props.item.link(route.data)
: props.item.link
)
const isActiveLink = computed(() => {
return isActive(
route.data.relativePath,
route.hash,
props.item.activeMatch || href.value,
!!props.item.activeMatch
)
})
const { href, isActiveLink, isCurrentLink } = useNavItemLink(() => props.item)
const { closeScreen } = inject(navInjectionKey)!
</script>
@ -34,6 +17,7 @@ const { closeScreen } = inject(navInjectionKey)!
<template>
<VPLink
:class="{ VPNavScreenMenuLink: true, active: isActiveLink }"
:aria-current="isCurrentLink ? 'page' : undefined"
:href
:target="item.target"
:rel="item.rel"

@ -15,6 +15,7 @@ const {
collapsible,
isLink,
isActiveLink,
isCurrentLink,
hasActiveLink,
hasChildren,
toggle
@ -54,6 +55,7 @@ function onItemClick() {
v-if="item.link"
:tag="linkTag"
class="link"
:aria-current="isCurrentLink ? 'page' : undefined"
:href="item.link"
:rel="item.rel"
:target="item.target"

@ -1,6 +1,16 @@
import { useMediaQuery, whenever } from '@vueuse/core'
import { useRoute } from 'vitepress'
import { ref, watch, type InjectionKey } from 'vue'
import type { DefaultTheme } from 'vitepress/theme'
import {
computed,
ref,
toValue,
watch,
type InjectionKey,
type MaybeRefOrGetter
} from 'vue'
import { isActive } from '../../shared'
export function useNav() {
const isScreenOpen = ref(false)
@ -32,6 +42,35 @@ export function useNav() {
}
}
export function useNavItemLink(
item: MaybeRefOrGetter<DefaultTheme.NavItemWithLink>
) {
const route = useRoute()
const href = computed(() => {
const { link } = toValue(item)
return typeof link === 'function' ? link(route.data) : link
})
const isActiveLink = computed(() => {
const { activeMatch } = toValue(item)
return isActive(
route.data.relativePath,
route.hash,
activeMatch || href.value,
!!activeMatch
)
})
// exact match only — a broad activeMatch keeps the visual active state
// without claiming aria-current
const isCurrentLink = computed(() => {
return isActive(route.data.relativePath, route.hash, href.value)
})
return { href, isActiveLink, isCurrentLink }
}
export interface NavExposedMethods {
closeScreen: () => void
}

@ -124,6 +124,15 @@ export function useSidebarItemControl(
watch([item, route], () => updateActiveLink())
onMounted(() => updateActiveLink())
// exact match only, unlike isActiveLink which skips the hash check before
// mount — links that differ only in hash must not claim aria-current in
// SSR output
const isCurrentLink = computed(() => {
return item.value.link
? isActive(route.data.relativePath, route.hash, item.value.link)
: false
})
const hasChildren = computed(() => {
return !!(item.value.items && item.value.items.length)
})
@ -143,6 +152,7 @@ export function useSidebarItemControl(
collapsible,
isLink,
isActiveLink: isActiveLink as ComputedRef<boolean>,
isCurrentLink,
hasActiveLink: hasActiveLink as ComputedRef<boolean>,
hasChildren,
toggle

Loading…
Cancel
Save