Skip to content

Commit

Permalink
Implement route announcer for app dir (#47018)
Browse files Browse the repository at this point in the history
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
shuding and ijjk authored Mar 13, 2023
1 parent b21c85b commit 9b40be8
Show file tree
Hide file tree
Showing 11 changed files with 182 additions and 2 deletions.
66 changes: 66 additions & 0 deletions packages/next/src/client/components/app-router-announcer.tsx
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
}
8 changes: 7 additions & 1 deletion packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -327,7 +328,12 @@ function Router({
}
}, [onPopState])

const content = <>{cache.subTreeData}</>
const content = (
<>
{cache.subTreeData}
<AppRouterAnnouncer tree={tree} />
</>
)

return (
<PathnameContext.Provider value={pathname}>
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/client/components/layout-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions test/e2e/app-dir/app-a11y/app/layout.js
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>
)
}
3 changes: 3 additions & 0 deletions test/e2e/app-dir/app-a11y/app/noop-layout/layout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Layout({ children }) {
return <div>{children}</div>
}
7 changes: 7 additions & 0 deletions test/e2e/app-dir/app-a11y/app/noop-layout/page-1/page.js
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>
)
}
7 changes: 7 additions & 0 deletions test/e2e/app-dir/app-a11y/app/noop-layout/page-2/page.js
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>
)
}
7 changes: 7 additions & 0 deletions test/e2e/app-dir/app-a11y/app/page-with-h1/page.js
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>
)
}
7 changes: 7 additions & 0 deletions test/e2e/app-dir/app-a11y/app/page-with-title/page.js
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',
}
44 changes: 44 additions & 0 deletions test/e2e/app-dir/app-a11y/index.test.ts
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')
})
})
}
)
5 changes: 5 additions & 0 deletions test/e2e/app-dir/app-a11y/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
experimental: {
appDir: true,
},
}

0 comments on commit 9b40be8

Please sign in to comment.