From f0b2ac5a944ef3d03aae631cfb1270891f4f78b8 Mon Sep 17 00:00:00 2001
From: Dominic Saadi <32992335+jtoar@users.noreply.github.com>
Date: Fri, 19 Mar 2021 02:36:20 -0700
Subject: [PATCH] Steps towards a11y for Redwood Router (#1817)
* 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
---
.../__tests__/fixtures/withSkipLinkLayout.tsx | 23 +++
.../generate/layout/__tests__/layout.test.ts | 17 +-
.../src/commands/generate/layout/layout.js | 15 +-
.../layout/templates/layout.tsx.a11yTemplate | 23 +++
packages/router/package.json | 1 +
.../src/__tests__/route-announcer.test.tsx | 153 ++++++++++++++++++
packages/router/src/__tests__/set.test.tsx | 14 ++
packages/router/src/index.ts | 5 +
packages/router/src/page-loader.tsx | 37 ++++-
packages/router/src/route-announcement.tsx | 41 +++++
packages/router/src/util.ts | 30 ++++
yarn.lock | 24 ++-
12 files changed, 378 insertions(+), 5 deletions(-)
create mode 100644 packages/cli/src/commands/generate/layout/__tests__/fixtures/withSkipLinkLayout.tsx
create mode 100644 packages/cli/src/commands/generate/layout/templates/layout.tsx.a11yTemplate
create mode 100644 packages/router/src/__tests__/route-announcer.test.tsx
create mode 100644 packages/router/src/route-announcement.tsx
diff --git a/packages/cli/src/commands/generate/layout/__tests__/fixtures/withSkipLinkLayout.tsx b/packages/cli/src/commands/generate/layout/__tests__/fixtures/withSkipLinkLayout.tsx
new file mode 100644
index 000000000000..915851e94385
--- /dev/null
+++ b/packages/cli/src/commands/generate/layout/__tests__/fixtures/withSkipLinkLayout.tsx
@@ -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 */}
+
+
+ {/* renders a div as the target for the link; put it next to your main content */}
+
+ {children}
+ >
+ )
+}
+
+export default A11yLayout
diff --git a/packages/cli/src/commands/generate/layout/__tests__/layout.test.ts b/packages/cli/src/commands/generate/layout/__tests__/layout.test.ts
index 6bea13053e16..7ea2f8bd3e60 100644
--- a/packages/cli/src/commands/generate/layout/__tests__/layout.test.ts
+++ b/packages/cli/src/commands/generate/layout/__tests__/layout.test.ts
@@ -10,7 +10,8 @@ let singleWordDefaultFiles,
javascriptFiles,
typescriptFiles,
withoutTestFiles,
- withoutStoryFiles
+ withoutStoryFiles,
+ withSkipLinkFiles
beforeAll(() => {
singleWordDefaultFiles = layout.files({ name: 'App' })
@@ -33,6 +34,10 @@ beforeAll(() => {
javascript: true,
stories: false,
})
+ withSkipLinkFiles = layout.files({
+ name: 'A11y',
+ skipLink: true,
+ })
})
test('returns exactly 3 files', () => {
@@ -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'))
+})
diff --git a/packages/cli/src/commands/generate/layout/layout.js b/packages/cli/src/commands/generate/layout/layout.js
index 3561a9e2762a..c5785d12792d 100644
--- a/packages/cli/src/commands/generate/layout/layout.js
+++ b/packages/cli/src/commands/generate/layout/layout.js
@@ -1,5 +1,6 @@
import { transformTSToJS } from 'src/lib'
+import { yargsDefaults } from '../../generate'
import {
templateForComponentFile,
createYargsForComponentGeneration,
@@ -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,
@@ -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,
@@ -70,4 +82,5 @@ export const {
} = createYargsForComponentGeneration({
componentName: 'layout',
filesFn: files,
+ builderObj,
})
diff --git a/packages/cli/src/commands/generate/layout/templates/layout.tsx.a11yTemplate b/packages/cli/src/commands/generate/layout/templates/layout.tsx.a11yTemplate
new file mode 100644
index 000000000000..9940cc5a0028
--- /dev/null
+++ b/packages/cli/src/commands/generate/layout/templates/layout.tsx.a11yTemplate
@@ -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 */}
+
+
+ {/* renders a div as the target for the link; put it next to your main content */}
+
+ {children}
+ >
+ )
+}
+
+export default ${singularPascalName}Layout
diff --git a/packages/router/package.json b/packages/router/package.json
index f1bde8caf876..ac91eb0d7d85 100644
--- a/packages/router/package.json
+++ b/packages/router/package.json
@@ -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"
},
diff --git a/packages/router/src/__tests__/route-announcer.test.tsx b/packages/router/src/__tests__/route-announcer.test.tsx
new file mode 100644
index 000000000000..1298a15da8a1
--- /dev/null
+++ b/packages/router/src/__tests__/route-announcer.test.tsx
@@ -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 = () =>
Home Page
+
+const RouteAnnouncementPage = () => (
+
+
+ title content
+
+
+ RouteAnnouncement Page
+
+ RouteAnnouncement content
+
+ main content
+
+
+)
+
+const H1Page = () => (
+
+
+ title content
+
+
+ H1 Page
+ main content
+
+
+)
+
+const NoH1Page = () => (
+
+
+ title content
+
+
+ NoH1 Page
+ main content
+
+
+)
+
+const NoH1OrTitlePage = () => (
+
+
+
+ NoH1OrTitle Page
+ main content
+
+
+)
+
+const EmptyH1Page = () => (
+
+
+ title content
+
+
+
+ Empty H1 Page
+
+
+)
+
+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 = () => (
+
+
+
+ )
+
+ const screen = render()
+
+ 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 = () => (
+
+
+
+
+
+
+ )
+
+ const screen = render()
+
+ // 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 = () => (
+
+
+
+ )
+
+ const screen = render()
+
+ await waitFor(() => {
+ screen.getByText(/Empty H1 Page/i)
+ expect(getAnnouncement()).toBe('title content')
+ })
+})
diff --git a/packages/router/src/__tests__/set.test.tsx b/packages/router/src/__tests__/set.test.tsx
index 4550bfcff13d..bec1e04be8c3 100644
--- a/packages/router/src/__tests__/set.test.tsx
+++ b/packages/router/src/__tests__/set.test.tsx
@@ -61,6 +61,13 @@ test('wraps components in other components', async () => {
ChildA
+
Layout for B
@@ -68,6 +75,13 @@ test('wraps components in other components', async () => {
ChildB
+