Skip to content

Commit d46107f

Browse files
authored
fix(client,a11y): improve focus handling and scrolling behavior in router (#4943)
1 parent 850c429 commit d46107f

File tree

3 files changed

+44
-49
lines changed

3 files changed

+44
-49
lines changed

src/client/app/router.ts

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export interface Router {
5454
export const RouterSymbol: InjectionKey<Router> = Symbol()
5555

5656
// we are just using URL to parse the pathname and hash - the base doesn't
57-
// matter and is only passed to support same-host hrefs.
57+
// matter and is only passed to support same-host hrefs
5858
const fakeHost = 'http://a.com'
5959

6060
const getDefaultRoute = (): Route => ({
@@ -261,35 +261,57 @@ export function scrollTo(hash: string, smooth = false, scrollPosition = 0) {
261261
return
262262
}
263263

264-
let target: Element | null = null
265-
264+
let target: HTMLElement | null = null
266265
try {
267266
target = document.getElementById(decodeURIComponent(hash).slice(1))
268267
} catch (e) {
269268
console.warn(e)
270269
}
270+
if (!target) return
271271

272-
if (target) {
273-
const targetPadding = parseInt(
274-
window.getComputedStyle(target).paddingTop,
275-
10
276-
)
277-
278-
const targetTop =
279-
window.scrollY +
272+
const targetTop =
273+
window.scrollY +
280274
target.getBoundingClientRect().top -
281275
getScrollOffset() +
282-
targetPadding
276+
Number.parseInt(window.getComputedStyle(target).paddingTop, 10) || 0
277+
278+
const behavior = window.matchMedia('(prefers-reduced-motion)').matches
279+
? 'instant'
280+
: // only smooth scroll if distance is smaller than screen height
281+
smooth && Math.abs(targetTop - window.scrollY) <= window.innerHeight
282+
? 'smooth'
283+
: 'auto'
284+
285+
const scrollToTarget = () => {
286+
window.scrollTo({ left: 0, top: targetTop, behavior })
287+
288+
// focus the target element for better accessibility
289+
target.focus({ preventScroll: true })
283290

284-
function scrollToTarget() {
285-
// only smooth scroll if distance is smaller than screen height.
286-
if (!smooth || Math.abs(targetTop - window.scrollY) > window.innerHeight)
287-
window.scrollTo(0, targetTop)
288-
else window.scrollTo({ left: 0, top: targetTop, behavior: 'smooth' })
291+
// return if focus worked
292+
if (document.activeElement === target) return
293+
294+
// element has tabindex already, likely not focusable
295+
// because of some other reason, bail out
296+
if (target.hasAttribute('tabindex')) return
297+
298+
const restoreTabindex = () => {
299+
target.removeAttribute('tabindex')
300+
target.removeEventListener('blur', restoreTabindex)
289301
}
290302

291-
requestAnimationFrame(scrollToTarget)
303+
// temporarily make the target element focusable
304+
target.setAttribute('tabindex', '-1')
305+
target.addEventListener('blur', restoreTabindex)
306+
307+
// try to focus again
308+
target.focus({ preventScroll: true })
309+
310+
// remove tabindex and event listener if focus still not worked
311+
if (document.activeElement !== target) restoreTabindex()
292312
}
313+
314+
requestAnimationFrame(scrollToTarget)
293315
}
294316

295317
function handleHMR(route: Route): void {
@@ -313,7 +335,7 @@ function shouldHotReload(payload: PageDataPayload): boolean {
313335
function normalizeHref(href: string): string {
314336
const url = new URL(href, fakeHost)
315337
url.pathname = url.pathname.replace(/(^|\/)index(\.html)?$/, '$1')
316-
// ensure correct deep link so page refresh lands on correct files.
338+
// ensure correct deep link so page refresh lands on correct files
317339
if (siteDataRef.value.cleanUrls) {
318340
url.pathname = url.pathname.replace(/\.html$/, '')
319341
} else if (!url.pathname.endsWith('/') && !url.pathname.endsWith('.html')) {

src/client/theme-default/components/VPDocOutlineItem.vue

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,12 @@ defineProps<{
55
headers: DefaultTheme.OutlineItem[]
66
root?: boolean
77
}>()
8-
9-
function onClick({ target: el }: Event) {
10-
const id = (el as HTMLAnchorElement).href!.split('#')[1]
11-
const heading = document.getElementById(decodeURIComponent(id))
12-
heading?.focus({ preventScroll: true })
13-
}
148
</script>
159

1610
<template>
1711
<ul class="VPDocOutlineItem" :class="root ? 'root' : 'nested'">
1812
<li v-for="{ children, link, title } in headers">
19-
<a class="outline-link" :href="link" @click="onClick" :title>
13+
<a class="outline-link" :href="link" :title>
2014
{{ title }}
2115
</a>
2216
<template v-if="children?.length">

src/client/theme-default/components/VPSkipLink.vue

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,39 +8,18 @@ const route = useRoute()
88
const backToTop = ref()
99
1010
watch(() => route.path, () => backToTop.value.focus())
11-
12-
function focusOnTargetAnchor({ target }: Event) {
13-
const el = document.getElementById(
14-
decodeURIComponent((target as HTMLAnchorElement).hash).slice(1)
15-
)
16-
17-
if (el) {
18-
const removeTabIndex = () => {
19-
el.removeAttribute('tabindex')
20-
el.removeEventListener('blur', removeTabIndex)
21-
}
22-
23-
el.setAttribute('tabindex', '-1')
24-
el.addEventListener('blur', removeTabIndex)
25-
el.focus()
26-
window.scrollTo(0, 0)
27-
}
28-
}
2911
</script>
3012

3113
<template>
3214
<span ref="backToTop" tabindex="-1" />
33-
<a
34-
href="#VPContent"
35-
class="VPSkipLink visually-hidden"
36-
@click="focusOnTargetAnchor"
37-
>
15+
<a href="#VPContent" class="VPSkipLink visually-hidden">
3816
{{ theme.skipToContentLabel || 'Skip to content' }}
3917
</a>
4018
</template>
4119

4220
<style scoped>
4321
.VPSkipLink {
22+
position: fixed;
4423
top: 8px;
4524
left: 8px;
4625
padding: 8px 16px;

0 commit comments

Comments
 (0)