fix(client,a11y): improve focus handling and scrolling behavior in router (#4943)

main
Divyansh Singh 1 week ago committed by GitHub
parent 850c429f14
commit d46107fa25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -54,7 +54,7 @@ export interface Router {
export const RouterSymbol: InjectionKey<Router> = Symbol() export const RouterSymbol: InjectionKey<Router> = Symbol()
// we are just using URL to parse the pathname and hash - the base doesn't // 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 fakeHost = 'http://a.com'
const getDefaultRoute = (): Route => ({ const getDefaultRoute = (): Route => ({
@ -261,35 +261,57 @@ export function scrollTo(hash: string, smooth = false, scrollPosition = 0) {
return return
} }
let target: Element | null = null let target: HTMLElement | null = null
try { try {
target = document.getElementById(decodeURIComponent(hash).slice(1)) target = document.getElementById(decodeURIComponent(hash).slice(1))
} catch (e) { } catch (e) {
console.warn(e) console.warn(e)
} }
if (!target) return
if (target) {
const targetPadding = parseInt(
window.getComputedStyle(target).paddingTop,
10
)
const targetTop = const targetTop =
window.scrollY + window.scrollY +
target.getBoundingClientRect().top - target.getBoundingClientRect().top -
getScrollOffset() + 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 })
// return if focus worked
if (document.activeElement === target) return
function scrollToTarget() { // element has tabindex already, likely not focusable
// only smooth scroll if distance is smaller than screen height. // because of some other reason, bail out
if (!smooth || Math.abs(targetTop - window.scrollY) > window.innerHeight) if (target.hasAttribute('tabindex')) return
window.scrollTo(0, targetTop)
else window.scrollTo({ left: 0, top: targetTop, behavior: 'smooth' }) 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 { function handleHMR(route: Route): void {
@ -313,7 +335,7 @@ function shouldHotReload(payload: PageDataPayload): boolean {
function normalizeHref(href: string): string { function normalizeHref(href: string): string {
const url = new URL(href, fakeHost) const url = new URL(href, fakeHost)
url.pathname = url.pathname.replace(/(^|\/)index(\.html)?$/, '$1') 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) { if (siteDataRef.value.cleanUrls) {
url.pathname = url.pathname.replace(/\.html$/, '') url.pathname = url.pathname.replace(/\.html$/, '')
} else if (!url.pathname.endsWith('/') && !url.pathname.endsWith('.html')) { } else if (!url.pathname.endsWith('/') && !url.pathname.endsWith('.html')) {

@ -5,18 +5,12 @@ defineProps<{
headers: DefaultTheme.OutlineItem[] headers: DefaultTheme.OutlineItem[]
root?: boolean 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 })
}
</script> </script>
<template> <template>
<ul class="VPDocOutlineItem" :class="root ? 'root' : 'nested'"> <ul class="VPDocOutlineItem" :class="root ? 'root' : 'nested'">
<li v-for="{ children, link, title } in headers"> <li v-for="{ children, link, title } in headers">
<a class="outline-link" :href="link" @click="onClick" :title> <a class="outline-link" :href="link" :title>
{{ title }} {{ title }}
</a> </a>
<template v-if="children?.length"> <template v-if="children?.length">

@ -8,39 +8,18 @@ const route = useRoute()
const backToTop = ref() const backToTop = ref()
watch(() => route.path, () => backToTop.value.focus()) watch(() => route.path, () => backToTop.value.focus())
function focusOnTargetAnchor({ target }: Event) {
const el = document.getElementById(
decodeURIComponent((target as HTMLAnchorElement).hash).slice(1)
)
if (el) {
const removeTabIndex = () => {
el.removeAttribute('tabindex')
el.removeEventListener('blur', removeTabIndex)
}
el.setAttribute('tabindex', '-1')
el.addEventListener('blur', removeTabIndex)
el.focus()
window.scrollTo(0, 0)
}
}
</script> </script>
<template> <template>
<span ref="backToTop" tabindex="-1" /> <span ref="backToTop" tabindex="-1" />
<a <a href="#VPContent" class="VPSkipLink visually-hidden">
href="#VPContent"
class="VPSkipLink visually-hidden"
@click="focusOnTargetAnchor"
>
{{ theme.skipToContentLabel || 'Skip to content' }} {{ theme.skipToContentLabel || 'Skip to content' }}
</a> </a>
</template> </template>
<style scoped> <style scoped>
.VPSkipLink { .VPSkipLink {
position: fixed;
top: 8px; top: 8px;
left: 8px; left: 8px;
padding: 8px 16px; padding: 8px 16px;

Loading…
Cancel
Save