Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement route announcer for app dir #47018

Merged
merged 5 commits into from
Mar 13, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
},
}