From 920429f73f560c8f081cad79c417f94b57c7f9be Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Fri, 10 Mar 2023 22:36:39 +0100 Subject: [PATCH 1/2] implement route announcer --- .../components/app-router-announcer.tsx | 66 +++++++++++++++++++ .../next/src/client/components/app-router.tsx | 8 ++- .../src/client/components/layout-router.tsx | 2 +- test/e2e/app-dir/app-a11y/app/layout.js | 28 ++++++++ .../app-a11y/app/noop-layout/layout.js | 3 + .../app-a11y/app/noop-layout/page-1/page.js | 7 ++ .../app-a11y/app/noop-layout/page-2/page.js | 7 ++ .../app-dir/app-a11y/app/page-with-h1/page.js | 7 ++ .../app-a11y/app/page-with-title/page.js | 7 ++ test/e2e/app-dir/app-a11y/index.test.ts | 44 +++++++++++++ test/e2e/app-dir/app-a11y/next.config.js | 5 ++ 11 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 packages/next/src/client/components/app-router-announcer.tsx create mode 100644 test/e2e/app-dir/app-a11y/app/layout.js create mode 100644 test/e2e/app-dir/app-a11y/app/noop-layout/layout.js create mode 100644 test/e2e/app-dir/app-a11y/app/noop-layout/page-1/page.js create mode 100644 test/e2e/app-dir/app-a11y/app/noop-layout/page-2/page.js create mode 100644 test/e2e/app-dir/app-a11y/app/page-with-h1/page.js create mode 100644 test/e2e/app-dir/app-a11y/app/page-with-title/page.js create mode 100644 test/e2e/app-dir/app-a11y/index.test.ts create mode 100644 test/e2e/app-dir/app-a11y/next.config.js diff --git a/packages/next/src/client/components/app-router-announcer.tsx b/packages/next/src/client/components/app-router-announcer.tsx new file mode 100644 index 0000000000000..e387fbd26b8d7 --- /dev/null +++ b/packages/next/src/client/components/app-router-announcer.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import type { FlightRouterState } from '../../server/app-render' + +const ANNOUNCER_TYPE = 'next-route-announcer' +const ANNOUNCER_ID = '__next-route-announcer__' + +function getAnnouncerNode() { + const existingAnnouncer = document.getElementsByName(ANNOUNCER_ID)[0] + if (existingAnnouncer?.shadowRoot?.childNodes[0]) { + return existingAnnouncer.shadowRoot.childNodes[0] as HTMLElement + } else { + const container = document.createElement(ANNOUNCER_TYPE) + const announcer = document.createElement('div') + announcer.setAttribute('aria-live', 'assertive') + announcer.setAttribute('id', ANNOUNCER_ID) + announcer.setAttribute('role', 'alert') + announcer.style.cssText = + 'position:absolute;border:0;height:1px;margin:-1px;padding:0;width:1px;clip:rect(0 0 0 0);overflow:hidden;white-space:nowrap;word-wrap:normal' + + // Use shadow DOM here to avoid any potential CSS bleed + const shadow = container.attachShadow({ mode: 'open' }) + shadow.appendChild(announcer) + document.body.appendChild(container) + return announcer + } +} + +export function AppRouterAnnouncer({ tree }: { tree: FlightRouterState }) { + const [portalNode, setPortalNode] = useState(null) + + useEffect(() => { + const announcer = getAnnouncerNode() + setPortalNode(announcer) + return () => { + const container = document.getElementsByTagName(ANNOUNCER_TYPE)[0] + if (container?.isConnected) { + document.body.removeChild(container) + } + } + }, []) + + const [routeAnnouncement, setRouteAnnouncement] = useState('') + const previousTitle = useRef() + + useEffect(() => { + let currentTitle = '' + if (document.title) { + currentTitle = document.title + } else { + const pageHeader = document.querySelector('h1') + if (pageHeader) { + currentTitle = pageHeader.innerText || pageHeader.textContent || '' + } + } + + // Only announce the title change, but not for the first load because screen + // readers do that automatically. + if (typeof previousTitle.current !== 'undefined') { + setRouteAnnouncement(currentTitle) + } + previousTitle.current = currentTitle + }, [tree]) + + return portalNode ? createPortal(routeAnnouncement, portalNode) : null +} diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index 49e263cac4633..d046d3d78e868 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -38,6 +38,7 @@ import { import { fetchServerResponse } from './router-reducer/fetch-server-response' import { isBot } from '../../shared/lib/router/utils/is-bot' import { addBasePath } from '../add-base-path' +import { AppRouterAnnouncer } from './app-router-announcer' const isServer = typeof window === 'undefined' @@ -327,7 +328,12 @@ function Router({ } }, [onPopState]) - const content = <>{cache.subTreeData} + const content = ( + <> + {cache.subTreeData} + + + ) return ( diff --git a/packages/next/src/client/components/layout-router.tsx b/packages/next/src/client/components/layout-router.tsx index 707b0e9f68947..78ceb4cefe790 100644 --- a/packages/next/src/client/components/layout-router.tsx +++ b/packages/next/src/client/components/layout-router.tsx @@ -173,7 +173,7 @@ class ScrollAndFocusHandler extends React.Component<{ /** * InnerLayoutRouter handles rendering the provided segment based on the cache. */ -export function InnerLayoutRouter({ +function InnerLayoutRouter({ parallelRouterKey, url, childNodes, diff --git a/test/e2e/app-dir/app-a11y/app/layout.js b/test/e2e/app-dir/app-a11y/app/layout.js new file mode 100644 index 0000000000000..71880780a26e1 --- /dev/null +++ b/test/e2e/app-dir/app-a11y/app/layout.js @@ -0,0 +1,28 @@ +import Link from 'next/link' + +export default function Layout({ children }) { + return ( + + + {children} +
+ + /page-with-h1 + +
+ + /page-with-title + +
+ + /noop-layout/page-1 + +
+ + /noop-layout/page-2 + +
+ + + ) +} diff --git a/test/e2e/app-dir/app-a11y/app/noop-layout/layout.js b/test/e2e/app-dir/app-a11y/app/noop-layout/layout.js new file mode 100644 index 0000000000000..d75c116266dca --- /dev/null +++ b/test/e2e/app-dir/app-a11y/app/noop-layout/layout.js @@ -0,0 +1,3 @@ +export default function Layout({ children }) { + return
{children}
+} diff --git a/test/e2e/app-dir/app-a11y/app/noop-layout/page-1/page.js b/test/e2e/app-dir/app-a11y/app/noop-layout/page-1/page.js new file mode 100644 index 0000000000000..5cbd709335680 --- /dev/null +++ b/test/e2e/app-dir/app-a11y/app/noop-layout/page-1/page.js @@ -0,0 +1,7 @@ +export default function Page() { + return ( +
+

noop-layout/page-1

+
+ ) +} diff --git a/test/e2e/app-dir/app-a11y/app/noop-layout/page-2/page.js b/test/e2e/app-dir/app-a11y/app/noop-layout/page-2/page.js new file mode 100644 index 0000000000000..8627c05ecc2b0 --- /dev/null +++ b/test/e2e/app-dir/app-a11y/app/noop-layout/page-2/page.js @@ -0,0 +1,7 @@ +export default function Page() { + return ( +
+

noop-layout/page-2

+
+ ) +} diff --git a/test/e2e/app-dir/app-a11y/app/page-with-h1/page.js b/test/e2e/app-dir/app-a11y/app/page-with-h1/page.js new file mode 100644 index 0000000000000..7ef5096d20caa --- /dev/null +++ b/test/e2e/app-dir/app-a11y/app/page-with-h1/page.js @@ -0,0 +1,7 @@ +export default function Page() { + return ( +
+

page-with-h1

+
+ ) +} diff --git a/test/e2e/app-dir/app-a11y/app/page-with-title/page.js b/test/e2e/app-dir/app-a11y/app/page-with-title/page.js new file mode 100644 index 0000000000000..ec4d7226a8046 --- /dev/null +++ b/test/e2e/app-dir/app-a11y/app/page-with-title/page.js @@ -0,0 +1,7 @@ +export default function Page() { + return
page
+} + +export const metadata = { + title: 'page-with-title', +} diff --git a/test/e2e/app-dir/app-a11y/index.test.ts b/test/e2e/app-dir/app-a11y/index.test.ts new file mode 100644 index 0000000000000..d16f0e9c14fdd --- /dev/null +++ b/test/e2e/app-dir/app-a11y/index.test.ts @@ -0,0 +1,44 @@ +import { createNextDescribe } from 'e2e-utils' +import { check } from 'next-test-utils' +import type { BrowserInterface } from 'test/lib/browsers/base' + +createNextDescribe( + 'app a11y features', + { + files: __dirname, + packageJson: {}, + skipDeployment: true, + }, + ({ next }) => { + describe('route announcer', () => { + async function getAnnouncerContent(browser: BrowserInterface) { + return browser.eval( + `document.getElementsByTagName('next-route-announcer')[0]?.shadowRoot.childNodes[0]?.innerHTML` + ) + } + + it('should not announce the initital title', async () => { + const browser = await next.browser('/page-with-h1') + await check(() => getAnnouncerContent(browser), '') + }) + + it('should announce document.title changes', async () => { + const browser = await next.browser('/page-with-h1') + await browser.elementById('page-with-title').click() + await check(() => getAnnouncerContent(browser), 'page-with-title') + }) + + it('should announce h1 changes', async () => { + const browser = await next.browser('/page-with-h1') + await browser.elementById('noop-layout-page-1').click() + await check(() => getAnnouncerContent(browser), 'noop-layout/page-1') + }) + + it('should announce route changes when h1 changes inside an inner layout', async () => { + const browser = await next.browser('/noop-layout/page-1') + await browser.elementById('noop-layout-page-2').click() + await check(() => getAnnouncerContent(browser), 'noop-layout/page-2') + }) + }) + } +) diff --git a/test/e2e/app-dir/app-a11y/next.config.js b/test/e2e/app-dir/app-a11y/next.config.js new file mode 100644 index 0000000000000..cfa3ac3d7aa94 --- /dev/null +++ b/test/e2e/app-dir/app-a11y/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + experimental: { + appDir: true, + }, +} From c7d62caa39c67d75926fe170310910ea3e81f9a0 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Fri, 10 Mar 2023 22:40:26 +0100 Subject: [PATCH 2/2] fix --- packages/next/src/client/components/app-router-announcer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/src/client/components/app-router-announcer.tsx b/packages/next/src/client/components/app-router-announcer.tsx index e387fbd26b8d7..9a62c7e5cdb04 100644 --- a/packages/next/src/client/components/app-router-announcer.tsx +++ b/packages/next/src/client/components/app-router-announcer.tsx @@ -6,7 +6,7 @@ const ANNOUNCER_TYPE = 'next-route-announcer' const ANNOUNCER_ID = '__next-route-announcer__' function getAnnouncerNode() { - const existingAnnouncer = document.getElementsByName(ANNOUNCER_ID)[0] + const existingAnnouncer = document.getElementsByName(ANNOUNCER_TYPE)[0] if (existingAnnouncer?.shadowRoot?.childNodes[0]) { return existingAnnouncer.shadowRoot.childNodes[0] as HTMLElement } else {