From 871d5ce9ef9e16f61dd7e7302075c6cfe8d60f44 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 11:55:59 +0100 Subject: [PATCH 01/11] feat(ui): add the shadcn/ui tabs component Added with the shadcn CLI (npx shadcn@latest add tabs) and formatted with Prettier. It is built on the Base UI tabs, so it follows the WAI-ARIA tabs pattern. --- src/components/ui/tabs.tsx | 82 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/components/ui/tabs.tsx diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..f25d771 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,82 @@ +// Added with `npx shadcn add tabs`. One change: the component asks for the attributes `data-horizontal` +// and `data-vertical`, but the Base UI that is installed (1.9) sets `data-orientation`, so those +// classes are written as `data-[orientation=horizontal]`. Nothing else is changed. +import { Tabs as TabsPrimitive } from '@base-ui/react/tabs' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from 'cn' + +function Tabs({ + className, + orientation = 'horizontal', + ...props +}: TabsPrimitive.Root.Props) { + return ( + + ) +} + +const tabsListVariants = cva( + 'group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none', + { + variants: { + variant: { + default: 'bg-muted', + line: 'gap-1 bg-transparent', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function TabsList({ + className, + variant = 'default', + ...props +}: TabsPrimitive.List.Props & VariantProps) { + return ( + + ) +} + +function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { + return ( + + ) +} + +function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } From 9307bf606ff37d74722846e832373f825b29b76b Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 11:56:00 +0100 Subject: [PATCH 02/11] refactor(gymnasts): share the confirmation dialog as ConfirmDialog The dialog of DeleteGymnastButton becomes its own component, so that removing an element from the list can use the same "are you sure?" dialog. --- src/components/ConfirmDialog.tsx | 113 +++++++++++++ .../gymnasts/DeleteGymnastButton.tsx | 117 +++++-------- src/tests/components/ConfirmDialog.test.tsx | 160 ++++++++++++++++++ 3 files changed, 320 insertions(+), 70 deletions(-) create mode 100644 src/components/ConfirmDialog.tsx create mode 100644 src/tests/components/ConfirmDialog.test.tsx diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..ede7333 --- /dev/null +++ b/src/components/ConfirmDialog.tsx @@ -0,0 +1,113 @@ +import { useRef, useState, type ReactNode } from 'react' +import { AlertDialog } from '@base-ui/react/alert-dialog' +import { TriangleAlert } from 'lucide-react' +import { Button } from '@/components/ui/button' + +// "Solid" is needed: the button sets `outline-none`, which a width and a color alone do not undo. +const FOCUS = + 'focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-(--text-color)' + +/** + * A question before something is removed that cannot be undone (WCAG 3.3.4). It is the Base + * UI alert dialog, as in `DeleteGymnastButton`: the focus stays inside it, Escape closes it, + * and it is read as a dialog with a title and a description. The first thing that can be + * reached is "No, keep it", so pressing Enter right away does not remove anything. + * + * The page owns it (`open`), and opens it from a button of its own. That way it stays when + * the thing it asks about is gone from the page, which is when the focus has to be moved. + * When it closes, the focus goes back to the button that opened it, unless `finalFocus` says + * otherwise (after a removal that button is gone). + * + * `onConfirm` does the removal. The dialog closes when it is done, also when it fails: the + * caller shows the error itself, because it knows where on the page to say it. While it works + * the answer is disabled, and a second press (Enter twice) is ignored. + */ +export default function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel, + busyLabel, + onConfirm, + finalFocus, +}: { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: ReactNode + confirmLabel: string + busyLabel: string + onConfirm: () => Promise + /** + * Where the focus goes when the dialog closes: an element, `true` for the way the dialog + * does it (back to what had the focus), or `false` to leave the focus alone (the caller moves it). + */ + finalFocus?: () => HTMLElement | boolean | null +}) { + const [busy, setBusy] = useState(false) + // The state changes one render too late for a second press that comes at once. + const working = useRef(false) + + async function confirm() { + if (working.current) return + working.current = true + setBusy(true) + try { + await onConfirm() + } catch { + // The caller has shown what went wrong; the dialog has nothing more to say. + } finally { + working.current = false + setBusy(false) + onOpenChange(false) + } + } + + return ( + + + {/* The page behind is dimmed. */} + + + + + + ) +} diff --git a/src/components/gymnasts/DeleteGymnastButton.tsx b/src/components/gymnasts/DeleteGymnastButton.tsx index 6108f67..81e3e85 100644 --- a/src/components/gymnasts/DeleteGymnastButton.tsx +++ b/src/components/gymnasts/DeleteGymnastButton.tsx @@ -1,7 +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 { Trash2 } from 'lucide-react' +import ConfirmDialog from '@/components/ConfirmDialog' import { Button } from '@/components/ui/button' import { describeError } from '@/lib/apollo/errors' import { @@ -13,10 +13,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 +33,54 @@ 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 ( - - - } + <> + + You’ll permanently lose {gymnast.name}’s: @@ -96,29 +89,13 @@ export default function DeleteGymnastButton({
  • Routines
  • List of elements
  • - -
    - - } - > - No, keep it - - -
    - - -
    + + } + confirmLabel="Yes, delete" + busyLabel="Deleting…" + onConfirm={confirm} + finalFocus={() => !deleted.current} + /> + ) } diff --git a/src/tests/components/ConfirmDialog.test.tsx b/src/tests/components/ConfirmDialog.test.tsx new file mode 100644 index 0000000..bddaf17 --- /dev/null +++ b/src/tests/components/ConfirmDialog.test.tsx @@ -0,0 +1,160 @@ +// @vitest-environment jsdom +// The dialog that asks "are you sure?" before something is removed (WCAG 3.3.4). + +import { useRef, useState } from 'react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import ConfirmDialog from '@/components/ConfirmDialog' + +afterEach(cleanup) + +/** The page that owns the dialog: a button opens it, as the Gymnast page does. */ +function Page({ + onConfirm, + focusElsewhere, +}: { + onConfirm: () => Promise + focusElsewhere: boolean +}) { + const [open, setOpen] = useState(false) + const elsewhere = useRef(null) + return ( + <> + +

    + Somewhere else +

    + elsewhere.current : undefined} + /> + + ) +} + +const renderDialog = ( + onConfirm: () => Promise = () => Promise.resolve(), + focusElsewhere = false, +) => render() + +const open = async () => { + const opener = screen.getByRole('button', { name: 'Remove it' }) + opener.focus() // a click focuses a button in the browser; fireEvent does not + fireEvent.click(opener) + return screen.findByRole('alertdialog') +} + +describe('ConfirmDialog', () => { + it('is closed until the page opens it', () => { + renderDialog() + + expect(screen.queryByRole('alertdialog')).toBeNull() + }) + + it('has a title and a description that are read as the name and description of the dialog', async () => { + renderDialog() + + const dialog = await open() + expect( + screen.getByRole('alertdialog', { name: 'Remove element' }), + ).toBeTruthy() + expect(dialog.getAttribute('aria-describedby')).toBeTruthy() + expect(dialog.textContent).toContain('1.302 will be removed from the list.') + }) + + it('puts the focus on the answer that does nothing, so Enter right away does not remove', async () => { + renderDialog() + await open() + + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('button', { name: 'No, keep it' }), + ), + ) + }) + + it('closes with Escape, does not confirm, and gives the focus back to the button that opened it', async () => { + const onConfirm = vi.fn(() => Promise.resolve()) + renderDialog(onConfirm) + await open() + + fireEvent.keyDown(screen.getByRole('alertdialog'), { key: 'Escape' }) + + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + expect(onConfirm).not.toHaveBeenCalled() + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('button', { name: 'Remove it' }), + ), + ) + }) + + it('calls onConfirm once and closes when it is done', async () => { + const onConfirm = vi.fn(() => Promise.resolve()) + renderDialog(onConfirm) + await open() + + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + it('moves the focus where finalFocus says when it closes', async () => { + renderDialog(() => Promise.resolve(), true) + await open() + + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('heading', { name: 'Somewhere else' }), + ), + ) + }) + + it('shows the busy text and cannot be confirmed again while it works', async () => { + let finish!: () => void + const onConfirm = vi.fn( + () => new Promise((resolve) => (finish = resolve)), + ) + renderDialog(onConfirm) + await open() + + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + const busy = await screen.findByRole('button', { name: 'Removing…' }) + fireEvent.click(busy) + + expect( + busy.hasAttribute('disabled') || + busy.getAttribute('aria-disabled') === 'true', + ).toBe(true) + expect(onConfirm).toHaveBeenCalledTimes(1) + finish() + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + }) + + it('closes also when onConfirm fails, so the page can say what happened', async () => { + const onConfirm = vi.fn(() => Promise.reject(new Error('no'))) + renderDialog(onConfirm) + await open() + + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + }) +}) From a001cdace5bfe9597c35f110a729d14ec04344e5 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 11:56:00 +0100 Subject: [PATCH 03/11] feat(gymnasts): group the data of one gymnast by apparatus One query asks for the gymnast with all routines and elements. gymnastDetail.ts groups them by apparatus and marks the highest routine, so changing apparatus costs no request. --- src/lib/gymnastDetail.ts | 173 +++++++++++++++++++++++++++ src/lib/gymnastDetailOperations.ts | 114 ++++++++++++++++++ src/tests/gymnastDetail.ts | 147 +++++++++++++++++++++++ src/tests/lib/gymnastDetail.test.ts | 178 ++++++++++++++++++++++++++++ 4 files changed, 612 insertions(+) create mode 100644 src/lib/gymnastDetail.ts create mode 100644 src/lib/gymnastDetailOperations.ts create mode 100644 src/tests/gymnastDetail.ts create mode 100644 src/tests/lib/gymnastDetail.test.ts diff --git a/src/lib/gymnastDetail.ts b/src/lib/gymnastDetail.ts new file mode 100644 index 0000000..b1501a6 --- /dev/null +++ b/src/lib/gymnastDetail.ts @@ -0,0 +1,173 @@ +// What the Gymnast page shows, worked out from the answer of the API: the routines and the +// elements of the gymnast grouped by apparatus, and what "highest" means. No React and no +// network, so it is tested by itself (src/tests/lib/gymnastDetail.test.ts). + +import type { + DetailApparatus, + DetailItem, + DetailRoutine, + GymnastDetailFields, +} from '@/lib/gymnastDetailOperations' + +export type RoutineView = { + id: string + name: string + /** The D-score, or null when the routine has an element that is no longer in the Code of Points. */ + score: number | null + hasRemovedItem: boolean + itemCount: number + /** ISO date of the last change; show it with `formatUpdated`. */ + updatedAt: string + /** One of the routines with the highest score (only marked when there are two or more to compare). */ + isHighest: boolean +} + +export type ElementView = { + id: string + apparatusId: string + code: string + name: string | null + description: string + removed: boolean + /** The value of an element, or the D-value of a vault. */ + value: number + isVault: boolean +} + +export type ApparatusView = { + id: string + name: string + /** The highest score of the routines on this apparatus, or null when there is none. */ + highest: number | null + routines: RoutineView[] + elements: ElementView[] +} + +export type GymnastView = { + name: string + routineCount: number + elementCount: number + apparatus: ApparatusView[] +} + +/** What names an element: the name it is known by, else its description. */ +export const elementLabel = ( + element: Pick, +) => element.name ?? element.description + +/** The id of the heading of the list of an apparatus, so the page can move the focus to it. */ +export const elementsHeadingId = (apparatusId: string) => + `elements-heading-${apparatusId}` + +/** Highest score first, a routine without a score last, and the name when the scores are equal. */ +function byScoreThenName(a: RoutineView, b: RoutineView): number { + if (a.score !== b.score) { + if (a.score === null) return 1 + if (b.score === null) return -1 + return b.score - a.score + } + return a.name.localeCompare(b.name) +} + +function toRoutineView(routine: DetailRoutine): RoutineView { + return { + id: routine.id, + name: routine.name, + score: routine.dScore, + hasRemovedItem: routine.items.some((item) => item.removed), + itemCount: routine.items.length, + updatedAt: routine.updatedAt, + isHighest: false, + } +} + +function toElementView(item: DetailItem): ElementView { + const isVault = item.__typename === 'Vault' + return { + id: item.id, + apparatusId: item.elementGroup.apparatus.id, + code: item.code, + name: item.name, + description: item.description, + removed: item.removed, + value: isVault ? item.dValue : item.value, + isVault, + } +} + +function toApparatusView( + apparatus: DetailApparatus, + gymnast: GymnastDetailFields, +): ApparatusView { + const routines = gymnast.routines + .filter((routine) => routine.apparatus.id === apparatus.id) + .map(toRoutineView) + .sort(byScoreThenName) + const scores = routines.flatMap((routine) => + routine.score === null ? [] : [routine.score], + ) + const highest = scores.length > 0 ? Math.max(...scores) : null + // "Highest" says nothing about a single routine, so it needs two to compare. + if (scores.length >= 2) { + for (const routine of routines) + routine.isHighest = routine.score === highest + } + return { + id: apparatus.id, + name: apparatus.name, + highest, + routines, + elements: gymnast.elements + .filter((item) => item.elementGroup.apparatus.id === apparatus.id) + .map(toElementView), + } +} + +/** + * The gymnast as the page shows it. `apparatus` is the structure from the API, so all the + * apparatus are there in the same order as everywhere else, also those with nothing yet. + */ +export function buildGymnastView( + gymnast: GymnastDetailFields, + apparatus: DetailApparatus[], +): GymnastView { + return { + name: gymnast.name, + routineCount: gymnast.routines.length, + elementCount: gymnast.elements.length, + apparatus: apparatus.map((one) => toApparatusView(one, gymnast)), + } +} + +/** The apparatus to open first: the first with a routine, else the first with an element, else the first. */ +export function defaultApparatusId(view: GymnastView): string { + const first = + view.apparatus.find((a) => a.routines.length > 0) ?? + view.apparatus.find((a) => a.elements.length > 0) ?? + view.apparatus[0] + return first?.id ?? '' +} + +const DAY_AND_MONTH = new Intl.DateTimeFormat('en-US', { + day: 'numeric', + month: 'short', +}) + +/** "27 Sep". The parts are put together here, because the order of them depends on the locale. */ +export function formatUpdated(iso: string): string { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return '' + const parts = DAY_AND_MONTH.formatToParts(date) + const day = parts.find((part) => part.type === 'day')?.value + const month = parts.find((part) => part.type === 'month')?.value + return `${day} ${month}` +} + +/** "1 routine", "8 elements". */ +export function countText( + count: number, + singular: string, + plural = `${singular}s`, +): string { + return `${count} ${count === 1 ? singular : plural}` +} diff --git a/src/lib/gymnastDetailOperations.ts b/src/lib/gymnastDetailOperations.ts new file mode 100644 index 0000000..dc17ca6 --- /dev/null +++ b/src/lib/gymnastDetailOperations.ts @@ -0,0 +1,114 @@ +// The two operations of the Gymnast page. The types are written by hand (two operations +// do not need code generation); the fields are the ones in docs/API.md ("gymnast", +// "Gymnast.routines", "Gymnast.elements" and "removeGymnastElement"). + +import { gql, type TypedDocumentNode } from '@apollo/client' + +export type DetailApparatus = { id: string; name: string } + +/** An element or a vault in the gymnast's list: an Element has `value`, a Vault has `dValue`. */ +export type DetailItem = { + id: string + code: string + name: string | null + description: string + /** True when the item is no longer in the Code of Points. It stays in the list. */ + removed: boolean + elementGroup: { apparatus: DetailApparatus } +} & ( + | { __typename: 'Element'; value: number } + | { __typename: 'Vault'; dValue: number } +) + +export type DetailRoutine = { + id: string + name: string + /** null when the routine has an item that is removed from the Code of Points. */ + dScore: number | null + updatedAt: string + apparatus: DetailApparatus + items: { id: string; removed: boolean }[] +} + +export type GymnastDetailFields = { + id: string + name: string + routines: DetailRoutine[] + elements: DetailItem[] +} + +// The same fields for the query and for the answer of the mutation, so the answer of +// "remove" is written to the cache as the list the page shows. +const ELEMENT_FIELDS = gql` + fragment GymnastListItem on CatalogItem { + id + code + name + description + removed + elementGroup { + id + apparatus { + id + name + } + } + ... on Element { + value + } + ... on Vault { + dValue + } + } +` + +/** + * One gymnast with all the routines and the whole list of elements. Both are bounded by + * the API (50 routines and 100 elements per gymnast), so the page asks once and groups + * them by apparatus itself. It does not ask for the elements of each routine, only how + * many there are and whether one is removed. + */ +export const GYMNAST_DETAIL_QUERY: TypedDocumentNode< + { gymnast: GymnastDetailFields | null }, + { id: string } +> = gql` + query GymnastDetail($id: ID!) { + gymnast(id: $id) { + id + name + routines { + id + name + dScore + updatedAt + apparatus { + id + name + } + items { + id + removed + } + } + elements { + ...GymnastListItem + } + } + } + ${ELEMENT_FIELDS} +` + +export const REMOVE_GYMNAST_ELEMENT_MUTATION: TypedDocumentNode< + { removeGymnastElement: Pick }, + { gymnastId: string; itemId: string } +> = gql` + mutation RemoveGymnastElement($gymnastId: ID!, $itemId: ID!) { + removeGymnastElement(gymnastId: $gymnastId, itemId: $itemId) { + id + elements { + ...GymnastListItem + } + } + } + ${ELEMENT_FIELDS} +` diff --git a/src/tests/gymnastDetail.ts b/src/tests/gymnastDetail.ts new file mode 100644 index 0000000..b726e9d --- /dev/null +++ b/src/tests/gymnastDetail.ts @@ -0,0 +1,147 @@ +// What the server says to `GymnastDetail` and `CatalogStructure`, for the tests of the +// Gymnast page: one gymnast with routines and elements on three of the four apparatus. +// The ids, codes and texts are real ones of the catalog. + +import { ok } from '@/tests/apollo' + +export const APPARATUS = [ + { id: 'VT', name: 'Vault' }, + { id: 'UB', name: 'Uneven bars' }, + { id: 'BB', name: 'Balance beam' }, + { id: 'FX', name: 'Floor exercise' }, +] as const + +const apparatus = (id: string) => { + const found = APPARATUS.find((a) => a.id === id)! + return { __typename: 'Apparatus', ...found } +} + +/** What the server says to `CatalogStructure`: the four apparatus. */ +export const structureAnswer = () => + ok({ + apparatus: APPARATUS.map((a) => ({ + __typename: 'Apparatus', + ...a, + difficultyValues: [], + groups: [], + })), + }) + +/** An element of the gymnast's list (an Element, with `value`). */ +export function elementItem( + id: string, + code: string, + apparatusId: string, + fields: { + name?: string | null + description?: string + value?: number + removed?: boolean + } = {}, +) { + return { + __typename: 'Element', + id, + code, + name: fields.name ?? null, + description: fields.description ?? `Description of ${code}`, + removed: fields.removed ?? false, + value: fields.value ?? 0.3, + elementGroup: { + __typename: 'ElementGroup', + id: `${apparatusId}-1`, + apparatus: apparatus(apparatusId), + }, + } +} + +/** A vault of the gymnast's list (a Vault, with `dValue`). */ +export function vaultItem( + id: string, + code: string, + fields: { description?: string; dValue?: number } = {}, +) { + return { + __typename: 'Vault', + id, + code, + name: null, + description: fields.description ?? `Description of ${code}`, + removed: false, + dValue: fields.dValue ?? 5.8, + elementGroup: { + __typename: 'ElementGroup', + id: 'VT-1', + apparatus: apparatus('VT'), + }, + } +} + +/** A routine with `count` items (only their ids and whether they are removed are asked for). */ +export function routine( + id: string, + name: string, + apparatusId: string, + dScore: number | null, + fields: { count?: number; updatedAt?: string; removedItem?: boolean } = {}, +) { + const count = fields.count ?? 8 + return { + __typename: 'Routine', + id, + name, + dScore, + updatedAt: fields.updatedAt ?? '2026-09-27T12:00:00.000Z', + apparatus: apparatus(apparatusId), + items: Array.from({ length: count }, (_, index) => ({ + __typename: 'Element', + id: `${id}-item-${index + 1}`, + removed: fields.removedItem === true && index === 0, + })), + } +} + +export type ServerGymnast = ReturnType + +/** + * Ingrid Nilsen, as in the mockup: three routines on uneven bars (3.4, 3.0, 2.6), one on + * balance beam (2.8), nothing on floor and vault; elements on uneven bars and balance beam. + */ +export function exampleGymnast() { + return { + __typename: 'Gymnast', + id: 'gymnast-1', + name: 'Ingrid Nilsen', + routines: [ + routine('r-1', 'Competition 1', 'UB', 3.0, { + count: 10, + updatedAt: '2026-09-20T12:00:00.000Z', + }), + routine('r-2', 'Upgrade with Ricna', 'UB', 3.4, { count: 9 }), + routine('r-3', 'Safe set', 'UB', 2.6, { + count: 8, + updatedAt: '2026-09-01T12:00:00.000Z', + }), + routine('r-4', 'Beam set', 'BB', 2.8, { count: 7 }), + ], + elements: [ + elementItem('UB-1.302-a', '1.302', 'UB', { + description: 'Glide kip, cast to handstand with 1/2 turn', + value: 0.3, + }), + elementItem('UB-4.504-a', '4.504', 'UB', { + name: 'Ricna', + description: 'Stalder to handstand', + value: 0.5, + }), + elementItem('BB-5.205-a', '5.205', 'BB', { + description: 'Clear hip circle backward to handstand', + value: 0.2, + }), + ], + } +} + +/** The answer of `GymnastDetail` for a gymnast. */ +export const gymnastAnswer = (gymnast: unknown = exampleGymnast()) => + ok({ gymnast }) diff --git a/src/tests/lib/gymnastDetail.test.ts b/src/tests/lib/gymnastDetail.test.ts new file mode 100644 index 0000000..34f7249 --- /dev/null +++ b/src/tests/lib/gymnastDetail.test.ts @@ -0,0 +1,178 @@ +// The logic behind the Gymnast page: how the routines and the elements of a gymnast are +// grouped by apparatus, and what "highest" means. No React and no network. + +import { describe, expect, it } from 'vitest' +import { + buildGymnastView, + countText, + defaultApparatusId, + formatUpdated, +} from '@/lib/gymnastDetail' +import { + APPARATUS, + elementItem, + exampleGymnast, + routine, + vaultItem, +} from '@/tests/gymnastDetail' + +const apparatusList = APPARATUS.map((a) => ({ id: a.id, name: a.name })) + +const viewOf = (gymnast: ReturnType) => + buildGymnastView(gymnast as never, apparatusList) + +const byId = (view: ReturnType, id: string) => + view.apparatus.find((a) => a.id === id)! + +describe('buildGymnastView', () => { + it('has all four apparatus, in the order of the structure, even without data', () => { + const view = viewOf({ ...exampleGymnast(), routines: [], elements: [] }) + + expect(view.apparatus.map((a) => a.id)).toEqual(['VT', 'UB', 'BB', 'FX']) + expect(view.apparatus.every((a) => a.highest === null)).toBe(true) + }) + + it('groups routines and elements by apparatus', () => { + const view = viewOf(exampleGymnast()) + + expect(byId(view, 'UB').routines).toHaveLength(3) + expect(byId(view, 'UB').elements).toHaveLength(2) + expect(byId(view, 'BB').routines).toHaveLength(1) + expect(byId(view, 'BB').elements).toHaveLength(1) + expect(byId(view, 'FX').routines).toHaveLength(0) + expect(byId(view, 'VT').elements).toHaveLength(0) + }) + + it('counts the routines and the elements of the whole gymnast', () => { + const view = viewOf(exampleGymnast()) + + expect(view.name).toBe('Ingrid Nilsen') + expect(view.routineCount).toBe(4) + expect(view.elementCount).toBe(3) + }) + + it('gives the highest score of each apparatus, and null when there is none', () => { + const view = viewOf(exampleGymnast()) + + expect(byId(view, 'UB').highest).toBe(3.4) + expect(byId(view, 'BB').highest).toBe(2.8) + expect(byId(view, 'FX').highest).toBeNull() + }) + + it('sorts the routines by score, highest first, and by name when equal', () => { + const gymnast = exampleGymnast() + gymnast.routines = [ + routine('a', 'Zebra', 'UB', 3.0), + routine('b', 'Alpha', 'UB', 3.0), + routine('c', 'Top', 'UB', 3.4), + ] + const names = byId(viewOf(gymnast), 'UB').routines.map((r) => r.name) + + expect(names).toEqual(['Top', 'Alpha', 'Zebra']) + }) + + it('does not count a routine without a score (a removed element), and puts it last', () => { + const gymnast = exampleGymnast() + gymnast.routines = [ + routine('a', 'Broken', 'UB', null, { removedItem: true }), + routine('b', 'Fine', 'UB', 2.0), + ] + const ub = byId(viewOf(gymnast), 'UB') + + expect(ub.highest).toBe(2.0) + expect(ub.routines.map((r) => r.name)).toEqual(['Fine', 'Broken']) + expect(ub.routines[1].score).toBeNull() + expect(ub.routines[1].hasRemovedItem).toBe(true) + }) + + it('marks the highest routine only when there is more than one with a score', () => { + const view = viewOf(exampleGymnast()) + const ub = byId(view, 'UB').routines + const bb = byId(view, 'BB').routines + + expect(ub.map((r) => r.isHighest)).toEqual([true, false, false]) + expect(bb.map((r) => r.isHighest)).toEqual([false]) + }) + + it('marks every routine that shares the highest score', () => { + const gymnast = exampleGymnast() + gymnast.routines = [ + routine('a', 'One', 'UB', 3.4), + routine('b', 'Two', 'UB', 3.4), + routine('c', 'Three', 'UB', 2.0), + ] + const flags = byId(viewOf(gymnast), 'UB').routines.map((r) => r.isHighest) + + expect(flags).toEqual([true, true, false]) + }) + + it('counts the items and keeps the date of the last change', () => { + const routineView = byId(viewOf(exampleGymnast()), 'UB').routines[0] + + expect(routineView.itemCount).toBe(9) + expect(routineView.updatedAt).toBe('2026-09-27T12:00:00.000Z') + }) + + it('reads the value of an element and the D-value of a vault', () => { + const gymnast = exampleGymnast() + gymnast.elements = [ + elementItem('UB-1.302-a', '1.302', 'UB', { value: 0.3 }), + vaultItem('VT-2.50-a', '2.50', { dValue: 5.8 }), + ] as typeof gymnast.elements + const view = viewOf(gymnast) + + expect(byId(view, 'UB').elements[0]).toMatchObject({ + value: 0.3, + isVault: false, + }) + expect(byId(view, 'VT').elements[0]).toMatchObject({ + value: 5.8, + isVault: true, + }) + }) + + it('keeps removed elements and says so', () => { + const gymnast = exampleGymnast() + gymnast.elements = [ + elementItem('UB-9.999-a', '9.999', 'UB', { removed: true }), + ] + + expect(byId(viewOf(gymnast), 'UB').elements[0].removed).toBe(true) + }) +}) + +describe('defaultApparatusId', () => { + it('is the first apparatus that has a routine', () => { + expect(defaultApparatusId(viewOf(exampleGymnast()))).toBe('UB') + }) + + it('is the first apparatus with an element when there is no routine', () => { + const gymnast = { ...exampleGymnast(), routines: [] } + + expect(defaultApparatusId(viewOf(gymnast))).toBe('UB') + }) + + it('is the first apparatus when the gymnast has nothing', () => { + const gymnast = { ...exampleGymnast(), routines: [], elements: [] } + + expect(defaultApparatusId(viewOf(gymnast))).toBe('VT') + }) +}) + +describe('formatUpdated', () => { + it('writes the day and the short month', () => { + expect(formatUpdated('2026-09-27T12:00:00.000Z')).toBe('27 Sep') + }) + + it('gives an empty text for a date that is not valid', () => { + expect(formatUpdated('not a date')).toBe('') + }) +}) + +describe('countText', () => { + it('uses the singular for one and the plural for the rest', () => { + expect(countText(1, 'routine')).toBe('1 routine') + expect(countText(0, 'routine')).toBe('0 routines') + expect(countText(8, 'element')).toBe('8 elements') + }) +}) From 9992b7516d92ddf4e0815e77c4a70913f7d3cfc5 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 11:56:00 +0100 Subject: [PATCH 04/11] feat(gymnasts): add the gymnast page Route /gymnasts/:gymnastId behind the login check, with a tab for each apparatus, the routines of the chosen apparatus, the list of elements with a remove button for each element, and loading, error, empty and not-found states. Refs: #149 --- src/App.tsx | 2 + src/components/gymnasts/ApparatusTabs.tsx | 60 +++ .../gymnasts/GymnastElementList.tsx | 111 ++++ src/components/gymnasts/GymnastHeader.tsx | 18 + src/components/gymnasts/GymnastPageStates.tsx | 96 ++++ src/components/gymnasts/RoutineList.tsx | 81 +++ src/components/gymnasts/listCard.ts | 13 + src/components/gymnasts/useElementRemoval.ts | 76 +++ src/pages/GymnastPage.tsx | 128 +++++ src/styles/global.css | 32 +- src/tests/App.test.tsx | 12 + src/tests/pages/GymnastPage.test.tsx | 486 ++++++++++++++++++ src/tests/styles/gymnastTheme.test.ts | 1 + 13 files changed, 1113 insertions(+), 3 deletions(-) create mode 100644 src/components/gymnasts/ApparatusTabs.tsx create mode 100644 src/components/gymnasts/GymnastElementList.tsx create mode 100644 src/components/gymnasts/GymnastHeader.tsx create mode 100644 src/components/gymnasts/GymnastPageStates.tsx create mode 100644 src/components/gymnasts/RoutineList.tsx create mode 100644 src/components/gymnasts/listCard.ts create mode 100644 src/components/gymnasts/useElementRemoval.ts create mode 100644 src/pages/GymnastPage.tsx create mode 100644 src/tests/pages/GymnastPage.test.tsx diff --git a/src/App.tsx b/src/App.tsx index b9bd1be..558b1f2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import AboutPage from '@/pages/AboutPage' import CalculatorPage from '@/pages/CalculatorPage' import ElementDetailPage from '@/pages/ElementDetailPage' import ElementsPage from '@/pages/ElementsPage' +import GymnastPage from '@/pages/GymnastPage' import LoginPage from '@/pages/LoginPage' import NotFoundPage from '@/pages/NotFoundPage' import YourGymnastsPage from '@/pages/YourGymnastsPage' @@ -53,6 +54,7 @@ export function App() { {/* Protected routes requiring login */} }> } /> + } /> } /> diff --git a/src/components/gymnasts/ApparatusTabs.tsx b/src/components/gymnasts/ApparatusTabs.tsx new file mode 100644 index 0000000..b323545 --- /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/GymnastElementList.tsx b/src/components/gymnasts/GymnastElementList.tsx new file mode 100644 index 0000000..977cde5 --- /dev/null +++ b/src/components/gymnasts/GymnastElementList.tsx @@ -0,0 +1,111 @@ +import { LIST_CARD, ROW_HOVER } from '@/components/gymnasts/listCard' +import { Link } from 'react-router-dom' +import { TriangleAlert, X } from 'lucide-react' +import DifficultyBadge from '@/components/ui/DifficultyBadge' +import { Button } from '@/components/ui/button' +import { getDifficultyLetter } from '@/lib/difficulty' +import { + 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 ( +
  • +
    + + {element.code}{' '} + {label} + + {element.removed && ( +

    +

    + )} +
    + + +
  • + ) +} + +/** + * The elements the gymnast can do or is practicing on one apparatus, in the order they were + * added. 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, + onAskRemove, +}: { + apparatusId: string + elements: ElementView[] + apparatusName: string + /** The visitor pressed the remove button of an element. The page asks "are you sure?". */ + onAskRemove: (element: ElementView, opener: HTMLElement) => void +}) { + const headingId = elementsHeadingId(apparatusId) + return ( +
    +

    + Element list +

    + {elements.length === 0 ? ( +

    No elements on {apparatusName} in the list yet.

    + ) : ( +
      + {elements.map((element) => ( + + ))} +
    + )} +
    + ) +} 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/GymnastPageStates.tsx b/src/components/gymnasts/GymnastPageStates.tsx new file mode 100644 index 0000000..7f277e6 --- /dev/null +++ b/src/components/gymnasts/GymnastPageStates.tsx @@ -0,0 +1,96 @@ +import { Link } from 'react-router-dom' +import PageTitle from '@/components/PageTitle' +import { Button } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' +import type { DescribedError } from '@/lib/apollo/errors' +import { LIST_CARD } from '@/components/gymnasts/listCard' + +/** The classes of the `
    ` of the Gymnast page, in all its states. */ +export const GYMNAST_PAGE = + 'gymnast-page min-h-svh bg-(--page-bg) text-(--text-color) px-5 py-7 text-left sm:px-8 sm:py-8 lg:px-12' + +/** The server says that this gymnast is not one of the coach's (or does not exist). */ +export function GymnastNotFound() { + return ( +
    + Gymnast not found +

    Gymnast not found

    +

    + No gymnast of yours matches this address. +

    + +
    + ) +} + +/** The gymnast could not be loaded: what went wrong, and a way on (log in, or try again). */ +export function GymnastUnavailable({ + problem, + gymnastId, + onRetry, +}: { + problem: DescribedError + gymnastId: string + onRetry: () => void +}) { + return ( +
    + Gymnast unavailable +

    Gymnast unavailable

    +

    + {problem.message} +

    + {problem.code === 'UNAUTHENTICATED' ? ( + + ) : ( + problem.retryable && ( + + ) + )} +
    + ) +} + +/** A skeleton of the page while the first answer is on its way. */ +export function GymnastLoading() { + return ( +
    + Loading gymnast +

    + Loading gymnast +

    +
    + ) +} diff --git a/src/components/gymnasts/RoutineList.tsx b/src/components/gymnasts/RoutineList.tsx new file mode 100644 index 0000000..f459ce2 --- /dev/null +++ b/src/components/gymnasts/RoutineList.tsx @@ -0,0 +1,81 @@ +import { LIST_CARD, ROW_HOVER } from '@/components/gymnasts/listCard' +import { TriangleAlert } from 'lucide-react' +import { countText, formatUpdated, type RoutineView } from '@/lib/gymnastDetail' + +/** One routine: its name, how big it is, when it changed and its D-score. */ +function RoutineRow({ routine }: { routine: RoutineView }) { + return ( +
  • +
    +

    + + {routine.name} + + {routine.isHighest && ( + + Highest + + )} +

    +

    + {countText(routine.itemCount, 'element')} + Updated{' '} + +

    + {routine.hasRemovedItem && ( +

    +

    + )} +
    +

    + {routine.score === null ? ( + + ) : ( + <> + D-score + {routine.score.toFixed(1)} + + )} +

    +
  • + ) +} + +/** The routines of one apparatus, highest score first. */ +export default function RoutineList({ + routines, + apparatusName, +}: { + routines: RoutineView[] + apparatusName: string +}) { + return ( +
    +

    Routines

    + {routines.length === 0 ? ( +

    No routines on {apparatusName} yet.

    + ) : ( +
      + {routines.map((routine) => ( + + ))} +
    + )} +
    + ) +} diff --git a/src/components/gymnasts/listCard.ts b/src/components/gymnasts/listCard.ts new file mode 100644 index 0000000..dd8fd5c --- /dev/null +++ b/src/components/gymnasts/listCard.ts @@ -0,0 +1,13 @@ +/** + * The card behind a list of the Gymnast page (the routines and the elements): the color of + * the cards on the Elements page and the gymnasts page (`--surface`) and their shadow. + */ +export const LIST_CARD = 'bg-(--surface) shadow-(--box-shadow)' + +/** + * The hover of a row in such a card, as on the gymnasts page: the card gets a tint of the + * text color. It is the same mix as there (8 %, which keeps the text above 4.5:1 in both + * themes, see gymnastTheme.test.ts). The pointer is for the rows that can be pressed. + */ +export const ROW_HOVER = + 'transition-[background-color] duration-150 hover:bg-[color-mix(in_srgb,var(--surface),var(--text-color)_8%)] motion-reduce:transition-none' diff --git a/src/components/gymnasts/useElementRemoval.ts b/src/components/gymnasts/useElementRemoval.ts new file mode 100644 index 0000000..c338f3e --- /dev/null +++ b/src/components/gymnasts/useElementRemoval.ts @@ -0,0 +1,76 @@ +import { useEffect, useRef, useState } from 'react' +import { useMutation } from '@apollo/client/react' +import { describeError } from '@/lib/apollo/errors' +import { elementsHeadingId, type ElementView } from '@/lib/gymnastDetail' +import { REMOVE_GYMNAST_ELEMENT_MUTATION } from '@/lib/gymnastDetailOperations' + +/** + * Removing an element from the list of a gymnast: the question "are you sure?" (the page + * shows it with `ConfirmDialog`), the removal, what is told to the visitor, and where the + * focus goes. The answer of the server updates the Apollo cache, so the list on the page is + * the new list without a second request. + */ +export function useElementRemoval(gymnastId: string) { + const [removeElement] = useMutation(REMOVE_GYMNAST_ELEMENT_MUTATION) + // The element the visitor is asked about, and whether the question is open. The element is + // kept after the question closes, so the text of it does not change while it closes. + const [pending, setPending] = useState(null) + const [asking, setAsking] = useState(false) + // Said to a screen reader (a status region) when an element has been removed. + const [announcement, setAnnouncement] = useState('') + const [error, setError] = useState('') + // True when the last removal worked: the button that was pressed is gone with its row, so + // the focus goes to the heading of the list, which is where the visitor was. + const removedLast = useRef(false) + // The button that opened the question, which gets the focus back when the answer is no. + // Not every browser focuses a button when it is clicked (Safari does not), so it is kept. + const opener = useRef(null) + + // After a removal the question has closed and the button is gone. The dialog is told to + // leave the focus alone (`finalFocus`), and it is moved here, when the dialog is gone. + // The heading is found by its id: the page has a list for each apparatus, and the one that + // was left is still there for a moment, so one ref for all of them would end up empty. + useEffect(() => { + if (!asking && removedLast.current && pending) { + removedLast.current = false + document.getElementById(elementsHeadingId(pending.apparatusId))?.focus() + } + }, [asking, pending]) + + function ask(element: ElementView, button: HTMLElement) { + opener.current = button + setPending(element) + setAsking(true) + } + + /** Removes the element that was asked about. On a failure it keeps the list and says why. */ + async function remove(): Promise { + if (!pending) return + setError('') + setAnnouncement('') + removedLast.current = false + try { + await removeElement({ variables: { gymnastId, itemId: pending.id } }) + } catch (problem) { + setError(describeError(problem).message) + return + } + removedLast.current = true + setAnnouncement(`${pending.code} removed from the list.`) + } + + /** Where the dialog gives the focus when it closes: nowhere after a removal (see above), else back. */ + const finalFocus = () => + removedLast.current ? false : (opener.current ?? true) + + return { + pending, + asking, + setAsking, + announcement, + error, + ask, + remove, + finalFocus, + } +} diff --git a/src/pages/GymnastPage.tsx b/src/pages/GymnastPage.tsx new file mode 100644 index 0000000..5b2a0e9 --- /dev/null +++ b/src/pages/GymnastPage.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react' +import { useQuery } from '@apollo/client/react' +import { useParams } from 'react-router-dom' +import ConfirmDialog from '@/components/ConfirmDialog' +import ApparatusTabs from '@/components/gymnasts/ApparatusTabs' +import GymnastElementList from '@/components/gymnasts/GymnastElementList' +import GymnastHeader from '@/components/gymnasts/GymnastHeader' +import { + GYMNAST_PAGE, + GymnastLoading, + GymnastNotFound, + GymnastUnavailable, +} from '@/components/gymnasts/GymnastPageStates' +import RoutineList from '@/components/gymnasts/RoutineList' +import { useElementRemoval } from '@/components/gymnasts/useElementRemoval' +import PageTitle from '@/components/PageTitle' +import { describeError } from '@/lib/apollo/errors' +import { STRUCTURE_QUERY } from '@/lib/elementFilters' +import { + buildGymnastView, + defaultApparatusId, + elementLabel, +} from '@/lib/gymnastDetail' +import { GYMNAST_DETAIL_QUERY } from '@/lib/gymnastDetailOperations' + +/** One gymnast of the logged-in coach: the routines and the list of elements, one apparatus at a time. */ +export default function GymnastPage() { + const { gymnastId = '' } = useParams() + // The gymnast belongs to the coach and can change in other places, so what is in the + // cache is shown at once and the server is asked in the same moment. + const detail = useQuery(GYMNAST_DETAIL_QUERY, { + variables: { id: gymnastId }, + fetchPolicy: 'cache-and-network', + }) + // The four apparatus in the order of the API: a few rows that are kept for the whole visit. + const structure = useQuery(STRUCTURE_QUERY) + const removal = useElementRemoval(gymnastId) + // The apparatus the visitor chose; until then, the first one that has something. + const [chosen, setChosen] = useState(null) + + const gymnast = detail.data?.gymnast + const apparatus = structure.data?.apparatus + const problem = + detail.error || structure.error + ? describeError(detail.error ?? structure.error) + : null + + if (problem?.code === 'NOT_FOUND' || (detail.data && gymnast === null)) { + return + } + if (problem) { + return ( + + void Promise.all([detail.refetch(), structure.refetch()]).catch( + () => undefined, + ) + } + /> + ) + } + if (!gymnast || !apparatus) return + + const view = buildGymnastView(gymnast, apparatus) + + return ( +
    + {view.name} + {/* React 19 moves a to the head of the document. Only this page has the description. */} + + {/* Says what happened when an element is removed, without moving the focus there. */} +

    + {removal.announcement} +

    + + {removal.error && ( +

    + {removal.error} +

    + )} +
    + ( +
    + + +
    + )} + /> +
    + + + {removal.pending.code} {elementLabel(removal.pending)} + + + It is removed from the list of elements. Routines are not + changed. + + + ) + } + confirmLabel="Yes, remove" + busyLabel="Removing…" + onConfirm={removal.remove} + finalFocus={removal.finalFocus} + /> +
    + ) +} diff --git a/src/styles/global.css b/src/styles/global.css index 7feca8b..dbee06d 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -9,9 +9,11 @@ border-inline: 0; } -/* The titles of the Elements, gymnasts and login pages are the same: the size +/* The titles of the Elements, gymnasts, gymnast and login pages are the same: the size of h1 comes from index.css (56px, 36px on a narrow window) for all of them. */ -#root :is(.elements-page, .your-gymnasts-page, .login-page) :is(h1, h2) { +#root + :is(.elements-page, .your-gymnasts-page, .gymnast-page, .login-page) + :is(h1, h2) { color: var(--text-color); font-weight: 700; letter-spacing: -0.01em; @@ -56,6 +58,30 @@ color: var(--pill-text); } +/* The same two changes for the difficulty badge in the list of elements of the Gymnast + page, only in dark mode: the value under the letter is dark navy and cannot be read on + the dark card, and the circle gets the light color of the pills. The badge itself is not + changed. */ +[data-theme='dark'] + #root + .gymnast-page + li + .inline-flex.shrink-0.flex-col + > span + + span { + color: var(--text-color); +} + +[data-theme='dark'] + #root + .gymnast-page + li + .inline-flex.shrink-0.flex-col + > span:first-child { + background-color: var(--pill-bg); + color: var(--pill-text); +} + /* The X that clears the search field (Chrome, Edge and Safari draw one in a color that cannot be seen on our field). It is drawn here in the color of the search icon (--text-color), in both themes, with a 24 px target around a 16 px X. */ @@ -83,7 +109,7 @@ margin-bottom: 0.75rem; } -#root .your-gymnasts-page h2 { +#root :is(.your-gymnasts-page, .gymnast-page) h2 { font-size: clamp(1.375rem, 4vw, 1.75rem); line-height: 1.2; margin: 0; diff --git a/src/tests/App.test.tsx b/src/tests/App.test.tsx index cd9fbd2..73ccfc4 100644 --- a/src/tests/App.test.tsx +++ b/src/tests/App.test.tsx @@ -136,6 +136,18 @@ describe('App', () => { expect(screen.getByRole('link', { name: 'Log in' })).toBeTruthy() }) + it('sends a visitor who is not logged in from the page of one gymnast to the login page', async () => { + window.history.replaceState( + {}, + '', + `${import.meta.env.BASE_URL}gymnasts/gymnast-1`, + ) + renderApp(() => ok({ me: null })) + + expect(await screen.findByRole('heading', { name: 'Log in' })).toBeTruthy() + expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}login`) + }) + it('keeps a logged-in coach on the Gymnasts route after a reload, and the menu agrees with the page', async () => { window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`) renderApp((operation) => diff --git a/src/tests/pages/GymnastPage.test.tsx b/src/tests/pages/GymnastPage.test.tsx new file mode 100644 index 0000000..f81362f --- /dev/null +++ b/src/tests/pages/GymnastPage.test.tsx @@ -0,0 +1,486 @@ +// @vitest-environment jsdom +// The Gymnast page runs on the real Apollo Client, with a stub for `fetch` (see +// src/tests/apollo.ts). The stub answers like the server does. + +import { ApolloProvider } from '@apollo/client/react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { afterEach, describe, expect, it } from 'vitest' +import { AuthProvider } from '@/components/auth/AuthProvider' +import GymnastPage from '@/pages/GymnastPage' +import { + createTestClient, + deferred, + failure, + ok, + type Answer, + type Handler, +} from '@/tests/apollo' +import { + exampleGymnast, + gymnastAnswer, + structureAnswer, + type ServerGymnast, +} from '@/tests/gymnastDetail' + +/** + * The page inside the same providers as in the app. `Me` (who is logged in) and the + * structure are answered here; `detail` answers `GymnastDetail`, `others` the rest. + */ +function renderPage( + detail: () => Answer | Promise = () => gymnastAnswer(), + others: Handler = () => failure('INTERNAL_SERVER_ERROR', 'unexpected'), + address = '/gymnasts/gymnast-1', +) { + const handler: Handler = (operation) => { + switch (operation.operationName) { + case 'Me': + return ok({ + me: { + __typename: 'Coach', + id: 'coach-1', + name: 'Anna', + email: 'anna@example.com', + }, + }) + case 'CatalogStructure': + return structureAnswer() + case 'GymnastDetail': + return detail() + default: + return others(operation) + } + } + const { client, calls } = createTestClient(handler) + render( + + + + + } /> + + + + , + ) + return { + calls, + removed: () => + calls.filter((c) => c.operationName === 'RemoveGymnastElement'), + } +} + +const tab = (name: RegExp | string) => screen.getByRole('tab', { name }) +const panel = () => screen.getByRole('tabpanel') +const routinesList = () => + within(panel()).getByRole('list', { name: 'Routines' }) +const elementsList = () => + within(panel()).getByRole('list', { name: 'Element list' }) + +afterEach(cleanup) + +describe('GymnastPage: loading', () => { + it('says that it is loading, and has a title for it (WCAG 2.4.2)', async () => { + const answer = deferred() + renderPage(() => answer.promise) + + expect(screen.getByRole('status').textContent).toContain('Loading') + expect(document.title).toBe('Loading gymnast – WAG CoP Planner') + answer.resolve(gymnastAnswer()) + expect( + await screen.findByRole('heading', { level: 1, name: 'Ingrid Nilsen' }), + ).toBeTruthy() + }) + + it('asks the server once for the gymnast and once for the apparatus', async () => { + const page = renderPage() + await screen.findByRole('heading', { level: 1 }) + + const names = page.calls.map((c) => c.operationName).sort() + expect(names.filter((n) => n === 'GymnastDetail')).toHaveLength(1) + expect(names.filter((n) => n === 'CatalogStructure')).toHaveLength(1) + expect( + page.calls.find((c) => c.operationName === 'GymnastDetail')?.variables, + ).toEqual({ + id: 'gymnast-1', + }) + }) +}) + +describe('GymnastPage: the header', () => { + it('has the name as the one h1 and the name in the title', async () => { + renderPage() + + const heading = await screen.findByRole('heading', { level: 1 }) + expect(heading.textContent).toBe('Ingrid Nilsen') + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1) + expect(document.title).toBe('Ingrid Nilsen – WAG CoP Planner') + }) + + it('has a description for search engines (the page is checked by Lighthouse)', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const meta = document.head.querySelector('meta[name="description"]') + expect(meta?.getAttribute('content')).toMatch(/routines/) + }) + + it('has a link back to all gymnasts', async () => { + renderPage() + + const link = await screen.findByRole('link', { name: 'All gymnasts' }) + expect(link.getAttribute('href')).toBe('/gymnasts') + }) +}) + +describe('GymnastPage: the apparatus', () => { + it('has a tab for each apparatus with only its name', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const tabs = within( + screen.getByRole('tablist', { name: 'Apparatus' }), + ).getAllByRole('tab') + expect(tabs.map((t) => t.textContent?.replace(/\s+/g, ' ').trim())).toEqual( + ['Vault', 'Uneven bars', 'Balance beam', 'Floor exercise'], + ) + }) + + it('opens the first apparatus that has a routine', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + expect(tab(/^Uneven bars/).getAttribute('aria-selected')).toBe('true') + expect(tab(/^Vault/).getAttribute('aria-selected')).toBe('false') + expect( + within(panel()) + .getAllByRole('heading', { level: 2 }) + .map((h) => h.textContent), + ).toEqual(['Routines', 'Element list']) + }) + + it('shows the routines and the elements of the chosen apparatus, and changes with the tab', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + expect(within(routinesList()).getAllByRole('listitem')).toHaveLength(3) + expect(within(elementsList()).getAllByRole('listitem')).toHaveLength(2) + + fireEvent.click(tab(/^Balance beam/)) + + await waitFor(() => + expect(within(routinesList()).getAllByRole('listitem')).toHaveLength(1), + ) + expect(within(routinesList()).getByText('Beam set')).toBeTruthy() + expect(within(elementsList()).getAllByRole('listitem')).toHaveLength(1) + }) + + it('can be used with the arrow keys, Home and End', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const ub = tab(/^Uneven bars/) + ub.focus() + fireEvent.keyDown(ub, { key: 'ArrowRight' }) + // The tab takes the focus, and its panel is shown with it (the tabs follow the focus). + await waitFor(() => + expect(document.activeElement).toBe(tab(/^Balance beam/)), + ) + await waitFor(() => + expect(tab(/^Balance beam/).getAttribute('aria-selected')).toBe('true'), + ) + + fireEvent.keyDown(document.activeElement!, { key: 'End' }) + await waitFor(() => + expect(document.activeElement).toBe(tab(/^Floor exercise/)), + ) + fireEvent.keyDown(document.activeElement!, { key: 'Home' }) + await waitFor(() => expect(document.activeElement).toBe(tab(/^Vault/))) + }) + + it('says that there is nothing yet, for an apparatus with no routines and no elements', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + fireEvent.click(tab(/^Floor exercise/)) + + expect( + await screen.findByText('No routines on Floor exercise yet.'), + ).toBeTruthy() + expect( + screen.getByText('No elements on Floor exercise in the list yet.'), + ).toBeTruthy() + }) +}) + +describe('GymnastPage: the routines', () => { + it('shows the name, the size, the date of the last change and the score', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const rows = within(routinesList()).getAllByRole('listitem') + expect(rows[0].textContent).toContain('Upgrade with Ricna') + expect(rows[0].textContent).toContain('9 elements · Updated 27 Sep') + expect(rows[0].textContent).toContain('3.4') + expect(rows[1].textContent).toContain('Competition 1') + expect(rows[2].textContent).toContain('Safe set') + }) + + it('marks the highest routine with text, and only that one', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const rows = within(routinesList()).getAllByRole('listitem') + expect(within(rows[0]).getByText('Highest')).toBeTruthy() + expect(within(rows[1]).queryByText('Highest')).toBeNull() + expect(within(rows[2]).queryByText('Highest')).toBeNull() + }) + + it('reads the score with a word before it, for a screen reader', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const first = within(routinesList()).getAllByRole('listitem')[0] + expect(first.textContent).toContain('D-score 3.4') + }) + + it('says that a routine without a score has an element that is gone, and does not show 0', async () => { + const gymnast: ServerGymnast = exampleGymnast() + gymnast.routines = [ + { + ...gymnast.routines[0], + dScore: null, + items: [{ ...gymnast.routines[0].items[0], removed: true }], + }, + ] + renderPage(() => gymnastAnswer(gymnast)) + await screen.findByRole('heading', { level: 1 }) + + const row = within(routinesList()).getAllByRole('listitem')[0] + expect(row.textContent).toMatch(/no d-score/i) + expect(row.textContent).toMatch(/no longer in the Code of Points/i) + expect(row.textContent).not.toContain('0.0') + }) +}) + +describe('GymnastPage: the list of elements', () => { + it('shows the code, the name or the description, the difficulty and a link to the element', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const rows = within(elementsList()).getAllByRole('listitem') + const first = within(rows[0]).getByRole('link', { + name: /^1\.302 Glide kip, cast to handstand with 1\/2 turn/, + }) + expect(first.getAttribute('href')).toBe('/elements/UB-1.302-a') + expect(rows[0].textContent).toContain('Difficulty C') + // An element with a name is shown by its name. + expect( + within(rows[1]).getByRole('link', { name: /^4\.504 Ricna/ }), + ).toBeTruthy() + }) + + it('warns about an element that is no longer in the Code of Points', async () => { + const gymnast = exampleGymnast() + gymnast.elements[0] = { ...gymnast.elements[0], removed: true } + renderPage(() => gymnastAnswer(gymnast)) + await screen.findByRole('heading', { level: 1 }) + + const first = within(elementsList()).getAllByRole('listitem')[0] + expect(first.textContent).toMatch(/no longer in the Code of Points/i) + }) +}) + +describe('GymnastPage: removing an element', () => { + const removeAnswer = (gymnast: ServerGymnast, itemId: string): Answer => + ok({ + removeGymnastElement: { + ...gymnast, + elements: gymnast.elements.filter((e) => e.id !== itemId), + }, + }) + + const removeButton = (name: RegExp | string) => + screen.getByRole('button', { name }) + + it('has a button for each element that says which element it removes', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + expect(removeButton(/^Remove 1\.302 Glide kip/)).toBeTruthy() + expect(removeButton(/^Remove 4\.504 Ricna/)).toBeTruthy() + }) + + it('asks before it removes, and changes nothing when the answer is no', async () => { + const page = renderPage() + await screen.findByRole('heading', { level: 1 }) + + fireEvent.click(removeButton(/^Remove 1\.302/)) + const dialog = await screen.findByRole('alertdialog') + expect(dialog.textContent).toContain('1.302') + expect(page.removed()).toHaveLength(0) + + fireEvent.click(screen.getByRole('button', { name: 'No, keep it' })) + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + expect(page.removed()).toHaveLength(0) + expect(within(elementsList()).getAllByRole('listitem')).toHaveLength(2) + }) + + it('gives the focus back to the button that was pressed when the answer is no', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + const button = removeButton(/^Remove 1.302/) + + fireEvent.click(button) + await screen.findByRole('alertdialog') + fireEvent.click(screen.getByRole('button', { name: 'No, keep it' })) + + await waitFor(() => expect(document.activeElement).toBe(button)) + }) + + it('removes the element, shows the new list, says so and moves the focus to the heading of the list', async () => { + const gymnast = exampleGymnast() + const page = renderPage( + () => gymnastAnswer(gymnast), + () => removeAnswer(gymnast, 'UB-1.302-a'), + ) + await screen.findByRole('heading', { level: 1 }) + + fireEvent.click(removeButton(/^Remove 1\.302/)) + await screen.findByRole('alertdialog') + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + + await waitFor(() => + expect(within(elementsList()).getAllByRole('listitem')).toHaveLength(1), + ) + expect(page.removed()[0].variables).toEqual({ + gymnastId: 'gymnast-1', + itemId: 'UB-1.302-a', + }) + expect(screen.getByRole('status').textContent).toContain( + '1.302 removed from the list.', + ) + expect(screen.queryByRole('alertdialog')).toBeNull() + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('heading', { level: 2, name: 'Element list' }), + ), + ) + }) + + it('sends one request even if it is confirmed twice quickly', async () => { + const gymnast = exampleGymnast() + const answer = deferred() + const page = renderPage( + () => gymnastAnswer(gymnast), + () => answer.promise, + ) + await screen.findByRole('heading', { level: 1 }) + fireEvent.click(removeButton(/^Remove 1\.302/)) + await screen.findByRole('alertdialog') + + const confirm = screen.getByRole('button', { name: 'Yes, remove' }) + fireEvent.click(confirm) + fireEvent.click(confirm) + + await waitFor(() => expect(page.removed()).toHaveLength(1)) + answer.resolve(removeAnswer(gymnast, 'UB-1.302-a')) + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + expect(page.removed()).toHaveLength(1) + }) + + it('keeps the element and tells why when the server says no', async () => { + renderPage( + () => gymnastAnswer(), + () => failure('INTERNAL_SERVER_ERROR', 'boom'), + ) + await screen.findByRole('heading', { level: 1 }) + + fireEvent.click(removeButton(/^Remove 1\.302/)) + await screen.findByRole('alertdialog') + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + + const alert = await screen.findByRole('alert') + expect(alert.textContent).toContain('Something went wrong on our side') + expect(within(elementsList()).getAllByRole('listitem')).toHaveLength(2) + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + }) +}) + +describe('GymnastPage: when it cannot show the gymnast', () => { + it('says that the gymnast was not found, with a link to the gymnasts', async () => { + renderPage(() => failure('NOT_FOUND', 'No such gymnast')) + + expect( + await screen.findByRole('heading', { + level: 1, + name: 'Gymnast not found', + }), + ).toBeTruthy() + expect(screen.getByRole('alert').textContent).toContain( + 'No gymnast of yours', + ) + expect( + screen + .getByRole('link', { name: 'Back to your gymnasts' }) + .getAttribute('href'), + ).toBe('/gymnasts') + expect(document.title).toBe('Gymnast not found – WAG CoP Planner') + }) + + it('says that there is no gymnast for a gymnast that is null', async () => { + renderPage(() => ok({ gymnast: null })) + + expect( + await screen.findByRole('heading', { + level: 1, + name: 'Gymnast not found', + }), + ).toBeTruthy() + }) + + it('shows the error and a retry button for an error that can be tried again', async () => { + let attempt = 0 + renderPage(() => { + attempt += 1 + if (attempt === 1) throw new TypeError('Failed to fetch') + return gymnastAnswer() + }) + + expect((await screen.findByRole('alert')).textContent).toContain( + 'Could not reach the server', + ) + expect(document.title).toBe('Gymnast unavailable – WAG CoP Planner') + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + expect( + await screen.findByRole('heading', { level: 1, name: 'Ingrid Nilsen' }), + ).toBeTruthy() + }) +}) + +describe('GymnastPage: structure for a screen reader', () => { + it('has the headings in order: h1, then h2 for the routines and the list, and no level skipped', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const levels = screen + .getAllByRole('heading') + .map((h) => Number(h.tagName[1])) + expect(levels).toEqual([1, 2, 2]) + }) + + it('has one main landmark', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + expect(screen.getAllByRole('main')).toHaveLength(1) + }) +}) diff --git a/src/tests/styles/gymnastTheme.test.ts b/src/tests/styles/gymnastTheme.test.ts index 983d389..3806438 100644 --- a/src/tests/styles/gymnastTheme.test.ts +++ b/src/tests/styles/gymnastTheme.test.ts @@ -206,6 +206,7 @@ describe('the components of the gymnasts and login pages', () => { // The two pages (src/pages/) and the components they use. const files = [ '../../pages/YourGymnastsPage.tsx', + '../../pages/GymnastPage.tsx', '../../pages/LoginPage.tsx', ...['../../components/gymnasts/', '../../components/auth/'].flatMap((dir) => readdirSync(here(dir)) From 798ad467d8d1d35b39ab3cd7611bfdd3b6b4126e Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 11:56:00 +0100 Subject: [PATCH 05/11] feat(gymnasts): link each gymnast card to the gymnast page The name is a link inside the heading, and its ::after covers the whole card. The delete button stays above it. Refs: #149 --- src/components/gymnasts/GymnastRow.tsx | 26 +++++++++++++----- .../components/gymnasts/GymnastList.test.tsx | 27 +++++++++++++++---- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/components/gymnasts/GymnastRow.tsx b/src/components/gymnasts/GymnastRow.tsx index 8b8b26f..e13a62d 100644 --- a/src/components/gymnasts/GymnastRow.tsx +++ b/src/components/gymnasts/GymnastRow.tsx @@ -1,3 +1,4 @@ +import { Link } from 'react-router-dom' import DeleteGymnastButton from '@/components/gymnasts/DeleteGymnastButton' import type { GymnastSummary } from '@/lib/gymnasts' import { getGymnastInitials } from '@/lib/gymnasts' @@ -12,7 +13,7 @@ export default function GymnastRow({ onFailed: (message: string) => void }) { return ( -
  • +
  • -

    {gymnast.name}

    - +

    + {/* The link covers the whole card (the ::after), as the pointer and the hover + suggest, so it can be pressed anywhere on it. The delete button is above it. */} + + {gymnast.name} + +

    +
    + +
  • ) diff --git a/src/tests/components/gymnasts/GymnastList.test.tsx b/src/tests/components/gymnasts/GymnastList.test.tsx index 1b59017..2975788 100644 --- a/src/tests/components/gymnasts/GymnastList.test.tsx +++ b/src/tests/components/gymnasts/GymnastList.test.tsx @@ -1,5 +1,6 @@ import { ApolloProvider } from '@apollo/client/react' import { renderToString } from 'react-dom/server' +import { MemoryRouter } from 'react-router-dom' import { describe, expect, it } from 'vitest' import { createTestClient } from '@/tests/apollo' import GymnastList from '@/components/gymnasts/GymnastList' @@ -9,11 +10,13 @@ const { client } = createTestClient(() => new Promise(() => {})) const renderList = (gymnasts: { id: string; name: string }[]) => renderToString( - {}} - onFailed={() => {}} - /> + + {}} + onFailed={() => {}} + /> + , ) @@ -30,6 +33,20 @@ describe('GymnastList', () => { expect(markup).toContain('EB') }) + it('links the name of each gymnast to the page of that gymnast, inside the heading of the card', () => { + const markup = renderList([ + { id: 'one', name: 'Ingrid Haugen' }, + { id: 'two', name: 'Emma Berg' }, + ]) + + expect(markup).toMatch( + /]*>]*href="\/gymnasts\/one"[^>]*>Ingrid Haugen<\/a><\/h2>/, + ) + expect(markup).toMatch( + /]*>]*href="\/gymnasts\/two"[^>]*>Emma Berg<\/a><\/h2>/, + ) + }) + it('has a heading for the list', () => { const markup = renderList([]) From 7d1ea4a154fb7b6d22cd2b7165eb365e74e2f39f Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 11:56:00 +0100 Subject: [PATCH 06/11] docs: document the AI use for the gymnast page --- docs/AI_USAGE.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index f278aa0..b8da276 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -532,3 +532,11 @@ - **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/lib/gymnastDetail*.ts`) + +- **Tool:** Claude Code +- **What the team did:** Wrote issue #149 and the mockup (`docs/design/gymnastDetail.png`), and decided the scope: the apparatus tabs (only the name of the apparatus), the routines and the list of elements of one apparatus, and removing an element from the list. "New routine", "Add from search", editing the name and deleting a routine belong to other issues. Decided that no other page is changed and that no new colors are made. +- **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). The question "are you sure?" is one shared component (`ConfirmDialog`, made from the dialog of `DeleteGymnastButton`), which the delete button and the removal of an element both use. +- **Accessibility check (WCAG 2.2 AA):** axe-core in light and dark mode, on every apparatus, with the dialog open, at 320 px and with text spacing; keyboard-only use and focus order in Chromium; target sizes; and Lighthouse on a production build. The checks found three problems that were fixed: 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). A screen reader was not used: that has to be done by a person. +- **What was not done by Claude:** the choices of scope, the wording and which findings to fix are the team's. Claude committed the work and opened the pull request as a draft when asked; it is not merged or approved. From 252dbdc662934d342233e94a9f96000afbbe71f3 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 17:11:17 +0100 Subject: [PATCH 07/11] feat(gymnasts): polish the gymnast page and share its buttons and errors Add New routine and Add from search buttons, a shared TrashButton, ListSection and StretchedLink, LinkButton (a real link with the pill look) and Button icon. Errors are in three layers: ErrorMessage, ErrorPanel and ErrorPage. Fix the layout on phones, the dark hover color and the Highest label. Refs: #149 --- src/components/ErrorMessage.tsx | 36 ++++++++ src/components/ErrorPage.tsx | 38 +++++++++ src/components/ErrorPanel.tsx | 49 +++++++++++ src/components/gymnasts/AddButton.tsx | 27 ++++++ src/components/gymnasts/ApparatusTabs.tsx | 2 +- .../gymnasts/DeleteGymnastButton.tsx | 20 ++--- .../gymnasts/GymnastElementList.tsx | 83 +++++++++---------- src/components/gymnasts/GymnastList.tsx | 2 +- src/components/gymnasts/GymnastPageStates.tsx | 70 +++++----------- src/components/gymnasts/GymnastRow.tsx | 18 ++-- src/components/gymnasts/ListSection.tsx | 56 +++++++++++++ src/components/gymnasts/RoutineList.tsx | 51 +++++++----- src/components/gymnasts/StretchedLink.tsx | 19 +++++ src/components/gymnasts/TrashButton.tsx | 41 +++++++++ src/components/gymnasts/listCard.ts | 12 +-- src/components/ui/LinkButton.tsx | 35 ++++++++ src/components/ui/button.tsx | 21 ++++- src/lib/gymnastDetail.ts | 19 +++++ src/lib/gymnastDetailOperations.ts | 21 +++++ src/pages/GymnastPage.tsx | 34 ++++---- src/pages/YourGymnastsPage.tsx | 2 +- src/styles/global.css | 26 +----- src/styles/variables.css | 11 +++ src/tests/components/errors.test.tsx | 76 +++++++++++++++++ .../components/gymnasts/AddButton.test.tsx | 24 ++++++ .../gymnasts/DeleteGymnastButton.test.tsx | 9 -- .../components/gymnasts/GymnastList.test.tsx | 3 - .../components/gymnasts/ListSection.test.tsx | 63 ++++++++++++++ .../components/gymnasts/TrashButton.test.tsx | 32 +++++++ src/tests/components/ui/button.test.tsx | 15 +++- src/tests/lib/gymnastDetail.test.ts | 22 +++++ src/tests/pages/GymnastPage.test.tsx | 24 +++++- src/tests/styles/gymnastTheme.test.ts | 36 ++------ 33 files changed, 758 insertions(+), 239 deletions(-) create mode 100644 src/components/ErrorMessage.tsx create mode 100644 src/components/ErrorPage.tsx create mode 100644 src/components/ErrorPanel.tsx create mode 100644 src/components/gymnasts/AddButton.tsx create mode 100644 src/components/gymnasts/ListSection.tsx create mode 100644 src/components/gymnasts/StretchedLink.tsx create mode 100644 src/components/gymnasts/TrashButton.tsx create mode 100644 src/components/ui/LinkButton.tsx create mode 100644 src/tests/components/errors.test.tsx create mode 100644 src/tests/components/gymnasts/AddButton.test.tsx create mode 100644 src/tests/components/gymnasts/ListSection.test.tsx create mode 100644 src/tests/components/gymnasts/TrashButton.test.tsx 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/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 index b323545..589d90c 100644 --- a/src/components/gymnasts/ApparatusTabs.tsx +++ b/src/components/gymnasts/ApparatusTabs.tsx @@ -32,7 +32,7 @@ export default function ApparatusTabs({ variant="line" aria-label="Apparatus" activateOnFocus - className="h-auto w-full flex-wrap group-data-[orientation=horizontal]/tabs:h-auto justify-start gap-1 border-b border-(--box-stroke) p-0 text-(--text-color)" + className="grid h-auto w-full grid-cols-2 sm:flex sm:flex-wrap group-data-[orientation=horizontal]/tabs:h-auto justify-start gap-1 border-b border-(--box-stroke) p-0 text-(--text-color)" > {apparatus.map((one) => ( - + className="ml-auto" + />
    - - {element.code}{' '} + {/* 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 && (

    - + className="relative z-10" + /> ) } /** * The elements the gymnast can do or is practicing on one apparatus, in the order they were - * added. 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. + * 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 }) { - const headingId = elementsHeadingId(apparatusId) return ( -

    -

    - Element list -

    - {elements.length === 0 ? ( -

    No elements on {apparatusName} in the list yet.

    - ) : ( -
      - {elements.map((element) => ( - - ))} -
    + ( + )} -
    + action={ + Add from search + } + /> ) } 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.

    ) : ( -
      +
        {gymnasts.map((gymnast) => ( - Gymnast not found -

        Gymnast not found

        -

        - No gymnast of yours matches this address. -

        - -
    + Back to your gymnasts} + /> ) } @@ -40,38 +31,23 @@ export function GymnastUnavailable({ onRetry: () => void }) { return ( -
    - Gymnast unavailable -

    Gymnast unavailable

    -

    - {problem.message} -

    - {problem.code === 'UNAUTHENTICATED' ? ( - - ) : ( - problem.retryable && ( - + Log in + ) - )} -
    + } + /> ) } diff --git a/src/components/gymnasts/GymnastRow.tsx b/src/components/gymnasts/GymnastRow.tsx index e13a62d..44d0f4a 100644 --- a/src/components/gymnasts/GymnastRow.tsx +++ b/src/components/gymnasts/GymnastRow.tsx @@ -1,5 +1,6 @@ -import { Link } from 'react-router-dom' import DeleteGymnastButton from '@/components/gymnasts/DeleteGymnastButton' +import { CARD_LIFT, LIST_CARD, ROW_HOVER } from '@/components/gymnasts/listCard' +import StretchedLink from '@/components/gymnasts/StretchedLink' import type { GymnastSummary } from '@/lib/gymnasts' import { getGymnastInitials } from '@/lib/gymnasts' @@ -13,7 +14,9 @@ export default function GymnastRow({ onFailed: (message: string) => void }) { return ( -
  • +
  • -

    - {/* The link covers the whole card (the ::after), as the pointer and the hover - suggest, so it can be pressed anywhere on it. The delete button is above it. */} - +

    + {gymnast.name} - +

    `; what is common is here, so the + * structure that a screen reader reads (a named section with a named list) is made in one + * place. + * + * The heading can get the focus from the code (`tabIndex={-1}`, so it is not in the tab order), + * which the page uses after an element has been removed and the button that was pressed is gone. + */ +export default function ListSection({ + headingId, + title, + items, + emptyText, + renderRow, + action, +}: { + /** The id of the heading. It names the section and the list, and it can be looked up to move the focus. */ + headingId: string + title: string + items: T[] + /** What is said when `items` is empty. */ + emptyText: string + /** One `
  • ` for one item. The key is set here. */ + renderRow: (item: T) => ReactNode + /** Shown to the right of the heading. */ + action?: ReactNode +}) { + return ( +
    +
    +

    + {title} +

    + {action} +
    + {items.length === 0 ? ( +

    {emptyText}

    + ) : ( +
      + {items.map((item) => ( + {renderRow(item)} + ))} +
    + )} +
    + ) +} diff --git a/src/components/gymnasts/RoutineList.tsx b/src/components/gymnasts/RoutineList.tsx index f459ce2..5796dd3 100644 --- a/src/components/gymnasts/RoutineList.tsx +++ b/src/components/gymnasts/RoutineList.tsx @@ -1,8 +1,19 @@ -import { LIST_CARD, ROW_HOVER } from '@/components/gymnasts/listCard' +import AddButton from '@/components/gymnasts/AddButton' +import ListSection from '@/components/gymnasts/ListSection' +import { ROW_HOVER } from '@/components/gymnasts/listCard' import { TriangleAlert } from 'lucide-react' -import { countText, formatUpdated, type RoutineView } from '@/lib/gymnastDetail' +import { + countText, + formatUpdated, + newRoutineHref, + type RoutineView, +} from '@/lib/gymnastDetail' -/** One routine: its name, how big it is, when it changed and its D-score. */ +/** + * One routine: its name, how big it is, when it changed and its D-score. The "Highest" label has + * the color of the buttons of the site (`--button-bg`, `--button-text`). In light mode that is the + * color of the card too, so the label has an edge (`--box-stroke`) to be seen against it. + */ function RoutineRow({ routine }: { routine: RoutineView }) { return (
  • {routine.isHighest && ( - + Highest )} @@ -39,7 +50,7 @@ function RoutineRow({ routine }: { routine: RoutineView }) {

    )} -

    +

    {routine.score === null ? ( ) : ( @@ -53,29 +64,27 @@ function RoutineRow({ routine }: { routine: RoutineView }) { ) } -/** The routines of one apparatus, highest score first. */ +/** + * The routines of one apparatus, highest score first, and the "New routine" button, which + * goes to the calculator with the gymnast in the address (`newRoutineHref`). + */ export default function RoutineList({ routines, apparatusName, + gymnastId, }: { routines: RoutineView[] apparatusName: string + gymnastId: string }) { return ( -

    -

    Routines

    - {routines.length === 0 ? ( -

    No routines on {apparatusName} yet.

    - ) : ( -
      - {routines.map((routine) => ( - - ))} -
    - )} -
    + } + action={New routine} + /> ) } diff --git a/src/components/gymnasts/StretchedLink.tsx b/src/components/gymnasts/StretchedLink.tsx new file mode 100644 index 0000000..9eb3ba4 --- /dev/null +++ b/src/components/gymnasts/StretchedLink.tsx @@ -0,0 +1,19 @@ +import { Link, type LinkProps } from 'react-router-dom' + +/** + * A link that covers the whole card or row it is in (its `::after` is stretched over the + * nearest positioned parent), so the card can be pressed anywhere, as the pointer and the + * hover suggest. The parent must be `relative`, and a button inside the card must be + * `relative z-10` to stay above the link. The focus ring is on the link itself. + */ +export default function StretchedLink({ + className = '', + ...props +}: Omit & { className?: string }) { + return ( + + ) +} diff --git a/src/components/gymnasts/TrashButton.tsx b/src/components/gymnasts/TrashButton.tsx new file mode 100644 index 0000000..d0e1cf7 --- /dev/null +++ b/src/components/gymnasts/TrashButton.tsx @@ -0,0 +1,41 @@ +import type { MouseEvent } from 'react' +import { Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' + +/** + * The trash icon button of the gymnasts pages: the one that deletes a gymnast on "Your + * gymnasts" and the one that removes an element on the Gymnast page. Both look and work the + * same, so they are this one component. It only asks (`onClick`); the dialog is the caller's. + * + * The icon is in the text color until the pointer is over it or it is pressed, and then it + * turns red with a tint of the red behind it. Red is the strongest color of the site, so it + * is kept for the moment it matters and a list of many rows is not a list of many red icons. + * `hover:` only applies to a device that can hover (Tailwind 4 wraps it in + * `@media (hover: hover)`), so a phone gets the red from `active:`, while the finger is down. The button is + * 48 by 48 CSS pixels (WCAG 2.5.8 asks for 24, 2.5.5 for 44), and the icon needs 3:1 on the + * card in both themes (checked in gymnastTheme.test.ts). `label` is what a screen reader + * says, so a list of them can be told apart. "Solid": the shared button sets `outline-none`, + * which a width and a color alone do not undo. + */ +export default function TrashButton({ + label, + onClick, + className = '', +}: { + label: string + onClick: (event: MouseEvent) => void + className?: string +}) { + return ( + + ) +} diff --git a/src/components/gymnasts/listCard.ts b/src/components/gymnasts/listCard.ts index dd8fd5c..7d6227b 100644 --- a/src/components/gymnasts/listCard.ts +++ b/src/components/gymnasts/listCard.ts @@ -3,11 +3,13 @@ * the cards on the Elements page and the gymnasts page (`--surface`) and their shadow. */ export const LIST_CARD = 'bg-(--surface) shadow-(--box-shadow)' - /** - * The hover of a row in such a card, as on the gymnasts page: the card gets a tint of the - * text color. It is the same mix as there (8 %, which keeps the text above 4.5:1 in both - * themes, see gymnastTheme.test.ts). The pointer is for the rows that can be pressed. + * The hover of a row or a card: the color is the theme value `--card-hover` (a tint of the text + * color in light mode, a deeper navy in dark mode, see variables.css), which keeps the text above + * 4.5:1 in both themes (gymnastTheme.test.ts). The pointer is for the rows that can be pressed. + * The shadow is in the transition too, for a card that also gets `CARD_LIFT`. */ export const ROW_HOVER = - 'transition-[background-color] duration-150 hover:bg-[color-mix(in_srgb,var(--surface),var(--text-color)_8%)] motion-reduce:transition-none' + 'transition-[background-color,box-shadow] duration-150 hover:bg-(--card-hover) motion-reduce:transition-none' +/** A card that stands alone (a gymnast on "Your gymnasts") also gets a deeper shadow on hover. */ +export const CARD_LIFT = 'hover:shadow-[0_6px_16px_rgba(3,2,2,0.22)]' diff --git a/src/components/ui/LinkButton.tsx b/src/components/ui/LinkButton.tsx new file mode 100644 index 0000000..593dc19 --- /dev/null +++ b/src/components/ui/LinkButton.tsx @@ -0,0 +1,35 @@ +import { cn } from 'cn' +import type { LucideIcon } from 'lucide-react' +import { Link, type LinkProps } from 'react-router-dom' +import { buttonVariants } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' + +/** + * A link that looks like the pill button of the site, with an optional icon before the text. + * It is a real ``, so a screen reader says "link" for something that goes to another page. + * The Base UI `Button` is not used for this: given a link in `render` it either warns that it is + * not a `) + + expect(screen.getByRole('button', { name: 'Add one' })).toBeTruthy() + }) + + it('has a heading that the code can focus but that is not in the tab order', () => { + renderSection(items) + + const heading = screen.getByRole('heading', { level: 2 }) + expect(heading.getAttribute('tabindex')).toBe('-1') + heading.focus() + expect(document.activeElement).toBe(heading) + }) +}) diff --git a/src/tests/components/gymnasts/TrashButton.test.tsx b/src/tests/components/gymnasts/TrashButton.test.tsx new file mode 100644 index 0000000..677bcf4 --- /dev/null +++ b/src/tests/components/gymnasts/TrashButton.test.tsx @@ -0,0 +1,32 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import TrashButton from '@/components/gymnasts/TrashButton' + +afterEach(cleanup) + +describe('TrashButton', () => { + it('is a button named by its label, with a trash icon that is hidden from a screen reader', () => { + render( {}} />) + + const button = screen.getByRole('button', { name: 'Delete Emma Berg' }) + const icon = button.querySelector('svg') + expect(icon).not.toBeNull() + expect(icon?.getAttribute('aria-hidden')).toBe('true') + }) + + it('calls onClick with the event, so the caller can keep the button that was pressed', () => { + let pressed: HTMLElement | null = null + const onClick = vi.fn((event: { currentTarget: HTMLElement }) => { + pressed = event.currentTarget + }) + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + expect(onClick).toHaveBeenCalledTimes(1) + expect(pressed).toBe(button) + }) +}) diff --git a/src/tests/components/ui/button.test.tsx b/src/tests/components/ui/button.test.tsx index ca49705..96ef7e1 100644 --- a/src/tests/components/ui/button.test.tsx +++ b/src/tests/components/ui/button.test.tsx @@ -19,9 +19,18 @@ describe('Button from shadcn/ui', () => { expect(html).toContain(' { - const html = renderToString() + it('shows the icon of the icon prop before the label, hidden from a screen reader', () => { + const html = renderToString() - expect(html).toContain('border-border') + expect(html.indexOf(' { + const html = renderToString() + + expect(html).not.toContain(' { expect(countText(8, 'element')).toBe('8 elements') }) }) + +describe('newRoutineHref', () => { + it('goes to the calculator with the gymnast in the address', () => { + expect(newRoutineHref('gymnast-1')).toBe('/calculator?gymnast=gymnast-1') + }) + + it('escapes an id that has characters of its own in an address', () => { + expect(newRoutineHref('a&b=c')).toBe('/calculator?gymnast=a%26b%3Dc') + }) +}) + +describe('addFromSearchHref', () => { + it('goes to the search of the elements with the gymnast in the address', () => { + expect(addFromSearchHref('gymnast-1')).toBe('/elements?gymnast=gymnast-1') + }) + + it('escapes an id that has characters of its own in an address', () => { + expect(addFromSearchHref('a&b=c')).toBe('/elements?gymnast=a%26b%3Dc') + }) +}) diff --git a/src/tests/pages/GymnastPage.test.tsx b/src/tests/pages/GymnastPage.test.tsx index f81362f..c8e5ab8 100644 --- a/src/tests/pages/GymnastPage.test.tsx +++ b/src/tests/pages/GymnastPage.test.tsx @@ -324,7 +324,9 @@ describe('GymnastPage: removing an element', () => { fireEvent.click(removeButton(/^Remove 1\.302/)) const dialog = await screen.findByRole('alertdialog') - expect(dialog.textContent).toContain('1.302') + expect(dialog.textContent).toContain( + '1.302 will be removed from Ingrid Nilsen', + ) expect(page.removed()).toHaveLength(0) fireEvent.click(screen.getByRole('button', { name: 'No, keep it' })) @@ -484,3 +486,23 @@ describe('GymnastPage: structure for a screen reader', () => { expect(screen.getAllByRole('main')).toHaveLength(1) }) }) + +describe('GymnastPage: New routine and Add from search', () => { + it('has a "New routine" button that goes to the calculator with the gymnast in the address', async () => { + renderPage() + + const link = await screen.findByRole('link', { name: 'New routine' }) + expect(link.getAttribute('href')).toBe('/calculator?gymnast=gymnast-1') + // A plus before the label, hidden from a screen reader: the name is still "New routine". + const plus = link.querySelector('svg.lucide-plus') + expect(plus?.getAttribute('aria-hidden')).toBe('true') + }) + + it('has an "Add from search" button that goes to the search with the gymnast in the address', async () => { + renderPage() + + const link = await screen.findByRole('link', { name: 'Add from search' }) + expect(link.getAttribute('href')).toBe('/elements?gymnast=gymnast-1') + expect(link.querySelector('svg.lucide-plus')).not.toBeNull() + }) +}) diff --git a/src/tests/styles/gymnastTheme.test.ts b/src/tests/styles/gymnastTheme.test.ts index 3806438..f6a729e 100644 --- a/src/tests/styles/gymnastTheme.test.ts +++ b/src/tests/styles/gymnastTheme.test.ts @@ -248,40 +248,16 @@ describe('the components of the gymnasts and login pages', () => { }) }) -/** The color that `color-mix(in srgb, a, b N%)` gives, as a hex color. */ -const mix = (a: string, b: string, percent: number) => { - const channel = (hex: string, i: number) => - parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) - const part = (i: number) => - Math.round( - channel(a, i) * (1 - percent / 100) + channel(b, i) * (percent / 100), - ) - return ( - '#' + [0, 1, 2].map((i) => part(i).toString(16).padStart(2, '0')).join('') - ) -} - -describe('the card of a gymnast on hover', () => { +describe('the "Highest" label of a routine', () => { it.each(themes)( - 'still has a name that can be read on the tinted card in the %s theme (at least 4.5:1)', + 'can be read in the colors of the buttons in the %s theme (at least 4.5:1)', (_name, block) => { - const hovered = mix( - valueOf(block, '--surface'), - valueOf(block, '--text-color'), - 8, - ) expect( - contrast(valueOf(block, '--text-color'), hovered), + contrast( + valueOf(block, '--button-text'), + valueOf(block, '--button-bg'), + ), ).toBeGreaterThanOrEqual(4.5) }, ) - - it.each(themes)( - 'is different from the card at rest in the %s theme, so the visitor can see it', - (_name, block) => { - const rest = valueOf(block, '--surface') - const hovered = mix(rest, valueOf(block, '--text-color'), 8) - expect(hovered).not.toBe(rest) - }, - ) }) From 61bb9d872f973cce3aad0fed212623fcd21fed18 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 17:11:18 +0100 Subject: [PATCH 08/11] feat(elements): dark mode, working Add to gymnast and themed vault badge The element page follows the theme with readable contrast. Add to gymnast is a menu for a logged-in coach and a link with a lock otherwise. Try in a routine uses Button with an icon, and DifficultyBadge is used for vaults. Refs: #149 --- e2e/element-page.e2e.ts | 139 +++++++ e2e/gymnast-helpers.ts | 251 +++++++++++++ e2e/gymnast-page.e2e.ts | 346 ++++++++++++++++++ playwright.config.ts | 4 + src/components/elements/AddToGymnast.tsx | 164 +++++++++ src/components/elements/ElementActions.tsx | 37 +- .../elements/ElementDetailHeader.tsx | 6 +- src/components/elements/ElementDiagram.tsx | 3 +- .../elements/ElementMetadataCard.tsx | 18 +- src/components/ui/DifficultyBadge.tsx | 4 +- src/components/ui/ElementValueBadge.tsx | 16 - src/components/ui/ElementVariants.tsx | 23 +- src/components/ui/menuStyles.ts | 10 + src/pages/ElementDetailPage.tsx | 24 +- .../components/elements/AddToGymnast.test.tsx | 264 +++++++++++++ .../elements/ElementActions.test.tsx | 73 +++- src/tests/pages/ElementDetailPage.test.tsx | 25 +- 17 files changed, 1307 insertions(+), 100 deletions(-) create mode 100644 e2e/element-page.e2e.ts create mode 100644 e2e/gymnast-helpers.ts create mode 100644 e2e/gymnast-page.e2e.ts create mode 100644 src/components/elements/AddToGymnast.tsx delete mode 100644 src/components/ui/ElementValueBadge.tsx create mode 100644 src/components/ui/menuStyles.ts create mode 100644 src/tests/components/elements/AddToGymnast.test.tsx diff --git a/e2e/element-page.e2e.ts b/e2e/element-page.e2e.ts new file mode 100644 index 0000000..1e9175c --- /dev/null +++ b/e2e/element-page.e2e.ts @@ -0,0 +1,139 @@ +// The page of one element (and of one vault) in a real browser: axe in both modes and on a +// phone, sideways scrolling, and "Add to gymnast", which is a link to the login for a visitor and +// a menu of her gymnasts for a logged-in coach. + +import { expect, test, type Page } from '@playwright/test' +import { + axeViolations, + catalogExamples, + chooseTheme, + createGymnast, + deleteGymnasts, + scrollsSideways, + signIn, + type TestGymnast, + type Theme, +} from './gymnast-helpers.ts' + +const COACH = 'e2e-element-page@example.test' + +let element: { id: string; code: string } +let notInList: { id: string; code: string } +let vaultId: string +let gymnast: TestGymnast + +test.beforeAll(async () => { + const examples = await catalogExamples() + element = examples.inList[0]! + notInList = examples.notInList + vaultId = examples.vaultId + await deleteGymnasts(COACH) + gymnast = await createGymnast(COACH, 'Ingrid Nilsen') +}) + +test.afterAll(async () => { + await deleteGymnasts(COACH) +}) + +async function openPage(page: Page, id: string, theme: Theme = 'light') { + await chooseTheme(page, theme) + await page.goto(`elements/${id}`) + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + await expect( + page.getByRole('region', { name: 'Element metadata' }), + ).toBeVisible() +} + +test.describe('accessibility (axe, WCAG 2.2 A and AA)', () => { + for (const theme of ['light', 'dark'] as const) { + test(`the page of an element in ${theme} mode`, async ({ page }) => { + await openPage(page, element.id, theme) + expect(await axeViolations(page)).toEqual([]) + }) + } + + test('the page of an element on a phone, 320 px wide, in dark mode', async ({ + page, + }) => { + await page.setViewportSize({ width: 320, height: 700 }) + await openPage(page, element.id, 'dark') + expect(await axeViolations(page)).toEqual([]) + expect(await scrollsSideways(page)).toBe(false) + }) + + // A vault has the difficulty badges, which an element does not. Dark mode on a phone is the + // hardest case for them, so one test is enough. + test('the page of a vault, with its badges, on a phone in dark mode', async ({ + page, + }) => { + await page.setViewportSize({ width: 320, height: 700 }) + await openPage(page, vaultId, 'dark') + expect(await axeViolations(page)).toEqual([]) + expect(await scrollsSideways(page)).toBe(false) + }) +}) + +test.describe('"Add to gymnast", not logged in', () => { + test('is a link with a lock to the login page, and not a disabled button', async ({ + page, + }) => { + await openPage(page, element.id) + const link = page.getByRole('link', { name: /^Add to gymnast/ }) + await expect(link).toBeVisible() + await expect(link).toBeEnabled() + + await link.click() + await expect(page).toHaveURL(/\/login$/) + await expect(page.getByRole('heading', { level: 1 })).toContainText( + /log in/i, + ) + }) +}) + +test.describe('"Add to gymnast", logged in', () => { + test('adds the element to the gymnast that is chosen, and it is in her list', async ({ + page, + }) => { + await signIn(page, COACH) + await page.goto(`elements/${notInList.id}`) + await expect( + page.getByRole('region', { name: 'Element metadata' }), + ).toBeVisible() + + await page.getByRole('button', { name: 'Add to gymnast' }).click() + await page.getByRole('menuitem', { name: gymnast.name }).click() + await expect( + page.getByText(`${notInList.code} added to ${gymnast.name}.`), + ).toBeVisible() + + await page.goto(`gymnasts/${gymnast.id}`) + await expect( + page + .getByRole('list', { name: 'Element list' }) + .getByText(notInList.code, { exact: true }), + ).toBeVisible() + }) + + test('opens the menu with the keyboard, and Escape closes it and gives the focus back', async ({ + page, + }) => { + await signIn(page, COACH) + await page.goto(`elements/${element.id}`) + await expect( + page.getByRole('region', { name: 'Element metadata' }), + ).toBeVisible() + + const button = page.getByRole('button', { name: 'Add to gymnast' }) + await expect(button).toBeEnabled() + await button.focus() + await page.keyboard.press('Enter') + await expect(page.getByRole('menu')).toBeVisible() + await expect( + page.getByRole('menuitem', { name: gymnast.name }), + ).toBeVisible() + + await page.keyboard.press('Escape') + await expect(page.getByRole('menu')).toBeHidden() + await expect(button).toBeFocused() + }) +}) diff --git a/e2e/gymnast-helpers.ts b/e2e/gymnast-helpers.ts new file mode 100644 index 0000000..44f2e72 --- /dev/null +++ b/e2e/gymnast-helpers.ts @@ -0,0 +1,251 @@ +// Helpers for the end-to-end tests of the Gymnast page and the element page: a login that +// does not go through the form, test data made through the API, axe, and the colors of the +// page as the browser works them out. + +import AxeBuilder from '@axe-core/playwright' +import type { Page } from '@playwright/test' +import { apiUrl } from './env.ts' + +export type Theme = 'light' | 'dark' + +/** The refresh token is kept under this name in localStorage (`REFRESH_TOKEN_KEY` in src/lib/apollo/session.ts). */ +const REFRESH_TOKEN_KEY = 'cop.refreshToken' + +async function graphql( + query: string, + variables: Record = {}, + token?: string, +): Promise { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ query, variables }), + }) + const body = (await response.json()) as { data?: T; errors?: unknown } + if (body.errors || !body.data) { + throw new Error(`The API refused: ${JSON.stringify(body.errors)}`) + } + return body.data +} + +/** + * Logs in as the test coach with this e-mail address (`devLogin`: the server must be started + * with DEV_LOGIN=true and a SESSION_SECRET, as playwright.config.ts does). A refresh token can + * be used once, so a test that opens the page must have a login of its own. + */ +async function login(email: string) { + const { devLogin } = await graphql<{ + devLogin: { accessToken: string; refreshToken: string } + }>( + 'mutation($email: String!) { devLogin(email: $email) { accessToken refreshToken } }', + { email }, + ) + return devLogin +} + +/** + * Makes the page open as this coach, in this theme: the login is put in localStorage before the + * page starts, where the app looks for it. Call it before `page.goto`. + */ +export async function signIn( + page: Page, + email: string, + theme: Theme = 'light', +) { + const { refreshToken } = await login(email) + await page.addInitScript( + ([key, token, chosen]) => { + // Only when there is none: the app writes a new one after each refresh, and the old one + // has been used by then. + if (!localStorage.getItem(key as string)) { + localStorage.setItem(key as string, token as string) + } + localStorage.setItem('theme', chosen as string) + }, + [REFRESH_TOKEN_KEY, refreshToken, theme], + ) +} + +/** The theme for a page that nobody is logged in on. */ +export async function chooseTheme(page: Page, theme: Theme) { + await page.addInitScript( + (chosen) => localStorage.setItem('theme', chosen), + theme, + ) +} + +export type TestGymnast = { + id: string + name: string + /** The codes of the elements in her list, in the order they were added. */ + codes: string[] +} + +/** The first elements of the uneven bars and the first vault, which the tests use as examples. */ +export async function catalogExamples() { + const data = await graphql<{ + bars: { edges: { node: { id: string; code: string } }[] } + vault: { edges: { node: { id: string } }[] } + }>(` + { + bars: searchCatalog(first: 9, filter: { apparatusId: "UB" }) { + edges { + node { + ... on Element { + id + code + } + } + } + } + vault: searchCatalog(first: 1, filter: { apparatusId: "VT" }) { + edges { + node { + ... on Vault { + id + } + } + } + } + } + `) + const bars = data.bars.edges.map((edge) => edge.node) + return { + /** Eight that the test gymnast has in her list. */ + inList: bars.slice(0, 8), + /** One that she does not have. */ + notInList: bars[8]!, + vaultId: data.vault.edges[0]!.node.id, + } +} + +/** Deletes every gymnast of the coach, so a run starts from the same place. */ +export async function deleteGymnasts(email: string) { + const { accessToken } = await login(email) + const { me } = await graphql<{ me: { gymnasts: { id: string }[] } }>( + '{ me { gymnasts { id } } }', + {}, + accessToken, + ) + for (const { id } of me.gymnasts) { + await graphql( + 'mutation($id: ID!) { deleteGymnast(id: $id) }', + { id }, + accessToken, + ) + } +} + +/** + * Makes a gymnast with eight elements and the routines (three by default) on the uneven bars. The coach is made + * on the first login. Other gymnasts of the coach are not touched. + */ +export async function createGymnast( + email: string, + name: string, + routines: string[] = ['Upgrade with Ricna', 'Competition 1', 'Safe set'], +): Promise { + const { accessToken } = await login(email) + const { inList } = await catalogExamples() + const { createGymnast: gymnast } = await graphql<{ + createGymnast: { id: string } + }>( + 'mutation($name: String!) { createGymnast(name: $name) { id } }', + { name }, + accessToken, + ) + for (const item of inList) { + await graphql( + 'mutation($g: ID!, $i: ID!) { addGymnastElement(gymnastId: $g, itemId: $i) { id } }', + { g: gymnast.id, i: item.id }, + accessToken, + ) + } + for (const routine of routines) { + const { createRoutine } = await graphql<{ createRoutine: { id: string } }>( + 'mutation($input: CreateRoutineInput!) { createRoutine(input: $input) { id } }', + { input: { gymnastId: gymnast.id, apparatusId: 'UB', name: routine } }, + accessToken, + ) + await graphql( + 'mutation($r: ID!, $ids: [ID!]!) { setRoutineItems(routineId: $r, itemIds: $ids) { id } }', + { r: createRoutine.id, ids: inList.slice(0, 5).map((item) => item.id) }, + accessToken, + ) + } + return { id: gymnast.id, name, codes: inList.map((item) => item.code) } +} + +const AXE_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] + +/** + * What axe finds on the page, as plain data (WCAG 2.0, 2.1 and 2.2, level A and AA). `region` + * is turned off for a state where a dialog or menu is open: they are in a portal at the end of + * the document (the Base UI default), which axe reports as best practice and not as a WCAG failure. + */ +export async function axeViolations( + page: Page, + options: { portalOpen?: boolean } = {}, +) { + const builder = new AxeBuilder({ page }).withTags(AXE_TAGS) + if (options.portalOpen) builder.disableRules(['region']) + const { violations } = await builder.analyze() + return violations.map((violation) => ({ + rule: violation.id, + impact: violation.impact, + nodes: violation.nodes.map((node) => node.target.join(' ')), + })) +} + +/** True when the page can be scrolled sideways, which it must not be on a narrow screen (WCAG 1.4.10). */ +export function scrollsSideways(page: Page) { + return page.evaluate( + () => + document.documentElement.scrollWidth > + document.documentElement.clientWidth, + ) +} + +/** The line spacing, letter spacing and word spacing that WCAG 1.4.12 says a page must survive. */ +export const TEXT_SPACING_CSS = + '* { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important } p { margin-bottom: 2em !important }' + +// --- Colors. The browser answers `rgb(...)`, `rgba(...)` or `color(srgb r g b)` (for a +// color-mix), and a token is a hex color. All of them become [r, g, b] in 0-1. + +export type Rgb = [number, number, number] + +export function parseColor(value: string): Rgb { + const text = value.trim() + const srgb = /^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/.exec(text) + if (srgb) return [Number(srgb[1]), Number(srgb[2]), Number(srgb[3])] + const rgb = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/.exec(text) + if (rgb) + return [Number(rgb[1]) / 255, Number(rgb[2]) / 255, Number(rgb[3]) / 255] + const hex = /^#([0-9a-f]{6})$/i.exec(text) + if (hex) { + const n = parseInt(hex[1]!, 16) + return [(n >> 16) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255] + } + throw new Error(`Cannot read the color "${value}"`) +} + +const linear = (c: number) => + c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 +const luminance = ([r, g, b]: Rgb) => + 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) + +/** The contrast ratio of WCAG between two colors, 1 to 21. */ +export function contrast(a: string, b: string): number { + const [x, y] = [luminance(parseColor(a)), luminance(parseColor(b))] + return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) +} + +/** True when the two colors are the same one, to what the eye can tell (a rounding of the browser). */ +export function sameColor(a: string, b: string): boolean { + const [x, y] = [parseColor(a), parseColor(b)] + return x.every((channel, i) => Math.abs(channel - y[i]!) < 0.01) +} diff --git a/e2e/gymnast-page.e2e.ts b/e2e/gymnast-page.e2e.ts new file mode 100644 index 0000000..204f864 --- /dev/null +++ b/e2e/gymnast-page.e2e.ts @@ -0,0 +1,346 @@ +// The Gymnast page and "Your gymnasts" in a real browser, logged in as a test coach (see +// signIn in gymnast-helpers.ts). This is where what a component test cannot see is checked, +// because jsdom has no layout, no focus rules and no real colors: axe, sideways scrolling on a +// narrow screen, the size of what is pressed, the arrow keys on the tabs, where the focus goes +// around the dialog, and the colors of a row and of the trash icon under the pointer. +// +// The test data is made through the API and is the coach's own, so the tests can be run again. + +import { expect, test, type Page } from '@playwright/test' +import { + axeViolations, + contrast, + createGymnast, + deleteGymnasts, + scrollsSideways, + sameColor, + signIn, + TEXT_SPACING_CSS, + type Theme, + type TestGymnast, +} from './gymnast-helpers.ts' + +const COACH = 'e2e-gymnast-page@example.test' + +let gymnast: TestGymnast +let longName: TestGymnast + +test.beforeAll(async () => { + await deleteGymnasts(COACH) + gymnast = await createGymnast(COACH, 'Ingrid Marie Nilsen-Haugeland') + // A name of 40 letters and a routine of 56 without a space, which have to be broken to fit a + // phone. A long word is what makes a grid column wider than the window. + longName = await createGymnast( + COACH, + 'Wolfeschlegelsteinhausenbergerdorffsmith', + ['Supercalifragilisticexpialidocious-routine-without-spaces'], + ) +}) + +test.afterAll(async () => { + await deleteGymnasts(COACH) +}) + +/** Opens the page of a gymnast, logged in, and waits until the lists are there. */ +async function openGymnast( + page: Page, + who: TestGymnast, + theme: Theme = 'light', +) { + await signIn(page, COACH, theme) + await page.goto(`gymnasts/${who.id}`) + await expect( + page.getByRole('heading', { level: 2, name: 'Element list' }), + ).toBeVisible() +} + +const elementList = (page: Page) => + page.getByRole('list', { name: 'Element list' }) +const trashButtons = (page: Page) => + page.getByRole('button', { name: /^Remove / }) + +test.describe('accessibility (axe, WCAG 2.2 A and AA)', () => { + for (const theme of ['light', 'dark'] as const) { + test(`the Gymnast page in ${theme} mode`, async ({ page }) => { + await openGymnast(page, gymnast, theme) + expect(await axeViolations(page)).toEqual([]) + }) + } + + test('the Gymnast page on a phone, 320 px wide', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 700 }) + await openGymnast(page, gymnast, 'dark') + expect(await axeViolations(page)).toEqual([]) + }) + + test('the Gymnast page with the question "are you sure?" open', async ({ + page, + }) => { + await openGymnast(page, gymnast) + await trashButtons(page).first().click() + await expect(page.getByRole('alertdialog')).toBeVisible() + expect(await axeViolations(page, { portalOpen: true })).toEqual([]) + }) + + test('"Your gymnasts" in light and dark mode', async ({ page }) => { + for (const theme of ['light', 'dark'] as const) { + await signIn(page, COACH, theme) + await page.goto('gymnasts') + await expect( + page.getByRole('heading', { level: 1, name: 'Your gymnasts' }), + ).toBeVisible() + await expect(page.getByRole('link', { name: gymnast.name })).toBeVisible() + expect(await axeViolations(page), `in ${theme} mode`).toEqual([]) + } + }) +}) + +test.describe('narrow screens (WCAG 1.4.10 reflow and 1.4.12 text spacing)', () => { + for (const width of [280, 320, 360, 390]) { + test(`no sideways scrolling at ${width} px, also with a long name and with the text spacing of 1.4.12`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 700 }) + await signIn(page, COACH) + const pages: [string, string][] = [ + ['the Gymnast page', `gymnasts/${gymnast.id}`], + ['the Gymnast page of the long name', `gymnasts/${longName.id}`], + ['"Your gymnasts"', 'gymnasts'], + ] + for (const [label, address] of pages) { + await page.goto(address) + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + await page.waitForLoadState('networkidle') + expect(await scrollsSideways(page), label).toBe(false) + + await page.addStyleTag({ content: TEXT_SPACING_CSS }) + expect( + await scrollsSideways(page), + `${label} with the text spacing of 1.4.12`, + ).toBe(false) + } + }) + } +}) + +test.describe('the size of what is pressed, on a phone', () => { + test.use({ viewport: { width: 320, height: 700 }, hasTouch: true }) + + // 40 px is what the Elements page is tested for. WCAG 2.2 asks for 24 (2.5.8, level AA). + // The header is outside
    and is not part of this. + const MIN = 40 + + for (const [label, address] of [ + ['the Gymnast page', () => `gymnasts/${gymnast.id}`], + ['"Your gymnasts"', () => 'gymnasts'], + ] as const) { + test(`every button, tab and link in ${label} is at least ${MIN} px tall and wide`, async ({ + page, + }) => { + await signIn(page, COACH) + await page.goto(address()) + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + await page.waitForLoadState('networkidle') + + const tooSmall = await page + .locator('main a, main button, main [role=tab]') + .evaluateAll((items, min) => { + const found: { name: string; width: number; height: number }[] = [] + for (const item of items) { + const box = item.getBoundingClientRect() + if (box.width === 0 || box.height === 0) continue // hidden + if (item.closest('.sr-only')) continue + if (Math.round(box.width) < min || Math.round(box.height) < min) { + found.push({ + name: ( + item.getAttribute('aria-label') ?? + item.textContent ?? + '' + ) + .trim() + .slice(0, 40), + width: Math.round(box.width), + height: Math.round(box.height), + }) + } + } + return found + }, MIN) + expect(tooSmall).toEqual([]) + }) + } +}) + +test.describe('the tabs, with the keyboard', () => { + test('the arrow keys, Home and End move between the apparatus, and the chosen one shows its own lists', async ({ + page, + }) => { + await openGymnast(page, gymnast) + const selected = () => page.getByRole('tab', { selected: true }) + + // She has routines on the uneven bars only, so that is where the page opens. + await expect(selected()).toHaveText('Uneven bars') + await selected().focus() + + await page.keyboard.press('ArrowRight') + await expect(selected()).toHaveText('Balance beam') + await expect(selected()).toBeFocused() + await expect( + page.getByText('No routines on Balance beam yet.'), + ).toBeVisible() + + await page.keyboard.press('End') + await expect(selected()).toHaveText('Floor exercise') + await expect( + page.getByText('No elements on Floor exercise in the list yet.'), + ).toBeVisible() + + await page.keyboard.press('Home') + await expect(selected()).toHaveText('Vault') + + await page.keyboard.press('ArrowRight') + await expect(selected()).toHaveText('Uneven bars') + await expect( + page.getByRole('heading', { level: 2, name: 'Routines' }), + ).toBeVisible() + await expect(elementList(page).getByRole('listitem')).toHaveCount( + gymnast.codes.length, + ) + }) +}) + +test.describe('the question "are you sure?" when an element is removed', () => { + test('closes with Escape and gives the focus back to the button', async ({ + page, + }) => { + await openGymnast(page, gymnast) + const trash = trashButtons(page).first() + await trash.focus() + await page.keyboard.press('Enter') + + const dialog = page.getByRole('alertdialog') + await expect(dialog).toBeVisible() + + await page.keyboard.press('Escape') + await expect(dialog).toBeHidden() + await expect(trash).toBeFocused() + await expect(elementList(page).getByRole('listitem')).toHaveCount( + gymnast.codes.length, + ) + }) + + test('removes the element when it is confirmed, and the focus goes to the heading of the list', async ({ + page, + }) => { + const own = await createGymnast(COACH, 'Removal Test') + await openGymnast(page, own) + const before = own.codes.length + await expect(elementList(page).getByRole('listitem')).toHaveCount(before) + + await trashButtons(page).first().click() + await page.getByRole('button', { name: 'Yes, remove' }).click() + + await expect(elementList(page).getByRole('listitem')).toHaveCount( + before - 1, + ) + await expect(page.getByRole('alertdialog')).toBeHidden() + await expect( + page.getByRole('heading', { level: 2, name: 'Element list' }), + ).toBeFocused() + await expect( + page + .getByRole('status') + .filter({ hasText: `${own.codes[0]} removed from the list.` }), + ).toBeAttached() + }) +}) + +test.describe('the colors under the pointer', () => { + test.use({ viewport: { width: 1100, height: 900 } }) + + const colorOf = + (page: Page, property: 'color' | 'backgroundColor') => (selector: string) => + page + .locator(selector) + .first() + .evaluate((el, p) => getComputedStyle(el)[p], property) + + /** The value of a token of the theme, as the browser has it. */ + const token = (page: Page, name: string) => + page.evaluate( + (n) => + getComputedStyle(document.documentElement).getPropertyValue(n).trim(), + name, + ) + + for (const theme of ['light', 'dark'] as const) { + test(`a row under the pointer can be told from the card, and its text can be read, in ${theme} mode`, async ({ + page, + }) => { + await openGymnast(page, gymnast, theme) + const list = elementList(page) + const row = list.getByRole('listitem').nth(1) + const card = await list.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + + await row.hover() + // The color changes over 150 ms, so wait until it has stopped changing. + await expect + .poll(() => row.evaluate((el) => getComputedStyle(el).backgroundColor)) + .not.toBe('rgba(0, 0, 0, 0)') + await page.waitForTimeout(300) + const hovered = await row.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + const text = await row + .getByRole('link') + .evaluate((el) => getComputedStyle(el).color) + + expect( + sameColor(hovered, card), + 'the row is not the color of the card', + ).toBe(false) + expect(contrast(text, hovered)).toBeGreaterThanOrEqual(4.5) + + if (theme === 'dark') { + // It was 1.05:1: the row looked like a hole in the card. + const pageColor = await colorOf(page, 'backgroundColor')('main') + expect(contrast(hovered, pageColor)).toBeGreaterThanOrEqual(1.5) + } + }) + } + + test('the trash icon is in the text color, turns red under the pointer, and is red while it is pressed', async ({ + page, + }) => { + await openGymnast(page, gymnast, 'dark') + const trash = trashButtons(page).first() + const colorNow = () => trash.evaluate((el) => getComputedStyle(el).color) + const danger = await token(page, '--danger') + const text = await token(page, '--text-color') + + expect( + sameColor(await colorNow(), text), + 'at rest it is the text color', + ).toBe(true) + + await trash.hover() + await expect + .poll(async () => sameColor(await colorNow(), danger)) + .toBe(true) + + // Pressed, and then let go away from the button, so that nothing is clicked. + await page.mouse.move(0, 0) + await expect.poll(async () => sameColor(await colorNow(), text)).toBe(true) + await trash.hover() + await page.mouse.down() + // The color changes over 150 ms. + await expect + .poll(async () => sameColor(await colorNow(), danger)) + .toBe(true) + await page.mouse.move(0, 0) + await page.mouse.up() + await expect(page.getByRole('alertdialog')).toBeHidden() + }) +}) diff --git a/playwright.config.ts b/playwright.config.ts index 4e501d5..1309f71 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -49,6 +49,10 @@ export default defineConfig({ PORT: String(backendPort), CORS_ORIGIN: `http://localhost:${frontendPort}`, IMAGE_BASE_URL: `http://localhost:${backendPort}/images`, + // The tests of the gymnast pages log in through the API (`devLogin`) and make + // their own test coach and gymnasts. Only for a backend that the tests start. + DEV_LOGIN: 'true', + SESSION_SECRET: 'end-to-end-tests-only-secret-32-characters', }, reuseExistingServer: !process.env['CI'], timeout: 60_000, diff --git a/src/components/elements/AddToGymnast.tsx b/src/components/elements/AddToGymnast.tsx new file mode 100644 index 0000000..8fb2c2f --- /dev/null +++ b/src/components/elements/AddToGymnast.tsx @@ -0,0 +1,164 @@ +import { useMutation, useQuery } from '@apollo/client/react' +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 { ADD_GYMNAST_ELEMENT_MUTATION } from '@/lib/gymnastDetailOperations' +import { GYMNASTS_QUERY } from '@/lib/gymnastOperations' + +/** 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 +}) { + // 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, + ) + const list = gymnasts.data?.me?.gymnasts + + 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 ( + + + } + > + Add to gymnast + + + + + + + Add {element.code} to + + {!list && gymnasts.loading ? ( +

    + Loading your gymnasts… +

    + ) : !list ? ( +

    + {describeError(gymnasts.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)" >