diff --git a/__tests__/e2e/.vitepress/config.ts b/__tests__/e2e/.vitepress/config.ts index cafcfd50152b..acd748929d29 100644 --- a/__tests__/e2e/.vitepress/config.ts +++ b/__tests__/e2e/.vitepress/config.ts @@ -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/': [ diff --git a/__tests__/e2e/multi-sidebar/index.test.ts b/__tests__/e2e/multi-sidebar/index.test.ts index cd6543017810..9f7d4a4c73a0 100644 --- a/__tests__/e2e/multi-sidebar/index.test.ts +++ b/__tests__/e2e/multi-sidebar/index.test.ts @@ -16,7 +16,8 @@ describe('test multi sidebar sort root', () => { 'Multi Sidebar Test', 'Dynamic Routes', 'Markdown Extensions', - 'Team & Sponsors' + 'Team & Sponsors', + 'Sidebar Hash' ]) }) }) diff --git a/__tests__/e2e/navigation.test.ts b/__tests__/e2e/navigation.test.ts new file mode 100644 index 000000000000..cece272a2a4b --- /dev/null +++ b/__tests__/e2e/navigation.test.ts @@ -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(/]*>/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/"')]) + } + ) +}) diff --git a/__tests__/e2e/sidebar-hash/index.md b/__tests__/e2e/sidebar-hash/index.md new file mode 100644 index 000000000000..4b3aad1748b6 --- /dev/null +++ b/__tests__/e2e/sidebar-hash/index.md @@ -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. diff --git a/src/client/theme-default/components/VPMenuLink.vue b/src/client/theme-default/components/VPMenuLink.vue index 89129dd60097..4591247969bc 100644 --- a/src/client/theme-default/components/VPMenuLink.vue +++ b/src/client/theme-default/components/VPMenuLink.vue @@ -1,9 +1,7 @@ @@ -36,6 +19,7 @@ defineOptions({ inheritAttrs: false }) -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)