Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
34592fb
feat: add the table row color and let DifficultyBadge hide its value
Oct 29, 2026
7309b22
feat: build the Elements page with the results table
Oct 29, 2026
cdda597
docs: describe the Elements page in AI_USAGE.md
Oct 29, 2026
42d6f68
Merge remote-tracking branch 'origin/dev' into 41-elements-page-ui
Oct 29, 2026
486c4e3
fix: remove the image-copy plugin that a merge brought back and broke…
Oct 29, 2026
8c6fc86
feat: rename Elements to Search in the navbar and point the logo to t…
Oct 29, 2026
07d4343
Merge remote-tracking branch 'origin/dev' into 41-elements-page-ui
Oct 29, 2026
759e02a
refactor: use DifficultyBadge as it is, without new props
Oct 29, 2026
6040e53
feat: let the whole row grow on hover and focus instead of underlinin…
Oct 29, 2026
84ac178
feat: make the hovered row taller instead of wider
Oct 29, 2026
0937096
feat: remove the lines between the table rows
Oct 29, 2026
a87f27b
feat: make the text of the hovered row look bolder without moving it
Oct 29, 2026
b06e8bb
feat: add the value column, chevron, bold code, focus rings and a dar…
Oct 29, 2026
b057ef4
Merge remote-tracking branch 'origin/dev' into 41-elements-page-ui
Oct 29, 2026
2f84882
feat: use the light More-variants color on the buttons and the value …
Oct 29, 2026
7b22db4
feat: give the search field the light button color
Oct 29, 2026
3aa10db
feat: make the search field see-through with a tan line in dark mode
Oct 29, 2026
440bea5
feat: use a tint of the blue accent for every other table row instead…
Oct 29, 2026
3e19543
feat: use the whole width on every page, and use the search field col…
Oct 29, 2026
603bfda
fix: no color change on hover in light mode, and keep the button text…
Oct 29, 2026
e13d75d
fix: make the text on a hovered row only a little bolder
Oct 29, 2026
b8825ec
fix: draw the clear X of the search field in the color of the search …
Oct 29, 2026
604074e
Merge remote-tracking branch 'origin/dev' into 41-elements-page-ui
Oct 29, 2026
593afd7
feat: show the apparatus and the group as labels in the mobile cards,…
Oct 29, 2026
e8801a1
feat: put the label above the value pill in the mobile cards, and kee…
Oct 29, 2026
6d13550
feat: more space around the apparatus and group labels in the mobile …
Oct 29, 2026
885d052
feat: indent the labels over the apparatus and group pills a little
Oct 29, 2026
a7e46e3
feat: keep the navy table heading on a phone (Element and D-value), a…
Oct 29, 2026
9868ce9
feat: show the results as cards on a small screen, with the drawing w…
Oct 29, 2026
bfcc78a
feat: cards with the drawing below, an arrow and a hover effect, two …
Oct 29, 2026
02ed9e7
feat: connect the search field and the results of the Elements page t…
Oct 29, 2026
acb278d
fix: call the group Element group in the table and the cards, and the…
Oct 29, 2026
4940b9c
Merge branch '41-elements-page-ui' into 117-connect-elements-search
Oct 29, 2026
8b76b5d
feat: make Load more elements fetch the next page, and disable it whe…
Oct 29, 2026
d2cd760
feat: stop counting the matches of a search at 1001
Oct 29, 2026
7d6295b
feat: say 1000+ when the server counts more than 1000
Oct 29, 2026
4cb06d4
Merge remote-tracking branch 'origin/110-cap-total-count' into 41-ele…
Oct 29, 2026
98423d0
refactor: call the value and the D-value "Difficulty" everywhere
Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/AI_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,19 @@
id, a stopped server and "Try again" work. The JavaScript bundle went from
665 kB to 522 kB.

## The Elements page (`src/components/elements/ElementsPage.tsx`, `ResultsTable.tsx`)

- **Tool:** Claude Code
- **What the team did:** Drew the page in a design (`elements_page.png`) and
chose that it should look exactly like it, without lines between the rows. Decided that this pull request is only the
design, and that the search, filters, sort and paging are connected in #110.
- **What Claude did:** Measured the colors in the design and used the existing
tokens for all of them but the purple of every other row (new:
`--table-row-alt`). Wrote the page and the table with the existing badge for
the circle. Checked the page at 390, 768, 927 and 1440 pixels wide in a
browser, and chose how the table becomes cards on a phone and how the whole
row is one link. Wrote the tests.

## Tests for the element page (`src/components/elements/ElementDetailPage.test.tsx`, `src/tests/apollo.ts`)

- **Tool:** Claude Code
Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Faustina:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap"
href="https://fonts.googleapis.com/css2?family=Faustina:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>t29-project-2</title>
Expand Down
24 changes: 20 additions & 4 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,30 @@ describe('App', () => {
expect(screen.getAllByRole('main')).toHaveLength(1);
});

it('opens the search page from the start address, the logo and the Search link', () => {
renderApp();
expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`);
expect(screen.getByRole('heading', { level: 1, name: 'Elements' })).toBeTruthy();

for (const link of [
() => screen.getByRole('link', { name: /wag cop planner home/i }),
() => screen.getByRole('link', { name: 'Search' }),
]) {
fireEvent.click(screen.getByRole('link', { name: 'About' }));
expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy();

fireEvent.click(link());
expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`);
expect(screen.getByRole('heading', { level: 1, name: 'Elements' })).toBeTruthy();
}
});

it('guards the Gymnasts route and makes it available after login', () => {
window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`);
renderApp();

// Logged out, /gymnasts goes to the start page, which shows an element.
expect(window.location.pathname).toBe(
`${import.meta.env.BASE_URL}elements/UB-1.101-a`,
);
// Logged out, /gymnasts goes to the start page, which is the Elements page.
expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`);
expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull();

fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
Expand Down
13 changes: 6 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SkipLink } from './components/skipLink/SkipLink';
import { ProtectedRoute } from './components/ProtectedRoute';

import ElementDetailPage from './components/elements/ElementDetailPage';
import ElementsPage from './components/elements/ElementsPage';
import YourGymnastsPage from './components/gymnasts/YourGymnastsPage';

import './index.css';
Expand All @@ -14,17 +15,17 @@ import './styles/global.css';
// Placeholder pages for routes without full implementations yet. Each page
// has its own <main>, like the real pages.
const CalculatorPage = () => (
<main>
<main className="px-5 py-8 text-left sm:px-8 lg:px-12">
<h1>Calculator</h1>
</main>
);
const AboutPage = () => (
<main>
<main className="px-5 py-8 text-left sm:px-8 lg:px-12">
<h1>About</h1>
</main>
);
const NotFoundPage = () => (
<main>
<main className="px-5 py-8 text-left sm:px-8 lg:px-12">
<h1>404 - Page not found</h1>
</main>
);
Expand Down Expand Up @@ -56,10 +57,8 @@ export function App() {
{/* The pages have their own <main>, so this is only the target of the skip link. */}
<div id="main-content" tabIndex={-1} className="outline-none">
<Routes>
<Route
path="/"
element={<Navigate to="/elements/UB-1.101-a" replace />}
/>
<Route path="/" element={<Navigate to="/elements" replace />} />
<Route path="/elements" element={<ElementsPage />} />
<Route path="/elements/:elementId" element={<ElementDetailPage />} />

<Route path="/calculator" element={<CalculatorPage />} />
Expand Down
16 changes: 8 additions & 8 deletions src/components/elements/ElementDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ export default function ElementDetailPage() {
if (problem) {
const { message, retryable } = problem
return (
<main className="element-detail-page min-h-svh bg-(--secondary-color) px-4 py-12 text-left">
<div className="mx-auto max-w-3xl space-y-3">
<main className="element-detail-page min-h-svh bg-(--secondary-color) px-5 py-12 text-left sm:px-8 lg:px-12">
<div className="space-y-3">
<h1 className="text-3xl font-bold">Element details unavailable</h1>
<p role="alert" className="text-(--text-color-dark)">
{message}
Expand All @@ -63,12 +63,12 @@ export default function ElementDetailPage() {
return (
<main
aria-busy="true"
className="element-detail-page min-h-svh bg-(--secondary-color) px-4 py-8 text-left"
className="element-detail-page min-h-svh bg-(--secondary-color) px-5 py-8 text-left sm:px-8 lg:px-12"
>
<p role="status" className="sr-only">
Loading element details
</p>
<div className="mx-auto max-w-3xl animate-pulse space-y-5 motion-reduce:animate-none">
<div className="animate-pulse space-y-5 motion-reduce:animate-none">
<div className="h-16 rounded-xl bg-(--background-color-element)" />
<div className="h-24 rounded-xl bg-(--background-color-element)" />
<div className="h-80 rounded-xl bg-(--background-color-element)" />
Expand All @@ -79,8 +79,8 @@ export default function ElementDetailPage() {

if (!item) {
return (
<main className="element-detail-page min-h-svh bg-(--secondary-color) px-4 py-12 text-left">
<div className="mx-auto max-w-3xl space-y-3">
<main className="element-detail-page min-h-svh bg-(--secondary-color) px-5 py-12 text-left sm:px-8 lg:px-12">
<div className="space-y-3">
<h1 className="text-3xl font-bold">Element not found</h1>
<p className="text-(--text-color-dark)">
No catalog item matches this address.
Expand All @@ -101,9 +101,9 @@ export default function ElementDetailPage() {
return (
<main
aria-busy={loading}
className="element-detail-page min-h-svh bg-(--secondary-color) px-4 py-6 text-left sm:py-10"
className="element-detail-page min-h-svh bg-(--secondary-color) px-5 py-6 text-left sm:px-8 sm:py-10 lg:px-12"
>
<div className="mx-auto flex max-w-3xl flex-col gap-5 sm:gap-6">
<div className="flex flex-col gap-5 sm:gap-6">
<ElementDetailHeader element={element} />
{element.removed && (
<p
Expand Down
2 changes: 1 addition & 1 deletion src/components/elements/ElementDiagram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default function ElementDiagram({
<img
src={element.imageUrl}
alt={`Illustration for ${displayCode(element, element.variants.length > 0)}${element.name ? `: ${element.name}` : ''}`}
className="block h-auto w-full object-contain"
className="block h-auto max-h-[75svh] w-full object-contain"
/>
</CardContent>
</Card>
Expand Down
112 changes: 112 additions & 0 deletions src/components/elements/ElementsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, describe, expect, it } from 'vitest'
import { exampleElementRows, exampleTotalCount } from '@/lib/elementList'
import ElementsPage from './ElementsPage'

afterEach(cleanup)

const renderPage = () =>
render(
<MemoryRouter>
<ElementsPage />
</MemoryRouter>,
)

describe('ElementsPage', () => {
it('has one h1, and the results under an h2 that names the section', () => {
renderPage()

expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1)
expect(screen.getByRole('region', { name: 'Results' })).toBeTruthy()
expect(
screen.getByRole('heading', { level: 2, name: 'Results' }),
).toBeTruthy()
})

it('has a labelled search field in a search landmark', () => {
renderPage()

const search = screen.getByRole('search')
expect(search).toBeTruthy()
expect(
screen.getByLabelText('Search elements by code, name or description'),
).toBeTruthy()
})

it('has the filters in a labelled group, and a sort button that says what it sorts', () => {
renderPage()

const filters = screen.getByRole('group', { name: 'Filters' })
for (const name of ['Apparatus', 'Element group', 'Difficulty']) {
expect(filters.querySelector('button')?.textContent).toBeTruthy()
expect(screen.getByRole('button', { name })).toBeTruthy()
}
expect(
screen.getByRole('button', { name: /Sort by Code\W+Ascending/ }),
).toBeTruthy()
})

it('can reach every control with the keyboard, and each one shows where the focus is', () => {
renderPage()

const controls = [
...screen.getAllByRole('button'),
...screen.getAllByRole('link'),
screen.getByRole('searchbox'),
]
// The search field, the three filters, the sort button, "Load more", and each row
// twice: in the table and in a card (CSS shows only one of them, and what is not
// shown cannot be reached with Tab).
expect(controls).toHaveLength(1 + 3 + 1 + 1 + 2 * exampleElementRows.length)
for (const control of controls) {
expect(control.getAttribute('tabindex')).not.toBe('-1')
expect(control.hasAttribute('disabled')).toBe(false)
}
for (const button of screen.getAllByRole('button')) {
// outline-none on the base button hides the ring unless the style is set again.
expect(button.className).toContain('focus-visible:outline-solid')
expect(button.className).toContain('hover:bg-(--button-hover-bg)')
// The button of the design system turns its text to --foreground (almost white in
// dark mode) on hover, which cannot be read on the tan button.
expect(button.className).toContain('hover:text-(--button-text)')
expect(button.className).toContain('dark:hover:text-(--button-text)')
}
})

it('shows how many of the results are loaded', () => {
renderPage()

expect(
screen.getByText(
`Showing ${exampleElementRows.length} of ${exampleTotalCount}`,
),
).toBeTruthy()
})

it('says so in a status when a control is not connected yet, and not before', () => {
renderPage()
const status = screen.getByRole('status')
expect(status.textContent).toBe('')

fireEvent.click(screen.getByRole('button', { name: 'Apparatus' }))
expect(status.textContent).toBe(
'The apparatus filter is not connected yet.',
)

fireEvent.click(
screen.getByRole('button', { name: /Sort by Code\W+Ascending/ }),
)
expect(status.textContent).toBe('Sorting is not connected yet.')

fireEvent.click(screen.getByRole('button', { name: 'Load more elements' }))
expect(status.textContent).toBe(
'Loading more elements is not connected yet.',
)

fireEvent.submit(screen.getByRole('search'))
expect(status.textContent).toBe('Search is not connected yet.')
})
})
110 changes: 110 additions & 0 deletions src/components/elements/ElementsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useState, type FormEvent } from 'react'
import { ArrowUpDown, ChevronDown, Plus, Search } from 'lucide-react'
import ResultsTable from '@/components/elements/ResultsTable'
import { Button } from '@/components/ui/button'
import { exampleElementRows, exampleTotalCount } from '@/lib/elementList'

const PILL =
'h-10 rounded-full border-0 bg-(--button-bg) px-4 text-sm font-medium text-(--button-text) shadow-(--box-shadow) transition-colors hover:bg-(--button-hover-bg) focus-visible:bg-(--button-hover-bg) dark:border-transparent dark:bg-(--button-bg) dark:hover:bg-(--button-hover-bg) hover:text-(--button-text) dark:hover:text-(--button-text) focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-(--text-color) motion-reduce:transition-none'

const FILTERS = ['Apparatus', 'Element group', 'Difficulty']

// TODO(#110): search, filters, sort and paging are only drawn so far. Each one
// says that it is not connected yet, like the buttons on the element page.
export default function ElementsPage() {
const [status, setStatus] = useState('')
const rows = exampleElementRows

function search(event: FormEvent) {
event.preventDefault()
setStatus('Search is not connected yet.')
}

return (
<main className="elements-page min-h-svh bg-(--page-bg) px-5 py-8 text-left sm:px-8 lg:px-12 lg:py-10">
<h1>Elements</h1>

<form
role="search"
onSubmit={search}
className="relative mt-6 w-full lg:w-2/3"
>
<label htmlFor="element-search" className="sr-only">
Search elements by code, name or description
</label>
<Search
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-4 size-4 -translate-y-1/2 text-(--text-color)"
/>
<input
id="element-search"
name="search"
type="search"
autoComplete="off"
placeholder="Search by code, name or description"
className="h-11 w-full rounded-full border border-(--search-border) bg-(--search-bg) pr-4 pl-11 text-sm font-medium text-(--text-color) shadow-(--box-shadow) transition-colors placeholder:text-(--text-color) hover:bg-(--search-hover-bg) focus-visible:outline-2 motion-reduce:transition-none focus-visible:outline-offset-2 focus-visible:outline-(--text-color)"
/>
</form>

<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<div role="group" aria-label="Filters" className="flex flex-wrap gap-3">
{FILTERS.map((filter) => (
<Button
key={filter}
type="button"
variant="outline"
className={PILL}
onClick={() =>
setStatus(
`The ${filter.toLowerCase()} filter is not connected yet.`,
)
}
>
{filter}
<ChevronDown aria-hidden="true" />
</Button>
))}
</div>
<Button
type="button"
variant="outline"
className={`${PILL} text-[0.8125rem]`}
onClick={() => setStatus('Sorting is not connected yet.')}
>
<ArrowUpDown aria-hidden="true" />
<span className="sr-only">Sort by</span> Code{' '}
<span aria-hidden="true">·</span> Ascending
</Button>
</div>

<p
role="status"
aria-live="polite"
className={`text-sm text-(--text-color) ${status ? 'mt-3' : ''}`}
>
{status}
</p>

<section aria-labelledby="results-heading" className="mt-8">
<h2 id="results-heading">Results</h2>
<ResultsTable rows={rows} />
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-medium text-(--text-color)">
Showing {rows.length} of {exampleTotalCount}
</p>
<Button
type="button"
variant="outline"
className={PILL}
onClick={() =>
setStatus('Loading more elements is not connected yet.')
}
>
<Plus aria-hidden="true" />
Load more elements
</Button>
</div>
</section>
</main>
)
}
Loading