-
Notifications
You must be signed in to change notification settings - Fork 26.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement route announcer for app dir (#47018)
This PR implements the route announcer for app directory. It almost uses the same logic as the route announcer inside pages, with one notable difference that the inner content node is now inside a shadow root. This makes sure that it does as little impact as possible, to the application. This is important as we no longer have the `__next` wrapper. Another thing worth mentioning is that the announced title is a global singleton of the website. It shouldn't be affected by the concept of layouts, but should be triggered when the router state (not just URL) changes. Closes NEXT-208. --------- Co-authored-by: JJ Kasper <jj@jjsweb.site>
- Loading branch information
Showing
11 changed files
with
182 additions
and
2 deletions.
There are no files selected for viewing
66 changes: 66 additions & 0 deletions
66
packages/next/src/client/components/app-router-announcer.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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_TYPE)[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<HTMLElement | null>(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<string | undefined>() | ||
|
||
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
import Link from 'next/link' | ||
|
||
export default function Layout({ children }) { | ||
return ( | ||
<html> | ||
<body> | ||
{children} | ||
<hr /> | ||
<Link href="/page-with-h1" id="page-with-h1"> | ||
/page-with-h1 | ||
</Link> | ||
<br /> | ||
<Link href="/page-with-title" id="page-with-title"> | ||
/page-with-title | ||
</Link> | ||
<br /> | ||
<Link href="/noop-layout/page-1" id="noop-layout-page-1"> | ||
/noop-layout/page-1 | ||
</Link> | ||
<br /> | ||
<Link href="/noop-layout/page-2" id="noop-layout-page-2"> | ||
/noop-layout/page-2 | ||
</Link> | ||
<br /> | ||
</body> | ||
</html> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export default function Layout({ children }) { | ||
return <div>{children}</div> | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
export default function Page() { | ||
return ( | ||
<div> | ||
<h1>noop-layout/page-1</h1> | ||
</div> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
export default function Page() { | ||
return ( | ||
<div> | ||
<h1>noop-layout/page-2</h1> | ||
</div> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
export default function Page() { | ||
return ( | ||
<div> | ||
<h1>page-with-h1</h1> | ||
</div> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
export default function Page() { | ||
return <div>page</div> | ||
} | ||
|
||
export const metadata = { | ||
title: 'page-with-title', | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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') | ||
}) | ||
}) | ||
} | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
module.exports = { | ||
experimental: { | ||
appDir: true, | ||
}, | ||
} |