From 807a09efdb932119d32a790fd3baebc54b7bd95a Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 14:37:33 +0100 Subject: [PATCH 1/3] feat(a11y): add a go-to-top link to every page Add a link at the end of every page that takes a keyboard or screen reader user back to the start of the page. It is read by a screen reader and not seen until it has the focus, like the skip link. - scroll to the top and put the focus on the first thing that can be reached, the "Skip to main content" link - do not follow the link, so the address and the history do not change - put the component once in App.tsx, so every page has it - add tests of the component and a test that the link comes after the page on each route - add the change to docs/AI_USAGE.md Closes #165 --- docs/AI_USAGE.md | 7 +++ src/App.tsx | 3 ++ src/components/goToTop/GoToTop.tsx | 24 ++++++++++ src/tests/App.test.tsx | 16 +++++++ src/tests/components/goToTop/GoToTop.test.tsx | 46 +++++++++++++++++++ 5 files changed, 96 insertions(+) create mode 100644 src/components/goToTop/GoToTop.tsx create mode 100644 src/tests/components/goToTop/GoToTop.test.tsx diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index f278aa0..dfeec2f 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -532,3 +532,10 @@ - **Tool:** Claude Code - **What the team did:** Wrote issue #97: move the pages out of `src/components/`, so it is clear which files are routes and which are parts of pages, and describe the folders in the README. - **What Claude did:** Moved the four pages and their tests with `git mv` (so their history follows them), and moved the three placeholder pages out of `App.tsx` into their own files, so every route has a file in `src/pages/`. Updated the imports, the links in the README and the docs, and the "Project structure" table. Found that `src/styles/gymnastTheme.test.ts` reads the files of the gymnasts and login pages by folder, so after the move it would have stopped checking the two pages without failing; added them to the list. The shared components in `components/ui/` were left where they are (see the issue). After that, asked to move the frontend tests to `src/tests/` as well: moved all 29 with `git mv` into the same folders as the code they test, changed their relative imports to the `@/` alias (so a test does not depend on where it is), and updated the paths that `gymnastTheme.test.ts` reads files from. + +## The go-to-top link on every page (`src/components/goToTop/GoToTop.tsx`, `src/App.tsx`) + +- **Tool:** Claude Code +- **What the team did:** First asked for the link on the About page, then decided that it belongs at the bottom of every page and in its own issue (#165) and pull request. Decided that it is read by a screen reader and not seen until it has the focus. +- **What Claude did:** Wrote the tests first (the link is hidden until it has the focus, it scrolls to the top and puts the focus on the first thing that can be reached, and it does not change the address), then the component, and put it once in `App.tsx` after the page content. Checked in a real browser on several pages that the link is 1 by 1 pixels when it does not have the focus, that it shows with an outline when it has, that Enter goes to the top and puts the focus on "Skip to main content", and that axe finds no violations in light and dark mode. The first version still had padding when it was hidden, and it sat against the left edge when it was shown; both were fixed. +- **What the team changed:** asked for the link to be moved from the About page to every page. diff --git a/src/App.tsx b/src/App.tsx index b9bd1be..d83bb87 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Navigate, Route, Routes } from 'react-router-dom' import { Navbar } from './components/navbar/Navbar' import { SkipLink } from './components/skipLink/SkipLink' +import { GoToTop } from './components/goToTop/GoToTop' import { ProtectedRoute } from './components/ProtectedRoute' import { AuthProvider } from './components/auth/AuthProvider' @@ -58,6 +59,8 @@ export function App() { } /> + {/* The last thing on every page: a link back to the start, for the keyboard and screen readers. */} + ) } diff --git a/src/components/goToTop/GoToTop.tsx b/src/components/goToTop/GoToTop.tsx new file mode 100644 index 0000000..87664dc --- /dev/null +++ b/src/components/goToTop/GoToTop.tsx @@ -0,0 +1,24 @@ +/** + * A link at the end of every page that takes a keyboard or screen reader user back to the + * start of the page, so they do not have to go back through everything. It is read by a + * screen reader and not seen, until it has the focus (like the skip link), and then it shows. + * + * It scrolls to the top and puts the focus on the first thing that can be reached, which is + * the "Skip to main content" link. A plain `#` link would only scroll, and the focus would + * stay at the bottom. It does not follow the link, so the address and the history do not change. + */ +export const GoToTop = () => { + return ( + { + event.preventDefault() + window.scrollTo({ top: 0 }) + document.querySelector('a[href], button')?.focus() + }} + className="sr-only rounded-sm font-text font-semibold text-(--text-color) underline underline-offset-4 focus:not-sr-only focus:block focus:w-fit focus:my-4 focus:ml-5 focus:px-md focus:py-sm sm:focus:ml-8 lg:focus:ml-12 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--text-color)" + > + Go to the top of the page + + ) +} diff --git a/src/tests/App.test.tsx b/src/tests/App.test.tsx index cd9fbd2..907c6c8 100644 --- a/src/tests/App.test.tsx +++ b/src/tests/App.test.tsx @@ -86,6 +86,22 @@ describe('App', () => { expect(screen.getAllByRole('main')).toHaveLength(1) }) + it('ends every page with the link back to the top, which comes after the page', () => { + renderApp() + + for (const name of ['Search', 'About', 'Calculator']) { + fireEvent.click(screen.getByRole('link', { name })) + + const top = screen.getByRole('link', { + name: 'Go to the top of the page', + }) + expect( + screen.getByRole('main').compareDocumentPosition(top) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + } + }) + it('gives every page its own title, so the tab and the history say where you are', () => { renderApp() expect(document.title).toBe('Elements – WAG CoP Planner') diff --git a/src/tests/components/goToTop/GoToTop.test.tsx b/src/tests/components/goToTop/GoToTop.test.tsx new file mode 100644 index 0000000..6937aa9 --- /dev/null +++ b/src/tests/components/goToTop/GoToTop.test.tsx @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { GoToTop } from '@/components/goToTop/GoToTop' + +afterEach(cleanup) + +const LINK = { name: 'Go to the top of the page' } + +describe('GoToTop', () => { + it('is a link that is read by a screen reader but not seen until it has the focus', () => { + render() + + const link = screen.getByRole('link', LINK) + expect(link.className).toContain('sr-only') + expect(link.className).toContain('focus:not-sr-only') + }) + + it('scrolls to the top and puts the focus on the first thing that can be reached', () => { + const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + render( + <> + Start of the page + + , + ) + + fireEvent.click(screen.getByRole('link', LINK)) + + expect(scrollTo).toHaveBeenCalledWith({ top: 0 }) + expect(document.activeElement).toBe( + screen.getByRole('link', { name: 'Start of the page' }), + ) + scrollTo.mockRestore() + }) + + it('does not follow the link, so the address and the history stay as they are', () => { + vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + render() + + const notCancelled = fireEvent.click(screen.getByRole('link', LINK)) + + // fireEvent gives false when something called preventDefault. + expect(notCancelled).toBe(false) + }) +}) From 86fbf0818f676d9550021aa60b8936e9ac88c552 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 14:45:35 +0100 Subject: [PATCH 2/3] docs(a11y): refer to WCAG 2.4.1 in the bypass block links Say in the comments of SkipLink and GoToTop that the skip link is what meets WCAG 2.4.1 Bypass Blocks (level A, technique G1), and that the go-to-top link is the way back, which WCAG does not require. --- src/components/goToTop/GoToTop.tsx | 4 ++++ src/components/skipLink/SkipLink.tsx | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/components/goToTop/GoToTop.tsx b/src/components/goToTop/GoToTop.tsx index 87664dc..94a9d0f 100644 --- a/src/components/goToTop/GoToTop.tsx +++ b/src/components/goToTop/GoToTop.tsx @@ -3,6 +3,10 @@ * start of the page, so they do not have to go back through everything. It is read by a * screen reader and not seen, until it has the focus (like the skip link), and then it shows. * + * It goes with the "Skip to main content" link (`SkipLink`), which is what meets WCAG 2.4.1 + * Bypass Blocks (level A, technique G1). This link is the way back: WCAG does not require it, + * but it saves the same users from going back through a long page. + * * It scrolls to the top and puts the focus on the first thing that can be reached, which is * the "Skip to main content" link. A plain `#` link would only scroll, and the focus would * stay at the bottom. It does not follow the link, so the address and the history do not change. diff --git a/src/components/skipLink/SkipLink.tsx b/src/components/skipLink/SkipLink.tsx index 89eebb5..f76ae2d 100644 --- a/src/components/skipLink/SkipLink.tsx +++ b/src/components/skipLink/SkipLink.tsx @@ -2,6 +2,11 @@ interface SkipLinkProps { mainId?: string } +/** + * The link that lets a keyboard or screen reader user skip the navigation and go to the main + * content. It is the first thing that can be reached, and it is seen when it has the focus. + * It meets WCAG 2.4.1 Bypass Blocks (level A, technique G1). `GoToTop` is the way back. + */ export const SkipLink = ({ mainId = 'main-content' }: SkipLinkProps) => { return ( Date: Fri, 6 Nov 2026 14:49:32 +0100 Subject: [PATCH 3/3] revert: docs(a11y): refer to WCAG 2.4.1 in the bypass block links This reverts commit 86fbf08. The skip link already existed, so the pull request only adds the go-to-top link and does not refer to WCAG 2.4.1. --- src/components/goToTop/GoToTop.tsx | 4 ---- src/components/skipLink/SkipLink.tsx | 5 ----- 2 files changed, 9 deletions(-) diff --git a/src/components/goToTop/GoToTop.tsx b/src/components/goToTop/GoToTop.tsx index 94a9d0f..87664dc 100644 --- a/src/components/goToTop/GoToTop.tsx +++ b/src/components/goToTop/GoToTop.tsx @@ -3,10 +3,6 @@ * start of the page, so they do not have to go back through everything. It is read by a * screen reader and not seen, until it has the focus (like the skip link), and then it shows. * - * It goes with the "Skip to main content" link (`SkipLink`), which is what meets WCAG 2.4.1 - * Bypass Blocks (level A, technique G1). This link is the way back: WCAG does not require it, - * but it saves the same users from going back through a long page. - * * It scrolls to the top and puts the focus on the first thing that can be reached, which is * the "Skip to main content" link. A plain `#` link would only scroll, and the focus would * stay at the bottom. It does not follow the link, so the address and the history do not change. diff --git a/src/components/skipLink/SkipLink.tsx b/src/components/skipLink/SkipLink.tsx index f76ae2d..89eebb5 100644 --- a/src/components/skipLink/SkipLink.tsx +++ b/src/components/skipLink/SkipLink.tsx @@ -2,11 +2,6 @@ interface SkipLinkProps { mainId?: string } -/** - * The link that lets a keyboard or screen reader user skip the navigation and go to the main - * content. It is the first thing that can be reached, and it is seen when it has the focus. - * It meets WCAG 2.4.1 Bypass Blocks (level A, technique G1). `GoToTop` is the way back. - */ export const SkipLink = ({ mainId = 'main-content' }: SkipLinkProps) => { return (