Skip to content

Commit 62d7e94

Browse files
JohnMcLearclaude
andauthored
fix(cookie-banner): stop blocking pointer events on the rest of the page (#397)
The cookie consent banner is rendered with a Radix `Dialog.Root`, which defaults to `modal=true`. In modal mode Radix's `DismissableLayer` sets `document.body.style.pointerEvents = "none"` so that nothing outside the dialog can be clicked. With no `Dialog.Overlay` rendered there's no visual hint that anything is "over" the page — but every link, button and form on the home page silently stops responding to clicks until the visitor accepts or declines cookies. First-time visitors on etherpad.org saw a top bar that didn't navigate and a Links section where none of the external anchors fired. Two changes to `src/components/CookieBanner.tsx`: - Pass `modal={false}` on `Dialog.Root`. The banner is informational, not a focus-trapping modal, so there's no reason to disable pointer events on the rest of the document. - Drop the `pointer-events-none` Tailwind class from `Dialog.Content`. It's been a no-op so far because Radix injects an inline `style={{pointerEvents: 'auto'}}` on the layer wrapper that wins against className-based rules — but with `modal={false}` that inline override is removed, and the inherited `pointer-events: none` would start propagating into the Accept / Decline buttons themselves and prevent the banner from ever being dismissed. Add Playwright with three regression tests that load the page with no `cookiesAccepted` cookie set (the exact state a fresh visitor sees) and assert that: - the top-bar "Links" item scrolls to `#links` while the banner is shown, - the Accept button dismisses the banner, - the Blog anchor in the Links section opens its popup. All three failed against the previous code (Playwright reported `<html lang="en">… intercepts pointer events`, exactly the body-level disable) and pass with the fix. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 11ae130 commit 62d7e94

7 files changed

Lines changed: 134 additions & 4 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@ dist/
55
package-lock.json
66
.next/
77
.idea/
8+
test-results/
9+
playwright-report/
10+
playwright/.cache/

next-env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3+
/// <reference path="./.next/types/routes.d.ts" />
34

45
// NOTE: This file should not be edited
56
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"dev": "next dev --turbo",
88
"build": "next build",
99
"start": "next start",
10-
"lint": "next lint"
10+
"lint": "next lint",
11+
"test:e2e": "playwright test"
1112
},
1213
"dependencies": {
1314
"@fortawesome/fontawesome-svg-core": "^7.2.0",
@@ -36,6 +37,7 @@
3637
"timeago": "^1.6.7"
3738
},
3839
"devDependencies": {
40+
"@playwright/test": "^1.59.1",
3941
"@saber2pr/types-github-api": "^0.0.9",
4042
"@types/node": "^24.3.0",
4143
"@types/react": "^19.2.14",

playwright.config.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { defineConfig, devices } from '@playwright/test'
2+
3+
const PORT = 3033
4+
5+
export default defineConfig({
6+
testDir: './tests',
7+
fullyParallel: true,
8+
forbidOnly: !!process.env.CI,
9+
retries: process.env.CI ? 2 : 0,
10+
workers: process.env.CI ? 1 : undefined,
11+
reporter: 'list',
12+
use: {
13+
baseURL: `http://localhost:${PORT}`,
14+
trace: 'retain-on-failure',
15+
},
16+
projects: [
17+
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
18+
],
19+
webServer: {
20+
command: `pnpm dev --port ${PORT}`,
21+
url: `http://localhost:${PORT}`,
22+
reuseExistingServer: !process.env.CI,
23+
timeout: 120_000,
24+
},
25+
})

pnpm-lock.yaml

Lines changed: 41 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/CookieBanner.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,15 @@ export const CookieBanner = ()=>{
3636
}
3737
}, []);
3838

39+
// modal={false} keeps the rest of the page interactive while the
40+
// banner is shown. Without it, Radix sets `pointer-events: none` on
41+
// <body>, which silently disables every link/button on the page until
42+
// the user clicks Accept or Decline.
3943
return <Dialog.Root
4044
open={cookiesAccepted === undefined}
45+
modal={false}
4146
><Dialog.Portal>
42-
<Dialog.Content className="sticky bottom-0 bg-gray-800 p-5 text-white grid grid-cols-[auto_1fr_auto] pointer-events-none">
47+
<Dialog.Content className="sticky bottom-0 bg-gray-800 p-5 text-white grid grid-cols-[auto_1fr_auto]">
4348
<Dialog.Title></Dialog.Title>
4449
<span className="self-center">This page uses Google analytics to track page visits.
4550
Are you okay with that?</span>

tests/cookie-banner.spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { expect, test } from '@playwright/test'
2+
3+
test.describe('cookie banner does not block page interaction', () => {
4+
test.beforeEach(async ({ context }) => {
5+
// Force first-visit state: no cookiesAccepted cookie, so the consent
6+
// banner is rendered.
7+
await context.clearCookies()
8+
})
9+
10+
test('top bar "Links" anchor scrolls to the Links section while banner is shown', async ({ page }) => {
11+
await page.goto('/')
12+
13+
// Sanity check: the cookie banner is open.
14+
await expect(page.getByRole('button', { name: 'Accept cookies' })).toBeVisible()
15+
16+
// Click the "Links" item in the top bar. The Header's onClick handler
17+
// calls router.push('/#links') and scrolls to #links. If the modal
18+
// dialog is blocking pointer events, the click never fires and the URL
19+
// stays at "/". (The nav anchors have no href, so we locate by text.)
20+
await page.locator('#nav').getByText('Links', { exact: true }).click()
21+
22+
await expect(page).toHaveURL(/#links$/)
23+
await expect(page.locator('#links')).toBeInViewport()
24+
})
25+
26+
test('cookie banner buttons are clickable', async ({ page }) => {
27+
await page.goto('/')
28+
29+
const acceptButton = page.getByRole('button', { name: 'Accept cookies' })
30+
await expect(acceptButton).toBeVisible()
31+
32+
// pointer-events:none on Dialog.Content inherits to children, so the
33+
// banner buttons themselves stop receiving clicks. Tapping Accept must
34+
// dismiss the banner.
35+
await acceptButton.click()
36+
37+
await expect(acceptButton).toBeHidden()
38+
})
39+
40+
test('Links section anchors fire clicks while banner is shown', async ({ page, context }) => {
41+
await page.goto('/')
42+
await expect(page.getByRole('button', { name: 'Accept cookies' })).toBeVisible()
43+
44+
await page.locator('#links').scrollIntoViewIfNeeded()
45+
46+
// The Blog anchor opens in a new tab (target="_blank"). If pointer
47+
// events are blocked we never get a popup event.
48+
const blogLink = page.getByRole('link', { name: 'Blog' })
49+
const popupPromise = context.waitForEvent('page', { timeout: 5_000 })
50+
await blogLink.click()
51+
const popup = await popupPromise
52+
expect(popup.url()).toContain('blog.etherpad.org')
53+
await popup.close()
54+
})
55+
})

0 commit comments

Comments
 (0)