fix(a11y): skip-link no longer blocks logo click outside focus

The "Skip to content" link is `position: fixed; top-2 left-2 z-[100]`
on every authenticated page. Even with `opacity-0` it still occupied
its bounding box, so a mouse click on the desktop logo (also at the
top-left corner) sometimes hit the invisible link instead.

Fix: keep the visual treatment (opacity, focus styles) but combine
`pointer-events-none` + `-translate-y-full` in the unfocused state so
the element neither receives clicks nor occupies any visible space.
On focus, both modifiers reverse — the link slides into view and
becomes interactive again. Existing keyboard flow is unchanged: Tab
focuses the link, the focus ring + translucent slide-in is preserved.

Tests: extend `e2e/a11y.spec.ts` with a Playwright check that the
desktop logo is clickable from its centre on the dashboard and the
URL stays at "/".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 20:15:21 +02:00
1 parent 85aa15b35b
commit 786b3951f6
2 files changed
+48 -1

No files matched your search

+39
View File
@@ -110,4 +110,43 @@ test.describe("axe-core authenticated surfaces", () => {
expect(blocking).toHaveLength(0);
});
}
// The keyboard-only "skip to content" link is fixed at the top-left
// corner of every authenticated page. Marc reported it intercepting
// logo clicks in v1.4.14 — the fix combines `pointer-events-none`
// with `-translate-y-full` so the element sits offscreen until
// focused. Regression guard: with no keyboard focus, the logo's own
// bounding box must accept the click.
test("skip-link does not block logo click outside focus", async ({
page,
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// Find the desktop sidebar logo link — `<Logo>` is rendered inside
// a `<Link href="/">` and the only logo at the absolute top-left
// of the layout. We resolve it by role / accessible name to avoid
// brittle selectors.
const logoLink = page
.locator("a[href='/']")
.filter({ hasText: "HealthLog" })
.first();
await expect(logoLink).toBeVisible();
const logoBox = await logoLink.boundingBox();
expect(logoBox).not.toBeNull();
if (!logoBox) return;
// Click the centre of the logo. If the skip-link is intercepting
// pointer events, Playwright's auto-waiting click would either time
// out or report the wrong element. The defensive `force: false`
// (default) ensures we go through the full hit-test pipeline.
await logoLink.click({
position: { x: logoBox.width / 2, y: logoBox.height / 2 },
});
// Logo points at "/", so we either stay on "/" or get a no-op
// reload. Either way the URL must remain on the dashboard.
await page.waitForURL(/\/$/);
});
});
+9 -1
View File
@@ -114,9 +114,17 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
{showUnlockNotifier && user?.id ? (
<AchievementUnlockNotifier userId={user.id} />
) : null}
{/*
Keyboard-only skip link. The non-focused state has both
`pointer-events-none` (so a mouse click in the top-left corner
passes through to the logo behind it) AND `-translate-y-full`
(so even an accidental tap with stylus / touch can't activate
it). Focus restores both — the element jumps back into the
viewport and becomes clickable.
*/}
<a
href="#main-content"
className="bg-primary text-primary-foreground fixed top-2 left-2 z-[100] rounded-md px-4 py-2 text-sm font-medium opacity-0 focus:opacity-100"
className="bg-primary text-primary-foreground fixed top-2 left-2 z-[100] -translate-y-full rounded-md px-4 py-2 text-sm font-medium opacity-0 transition-transform pointer-events-none focus:translate-y-0 focus:opacity-100 focus:pointer-events-auto"
>
{t("nav.skipToContent")}
</a>