diff --git a/README.md b/README.md index 1d463c4..417aa2f 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ The calculator and About pages are placeholders. Adding items from the catalog t | [docs/API.md](docs/API.md) | API design, schema, search, pagination, authentication, and errors | | [docs/AI_USAGE.md](docs/AI_USAGE.md) | How AI was used and how the work was reviewed | | [docs/ACCESSIBILITY.md](docs/ACCESSIBILITY.md) | Accessibility choices, testing, and known open points | +| [docs/STATE_MANAGEMENT.md](docs/STATE_MANAGEMENT.md) | Where each kind of state lives, and the shared parts | | [docs/SUSTAINABILITY.md](docs/SUSTAINABILITY.md) | Sustainable design choices, measurements, and what remains unmeasured | ### How to try it @@ -546,13 +547,16 @@ Run `ANALYZE` after adding or removing many rows, or PostgreSQL plans from old s The assignment asks for "a form of automated end-to-end testing" of "a longer sequence of interactions", and for testing of the API. The component tests (Vitest) and the API tests in `backend/src/server/` already exist; the end-to-end tests drive the **whole application in a real browser**, against the real backend and the real database, the way a user does. They are in `e2e/` and use [Playwright](https://playwright.dev), with [axe-core](https://github.com/dequelabs/axe-core-npm/tree/develop/packages/playwright) for the accessibility checks. -| File | What it tests | -| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `e2e/elements-flow.e2e.ts` | One long flow: search, filter by apparatus and group, "Load more", sort, open an element, go back, "Clear filters". It checks the address, the results, the title and the focus at each step. | -| `e2e/vault.e2e.ts` | The vault difficulty bands, and sorting vault by its D-score in both directions. | -| `e2e/atypical.e2e.ts` | Atypical use: an address with filters that do not exist, a search without a result, the 80-character limit, a structure that fails to load (the request is made to fail), the disabled group filter, and keyboard-only use. | -| `e2e/accessibility.e2e.ts` | axe-core on the page in its main states: filtered and sorted, a menu open, the reason for a disabled filter shown, vault, no result, dark mode, a phone (375 px) and reflow at 320 px. A violation fails the test. | -| `e2e/api.e2e.ts` | The GraphQL API over HTTP: search, filter, sort, cursors, the error codes and `Apparatus.difficultyValues`. The same requests can be sent to the virtual machine. | +| File | What it tests | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `e2e/elements-flow.e2e.ts` | One long flow: search, filter by apparatus and group, "Load more", sort, open an element, go back, "Clear filters". It checks the address, the results, the title and the focus at each step. | +| `e2e/vault.e2e.ts` | The vault difficulty bands, and sorting vault by its D-score in both directions. | +| `e2e/atypical.e2e.ts` | Atypical use: an address with filters that do not exist, a search without a result, the 80-character limit, a structure that fails to load (the request is made to fail), the disabled group filter, and keyboard-only use. | +| `e2e/accessibility.e2e.ts` | axe-core on the page in its main states: filtered and sorted, a menu open, the reason for a disabled filter shown, vault, no result, dark mode, a phone (375 px) and reflow at 320 px. A violation fails the test. | +| `e2e/api.e2e.ts` | The GraphQL API over HTTP: search, filter, sort, cursors, the error codes and `Apparatus.difficultyValues`. The same requests can be sent to the virtual machine. | +| `e2e/gymnast-page.e2e.ts` | The Gymnast page and "Your gymnasts" logged in as a test coach: axe in light and dark mode, on a phone and with the remove dialog open; no sideways scrolling at 280, 320, 360 and 390 px (also with long names and the text spacing of WCAG 1.4.12); the size of what is pressed on a phone; the tabs with the arrow keys, Home and End; where the focus goes around the remove dialog; and the colors of a row and of the trash icon under the pointer. | +| `e2e/element-page.e2e.ts` | The page of an element and of a vault: axe in light and dark mode and on a phone, and "Add to gymnast" (a link to the login for a visitor, a menu of the gymnasts for a coach, and the element in the list afterwards). | +| `e2e/gymnast-helpers.ts` | Not a test: the login through the API, the test data, axe and the colors as the browser works them out, for the two files above. | ### How to run them @@ -565,7 +569,7 @@ npx playwright install chromium # the browser, once (about 150 MB) npm run e2e ``` -`npm run e2e` starts the backend and the frontend itself (and uses them if they are already running), runs the tests, and prints the result. `npm run e2e:report` opens the report with a screenshot and a trace of every test that failed. The tests only read the catalog, so they can be run again and again. +`npm run e2e` starts the backend and the frontend itself (and uses them if they are already running), runs the tests, and prints the result. `npm run e2e:report` opens the report with a screenshot and a trace of every test that failed. The tests of the catalog only read it. The tests of the gymnast pages log in through the API (`devLogin`) as a test coach of their own (`e2e-gymnast-page@example.test`, `e2e-element-page@example.test`), make gymnasts for it, and delete them at the start and at the end, so all of them can be run again and again. The backend that the tests start has `DEV_LOGIN` turned on for this (`playwright.config.ts`); an address given with `E2E_BASE_URL` must have it too (the virtual machine has, see `docs/VM_SETUP.md`). Settings, all optional, as environment variables: @@ -580,26 +584,27 @@ Settings, all optional, as environment variables: - **Playwright, not Cypress.** It is made for end-to-end tests, drives real Chromium, waits for the page by itself (a test says what it expects, and Playwright retries until it is true), and has an official axe-core integration. The tests are TypeScript like the rest of the project, and the files end in `.e2e.ts` so that Vitest does not pick them up. - **The tests depend on the seeded catalog** (the data in `data/`). They avoid exact numbers where a relation is enough ("fewer than the whole catalog", "every code starts with the number of the group"), but they do assume that uneven bars, floor and vault exist with their groups and difficulty values. +- **A test that has never failed is not known to work.** The tests of the gymnast pages were checked by breaking the code on purpose (the grid column that made a page wider than a phone, the dark hover color, the size of the trash button, the dark background of the element page, the arrow keys of the tabs, and the focus after the dialog) and seeing the right test fail. A new test of this kind should be checked the same way. - **The accessibility tests are automated checks, not a full audit.** axe-core finds what a program can find. What needs a person, such as a screen reader (NVDA, VoiceOver) and whether the order of the focus makes sense, has to be tested by hand. `region` is turned off for a state with a menu open: the menus are in a portal (the Base UI default), which axe reports as best practice, not as a WCAG failure. - **They are not run by a server on every pull request.** The course git server has no runners (see issue #76), so they are run by the person who makes the change, and before each delivery against the virtual machine with `E2E_BASE_URL`. ## Project structure -| Folder | Contents | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `backend/` | The backend package: TypeScript, Prisma, the seed script, the catalog search, the GraphQL server and the GraphQL schema | -| `backend/src/` | The backend code, with its tests next to the code they test (`*.test.ts`). See "Where the tests are" below | -| `data/` | The catalog data extracted from the Code of Points: JSON files and one image per element | -| `docs/` | Project plans, data model, API, accessibility, sustainability, AI usage, and catalog extraction documentation | -| `e2e/` | The end-to-end tests (Playwright): whole flows in a real browser, accessibility checks and the API over HTTP. See "End-to-end tests" | -| `public/` | Static files served as they are, such as the favicon | -| `scripts/extract-cop/` | The Python script that extracts `data/` from the Code of Points PDF | -| `src/` | The React frontend, shared UI components, API operations, Apollo Client, and frontend tests | -| `src/pages/` | One file for each route in `src/App.tsx` (the Elements page, the element page, Your gymnasts, Log in and the placeholder pages) | -| `src/components/` | The parts of the pages, in one folder for each part of the site (`elements/`, `gymnasts/`, `auth/`, `navbar/`) | -| `src/components/ui/` | The shadcn/ui components (written by the shadcn/ui CLI) and a few small shared components (`DifficultyBadge`, `ElementValueBadge`, `ElementVariants`) | -| `src/lib/` | The GraphQL operations, Apollo Client and the logic that is not a component | -| `src/tests/` | The frontend tests (Vitest), in the same folders as the code they test, and the shared test helpers. See "Where the tests are" below | +| Folder | Contents | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `backend/` | The backend package: TypeScript, Prisma, the seed script, the catalog search, the GraphQL server and the GraphQL schema | +| `backend/src/` | The backend code, with its tests next to the code they test (`*.test.ts`). See "Where the tests are" below | +| `data/` | The catalog data extracted from the Code of Points: JSON files and one image per element | +| `docs/` | Project plans, data model, API, accessibility, sustainability, AI usage, and catalog extraction documentation | +| `e2e/` | The end-to-end tests (Playwright): whole flows in a real browser, accessibility checks and the API over HTTP. See "End-to-end tests" | +| `public/` | Static files served as they are, such as the favicon | +| `scripts/extract-cop/` | The Python script that extracts `data/` from the Code of Points PDF | +| `src/` | The React frontend, shared UI components, API operations, Apollo Client, and frontend tests | +| `src/pages/` | One file for each route in `src/App.tsx` (the Elements page, the element page, Your gymnasts, Log in and the placeholder pages) | +| `src/components/` | The parts of the pages, in one folder for each part of the site (`elements/`, `gymnasts/`, `auth/`, `navbar/`) | +| `src/components/ui/` | The shadcn/ui components (written by the shadcn/ui CLI) and a few small shared components (`DifficultyBadge`, `ElementVariants`, `LinkButton`) | +| `src/lib/` | The GraphQL operations, Apollo Client and the logic that is not a component | +| `src/tests/` | The frontend tests (Vitest), in the same folders as the code they test, and the shared test helpers. See "Where the tests are" below | ### Where the tests are diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md index 33b8ea6..447bc97 100644 --- a/docs/ACCESSIBILITY.md +++ b/docs/ACCESSIBILITY.md @@ -39,6 +39,19 @@ The interface supports light and dark themes with separate color tokens. Interac The color themes have not yet been comprehensively measured against WCAG contrast criteria. Focus, contrast, zoom, and screen-reader behavior need verification in the complete application. +### The Gymnast page and the element page + +These pages were checked for a narrow phone and for both themes, in a real browser (`e2e/gymnast-page.e2e.ts`, `e2e/element-page.e2e.ts`): + +- **Reflow (WCAG 1.4.10) and text spacing (1.4.12):** no sideways scrolling at 280, 320, 360 and 390 px, also with a name of 56 letters without a space and with the text spacing of 1.4.12. +- **Contrast (1.4.3, 1.4.11):** axe finds no violation in light and dark mode, and the dark mode of the element page and the badges of the vaults. The color under the pointer on a row is measured in the browser, and the text on it can be read. +- **Not only color (1.4.1):** the trash icon turns red on hover and while it is pressed, but its meaning is in its name ("Delete ..."), and a touch screen, which cannot hover, gets the red from the pressed state. +- **Keyboard (2.1.1, 2.4.3, 2.4.7):** the arrow keys, Home and End move between the apparatus tabs; the question "are you sure?" closes with Escape and gives the focus back to the button; after a removal the focus goes to the heading of the list; the menu of "Add to gymnast" opens with the keyboard. +- **Size of what is pressed (2.5.8):** every button, tab and link in the main part is at least 40 px tall and wide on a phone (the criterion asks for 24). +- **Names and roles (4.1.2):** a link that looks like a button is still a link (`LinkButton`); "Add to gymnast" for a visitor who is not logged in is a link with a lock and the text "(log in first)", not a disabled button, which the keyboard cannot reach and which does not say why. + +**Where:** [GymnastPage.tsx](../src/pages/GymnastPage.tsx), [ApparatusTabs.tsx](../src/components/gymnasts/ApparatusTabs.tsx), [TrashButton.tsx](../src/components/gymnasts/TrashButton.tsx), [LinkButton.tsx](../src/components/ui/LinkButton.tsx), [AddToGymnast.tsx](../src/components/elements/AddToGymnast.tsx). + ### Base UI The project uses Base UI primitives through the shared button component. This gives buttons a reusable, accessible foundation rather than implementing their basic behavior from scratch. It does not automatically make every composed page or interaction accessible; labels, descriptions, keyboard flow, and visual contrast still need to be designed and checked by the project. @@ -61,4 +74,5 @@ Accessibility requirements should be considered while planning each feature and ## Related documentation - [Sustainable choices](./SUSTAINABILITY.md) – data transfer, image delivery, performance, and maintainability. +- [State management](./STATE_MANAGEMENT.md) – where each kind of state lives, and the shared parts. - [UI plans](./UI_PLANS.md) – target users and user needs. diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index f278aa0..e3dc921 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -532,3 +532,45 @@ - **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 Gymnast page (`src/pages/GymnastPage.tsx`, `src/components/gymnasts/`, `src/components/ConfirmDialog.tsx`, `src/components/Error*.tsx`, `src/lib/gymnastDetail*.ts`, and the parts of the element page that grew out of it) + +- **Tool:** Claude Code +- **What the team did:** Wrote issue #149 and the mockup (`docs/design/gymnastDetail.png`), and decided the first scope: the apparatus tabs, the routines and the list of elements of one apparatus, and removing an element from the list. Editing the name and deleting a routine belong to other issues. After the first result the team kept directing the work in several rounds (next bullet), and the scope grew: "New routine" and "Add from search" became links, and the element page got a working "Add to gymnast", the shared button for "Try in a routine", a dark mode and the difficulty badge for vaults. The first plan said that no other page would be changed; that is no longer true, and the pages that were changed are listed under "Simplifications and refactoring". +- **How the design was reached:** The first result was not the final one. The design went through several iterations, and the team asked for changes after each result. Each result was checked by the team on localhost (the dev server, in light and dark mode, and at narrow widths in the browser's inspector) and by reading the code, and what was seen there decided the next request. The requests, in the order they came: + - The tabs: only the name of the apparatus (no highest score), only the line like the shadcn/ui "line" example (no white tile), the line under the chosen tab on the same line as the line below it, and a lower tab. + - No count under the name of the gymnast ("5 routines · 11 elements in the list"). + - The remove button: a trash icon instead of a cross, then one shared component for the gymnasts page and the Gymnast page, in the text color until the pointer is over it or it is pressed. + - The D-score of a routine in a smaller size, and no underline when the pointer is over the name of an element. + - A "New routine" button as in the mockup, the same button component and color as the other buttons, linking to the calculator with the gymnast in the address; then a plus in the button, and an "Add from search" button of the same kind. + - The text of the remove dialog: "1.104 will be removed from [name of the gymnast]". + - The hover color of the lists in dark mode, the color of the "Highest" label (the color of the buttons), and a layout that works on a phone. + - On the element page: "Try in a routine" in the shared button, an "Add to gymnast" that works, dark mode, and the difficulty badge for vaults. + + In some of the rounds the team asked how to decide, and Claude answered with options and a recommendation first (the red of the trash icon on a phone, whether the lists and rows should be one component, one shared error component). The team chose, and Claude changed the code. + +- **What Claude did:** Wrote the plan and the tests first (the grouping logic, the page on the real Apollo Client with a stub for `fetch`, and the confirmation dialog), watched them fail, and then wrote the page. One query asks for the gymnast with all routines and elements (they are limited by the API), and the page groups them by apparatus itself, so changing apparatus costs no request. Reused `PageTitle`, `Button` with `PILL`, `DifficultyBadge`, the structure query of the filters, the Base UI tabs and alert dialog, and the existing color tokens. The tabs are the shadcn/ui tabs (added with the shadcn CLI). In the later rounds Claude made the changes above, one at a time, ran the checks after each round (the lint, the type check, the tests and mostly the build), and wrote tests for the new behavior. "Add to gymnast" is a menu of the gymnasts of the coach when someone is logged in, and for a visitor who is not logged in it is a link to the login page with a lock icon, which sends the visitor back to the element after the login. Claude recommended that over a disabled button (a disabled button cannot be reached by the keyboard and does not say why), and the team has not asked for the other way. +- **Accessibility and phone check (WCAG 2.2 AA):** Done with axe-core and Playwright against the running app, logged in with the test login, on a test gymnast with long names and routines. The widths were 280, 320, 360, 390 and 768 px, in light and dark mode, also with the text spacing of WCAG 1.4.12 added, and with the keyboard on the tabs and the remove dialog. The contrast of the hover colors was calculated from the tokens and not judged by eye. It found: + - At 320 px the page was 3 px wider than the window, because a grid column took the width of its content. Fixed with `minmax(0, 1fr)`. + - At 320 px the name of an element got about 60 px, one word on a line, because the code was beside it. The code is now above the name on a narrow window. + - The four tabs broke into three rows. They are now two by two on a narrow window. + - In dark mode the hover color of a row was 1.05:1 to the page behind the card, so a row under the pointer looked like a hole in the card. It is now a deeper navy of the theme (`--card-hover`): 1.67:1 to the page, 1.29:1 to the card at rest, and 12.4:1 for the text. A test follows the token. + - "Your gymnasts" ran out of the window at every width: the title and the button did not wrap, the list had the same grid problem, and a long name without spaces was not broken. + - The value under the difficulty badge could not be read in dark mode, the focus ring of the icon buttons was missing (the shared button sets `outline-none`), and the focus was lost after removing an element (a disabled button loses the focus, and one `ref` shared by the panels of all apparatus ended up empty). These three were found in the first round and fixed then. + + After the fixes the Gymnast page has no sideways scroll at any of the widths, in both modes, also with a name of about 40 letters without spaces and with the text spacing of 1.4.12, and axe reports no violations. The arrow keys change the tab, the remove dialog fits in a 320 px window, the focus is inside it, and Escape gives the focus back to the button that was pressed. The element page (an element and a vault, in both modes, at 320 and 1100 px) also has no violations, with the color contrast checked, and no sideways scroll. + + One mistake in the check itself is worth noting: the first runs used the same refresh token in several browser contexts, but a refresh token can be used once, so the login ended and some of the runs measured the login page and not the Gymnast page. It was found when a later step could not find the tabs on the page. Every context now logs in for itself, and the run stops if the heading of the page it got is "Log in". + +- **Tests, and what the tests are worth:** The checks above were first run by hand with scripts that were not kept. At the team's request the part that a component test cannot see (jsdom has no layout, no focus rules and no real colors) was written as end-to-end tests in `e2e/gymnast-page.e2e.ts` and `e2e/element-page.e2e.ts` (see "End-to-end tests" in the README): axe in both modes and on a phone, no sideways scrolling at four narrow widths, the size of what is pressed on a phone, the arrow keys on the tabs, where the focus goes around the dialog, the real hover color of a row and of the trash icon, and "Add to gymnast" in a real browser. The team also asked for a list of every test to judge them one by one, and removed the ones that only compared class names or read source files, because they break on a harmless change and stay green when the real thing is broken: the text and icon colors of `ErrorMessage`, the caller's classes on `ErrorPage` and `TrashButton`, the look of `AddButton`, the plus and the placeholder message of "Try in a routine", the classes of the trash button and of the cards, the trash icon and the vault badge by class name, the eight tests that read the source of the element page for light-only colors, the test that read `RoutineList` for the button colors, the tab order test, and a touch test that stayed green when the bug it should catch was added. In a second round the team removed more tests that checked how something is written and not what it does, or that tested the library and not our code: the classes and the icon class names (`lucide-plus`, `lucide-lock-keyhole`, `lucide-trash-2`), a regex over the HTML of `Button`, the tests that parsed `--card-hover` in the CSS and checked a ratio of 1.5:1 that was our own choice (the real hover color is measured in the browser instead), the fifth Tab press in the dialog (that is the behaviour of Base UI, and what is ours, the focus back to the button, is kept), and the repeated checks of the exact dialog text in the browser (a component test has it). The four axe runs of the vault page became one, in dark mode on a phone, which is the hardest case for the badges. The team said that test coverage is measured by someone else, so no coverage tool was added. A better touch test was found (a device that cannot hover must not get the hover color; it passes now and fails when the hover is not gated), but it has not been added to the repository. A green test can be worthless, so each important end-to-end test was checked by breaking the code on purpose and seeing it fail: the grid column that makes a page wider than a phone, the dark hover color, the size of the trash button, the dark background of the element page, the arrow keys of the tabs, and the focus after the dialog. That check found one weak test: removing the grid fix did not fail the sideways-scrolling test, because the routine names of the test data were short enough to break. The test data now has a routine name of 56 letters without a space, and the test fails without the fix. Two other tests first failed because they read the focus and a color that changes over 150 ms too early; they now wait, and the app was not wrong. +- **Simplifications and refactoring:** Claude first listed what was repeated, and then which of it should be shared and which should not. + - Found: the same skeleton of a list (section, heading, empty text, list in a card) in three places; the long hover string of a card in two places; a link that covers a whole card copied three times; two almost equal error states; and eleven places with `role="alert"` that differ in color, markup and button. + - Shared: `ListSection` (the routines and the elements), `StretchedLink`, `AddButton` and `LinkButton` (a link with the look of the pill button; `Button` also takes an `icon` now, which the menu buttons use), `TrashButton`, `ConfirmDialog`, and the constants of `listCard.ts` (also used by the cards of the gymnasts page). Errors are in three layers, `ErrorMessage`, `ErrorPanel` and `ErrorPage`, which the Gymnast page uses. + - `LinkButton` exists because of a finding of the end-to-end run: given a link, the Base UI `Button` warns in the console that it is not a ` + + + + + ) +} diff --git a/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx new file mode 100644 index 0000000..8d12675 --- /dev/null +++ b/src/components/ErrorMessage.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react' +import { TriangleAlert } from 'lucide-react' + +/** + * One error message, the first of the three layers (`ErrorMessage`, `ErrorPanel`, + * `ErrorPage`) that every error of the app is shown with, so that they look and are read the + * same. It is a `role="alert"`, so a screen reader reads it when it appears, and only the + * text is read: a button beside it is not part of the alert. The meaning is in the icon and + * the words and not in the color alone (WCAG 1.4.1); the icon is the red (3:1 on the card in + * both themes, checked in gymnastTheme.test.ts), and the text is the text color, which can be + * read on the page and on a card. Give it an `id` when a field points at it with + * `aria-describedby`. + */ +export default function ErrorMessage({ + children, + id, + className = '', +}: { + children: ReactNode + id?: string + className?: string +}) { + return ( + + ) +} diff --git a/src/components/ErrorPage.tsx b/src/components/ErrorPage.tsx new file mode 100644 index 0000000..915a988 --- /dev/null +++ b/src/components/ErrorPage.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react' +import ErrorPanel from '@/components/ErrorPanel' +import PageTitle from '@/components/PageTitle' + +/** + * The third layer: a whole page that says what went wrong. It has the title of the page (the + * tab, the history, a screen reader), the `h1`, and an `ErrorPanel`. `className` is the + * classes of the `
`, so the page keeps the look of its other states. + */ +export default function ErrorPage({ + title, + message, + retryable, + onRetry, + action, + className = '', +}: { + title: string + message: string + retryable?: boolean + onRetry?: () => void + action?: ReactNode + className?: string +}) { + return ( +
+ {title} +

{title}

+ +
+ ) +} diff --git a/src/components/ErrorPanel.tsx b/src/components/ErrorPanel.tsx new file mode 100644 index 0000000..6cfa4a8 --- /dev/null +++ b/src/components/ErrorPanel.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react' +import ErrorMessage from '@/components/ErrorMessage' +import { Button } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' + +/** + * The second layer: an error message in a card, with a way on. "Try again" is shown when the + * error can be tried again (`retryable`, from `describeError`) and the caller can do it + * (`onRetry`); `action` is anything else that helps, such as a link to log in. Use it for a + * part of a page that could not be loaded. The message is the only alert: the buttons are not + * read out as a part of it. + */ +export default function ErrorPanel({ + message, + retryable = false, + onRetry, + action, + className = '', +}: { + message: string + retryable?: boolean + onRetry?: () => void + action?: ReactNode + className?: string +}) { + const canRetry = retryable && onRetry !== undefined + return ( +
+ {message} + {(canRetry || action) && ( +
+ {canRetry && ( + + )} + {action} +
+ )} +
+ ) +} diff --git a/src/components/elements/AddToGymnast.tsx b/src/components/elements/AddToGymnast.tsx new file mode 100644 index 0000000..2e732c7 --- /dev/null +++ b/src/components/elements/AddToGymnast.tsx @@ -0,0 +1,146 @@ +import { Menu } from '@base-ui/react/menu' +import { LockKeyhole, Plus } from 'lucide-react' +import { Link, useLocation } from 'react-router-dom' +import { useAuth } from '@/components/auth/useAuth' +import AddButton from '@/components/gymnasts/AddButton' +import { Button } from '@/components/ui/button' +import { MENU_ITEM, MENU_LABEL, MENU_POPUP } from '@/components/ui/menuStyles' +import { PILL } from '@/components/ui/pill' +import { describeError } from '@/lib/apollo/errors' +import type { ElementDetail } from '@/lib/catalog' +import { useAddElementToGymnast } from '@/components/elements/useAddElementToGymnast' + +/** The button when nothing can be added: a removed element, or the answer about the login is on its way. */ +function Unavailable({ describedBy }: { describedBy?: string }) { + return ( + + ) +} + +/** The gymnasts of the coach in a menu, one choice for each gymnast. */ +function GymnastMenu({ + element, + onAdded, + onFailed, +}: { + element: ElementDetail + onAdded: (message: string) => void + onFailed: (message: string) => void +}) { + const { list, loading, error, adding, add } = useAddElementToGymnast( + element, + onAdded, + onFailed, + ) + + return ( + + + } + > + Add to gymnast + + + + + + + Add {element.code} to + + {!list && loading ? ( +

+ Loading your gymnasts… +

+ ) : !list ? ( +

+ {describeError(error).message} +

+ ) : list.length === 0 ? ( + <> +

You have no gymnasts yet.

+ } + className={MENU_ITEM} + > + Go to your gymnasts + + + ) : ( + list.map((gymnast) => ( + void add(gymnast)} + className={MENU_ITEM} + > + {gymnast.name} + + )) + )} +
+
+
+
+
+ ) +} + +/** + * "Add to gymnast" on the page of an element. What it is depends on the login, because only a + * logged-in coach has gymnasts: + * - logged in: a menu with the gymnasts of the coach; the choice adds the element to that list; + * - not logged in: a link to the login page, with a lock, which sends the visitor back to this + * page after the login. It is not disabled: a disabled button cannot be reached by the + * keyboard, says nothing about why, and leaves the visitor without a way on; + * - a removed element (the API refuses it) or an answer about the login that is not here yet: + * a disabled button. `describedBy` is the reason, which a screen reader reads with it. + */ +export default function AddToGymnast({ + element, + describedBy, + onAdded, + onFailed, +}: { + element: ElementDetail + describedBy?: string + onAdded: (message: string) => void + onFailed: (message: string) => void +}) { + const auth = useAuth() + const location = useLocation() + + if (element.removed || auth.status === 'loading') { + return + } + if (auth.status === 'anonymous') { + return ( + + Add to gymnast + (log in first) + + ) + } + return +} diff --git a/src/components/elements/ElementActions.tsx b/src/components/elements/ElementActions.tsx index e094740..1002314 100644 --- a/src/components/elements/ElementActions.tsx +++ b/src/components/elements/ElementActions.tsx @@ -1,6 +1,9 @@ import { useId, useState } from 'react' -import { LockKeyhole, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' +import AddToGymnast from '@/components/elements/AddToGymnast' +import ErrorMessage from '@/components/ErrorMessage' import { Button } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' import type { ElementDetail } from '@/lib/catalog' export default function ElementActions({ @@ -9,6 +12,7 @@ export default function ElementActions({ element: ElementDetail }) { const [status, setStatus] = useState('') + const [error, setError] = useState('') const reasonId = useId() // A screen reader reads the reason with each disabled button. const describedBy = element.removed ? reasonId : undefined @@ -20,28 +24,30 @@ export default function ElementActions({ > - + { + setError('') + setStatus(message) + }} + onFailed={(message) => { + setStatus('') + setError(message) + }} + /> {element.removed && ( -

+

A removed element cannot be added to a routine or a gymnast.

)} @@ -49,10 +55,11 @@ export default function ElementActions({ role="status" aria-live="polite" aria-atomic="true" - className="text-xs text-(--text-color-dark)" + className="text-xs text-(--text-color)" > {status}

+ {error && {error}} ) } diff --git a/src/components/elements/ElementDetailHeader.tsx b/src/components/elements/ElementDetailHeader.tsx index 70eabdb..bac14d7 100644 --- a/src/components/elements/ElementDetailHeader.tsx +++ b/src/components/elements/ElementDetailHeader.tsx @@ -15,13 +15,13 @@ export default function ElementDetailHeader({ type="button" onClick={() => navigate(-1)} aria-label="Go back to previous page" - className="inline-flex items-center gap-2 rounded-md py-2 text-sm font-medium text-(--text-color-dark) underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" + className="inline-flex items-center gap-2 rounded-md py-2 text-sm font-medium text-(--text-color) underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--text-color)" >
-

+

{displayCode(element, element.variants.length > 0)} {element.name && ( <> @@ -31,7 +31,7 @@ export default function ElementDetailHeader({ )}

{element.description && ( -

+

{element.description}

)} diff --git a/src/components/elements/ElementDiagram.tsx b/src/components/elements/ElementDiagram.tsx index 6e56b6c..f1af41c 100644 --- a/src/components/elements/ElementDiagram.tsx +++ b/src/components/elements/ElementDiagram.tsx @@ -8,7 +8,8 @@ export default function ElementDiagram({ }) { return (
- + {/* The drawings are black lines, so they keep a white ground in both themes. */} + -
+
{label}
-
+
{children}
@@ -30,21 +29,18 @@ export default function ElementMetadataCard({ }: { element: ElementDetail }) { + // An element has a letter A-J; a vault has its D-value, which the badge writes as a number. const difficulty = element.isVault - ? undefined - : getDifficultyLetter(element.value) + ? element.value + : (getDifficultyLetter(element.value) ?? element.value) return (
- +
- {element.isVault || !difficulty ? ( - - ) : ( - - )} + {element.apparatusName} diff --git a/src/components/elements/useAddElementToGymnast.ts b/src/components/elements/useAddElementToGymnast.ts new file mode 100644 index 0000000..82d5f3d --- /dev/null +++ b/src/components/elements/useAddElementToGymnast.ts @@ -0,0 +1,46 @@ +import { useMutation, useQuery } from '@apollo/client/react' +import { describeError } from '@/lib/apollo/errors' +import type { ElementDetail } from '@/lib/catalog' +import { ADD_GYMNAST_ELEMENT_MUTATION } from '@/lib/gymnastDetailOperations' +import { GYMNASTS_QUERY } from '@/lib/gymnastOperations' + +/** + * Adding an element to the list of one of the coach's gymnasts: the gymnasts to choose from, the + * mutation, and what is told to the visitor (`onAdded` on success, `onFailed` with the reason when + * the server refuses). The menu that shows the choice (`AddToGymnast`) has no rules of its own. + */ +export function useAddElementToGymnast( + element: ElementDetail, + onAdded: (message: string) => void, + onFailed: (message: string) => void, +) { + // The gymnasts can have changed on the other page, so the cache is shown and the server is asked. + const gymnasts = useQuery(GYMNASTS_QUERY, { + fetchPolicy: 'cache-and-network', + }) + const [addElement, { loading: adding }] = useMutation( + ADD_GYMNAST_ELEMENT_MUTATION, + ) + + async function add(gymnast: { id: string; name: string }) { + if (adding) return + try { + await addElement({ + variables: { gymnastId: gymnast.id, itemId: element.id }, + }) + } catch (problem) { + onFailed(describeError(problem).message) + return + } + onAdded(`${element.code} added to ${gymnast.name}.`) + } + + return { + /** The gymnasts of the coach, or undefined until the first answer. */ + list: gymnasts.data?.me?.gymnasts, + loading: gymnasts.loading, + error: gymnasts.error, + adding, + add, + } +} diff --git a/src/components/gymnasts/AddButton.tsx b/src/components/gymnasts/AddButton.tsx new file mode 100644 index 0000000..8eb58ce --- /dev/null +++ b/src/components/gymnasts/AddButton.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from 'react' +import { Plus, type LucideIcon } from 'lucide-react' +import LinkButton from '@/components/ui/LinkButton' + +/** + * The pill button that is a link, with an icon before the text: a plus to add something + * ("New routine", "Add from search"), or another icon for another meaning (a lock on "Add to + * gymnast" when the visitor must log in first). `state` is the state of the navigation, for + * the login page, which sends the visitor back to the page in `state.from`. + */ +export default function AddButton({ + to, + state, + icon = Plus, + children, +}: { + to: string + state?: unknown + icon?: LucideIcon + children: ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/src/components/gymnasts/ApparatusTabs.tsx b/src/components/gymnasts/ApparatusTabs.tsx new file mode 100644 index 0000000..589d90c --- /dev/null +++ b/src/components/gymnasts/ApparatusTabs.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import type { ApparatusView } from '@/lib/gymnastDetail' + +/** + * One tab for each apparatus, with only its name. It is the shadcn/ui + * tabs (`components/ui/tabs.tsx`, on the Base UI tabs, so the WAI-ARIA tabs pattern: arrow + * keys, Home and End, a panel for each tab) in its "line" look. The panel of the chosen + * apparatus is made by `renderPanel`. The tabs follow the focus, because a panel shows at + * once and costs nothing to open. + * + * The classes only set what the shadcn/ui defaults cannot do on this site: the colors of the + * site (the defaults are 60 % gray text and a faint focus ring, which are not enough, WCAG + * 1.4.3 and 2.4.7), a tab that is 44 px high, and a hover that can be seen. The chosen tab + * has the line of the component under it and a bold name, so it is not told by a tone alone + * (WCAG 1.4.1), and `aria-selected` tells a screen reader. + */ +export default function ApparatusTabs({ + apparatus, + value, + onValueChange, + renderPanel, +}: { + apparatus: ApparatusView[] + value: string + onValueChange: (id: string) => void + renderPanel: (apparatus: ApparatusView) => ReactNode +}) { + return ( + onValueChange(String(id))}> + + {apparatus.map((one) => ( + + + {one.name} + + + ))} + + {apparatus.map((one) => ( + + {renderPanel(one)} + + ))} + + ) +} diff --git a/src/components/gymnasts/DeleteGymnastButton.tsx b/src/components/gymnasts/DeleteGymnastButton.tsx index 6108f67..7a3a9a9 100644 --- a/src/components/gymnasts/DeleteGymnastButton.tsx +++ b/src/components/gymnasts/DeleteGymnastButton.tsx @@ -1,8 +1,7 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import { useMutation } from '@apollo/client/react' -import { AlertDialog } from '@base-ui/react/alert-dialog' -import { Trash2, TriangleAlert } from 'lucide-react' -import { Button } from '@/components/ui/button' +import ConfirmDialog from '@/components/ConfirmDialog' +import TrashButton from '@/components/gymnasts/TrashButton' import { describeError } from '@/lib/apollo/errors' import { DELETE_GYMNAST_MUTATION, @@ -13,10 +12,11 @@ import type { GymnastSummary } from '@/lib/gymnasts' /** * Deletes a gymnast, after the coach has confirmed it in a dialog. Deleting * cannot be undone and takes the gymnast's routines and list of elements with - * it, so a click on the icon only opens the question. The dialog is the Base UI - * alert dialog: the focus stays inside it, Escape closes it, and it is read as - * a dialog with a title and a description. The button is named after the - * gymnast, so a list of them can be told apart by a screen reader. + * it, so a click on the icon only opens the question. The question is the shared + * `ConfirmDialog` (the Base UI alert dialog: the focus stays inside it, Escape + * closes it, and it is read as a dialog with a title and a description). The + * button is named after the gymnast, so a list of them can be told apart by a + * screen reader. * * `onDeleted` is called with the name when the server has deleted the gymnast, * and `onFailed` with the message when it has not. The page tells the visitor @@ -32,62 +32,45 @@ export default function DeleteGymnastButton({ onFailed: (message: string) => void }) { const [open, setOpen] = useState(false) + // True after a deletion: the page has moved the focus, and the dialog must not move it back + // to this button, which is about to be gone. + const deleted = useRef(false) // The list is fetched again: the gymnast is gone from it, and the API sorts it. - const [deleteGymnast, { loading }] = useMutation(DELETE_GYMNAST_MUTATION, { + const [deleteGymnast] = useMutation(DELETE_GYMNAST_MUTATION, { refetchQueries: [GYMNASTS_QUERY], }) async function confirm() { + deleted.current = false try { await deleteGymnast({ variables: { id: gymnast.id } }) - setOpen(false) - onDeleted(gymnast.name) } catch (problem) { setOpen(false) onFailed(describeError(problem).message) + return } + deleted.current = true + setOpen(false) + onDeleted(gymnast.name) } return ( - - - } - > - - - {/* The page behind is dimmed. */} - - {/* A card like the others on the site (rounded-lg), with the warning, the - question and the two answers. The first thing in it that can be reached - is "No, keep it", so Enter right away does not delete. */} - - - - + + } + confirmLabel="Yes, delete" + busyLabel="Deleting…" + onConfirm={confirm} + finalFocus={() => !deleted.current} + /> + ) } diff --git a/src/components/gymnasts/GymnastElementList.tsx b/src/components/gymnasts/GymnastElementList.tsx new file mode 100644 index 0000000..0d2039f --- /dev/null +++ b/src/components/gymnasts/GymnastElementList.tsx @@ -0,0 +1,102 @@ +import AddButton from '@/components/gymnasts/AddButton' +import ListSection from '@/components/gymnasts/ListSection' +import { ROW_HOVER } from '@/components/gymnasts/listCard' +import StretchedLink from '@/components/gymnasts/StretchedLink' +import { TriangleAlert } from 'lucide-react' +import TrashButton from '@/components/gymnasts/TrashButton' +import DifficultyBadge from '@/components/ui/DifficultyBadge' +import { getDifficultyLetter } from '@/lib/difficulty' +import { + addFromSearchHref, + elementLabel, + elementsHeadingId, + type ElementView, +} from '@/lib/gymnastDetail' + +/** One element of the list: its code and name (a link to the element), its difficulty, and a button that asks to remove it. */ +function ElementRow({ + element, + onAskRemove, +}: { + element: ElementView + onAskRemove: (element: ElementView, opener: HTMLElement) => void +}) { + const label = elementLabel(element) + // An element has a value 0.1-1.0 that is shown as a letter A-J; a vault has a D-value. + const difficulty = element.isVault + ? element.value + : (getDifficultyLetter(element.value) ?? element.value) + + return ( +
  • +
    + + {/* The code is above the name on a narrow screen: beside it, the name got about 60 px + at 320 px wide, and one word a line. */} + {element.code}{' '} + {label} + + {element.removed && ( +

    +

    + )} +
    + + {/* Above the link that covers the row (its ::after), so the button can be pressed. */} + onAskRemove(element, event.currentTarget)} + className="relative z-10" + /> +
  • + ) +} + +/** + * The elements the gymnast can do or is practicing on one apparatus, in the order they were + * added, and the "Add from search" button, which goes to the search with the gymnast in the + * address (`addFromSearchHref`). The page can move the focus to the heading + * (`elementsHeadingId`): after an element has been removed the button that was pressed is + * gone, and this is where the visitor was. The id has the apparatus in it, because the panel + * of the apparatus that was left is still in the page for a moment, and two headings must not + * have the same id. + */ +export default function GymnastElementList({ + apparatusId, + elements, + apparatusName, + gymnastId, + onAskRemove, +}: { + apparatusId: string + elements: ElementView[] + apparatusName: string + gymnastId: string + /** The visitor pressed the remove button of an element. The page asks "are you sure?". */ + onAskRemove: (element: ElementView, opener: HTMLElement) => void +}) { + return ( + ( + + )} + action={ + Add from search + } + /> + ) +} diff --git a/src/components/gymnasts/GymnastHeader.tsx b/src/components/gymnasts/GymnastHeader.tsx new file mode 100644 index 0000000..386ba5e --- /dev/null +++ b/src/components/gymnasts/GymnastHeader.tsx @@ -0,0 +1,18 @@ +import { ChevronLeft } from 'lucide-react' +import { Link } from 'react-router-dom' + +/** The way back and the name of the gymnast (the h1 of the page). */ +export default function GymnastHeader({ name }: { name: string }) { + return ( +
    + +
    + ) +} diff --git a/src/components/gymnasts/GymnastList.tsx b/src/components/gymnasts/GymnastList.tsx index 86c95da..ba8bcbd 100644 --- a/src/components/gymnasts/GymnastList.tsx +++ b/src/components/gymnasts/GymnastList.tsx @@ -20,7 +20,7 @@ export default function GymnastList({ You haven’t added any gymnasts yet.

    ) : ( -