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

+