Skip to content

Commit

Permalink
Steps towards a11y for Redwood Router (#1817)
Browse files Browse the repository at this point in the history
* feat: announce page and scroll to top

* fix: update yarn.lock

* fix: update typing and test

* feat: return early in getAnnouncement

* fix: update resetNamedRoutes intest

* fix: update set test to include route announcement
  • Loading branch information
jtoar authored Mar 19, 2021
1 parent 438efbc commit f0b2ac5
Show file tree
Hide file tree
Showing 12 changed files with 378 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { SkipNavLink, SkipNavContent } from '@redwoodjs/router'
import '@reach/skip-nav/styles.css'

/**
* since the main content isn't usually the first thing in the document,
* it's important to provide a shortcut for keyboard and screen-reader users to skip to the main content
* API docs: https://reach.tech/skip-nav/#reach-skip-nav
*/

const A11yLayout: React.FunctionComponent = ({ children }) => {
return (
<>
{/* renders a link that remains hidden till focused; put it at the top of your layout */}
<SkipNavLink />
<nav></nav>
{/* renders a div as the target for the link; put it next to your main content */}
<SkipNavContent />
<main>{children}</main>
</>
)
}

export default A11yLayout
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ let singleWordDefaultFiles,
javascriptFiles,
typescriptFiles,
withoutTestFiles,
withoutStoryFiles
withoutStoryFiles,
withSkipLinkFiles

beforeAll(() => {
singleWordDefaultFiles = layout.files({ name: 'App' })
Expand All @@ -33,6 +34,10 @@ beforeAll(() => {
javascript: true,
stories: false,
})
withSkipLinkFiles = layout.files({
name: 'A11y',
skipLink: true,
})
})

test('returns exactly 3 files', () => {
Expand Down Expand Up @@ -152,3 +157,13 @@ test("doesn't include test file when --tests is set to false", () => {
),
])
})

test.only('includes skip link when --skipLink is set to true', () => {
expect(
withSkipLinkFiles[
path.normalize(
'/path/to/project/web/src/layouts/A11yLayout/A11yLayout.tsx'
)
]
).toEqual(loadGeneratorFixture('layout', 'withSkipLinkLayout.tsx'))
})
15 changes: 14 additions & 1 deletion packages/cli/src/commands/generate/layout/layout.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { transformTSToJS } from 'src/lib'

import { yargsDefaults } from '../../generate'
import {
templateForComponentFile,
createYargsForComponentGeneration,
Expand All @@ -17,7 +18,9 @@ export const files = ({ name, tests = true, stories = true, ...options }) => {
webPathSection: REDWOOD_WEB_PATH_NAME,
extension: isJavascript ? '.js' : '.tsx',
generator: 'layout',
templatePath: 'layout.tsx.template',
templatePath: options.skipLink
? 'layout.tsx.a11yTemplate'
: 'layout.tsx.template',
})
const testFile = templateForComponentFile({
name,
Expand Down Expand Up @@ -62,6 +65,15 @@ export const files = ({ name, tests = true, stories = true, ...options }) => {
}, {})
}

const builderObj = {
skipLink: {
default: false,
description: 'Generate with skip link',
type: 'boolean',
},
...yargsDefaults,
}

export const {
command,
description,
Expand All @@ -70,4 +82,5 @@ export const {
} = createYargsForComponentGeneration({
componentName: 'layout',
filesFn: files,
builderObj,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { SkipNavLink, SkipNavContent } from '@redwoodjs/router'
import '@reach/skip-nav/styles.css'

/**
* since the main content isn't usually the first thing in the document,
* it's important to provide a shortcut for keyboard and screen-reader users to skip to the main content
* API docs: https://reach.tech/skip-nav/#reach-skip-nav
*/

const ${singularPascalName}Layout: React.FunctionComponent = ({ children }) => {
return (
<>
{/* renders a link that remains hidden till focused; put it at the top of your layout */}
<SkipNavLink />
<nav></nav>
{/* renders a div as the target for the link; put it next to your main content */}
<SkipNavContent />
<main>{children}</main>
</>
)
}

export default ${singularPascalName}Layout
1 change: 1 addition & 0 deletions packages/router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"license": "MIT",
"dependencies": {
"@redwoodjs/auth": "^0.27.1",
"@reach/skip-nav": "^0.13.2",
"core-js": "3.6.5",
"lodash.isequal": "^4.5.0"
},
Expand Down
153 changes: 153 additions & 0 deletions packages/router/src/__tests__/route-announcer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { render, waitFor, act } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'

import { Router, Route, navigate, routes, getAnnouncement } from '../internal'
import RouteAnnouncement from '../route-announcement'

// SETUP
const HomePage = () => <h1>Home Page</h1>

const RouteAnnouncementPage = () => (
<html>
<head>
<title>title content</title>
</head>
<body>
<h1>RouteAnnouncement Page </h1>
<RouteAnnouncement visuallyHidden>
RouteAnnouncement content
</RouteAnnouncement>
<main>main content</main>
</body>
</html>
)

const H1Page = () => (
<html>
<head>
<title>title content</title>
</head>
<body>
<h1>H1 Page</h1>
<main>main content</main>
</body>
</html>
)

const NoH1Page = () => (
<html>
<head>
<title>title content</title>
</head>
<body>
<div>NoH1 Page</div>
<main>main content</main>
</body>
</html>
)

const NoH1OrTitlePage = () => (
<html>
<head></head>
<body>
<div>NoH1OrTitle Page</div>
<main>main content</main>
</body>
</html>
)

const EmptyH1Page = () => (
<html>
<head>
<title>title content</title>
</head>
<body>
<h1></h1>
<main>Empty H1 Page</main>
</body>
</html>
)

beforeEach(() => {
window.history.pushState({}, null, '/')
Object.keys(routes).forEach((key) => delete routes[key])
})

test('route announcer renders with aria-live="assertive" and role="alert"', async () => {
const TestRouter = () => (
<Router>
<Route path="/" page={HomePage} name="home" />
</Router>
)

const screen = render(<TestRouter />)

await waitFor(() => {
screen.getByText(/Home Page/i)
const routeAnnouncer = screen.getByRole('alert')
const ariaLiveValue = routeAnnouncer.getAttribute('aria-live')
const roleValue = routeAnnouncer.getAttribute('role')
expect(ariaLiveValue).toBe('assertive')
expect(roleValue).toBe('alert')
})
})

test('gets the announcement in the correct order of priority', async () => {
const TestRouter = () => (
<Router>
<Route path="/" page={RouteAnnouncementPage} name="routeAnnouncement" />
<Route path="/h1" page={H1Page} name="h1" />
<Route path="/noH1" page={NoH1Page} name="noH1" />
<Route path="/noH1OrTitle" page={NoH1OrTitlePage} name="noH1OrTitle" />
</Router>
)

const screen = render(<TestRouter />)

// starts on route announcement.
// since there's a RouteAnnouncement, it should announce that.
await waitFor(() => {
screen.getByText(/RouteAnnouncement Page/i)
expect(getAnnouncement()).toBe('RouteAnnouncement content')
})

// navigate to h1
// since there's no RouteAnnouncement, it should announce the h1.
act(() => navigate(routes.h1()))
await waitFor(() => {
screen.getByText(/H1 Page/i)
expect(getAnnouncement()).toBe('H1 Page')
})

// navigate to noH1.
// since there's no h1, it should announce the title.
act(() => navigate(routes.noH1()))
await waitFor(() => {
screen.getByText(/NoH1 Page/i)
expect(getAnnouncement()).toBe('title content')
})

// navigate to noH1OrTitle.
// since there's no h1 or title,
// it should announce the location.
act(() => navigate(routes.noH1OrTitle()))
await waitFor(() => {
screen.getByText(/NoH1OrTitle Page/i)
expect(getAnnouncement()).toBe('new page at /noH1OrTitle')
})
})

test('getAnnouncement handles empty PageHeader', async () => {
const TestRouter = () => (
<Router>
<Route path="/" page={EmptyH1Page} name="emptyH1" />
</Router>
)

const screen = render(<TestRouter />)

await waitFor(() => {
screen.getByText(/Empty H1 Page/i)
expect(getAnnouncement()).toBe('title content')
})
})
14 changes: 14 additions & 0 deletions packages/router/src/__tests__/set.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,27 @@ test('wraps components in other components', async () => {
<h1>
ChildA
</h1>
<div
aria-atomic="true"
aria-live="assertive"
id="redwood-announcer"
role="alert"
style="position: absolute; top: 0px; width: 1px; height: 1px; padding: 0px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; border: 0px;"
/>
<div>
<h1>
Layout for B
</h1>
<h1>
ChildB
</h1>
<div
aria-atomic="true"
aria-live="assertive"
id="redwood-announcer"
role="alert"
style="position: absolute; top: 0px; width: 1px; height: 1px; padding: 0px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; border: 0px;"
/>
</div>
<footer>
This is a footer
Expand Down
5 changes: 5 additions & 0 deletions packages/router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,8 @@ export {
export * from './Set'

export { usePageLoadingContext } from './page-loader'

export { default as RouteAnnouncement } from './route-announcement'
export * from './route-announcement'

export { SkipNavLink, SkipNavContent } from '@reach/skip-nav'
37 changes: 35 additions & 2 deletions packages/router/src/page-loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import React, { useContext } from 'react'

import isEqual from 'lodash.isequal'

import { createNamedContext, ParamsContext, Spec } from './internal'
import {
createNamedContext,
ParamsContext,
Spec,
getAnnouncement,
} from './internal'

export interface PageLoadingContextInterface {
loading: boolean
Expand Down Expand Up @@ -79,11 +84,21 @@ export class PageLoader extends React.Component<Props> {
this.startPageLoadTransition(this.props)
}

componentDidUpdate(prevProps: Props) {
// for announcing the new page to screen readers
announcementRef = React.createRef<HTMLDivElement>()

componentDidUpdate(prevProps: Props, prevState: State) {
if (this.propsChanged(prevProps, this.props)) {
this.clearLoadingTimeout()
this.startPageLoadTransition(this.props)
}

if (this.stateChanged(prevState, this.state)) {
global?.scrollTo(0, 0)
if (this.announcementRef.current) {
this.announcementRef.current.innerText = getAnnouncement()
}
}
}

clearLoadingTimeout = () => {
Expand Down Expand Up @@ -145,6 +160,24 @@ export class PageLoader extends React.Component<Props> {
value={{ loading: this.state.slowModuleImport }}
>
<Page {...this.state.params} />
<div
id="redwood-announcer"
style={{
position: 'absolute',
top: 0,
width: 1,
height: 1,
padding: 0,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
}}
role="alert"
aria-live="assertive"
aria-atomic="true"
ref={this.announcementRef}
></div>
</PageLoadingContext.Provider>
</ParamsContext.Provider>
)
Expand Down
Loading

0 comments on commit f0b2ac5

Please sign in to comment.