Skip to content

Commit 37d98e3

Browse files
authored
Announce route changes for screen readers (#3332)
Similar deal to oxidecomputer/rfd-site#258 ## 🤖 summary A client-side nav doesn't tell a screen reader anything: React Router swaps the DOM in place and focus stays on the link that was clicked, which usually isn't in the document anymore. Next.js ships a route announcer for this; RR leaves it to the app. `useRouteAnnouncer` in `RootLayout` announces the crumbs deepest-first ("Instances, mock-project, Projects") through the react-aria live announcer we already use for toasts and field errors. Polite rather than assertive so it doesn't preempt the toast on flows that toast and then navigate. It also puts focus on the existing skip link target, but only when focus has fallen to `<body>` — if you clicked a sidebar link that's still sitting there, leave it alone. Use `preventScroll` to avoid conflicts with `useScrollRestoration` on back/forward. Side modal forms get their own routes, so the announcer has to know that a form opening on top of a page isn't a page change. It keys off `titleOnly` crumbs, which already mark exactly those routes (had to fix `ip-pool-edit` and `subnet-pool-edit`, which were using `makeCrumb`). Detecting the open dialog in the DOM instead seemed simpler, but it ran into races.
1 parent a842fbe commit 37d98e3

10 files changed

Lines changed: 197 additions & 21 deletions

File tree

‎app/forms/ip-pool-edit.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { NameField } from '~/components/form/fields/NameField'
1616
import { FormMetadata } from '~/components/form/FormMetadata'
1717
import { SideModalForm } from '~/components/form/SideModalForm'
1818
import { HL } from '~/components/HL'
19-
import { makeCrumb } from '~/hooks/use-crumbs'
19+
import { titleCrumb } from '~/hooks/use-crumbs'
2020
import { getIpPoolSelector, useIpPoolSelector } from '~/hooks/use-params'
2121
import { addToast } from '~/stores/toast'
2222
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
@@ -34,7 +34,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
3434
return null
3535
}
3636

37-
export const handle = makeCrumb('Edit IP pool')
37+
export const handle = titleCrumb('Edit IP pool')
3838

3939
export default function EditIpPoolSideModalForm() {
4040
const navigate = useNavigate()

‎app/forms/subnet-pool-edit.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { NameField } from '~/components/form/fields/NameField'
1616
import { FormMetadata } from '~/components/form/FormMetadata'
1717
import { SideModalForm } from '~/components/form/SideModalForm'
1818
import { HL } from '~/components/HL'
19-
import { makeCrumb } from '~/hooks/use-crumbs'
19+
import { titleCrumb } from '~/hooks/use-crumbs'
2020
import { getSubnetPoolSelector, useSubnetPoolSelector } from '~/hooks/use-params'
2121
import { addToast } from '~/stores/toast'
2222
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
@@ -35,7 +35,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
3535
return null
3636
}
3737

38-
export const handle = makeCrumb('Edit subnet pool')
38+
export const handle = titleCrumb('Edit subnet pool')
3939

4040
export default function EditSubnetPoolSideModalForm() {
4141
const navigate = useNavigate()

‎app/hooks/use-crumbs.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,14 @@ export const matchesToCrumbs = (matches: UIMatch[]) =>
6969
})
7070

7171
export const useCrumbs = () => matchesToCrumbs(useMatches())
72+
73+
/**
74+
* Whether the current route is a side modal (form or detail panel) opening on
75+
* top of a page. Keys off the `titleOnly` crumb flag because it exists for the
76+
* same reason: the page underneath doesn't change.
77+
*
78+
* Not the same as `useIsInSideModal`, which asks whether the calling component
79+
* is rendered inside a `SideModal`. That one is false on the page underneath;
80+
* this one is true.
81+
*/
82+
export const useIsSideModalRoute = () => useCrumbs().some((c) => c.titleOnly)

‎app/hooks/use-route-announcer.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { announce } from '@react-aria/live-announcer'
9+
import { useEffect, useRef } from 'react'
10+
import { useLocation } from 'react-router'
11+
12+
import { useCrumbs, useIsSideModalRoute } from './use-crumbs'
13+
14+
/**
15+
* A real page load tells a screen reader where it landed: the new page gets
16+
* announced and the reading position goes back to the top. A client-side nav
17+
* does neither — React Router swaps the DOM in place and focus stays on the
18+
* link that was clicked, which often doesn't exist anymore. Next.js ships a
19+
* route announcer for this; React Router leaves it to the app.
20+
*
21+
* So on every page change we do it ourselves: announce the new page in a live
22+
* region, and move focus to the top of the content, which is where the skip
23+
* link points too.
24+
*/
25+
export function useRouteAnnouncer() {
26+
const { pathname } = useLocation()
27+
const crumbs = useCrumbs()
28+
29+
// deepest crumb first, like the document title, so the specific page comes
30+
// before its containers: "Instances, mock-project, Projects"
31+
const pageName = crumbs
32+
.map((c) => c.label)
33+
.reverse()
34+
.join(', ')
35+
36+
const isSideModal = useIsSideModalRoute()
37+
38+
// initialized with the current path so we don't announce the page we loaded
39+
// on — the browser already did that
40+
const lastAnnounced = useRef(pathname)
41+
42+
useEffect(() => {
43+
// A side modal is open, so we're still on the page underneath and there's
44+
// nothing to announce. The dialog announces its own title and manages its
45+
// own focus, so stay out of its way. Leaving the ref alone also makes
46+
// dismissing it — a nav back to that same page — a no-op.
47+
if (isSideModal) return
48+
49+
if (pathname === lastAnnounced.current) return
50+
lastAnnounced.current = pathname
51+
52+
// polite rather than assertive so we don't cut off a toast announcing the
53+
// result of the action that navigated us here
54+
announce(pageName || 'Oxide Console', 'polite')
55+
56+
// Move focus to the top of the new page, like a real page load would.
57+
// Leaving it where it was is unreliable anyway: Safari doesn't focus links
58+
// on click, so after a sidebar click, focus is on the link in Firefox but
59+
// on the body in Safari. The exception is tabs — the ARIA tabs pattern
60+
// keeps focus on the tab after activating it, even when (as with VPC tabs)
61+
// the tab is a route.
62+
//
63+
// Prefer the page's h1 over <main> itself. VoiceOver reads a focused
64+
// heading's text, whereas focusing a big landmark container just gets
65+
// "main" with no content. The h1 also remounts on every page change, while
66+
// <main> persists across navs — refocusing an already-focused element is a
67+
// no-op that fires no event, so the VO cursor would never move after the
68+
// first nav. Focusing the destination page's heading is the standard
69+
// recommendation for SPA route changes:
70+
// https://www.deque.com/blog/single-page-apps-focus-management/
71+
// https://www.gatsbyjs.com/blog/2019-07-11-user-testing-accessible-client-routing/
72+
//
73+
// preventScroll because scroll position is useScrollRestoration's job:
74+
// without it, focusing the top of the page would clobber the restored
75+
// position on back/forward nav.
76+
if (document.activeElement?.getAttribute('role') !== 'tab') {
77+
const main = document.getElementById('content')
78+
const heading = main?.querySelector('h1')
79+
if (heading) {
80+
heading.tabIndex = -1 // headings aren't focusable by default
81+
// Safari (unlike Chrome/FF) matches :focus-visible on programmatic
82+
// focus even when the nav came from a click. The heading isn't
83+
// interactive, so never show a ring.
84+
heading.classList.add('outline-none')
85+
}
86+
;(heading || main)?.focus({ preventScroll: true })
87+
}
88+
}, [pathname, pageName, isSideModal])
89+
}

‎app/layouts/RootLayout.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Outlet, useNavigation } from 'react-router'
1010

1111
import { ToastStack } from '~/components/ToastStack'
1212
import { useCrumbs } from '~/hooks/use-crumbs'
13+
import { useRouteAnnouncer } from '~/hooks/use-route-announcer'
1314
import { useApplyTheme } from '~/stores/theme'
1415

1516
/**
@@ -29,6 +30,7 @@ const useTitle = () =>
2930
*/
3031
export default function RootLayout() {
3132
useApplyTheme()
33+
useRouteAnnouncer()
3234
const title = useTitle()
3335
useEffect(() => {
3436
document.title = title

‎app/layouts/helpers.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { Outlet } from 'react-router'
1010
import { PageActionsTarget } from '~/components/PageActions'
1111
import { Pagination } from '~/components/Pagination'
1212
import { useScrollRestoration } from '~/hooks/use-scroll-restoration'
13-
import { SkipLinkTarget } from '~/ui/lib/SkipLink'
1413
import { classed } from '~/util/classed'
1514

1615
export const PageContainer = classed.div`min-h-full pt-[calc(var(--top-bar-height)+var(--preview-banner-height))]`
@@ -26,8 +25,10 @@ export function ContentPane() {
2625
return (
2726
<div className="light:bg-raise ml-(--sidebar-width) flex min-h-[calc(100vh-var(--top-bar-height)-var(--preview-banner-height))] flex-col">
2827
<div className="flex grow flex-col pb-8">
29-
<SkipLinkTarget />
30-
<main className="*:gutter">
28+
{/* id/tabIndex make this the skip link target and where useRouteAnnouncer
29+
puts focus after a nav. It has to be a real element in the a11y tree
30+
(not an empty div) or the VoiceOver cursor won't follow the focus. */}
31+
<main id="content" tabIndex={-1} className="*:gutter outline-none">
3132
<Outlet />
3233
</main>
3334
</div>
@@ -47,8 +48,7 @@ export function ContentPane() {
4748
*/
4849
export const SerialConsoleContentPane = () => (
4950
<div className="ml-(--sidebar-width) flex h-[calc(100vh-var(--top-bar-height)-var(--preview-banner-height))] flex-col overflow-hidden">
50-
<SkipLinkTarget />
51-
<main className="*:gutter h-full">
51+
<main id="content" tabIndex={-1} className="*:gutter h-full outline-none">
5252
<Outlet />
5353
</main>
5454
</div>

‎app/ui/lib/SkipLink.tsx‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,3 @@ export const SkipLink = ({
3434
</a>
3535
)
3636
}
37-
38-
export const SkipLinkTarget = ({ id = 'content' }) => {
39-
return <div id={id} className="h-0" />
40-
}

‎app/ui/lib/modal-context.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,8 @@ export const ModalContext = createContext(false)
1212
export const useIsInModal = () => useContext(ModalContext)
1313

1414
export const SideModalContext = createContext(false)
15+
/**
16+
* Whether the calling component is rendered inside a `SideModal`. For "is a
17+
* side modal open on top of the current page", see `useIsSideModalRoute`.
18+
*/
1519
export const useIsInSideModal = () => useContext(SideModalContext)

‎app/util/__snapshots__/path-builder.spec.ts.snap‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -442,10 +442,6 @@ exports[`breadcrumbs 2`] = `
442442
"label": "pl",
443443
"path": "/system/networking/ip-pools/pl",
444444
},
445-
{
446-
"label": "Edit IP pool",
447-
"path": "/system/networking/ip-pools/pl/edit",
448-
},
449445
],
450446
"ipPoolRangeAdd (/system/networking/ip-pools/pl/ranges-add)": [
451447
{
@@ -870,10 +866,6 @@ exports[`breadcrumbs 2`] = `
870866
"label": "sp",
871867
"path": "/system/networking/subnet-pools/sp",
872868
},
873-
{
874-
"label": "Edit subnet pool",
875-
"path": "/system/networking/subnet-pools/sp/edit",
876-
},
877869
],
878870
"subnetPoolMemberAdd (/system/networking/subnet-pools/sp/members-add)": [
879871
{

‎test/e2e/route-announcer.e2e.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { expect, test, type Page } from '@playwright/test'
9+
10+
// react-aria appends each announcement as a child of a hidden polite live
11+
// region shared with toasts. CSS locator because there's a second region for
12+
// assertive announcements and we only want the messages from this one.
13+
const announcements = (page: Page) =>
14+
page.locator('[data-live-announcer] [aria-live="polite"] > div')
15+
16+
test('route announcer', async ({ page }) => {
17+
await page.goto('/projects')
18+
await page.getByRole('heading', { name: 'Projects' }).waitFor()
19+
20+
// nothing on first load — the browser already announced the page
21+
await expect(announcements(page)).toHaveCount(0)
22+
23+
// nav by a link inside the content, which unmounts along with the page
24+
await page.getByRole('link', { name: 'mock-project' }).click()
25+
await expect(announcements(page)).toHaveText(['Instances, mock-project, Projects'])
26+
// focus goes to the new page's heading, like (better than) a real page load
27+
await expect(page.getByRole('heading', { name: 'Instances' })).toBeFocused()
28+
29+
// nav by a sidebar link: the link survives the nav, but focus still moves to
30+
// the top of the new page
31+
const disksLink = page
32+
.getByRole('navigation', { name: 'Sidebar navigation' })
33+
.getByRole('link', { name: 'Disks' })
34+
await disksLink.click()
35+
await expect(announcements(page)).toHaveText([
36+
'Instances, mock-project, Projects',
37+
'Disks, mock-project, Projects',
38+
])
39+
await expect(page.getByRole('heading', { name: 'Disks' })).toBeFocused()
40+
41+
// a side modal form is its own route, but it opens on top of the page rather
42+
// than replacing it, so it doesn't announce or take focus from the dialog
43+
await page.getByRole('link', { name: 'New Disk' }).click()
44+
await expect(page.getByRole('dialog', { name: 'Create disk' })).toBeVisible()
45+
await expect(announcements(page)).toHaveCount(2)
46+
47+
// ...and neither does dismissing it
48+
await page.getByRole('button', { name: 'Cancel' }).click()
49+
await expect(page.getByRole('dialog', { name: 'Create disk' })).toBeHidden()
50+
await expect(announcements(page)).toHaveCount(2)
51+
})
52+
53+
// tabs that live in the query param rather than the path aren't navigations as
54+
// far as the router is concerned, so there's nothing to announce
55+
test('route announcer ignores query param tabs', async ({ page }) => {
56+
await page.goto('/system/utilization')
57+
await page.getByRole('tab', { name: 'Metrics' }).click()
58+
await expect(page).toHaveURL(/tab=metrics/)
59+
await expect(announcements(page)).toHaveCount(0)
60+
})
61+
62+
// VPC tabs, on the other hand, are real routes. Routes like these share a crumb
63+
// path with their siblings, which is why the announcer keys off the pathname.
64+
test('route announcer on tab routes', async ({ page }) => {
65+
await page.goto('/projects/mock-project/vpcs/default/firewall-rules')
66+
const tab = page.getByRole('tab', { name: 'Routers' })
67+
await tab.click()
68+
await expect(announcements(page)).toHaveText([
69+
'Routers, default, VPCs, mock-project, Projects',
70+
])
71+
// the tab is still there, so it keeps focus
72+
await expect(tab).toBeFocused()
73+
})
74+
75+
// the image detail side modal isn't a form, but it's still a dialog on top of
76+
// the list rather than a new page
77+
test('route announcer ignores detail side modals', async ({ page }) => {
78+
await page.goto('/images')
79+
await page.getByRole('link', { name: 'ubuntu-22-04' }).click()
80+
await expect(page.getByRole('dialog', { name: 'Image details' })).toBeVisible()
81+
await expect(announcements(page)).toHaveCount(0)
82+
})

0 commit comments

Comments
 (0)