From d46107fa254d662d297b1362aa0d3b898ef96e2c Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Sun, 14 Sep 2025 16:56:15 +0530 Subject: [PATCH] fix(client,a11y): improve focus handling and scrolling behavior in router (#4943) --- src/client/app/router.ts | 60 +++++++++++++------ .../components/VPDocOutlineItem.vue | 8 +-- .../theme-default/components/VPSkipLink.vue | 25 +------- 3 files changed, 44 insertions(+), 49 deletions(-) diff --git a/src/client/app/router.ts b/src/client/app/router.ts index 09d916ec..de7fe261 100644 --- a/src/client/app/router.ts +++ b/src/client/app/router.ts @@ -54,7 +54,7 @@ export interface Router { export const RouterSymbol: InjectionKey = Symbol() // we are just using URL to parse the pathname and hash - the base doesn't -// matter and is only passed to support same-host hrefs. +// matter and is only passed to support same-host hrefs const fakeHost = 'http://a.com' const getDefaultRoute = (): Route => ({ @@ -261,35 +261,57 @@ export function scrollTo(hash: string, smooth = false, scrollPosition = 0) { return } - let target: Element | null = null - + let target: HTMLElement | null = null try { target = document.getElementById(decodeURIComponent(hash).slice(1)) } catch (e) { console.warn(e) } + if (!target) return - if (target) { - const targetPadding = parseInt( - window.getComputedStyle(target).paddingTop, - 10 - ) - - const targetTop = - window.scrollY + + const targetTop = + window.scrollY + target.getBoundingClientRect().top - getScrollOffset() + - targetPadding + Number.parseInt(window.getComputedStyle(target).paddingTop, 10) || 0 + + const behavior = window.matchMedia('(prefers-reduced-motion)').matches + ? 'instant' + : // only smooth scroll if distance is smaller than screen height + smooth && Math.abs(targetTop - window.scrollY) <= window.innerHeight + ? 'smooth' + : 'auto' + + const scrollToTarget = () => { + window.scrollTo({ left: 0, top: targetTop, behavior }) + + // focus the target element for better accessibility + target.focus({ preventScroll: true }) - function scrollToTarget() { - // only smooth scroll if distance is smaller than screen height. - if (!smooth || Math.abs(targetTop - window.scrollY) > window.innerHeight) - window.scrollTo(0, targetTop) - else window.scrollTo({ left: 0, top: targetTop, behavior: 'smooth' }) + // return if focus worked + if (document.activeElement === target) return + + // element has tabindex already, likely not focusable + // because of some other reason, bail out + if (target.hasAttribute('tabindex')) return + + const restoreTabindex = () => { + target.removeAttribute('tabindex') + target.removeEventListener('blur', restoreTabindex) } - requestAnimationFrame(scrollToTarget) + // temporarily make the target element focusable + target.setAttribute('tabindex', '-1') + target.addEventListener('blur', restoreTabindex) + + // try to focus again + target.focus({ preventScroll: true }) + + // remove tabindex and event listener if focus still not worked + if (document.activeElement !== target) restoreTabindex() } + + requestAnimationFrame(scrollToTarget) } function handleHMR(route: Route): void { @@ -313,7 +335,7 @@ function shouldHotReload(payload: PageDataPayload): boolean { function normalizeHref(href: string): string { const url = new URL(href, fakeHost) url.pathname = url.pathname.replace(/(^|\/)index(\.html)?$/, '$1') - // ensure correct deep link so page refresh lands on correct files. + // ensure correct deep link so page refresh lands on correct files if (siteDataRef.value.cleanUrls) { url.pathname = url.pathname.replace(/\.html$/, '') } else if (!url.pathname.endsWith('/') && !url.pathname.endsWith('.html')) { diff --git a/src/client/theme-default/components/VPDocOutlineItem.vue b/src/client/theme-default/components/VPDocOutlineItem.vue index c945fc21..4cff1b38 100644 --- a/src/client/theme-default/components/VPDocOutlineItem.vue +++ b/src/client/theme-default/components/VPDocOutlineItem.vue @@ -5,18 +5,12 @@ defineProps<{ headers: DefaultTheme.OutlineItem[] root?: boolean }>() - -function onClick({ target: el }: Event) { - const id = (el as HTMLAnchorElement).href!.split('#')[1] - const heading = document.getElementById(decodeURIComponent(id)) - heading?.focus({ preventScroll: true }) -}