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' 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/': [ '/multi-sidebar/': [

@ -16,7 +16,8 @@ describe('test multi sidebar sort root', () => {
'Multi Sidebar Test', 'Multi Sidebar Test',
'Dynamic Routes', 'Dynamic Routes',
'Markdown Extensions', '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"> <script lang="ts" setup generic="T extends DefaultTheme.NavItemWithLink">
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { computed } from 'vue'
import { isActive } from '../../shared' import { useNavItemLink } from '../composables/nav'
import VPLink from './VPLink.vue' import VPLink from './VPLink.vue'
const props = defineProps<{ const props = defineProps<{
@ -11,22 +9,7 @@ const props = defineProps<{
rel?: string rel?: string
}>() }>()
const route = useRoute() const { href, isActiveLink, isCurrentLink } = useNavItemLink(() => props.item)
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
)
})
defineOptions({ inheritAttrs: false }) defineOptions({ inheritAttrs: false })
</script> </script>
@ -36,6 +19,7 @@ defineOptions({ inheritAttrs: false })
<VPLink <VPLink
v-bind="$attrs" v-bind="$attrs"
:class="{ active: isActiveLink }" :class="{ active: isActiveLink }"
:aria-current="isCurrentLink ? 'page' : undefined"
:href :href
:target="item.target" :target="item.target"
:rel="props.rel ?? item.rel" :rel="props.rel ?? item.rel"

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

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

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

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

@ -1,6 +1,16 @@
import { useMediaQuery, whenever } from '@vueuse/core' import { useMediaQuery, whenever } from '@vueuse/core'
import { useRoute } from 'vitepress' 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() { export function useNav() {
const isScreenOpen = ref(false) 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 { export interface NavExposedMethods {
closeScreen: () => void closeScreen: () => void
} }

@ -124,6 +124,15 @@ export function useSidebarItemControl(
watch([item, route], () => updateActiveLink()) watch([item, route], () => updateActiveLink())
onMounted(() => 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(() => { const hasChildren = computed(() => {
return !!(item.value.items && item.value.items.length) return !!(item.value.items && item.value.items.length)
}) })
@ -143,6 +152,7 @@ export function useSidebarItemControl(
collapsible, collapsible,
isLink, isLink,
isActiveLink: isActiveLink as ComputedRef<boolean>, isActiveLink: isActiveLink as ComputedRef<boolean>,
isCurrentLink,
hasActiveLink: hasActiveLink as ComputedRef<boolean>, hasActiveLink: hasActiveLink as ComputedRef<boolean>,
hasChildren, hasChildren,
toggle toggle

Loading…
Cancel
Save