From fdcdf7fa9fa206729a0854797cad21817f825e1c Mon Sep 17 00:00:00 2001 From: Thor Tveito Date: Thu, 5 Nov 2026 10:34:55 +0100 Subject: [PATCH 1/5] Refactor: Standardize country names in filters and sightings --- frontend/e2e/accessibility.spec.ts | 2 +- frontend/e2e/submit-sighting.spec.ts | 2 +- frontend/src/components/SearchFilters.tsx | 15 ++-------- frontend/src/tests/SearchPage.test.tsx | 10 ++++--- .../src/tests/SightingDetailPage.test.tsx | 24 ++++++++++----- frontend/src/tests/SightingList.test.tsx | 8 +++-- frontend/src/tests/SightingMap.test.tsx | 6 ++-- .../src/tests/SubmitSightingPage.test.tsx | 2 +- frontend/src/tests/format-sighting.test.ts | 30 +++++++++++++++---- frontend/src/utils/format-sighting.ts | 24 +++++++++++---- 10 files changed, 81 insertions(+), 42 deletions(-) diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index 64763e5..14bf6ae 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -32,7 +32,7 @@ const pages: { path: './#/sightings/3', ready: (page) => expect( - page.getByRole('heading', { level: 1, name: 'Oslo, NO' }), + page.getByRole('heading', { level: 1, name: 'Oslo, Norway' }), ).toBeVisible(), }, { diff --git a/frontend/e2e/submit-sighting.spec.ts b/frontend/e2e/submit-sighting.spec.ts index 7b63a52..11c6c69 100644 --- a/frontend/e2e/submit-sighting.spec.ts +++ b/frontend/e2e/submit-sighting.spec.ts @@ -123,7 +123,7 @@ test('fill in and submit the form with the keyboard', async ({ page }) => { await tabTo(page, page.getByRole('link', { name: 'View your sighting' })) await page.keyboard.press('Enter') await expect( - page.getByRole('heading', { level: 1, name: 'Trondheim, NO' }), + page.getByRole('heading', { level: 1, name: 'Trondheim, Norway' }), ).toBeVisible() await expect(page.getByText('User submitted')).toBeVisible() await expect( diff --git a/frontend/src/components/SearchFilters.tsx b/frontend/src/components/SearchFilters.tsx index a72e7c1..a1664e0 100644 --- a/frontend/src/components/SearchFilters.tsx +++ b/frontend/src/components/SearchFilters.tsx @@ -1,18 +1,9 @@ import { useQuery } from '@apollo/client/react' import { FILTER_OPTIONS_QUERY } from '../graphql/queries' import { createRetry } from '../utils/create-retry' +import { formatCountry } from '../utils/format-sighting' import './SearchFilters.css' -const countryNames = new Intl.DisplayNames(['en'], { type: 'region' }) - -function countryName(code: string) { - try { - return countryNames.of(code.toUpperCase()) ?? code - } catch { - return code - } -} - export interface SearchFiltersValue { shape: string country: string @@ -38,7 +29,7 @@ export default function SearchFilters({ const countries = [ ...new Set([...(data?.countries ?? []), filters.country].filter(Boolean)), ] - .map((code) => ({ code, name: countryName(code) })) + .map((code) => ({ code, name: formatCountry(code) })) .sort((left, right) => left.name.localeCompare(right.name, 'en')) const chips: { key: keyof SearchFiltersValue; label: string }[] = [ { @@ -47,7 +38,7 @@ export default function SearchFilters({ }, { key: 'country', - label: filters.country && `Country: ${countryName(filters.country)}`, + label: filters.country && `Country: ${formatCountry(filters.country)}`, }, { key: 'from', label: filters.from && `From: ${filters.from}` }, { key: 'to', label: filters.to && `To: ${filters.to}` }, diff --git a/frontend/src/tests/SearchPage.test.tsx b/frontend/src/tests/SearchPage.test.tsx index 2bd403f..6eee20d 100644 --- a/frontend/src/tests/SearchPage.test.tsx +++ b/frontend/src/tests/SearchPage.test.tsx @@ -147,8 +147,10 @@ describe('SearchPage', () => { target: { value: 'nz' }, }) expect(await screen.findByText('1 result')).toBeInTheDocument() - expect(await screen.findByText('Gisborne, NZ')).toBeInTheDocument() - expect(screen.queryByText('San Marcos, TX, US')).not.toBeInTheDocument() + expect(await screen.findByText('Gisborne, New Zealand')).toBeInTheDocument() + expect( + screen.queryByText('San Marcos, TX, United States'), + ).not.toBeInTheDocument() expect(requests.at(-1)?.filter).toEqual({ country: 'nz' }) expect(screen.getByLabelText('Current URL')).toHaveTextContent('country=nz') }) @@ -314,7 +316,7 @@ describe('SearchPage', () => { expect(screen.getByRole('searchbox')).toHaveValue('oslo') expect( - await screen.findByRole('link', { name: 'Oslo, NO' }), + await screen.findByRole('link', { name: 'Oslo, Norway' }), ).toBeInTheDocument() expect(requests).toEqual([{ search: 'oslo', sort: 'DATE_DESC', first: 20 }]) }) @@ -385,7 +387,7 @@ describe('SearchPage', () => { fireEvent.click(screen.getByRole('button', { name: 'Try again' })) expect( - await screen.findByRole('link', { name: 'Oslo, NO' }), + await screen.findByRole('link', { name: 'Oslo, Norway' }), ).toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect(requests).toHaveLength(2) diff --git a/frontend/src/tests/SightingDetailPage.test.tsx b/frontend/src/tests/SightingDetailPage.test.tsx index 10678c4..d12e80b 100644 --- a/frontend/src/tests/SightingDetailPage.test.tsx +++ b/frontend/src/tests/SightingDetailPage.test.tsx @@ -72,11 +72,13 @@ describe('SightingDetailPage', () => { screen.getByRole('heading', { name: 'Loading sighting…' }), ).toBeInTheDocument() expect( - await screen.findByRole('heading', { name: 'San Marcos, TX, US' }), + await screen.findByRole('heading', { + name: 'San Marcos, TX, United States', + }), ).toBeInTheDocument() expect(container.querySelectorAll('[aria-live]')).toHaveLength(1) expect(container.querySelector('[aria-live]')).toBe(announcement) - expect(announcement).toHaveTextContent('San Marcos, TX, US') + expect(announcement).toHaveTextContent('San Marcos, TX, United States') expect(screen.getByRole('article')).toBe(content) expect(content).toHaveAttribute('aria-busy', 'false') expect(screen.getByText('45 min')).toBeInTheDocument() @@ -92,7 +94,7 @@ describe('SightingDetailPage', () => { 'true', ) expect(screen.getByTestId('detail-map')).toHaveAttribute('data-zoom', '11') - expect(document.title).toBe('San Marcos, TX, US · UFOund it') + expect(document.title).toBe('San Marcos, TX, United States · UFOund it') }) it('shows fallbacks for a missing shape and hides missing comments', async () => { @@ -107,7 +109,9 @@ describe('SightingDetailPage', () => { ]) expect( - await screen.findByRole('heading', { name: 'San Marcos, TX, US' }), + await screen.findByRole('heading', { + name: 'San Marcos, TX, United States', + }), ).toBeInTheDocument() expect(screen.getByText('Unknown')).toBeInTheDocument() expect(screen.queryByText(/This event took place/)).not.toBeInTheDocument() @@ -159,7 +163,7 @@ describe('SightingDetailPage', () => { expect(await screen.findByText('User submitted')).toBeInTheDocument() // Outside the heading, so the page title is still just the location. expect( - screen.getByRole('heading', { name: 'San Marcos, TX, US' }), + screen.getByRole('heading', { name: 'San Marcos, TX, United States' }), ).toBeInTheDocument() }) @@ -168,7 +172,9 @@ describe('SightingDetailPage', () => { renderDetailPage('/sightings/1', [found]) expect( - await screen.findByRole('heading', { name: 'San Marcos, TX, US' }), + await screen.findByRole('heading', { + name: 'San Marcos, TX, United States', + }), ).toBeInTheDocument() expect(screen.queryByText('User submitted')).not.toBeInTheDocument() }) @@ -241,11 +247,13 @@ describe('SightingDetailPage', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect( - await screen.findByRole('heading', { name: 'San Marcos, TX, US' }), + await screen.findByRole('heading', { + name: 'San Marcos, TX, United States', + }), ).toBeInTheDocument() expect(container.querySelectorAll('[aria-live]')).toHaveLength(1) expect(container.querySelector('[aria-live]')).toBe(announcement) - expect(announcement).toHaveTextContent('San Marcos, TX, US') + expect(announcement).toHaveTextContent('San Marcos, TX, United States') expect(screen.getByRole('article')).toHaveAttribute('aria-busy', 'false') }) }) diff --git a/frontend/src/tests/SightingList.test.tsx b/frontend/src/tests/SightingList.test.tsx index cca71f1..db62a32 100644 --- a/frontend/src/tests/SightingList.test.tsx +++ b/frontend/src/tests/SightingList.test.tsx @@ -46,7 +46,7 @@ describe('SightingList', () => { expect(screen.getByText('1 result')).toBeInTheDocument() expect( - screen.getByRole('link', { name: 'San Marcos, TX, US' }), + screen.getByRole('link', { name: 'San Marcos, TX, United States' }), ).toHaveAttribute('href', '/sightings/1') expect(screen.getByText('cylinder')).toBeInTheDocument() expect(screen.getByText('45 min')).toBeInTheDocument() @@ -67,7 +67,9 @@ describe('SightingList', () => { expect(screen.getAllByText('User submitted')).toHaveLength(1) // The badge is not part of the link, so its name stays the location. - const link = screen.getByRole('link', { name: 'San Marcos, TX, US' }) + const link = screen.getByRole('link', { + name: 'San Marcos, TX, United States', + }) expect(link.closest('li')).toHaveTextContent('User submitted') }) @@ -184,7 +186,7 @@ describe('SightingList', () => { expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'true') expect( - screen.getByRole('link', { name: 'San Marcos, TX, US' }), + screen.getByRole('link', { name: 'San Marcos, TX, United States' }), ).toBeInTheDocument() }) diff --git a/frontend/src/tests/SightingMap.test.tsx b/frontend/src/tests/SightingMap.test.tsx index bb23a95..c22641c 100644 --- a/frontend/src/tests/SightingMap.test.tsx +++ b/frontend/src/tests/SightingMap.test.tsx @@ -87,7 +87,9 @@ describe('SightingMap', () => { 'data-position', `${sighting.latitude},${sighting.longitude}`, ) - expect(screen.getByText('San Marcos, TX, US')).toBeInTheDocument() + expect( + screen.getByText('San Marcos, TX, United States'), + ).toBeInTheDocument() }) it('shows a placeholder instead of a map when coordinates are missing', () => { @@ -126,7 +128,7 @@ describe('SightingMap', () => { expect(leafletContainer).toHaveAttribute('role', 'region') expect(leafletContainer).toHaveAttribute( 'aria-label', - 'Map showing San Marcos, TX, US', + 'Map showing San Marcos, TX, United States', ) }) }) diff --git a/frontend/src/tests/SubmitSightingPage.test.tsx b/frontend/src/tests/SubmitSightingPage.test.tsx index d6f67b2..3016ae0 100644 --- a/frontend/src/tests/SubmitSightingPage.test.tsx +++ b/frontend/src/tests/SubmitSightingPage.test.tsx @@ -532,7 +532,7 @@ describe('SubmitSightingPage', () => { expect(screen.getByText('13 results')).toBeInTheDocument(), ) expect( - screen.getByRole('link', { name: 'trondheim, NO' }), + screen.getByRole('link', { name: 'trondheim, Norway' }), ).toBeInTheDocument() }) }) diff --git a/frontend/src/tests/format-sighting.test.ts b/frontend/src/tests/format-sighting.test.ts index 32f8c51..75ec312 100644 --- a/frontend/src/tests/format-sighting.test.ts +++ b/frontend/src/tests/format-sighting.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { UfoSighting } from '../types/ufo-sighting' import { formatCoordinates, + formatCountry, formatDate, formatDuration, formatLocation, @@ -30,10 +31,29 @@ describe('sighting formatters', () => { expect(formatDuration(125)).toBe('2 min 5 sec') }) - it('formats a location with available region codes in uppercase', () => { - expect(formatLocation(sighting)).toBe('Oslo, OS, NO') - expect(formatLocation({ ...sighting, state: '' })).toBe('Oslo, NO') - expect(formatLocation({ ...sighting, state: '', country: '' })).toBe('Oslo') + it('names a known country code in either case', () => { + expect(formatCountry('no')).toBe('Norway') + expect(formatCountry('GB')).toBe('United Kingdom') + }) + + it('shows a code that is not a country as it is, in uppercase', () => { + expect(formatCountry('eu')).toBe('EU') + expect(formatCountry('xx')).toBe('XX') + }) + + it('says when the country is missing', () => { + expect(formatCountry(null)).toBe('Unknown country') + expect(formatCountry('')).toBe('Unknown country') + expect(formatCountry(' ')).toBe('Unknown country') + }) + + it('formats a location with the state code and the country name', () => { + expect(formatLocation(sighting)).toBe('Oslo, OS, Norway') + expect(formatLocation({ ...sighting, state: '' })).toBe('Oslo, Norway') + expect(formatLocation({ ...sighting, country: 'xx' })).toBe('Oslo, OS, XX') + expect(formatLocation({ ...sighting, state: null, country: null })).toBe( + 'Oslo, Unknown country', + ) }) it('falls back to readable text when the API returns null', () => { @@ -42,7 +62,7 @@ describe('sighting formatters', () => { expect(formatDuration(null)).toBe('Unknown') expect(formatShape(null)).toBe('Unknown') expect(formatLocation({ ...sighting, city: null })).toBe( - 'Unknown location, OS, NO', + 'Unknown location, OS, Norway', ) expect( formatLocation({ ...sighting, city: null, state: null, country: null }), diff --git a/frontend/src/utils/format-sighting.ts b/frontend/src/utils/format-sighting.ts index 8a20c2c..f427228 100644 --- a/frontend/src/utils/format-sighting.ts +++ b/frontend/src/utils/format-sighting.ts @@ -1,4 +1,5 @@ import type { UfoSighting } from '../types/ufo-sighting' +import { countryNameFromCode } from './countries' export function formatDate(date: string | null) { if (!date) return 'Unknown date' @@ -21,14 +22,27 @@ export function formatDuration(seconds: number | null) { return `${minutes} min ${remainingSeconds} sec` } +/** + * Names a country code, e.g. "no" as "Norway". A code that is not an ISO + * country stays visible in uppercase instead of being hidden behind a + * guessed name, and a missing one says so. + */ +export function formatCountry(code: string | null) { + if (!code?.trim()) return 'Unknown country' + return countryNameFromCode(code.trim()) ?? code.trim().toUpperCase() +} + export function formatLocation(sighting: UfoSighting) { - const city = sighting.city ?? 'Unknown location' - const region = [sighting.state, sighting.country] + const { city, state, country } = sighting + if (!city && !state && !country) return 'Unknown location' + + return [ + city ?? 'Unknown location', + state?.toUpperCase(), + formatCountry(country), + ] .filter(Boolean) .join(', ') - .toUpperCase() - - return region ? `${city}, ${region}` : city } export function formatShape(shape: string | null) { From 5d4d13ca973f65da266d4241bda137af97be5a09 Mon Sep 17 00:00:00 2001 From: Thor Tveito Date: Thu, 5 Nov 2026 10:46:50 +0100 Subject: [PATCH 2/5] feat: Add BackLink component and integrate it into Sighting and Submit pages --- frontend/src/components/BackLink.css | 33 +++++++++++++++++++++++ frontend/src/components/BackLink.tsx | 24 +++++++++++++++++ frontend/src/pages/SightingDetailPage.css | 32 ---------------------- frontend/src/pages/SightingDetailPage.tsx | 9 +++---- frontend/src/pages/SubmitSightingPage.css | 11 +++----- frontend/src/pages/SubmitSightingPage.tsx | 9 +++---- frontend/src/tests/BackLink.test.tsx | 20 ++++++++++++++ 7 files changed, 86 insertions(+), 52 deletions(-) create mode 100644 frontend/src/components/BackLink.css create mode 100644 frontend/src/components/BackLink.tsx create mode 100644 frontend/src/tests/BackLink.test.tsx diff --git a/frontend/src/components/BackLink.css b/frontend/src/components/BackLink.css new file mode 100644 index 0000000..dbac36b --- /dev/null +++ b/frontend/src/components/BackLink.css @@ -0,0 +1,33 @@ +/* A link styled like the other secondary buttons (Load more, Clear + filters). Inline-block so the padding keeps its space outside a grid + too, as on the submit page. */ +.back-link { + display: inline-block; + justify-self: start; + padding: 7px 14px; + border-radius: 12px; + color: var(--accent-strong); + background: var(--accent-bg); + font-size: 15px; + font-weight: 600; + text-decoration: none; + transition: background-color 0.15s; + + &:hover { + background: var(--accent-bg-strong); + } + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + + /* High-contrast modes drop the tint, so a border keeps the shape. */ + @media (forced-colors: active) { + border: 1px solid LinkText; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} diff --git a/frontend/src/components/BackLink.tsx b/frontend/src/components/BackLink.tsx new file mode 100644 index 0000000..d1cc690 --- /dev/null +++ b/frontend/src/components/BackLink.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react' +import { Link, type To } from 'react-router' +import './BackLink.css' + +interface BackLinkProps { + to: To + children: ReactNode +} + +/** + * The link back to the list at the top of the detail and submit pages, so + * it has the same shape, spacing and arrow on both. The arrow is hidden from + * screen readers, which read just the link text. + */ +function BackLink({ to, children }: BackLinkProps) { + return ( + + + {children} + + ) +} + +export default BackLink diff --git a/frontend/src/pages/SightingDetailPage.css b/frontend/src/pages/SightingDetailPage.css index 99f5692..bfe9f1d 100644 --- a/frontend/src/pages/SightingDetailPage.css +++ b/frontend/src/pages/SightingDetailPage.css @@ -15,38 +15,6 @@ border: 0; } -/* A link styled like the other secondary buttons (Load more, Clear - filters). */ -.sighting-detail__back { - justify-self: start; - padding: 7px 14px; - border-radius: 12px; - color: var(--accent-strong); - background: var(--accent-bg); - font-size: 15px; - font-weight: 600; - text-decoration: none; - transition: background-color 0.15s; - - &:hover { - background: var(--accent-bg-strong); - } - - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } - - /* High-contrast modes drop the tint, so a border keeps the shape. */ - @media (forced-colors: active) { - border: 1px solid LinkText; - } - - @media (prefers-reduced-motion: reduce) { - transition: none; - } -} - .sighting-detail h1 { font-size: 40px; margin: 0 0 4px; diff --git a/frontend/src/pages/SightingDetailPage.tsx b/frontend/src/pages/SightingDetailPage.tsx index bc1b7f8..a572bab 100644 --- a/frontend/src/pages/SightingDetailPage.tsx +++ b/frontend/src/pages/SightingDetailPage.tsx @@ -1,6 +1,7 @@ import { CombinedGraphQLErrors } from '@apollo/client' import { useEffect } from 'react' -import { Link, useLocation, useParams } from 'react-router' +import { useLocation, useParams } from 'react-router' +import BackLink from '../components/BackLink' import SightingMap from '../components/SightingMap' import UserSubmittedBadge from '../components/UserSubmittedBadge' import { useDocumentTitle } from '../hooks/use-document-title' @@ -51,11 +52,7 @@ function SightingDetailPage() { useDocumentTitle(title) - const backLink = ( - - Back to results - - ) + const backLink = Back to results return ( <> diff --git a/frontend/src/pages/SubmitSightingPage.css b/frontend/src/pages/SubmitSightingPage.css index 5b65773..e82ebe7 100644 --- a/frontend/src/pages/SubmitSightingPage.css +++ b/frontend/src/pages/SubmitSightingPage.css @@ -3,13 +3,9 @@ margin: 0 auto; } -.submit-sighting__back { - color: var(--accent); - text-underline-offset: 4px; -} - +/* The same 24px below the back link as on the detail page. */ .submit-sighting h1 { - margin-bottom: 16px; + margin: 24px 0 16px; } .submit-sighting__intro { @@ -86,8 +82,7 @@ .submit-sighting__field select:focus-visible, .submit-sighting__field textarea:focus-visible, .submit-sighting button:focus-visible, -.submit-sighting__button:focus-visible, -.submit-sighting__back:focus-visible { +.submit-sighting__button:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } diff --git a/frontend/src/pages/SubmitSightingPage.tsx b/frontend/src/pages/SubmitSightingPage.tsx index 483d020..08bd82e 100644 --- a/frontend/src/pages/SubmitSightingPage.tsx +++ b/frontend/src/pages/SubmitSightingPage.tsx @@ -6,6 +6,7 @@ import { CREATE_SIGHTING_MUTATION, type SightingFieldError, } from '../graphql/mutations' +import BackLink from '../components/BackLink' import LocationPicker from '../components/LocationPicker' import { SHAPES_QUERY } from '../graphql/queries' @@ -254,9 +255,7 @@ function SubmitSightingPage() { if (submittedId) { return (
- - Back to sightings - + Back to sightings

Sighting submitted

@@ -286,9 +285,7 @@ function SubmitSightingPage() { return (
- - Back to sightings - + Back to sightings

Report a sighting

diff --git a/frontend/src/tests/BackLink.test.tsx b/frontend/src/tests/BackLink.test.tsx new file mode 100644 index 0000000..4697ea9 --- /dev/null +++ b/frontend/src/tests/BackLink.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { describe, expect, it } from 'vitest' +import BackLink from '../components/BackLink' + +describe('BackLink', () => { + it('links to its target and is named by its text, not the arrow', () => { + render( + + + Back to results + + , + ) + + const link = screen.getByRole('link', { name: 'Back to results' }) + expect(link).toHaveAttribute('href', '/?q=disk') + expect(link).toHaveTextContent('← Back to results') + }) +}) From 1364519e26ead0f67f22fb1b1a1aa447b17dfb27 Mon Sep 17 00:00:00 2001 From: Thor Tveito Date: Thu, 5 Nov 2026 11:10:40 +0100 Subject: [PATCH 3/5] Refactor: Enhance mobile search controls and results layout for better usability --- frontend/e2e/mobile-search.spec.ts | 68 ++++++++++++++++++++ frontend/src/components/SearchFilters.css | 77 +++++++++++++++++++++++ frontend/src/components/SearchFilters.tsx | 34 ++++++++-- frontend/src/components/SightingList.css | 12 ++-- frontend/src/tests/SearchPage.test.tsx | 32 ++++++++++ 5 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 frontend/e2e/mobile-search.spec.ts diff --git a/frontend/e2e/mobile-search.spec.ts b/frontend/e2e/mobile-search.spec.ts new file mode 100644 index 0000000..b4c7a1a --- /dev/null +++ b/frontend/e2e/mobile-search.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test' +import { mockGraphql } from './mock-graphql' + +// The filters fold away and the list maps are hidden only on small +// screens, through CSS, so this runs in a real browser at phone width. +test.describe('on a phone', () => { + test.use({ viewport: { width: 320, height: 640 } }) + + test.beforeEach(async ({ page }) => { + await mockGraphql(page) + await page.goto('./') + await expect(page.getByText('Showing 2 of 12 results')).toBeVisible() + }) + + test('shows the first result without scrolling past the filters', async ({ + page, + }) => { + await expect(page.getByRole('combobox', { name: 'Country' })).toBeHidden() + await expect( + page.locator('.sighting-list__item h2 a').first(), + ).toBeInViewport() + // The list has no maps, so it loads no map tiles either. + await expect(page.locator('.sighting-list .leaflet-container')).toHaveCount( + 0, + ) + }) + + test('opens, uses and closes the filters with the keyboard', async ({ + page, + }) => { + const toggle = page.getByRole('button', { name: 'Filters', exact: true }) + + await page.getByRole('combobox', { name: 'Sort by' }).focus() + await page.keyboard.press('Tab') + await expect(toggle).toBeFocused() + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + + await page.keyboard.press('Enter') + await expect(toggle).toHaveAttribute('aria-expanded', 'true') + await page.keyboard.press('Tab') + await expect(page.getByRole('combobox', { name: 'Shape' })).toBeFocused() + + await page + .getByRole('combobox', { name: 'Country' }) + .selectOption({ label: 'Norway' }) + await expect(page).toHaveURL(/[?&]country=no/) + + // Closed, the fields are gone but the active filter still shows, and + // removing it moves focus to the toggle instead of a hidden field. + await toggle.click() + await expect(page.getByRole('combobox', { name: 'Country' })).toBeHidden() + await page.getByRole('button', { name: 'Remove Country: Norway' }).click() + await expect(page).not.toHaveURL(/country=/) + await expect(toggle).toBeFocused() + }) +}) + +test('shows the filter fields without a toggle on wide screens', async ({ + page, +}) => { + await page.setViewportSize({ width: 1280, height: 800 }) + await mockGraphql(page) + await page.goto('./') + await expect(page.getByRole('combobox', { name: 'Country' })).toBeVisible() + await expect( + page.getByRole('button', { name: 'Filters', exact: true }), + ).toBeHidden() +}) diff --git a/frontend/src/components/SearchFilters.css b/frontend/src/components/SearchFilters.css index 0669a25..c6c2312 100644 --- a/frontend/src/components/SearchFilters.css +++ b/frontend/src/components/SearchFilters.css @@ -88,3 +88,80 @@ transition: none; } } + +/* Shown only on small screens, where the four fields would push the + results below the fold. */ +.search-filters__toggle { + display: none; +} + +@media (max-width: 640px) { + .search-filters__toggle { + display: inline-flex; + align-items: center; + gap: 10px; + margin-top: 12px; + padding: 8px 16px; + border: 1px solid var(--border); + border-radius: 12px; + color: var(--text-h); + background: var(--code-bg); + font: inherit; + font-weight: 600; + cursor: pointer; + transition: border-color 0.15s; + + /* A chevron that points up while the filters are shown. */ + &::after { + content: ''; + width: 6px; + height: 6px; + border: solid var(--accent); + border-width: 0 2px 2px 0; + transform: translateY(-2px) rotate(45deg); + } + + &[aria-expanded='true']::after { + transform: translateY(2px) rotate(-135deg); + } + + &:hover { + border-color: var(--accent-border); + } + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + + @media (forced-colors: active) { + border-color: ButtonText; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + } + + .search-filters { + margin-top: 12px; + } + + .search-filters--collapsed { + display: none; + } + + /* The toggle already says "Filters", so the legend is only needed by + screen readers, which still announce the group by it. */ + .search-filters legend { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; + } +} diff --git a/frontend/src/components/SearchFilters.tsx b/frontend/src/components/SearchFilters.tsx index a1664e0..ff7a178 100644 --- a/frontend/src/components/SearchFilters.tsx +++ b/frontend/src/components/SearchFilters.tsx @@ -1,4 +1,5 @@ import { useQuery } from '@apollo/client/react' +import { useRef, useState } from 'react' import { FILTER_OPTIONS_QUERY } from '../graphql/queries' import { createRetry } from '../utils/create-retry' import { formatCountry } from '../utils/format-sighting' @@ -23,6 +24,9 @@ export default function SearchFilters({ onClear, }: SearchFiltersProps) { const { data, loading, error, refetch } = useQuery(FILTER_OPTIONS_QUERY) + // Only small screens hide the fields behind the toggle (SearchFilters.css). + const [open, setOpen] = useState(false) + const toggleRef = useRef(null) const shapes = [ ...new Set([...(data?.shapes ?? []), filters.shape].filter(Boolean)), ] @@ -46,22 +50,44 @@ export default function SearchFilters({ const active = chips.filter((filter) => filter.label) // The removed chip, or the whole row of them, disappears, so focus moves - // to the filter's own field instead of being lost. + // to the filter's own field instead of being lost. While the fields are + // hidden on a small screen, it moves to the toggle that shows them. + // jsdom has no checkVisibility, and it shows every field anyway. + function focusField(key: keyof SearchFiltersValue) { + const field = document.getElementById(`filter-${key}`) + if (field && (field.checkVisibility?.() ?? true)) field.focus() + else toggleRef.current?.focus() + } + function remove(key: keyof SearchFiltersValue) { onChange(key, '') - document.getElementById(`filter-${key}`)?.focus() + focusField(key) } function clear() { onClear() - document.getElementById('filter-shape')?.focus() + focusField('shape') } // The messages are outside the fieldset, so on wide screens the fields // can form one bar with the search and the messages go below it. return ( <> -
+ +
Filters
diff --git a/frontend/src/components/SightingList.css b/frontend/src/components/SightingList.css index 2fbe296..26aaceb 100644 --- a/frontend/src/components/SightingList.css +++ b/frontend/src/components/SightingList.css @@ -230,7 +230,7 @@ @media (max-width: 640px) { .sighting-results { - margin-top: 32px; + margin-top: 24px; } .sighting-results__header h1 { @@ -246,6 +246,7 @@ .sighting-list__item { gap: 16px; grid-template-columns: 140px minmax(0, 1fr); + padding: 16px 0; } .sighting-list__item .sighting-map, @@ -254,13 +255,16 @@ } } +/* A full-width map would fill most of a phone screen per result, so the + list shows only the text and the detail page shows the map. A hidden + placeholder never scrolls into view, so its map and tiles are never + loaded either. */ @media (max-width: 480px) { .sighting-list__item { grid-template-columns: 1fr; } - .sighting-list__item .sighting-map, - .sighting-list__item .sighting-map .leaflet-container { - min-height: 180px; + .sighting-list__item .sighting-map { + display: none; } } diff --git a/frontend/src/tests/SearchPage.test.tsx b/frontend/src/tests/SearchPage.test.tsx index 6eee20d..e76240a 100644 --- a/frontend/src/tests/SearchPage.test.tsx +++ b/frontend/src/tests/SearchPage.test.tsx @@ -262,6 +262,38 @@ describe('SearchPage', () => { ).toBeInTheDocument() }) + it('shows and hides the filter fields from the Filters toggle', async () => { + const user = userEvent.setup() + renderSearchPage() + const toggle = screen.getByRole('button', { name: 'Filters' }) + const fields = screen.getByRole('group', { name: 'Filters' }) + + expect(toggle).toHaveAttribute('aria-expanded', 'false') + expect(toggle).toHaveAttribute('aria-controls', fields.id) + expect(fields).toHaveClass('search-filters--collapsed') + + await user.click(toggle) + expect(toggle).toHaveAttribute('aria-expanded', 'true') + expect(fields).not.toHaveClass('search-filters--collapsed') + }) + + it('moves focus to the Filters toggle when the fields are hidden', async () => { + // Stands in for a small screen, where CSS hides the collapsed fields. + // jsdom applies no CSS and has no checkVisibility of its own. + HTMLElement.prototype.checkVisibility = () => false + try { + const user = userEvent.setup() + renderSearchPage('/?country=no') + await user.click( + await screen.findByRole('button', { name: 'Remove Country: Norway' }), + ) + expect(screen.getByRole('button', { name: 'Filters' })).toHaveFocus() + } finally { + // @ts-expect-error Removes the stub so jsdom is as before. + delete HTMLElement.prototype.checkVisibility + } + }) + it('keeps filters on load more and separates filtered pages in the cache', async () => { const requests = renderSearchPage('/?country=us&sort=date-asc') await screen.findByText('Showing 5 of 6 results') From 213388bb08436338138fb8e8907faf0edd57382c Mon Sep 17 00:00:00 2001 From: Thor Tveito Date: Thu, 5 Nov 2026 11:28:07 +0100 Subject: [PATCH 4/5] feat: Refine header layout and theme switch for narrow widths --- frontend/e2e/header.spec.ts | 70 ++++++++++++++++++++++++++++++ frontend/src/components/Banner.css | 37 +++++++++++++++- 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 frontend/e2e/header.spec.ts diff --git a/frontend/e2e/header.spec.ts b/frontend/e2e/header.spec.ts new file mode 100644 index 0000000..2dfaa4b --- /dev/null +++ b/frontend/e2e/header.spec.ts @@ -0,0 +1,70 @@ +import { expect, test, type Page } from '@playwright/test' +import { mockGraphql } from './mock-graphql' + +function header(page: Page) { + const banner = page.getByRole('banner') + return { + home: banner.getByRole('link', { name: 'UFOund it' }), + report: banner.getByRole('link', { name: 'Report a sighting' }), + theme: banner.getByRole('switch', { name: 'Dark mode' }), + } +} + +async function boxOf(locator: ReturnType) { + const box = await locator.boundingBox() + if (!box) throw new Error('Not rendered') + return box +} + +// The layout comes from CSS media queries, so it is checked in a real +// browser at the widths where it changes. +for (const colorScheme of ['light', 'dark'] as const) { + test(`puts the report link and theme switch on one row at 320 px (${colorScheme})`, async ({ + page, + }) => { + await page.setViewportSize({ width: 320, height: 640 }) + await page.emulateMedia({ colorScheme }) + await mockGraphql(page) + await page.goto('./') + const { home, report, theme } = header(page) + + const homeBox = await boxOf(home) + const reportBox = await boxOf(report) + const themeBox = await boxOf(theme) + + // The logo has the first row, the link and the switch share the second + // at the same height, and the rows line up at the left edge. + expect(reportBox.y).toBeGreaterThan(homeBox.y + homeBox.height) + expect(reportBox.y).toBe(themeBox.y) + expect(reportBox.height).toBe(themeBox.height) + expect(reportBox.x).toBe(homeBox.x) + expect(reportBox.x + reportBox.width).toBeLessThan(themeBox.x) + expect(themeBox.x + themeBox.width).toBeLessThanOrEqual(320) + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBe(320) + + // The Tab order follows the rows. + await home.focus() + await page.keyboard.press('Tab') + await expect(report).toBeFocused() + await page.keyboard.press('Tab') + await expect(theme).toBeFocused() + }) +} + +// Just above the two-row layout, the smaller logo keeps the header from +// wrapping on its own. +for (const width of [481, 520]) { + test(`keeps the header on one row at ${width} px`, async ({ page }) => { + await page.setViewportSize({ width, height: 640 }) + await mockGraphql(page) + await page.goto('./') + const { home, report, theme } = header(page) + + const homeBox = await boxOf(home) + for (const box of [await boxOf(report), await boxOf(theme)]) { + expect(box.y).toBeLessThan(homeBox.y + homeBox.height) + } + }) +} diff --git a/frontend/src/components/Banner.css b/frontend/src/components/Banner.css index 912f8b0..ae58b27 100644 --- a/frontend/src/components/Banner.css +++ b/frontend/src/components/Banner.css @@ -96,7 +96,10 @@ color: var(--accent); } -@media (max-width: 480px) { +/* The full-size logo, the link and the switch need about 490 px, so the + smaller logo starts above the 480 px where the header wraps. Otherwise + it would wrap on its own, with the switch between the two rows. */ +@media (max-width: 520px) { .banner__title { gap: 8px; font-size: 1.3rem; @@ -108,6 +111,38 @@ } } +/* Too narrow for one row, so the logo gets the first row, and the link + and the switch share the second, in the same order as the Tab order. + The link fills the space next to the switch, so both rows line up with + the page's edges, and is as tall as the switch. */ +@media (max-width: 480px) { + .banner__content { + flex-wrap: nowrap; + align-items: flex-end; + gap: 12px; + } + + .banner__nav { + flex-direction: column; + align-items: flex-start; + gap: 12px; + min-width: 0; + } + + .banner__submit { + align-self: stretch; + display: flex; + align-items: center; + justify-content: center; + min-height: 42px; + box-sizing: border-box; + } + + .banner__theme { + flex: 0 0 auto; + } +} + /* A pill with a moon on the left and a sun on the right. The thumb slides behind the icon of the mode in use. */ .banner__theme { From 5eff7564d9a25d67d65a4921c6d971f0ab849304 Mon Sep 17 00:00:00 2001 From: Thor Tveito Date: Thu, 5 Nov 2026 11:45:57 +0100 Subject: [PATCH 5/5] feat: Make header more compact on mobile by adjusting layout and link visibility --- frontend/e2e/header.spec.ts | 43 +++++++------- frontend/src/components/Banner.css | 95 ++++++++++++++++++------------ frontend/src/components/Banner.tsx | 5 +- 3 files changed, 80 insertions(+), 63 deletions(-) diff --git a/frontend/e2e/header.spec.ts b/frontend/e2e/header.spec.ts index 2dfaa4b..a350516 100644 --- a/frontend/e2e/header.spec.ts +++ b/frontend/e2e/header.spec.ts @@ -16,10 +16,20 @@ async function boxOf(locator: ReturnType) { return box } +// Whether every part shares the logo's row. +async function expectOneRow(page: Page) { + const { home, report, theme } = header(page) + const homeBox = await boxOf(home) + for (const box of [await boxOf(report), await boxOf(theme)]) { + expect(box.y).toBeLessThan(homeBox.y + homeBox.height) + expect(box.y + box.height).toBeGreaterThan(homeBox.y) + } +} + // The layout comes from CSS media queries, so it is checked in a real // browser at the widths where it changes. for (const colorScheme of ['light', 'dark'] as const) { - test(`puts the report link and theme switch on one row at 320 px (${colorScheme})`, async ({ + test(`fits the header on one row at 320 px (${colorScheme})`, async ({ page, }) => { await page.setViewportSize({ width: 320, height: 640 }) @@ -28,23 +38,15 @@ for (const colorScheme of ['light', 'dark'] as const) { await page.goto('./') const { home, report, theme } = header(page) - const homeBox = await boxOf(home) - const reportBox = await boxOf(report) - const themeBox = await boxOf(theme) - - // The logo has the first row, the link and the switch share the second - // at the same height, and the rows line up at the left edge. - expect(reportBox.y).toBeGreaterThan(homeBox.y + homeBox.height) - expect(reportBox.y).toBe(themeBox.y) - expect(reportBox.height).toBe(themeBox.height) - expect(reportBox.x).toBe(homeBox.x) - expect(reportBox.x + reportBox.width).toBeLessThan(themeBox.x) - expect(themeBox.x + themeBox.width).toBeLessThanOrEqual(320) + await expectOneRow(page) expect( await page.evaluate(() => document.documentElement.scrollWidth), ).toBe(320) + // Only "Report" shows, but the name is still the whole action. + await expect(report).toHaveAccessibleName('Report a sighting') + const hidden = await boxOf(report.locator('.banner__submit-more')) + expect(hidden.width).toBeLessThanOrEqual(1) - // The Tab order follows the rows. await home.focus() await page.keyboard.press('Tab') await expect(report).toBeFocused() @@ -53,18 +55,13 @@ for (const colorScheme of ['light', 'dark'] as const) { }) } -// Just above the two-row layout, the smaller logo keeps the header from -// wrapping on its own. -for (const width of [481, 520]) { +// 480 px is the widest with the short link, and 481 to 520 px the range +// where the smaller logo keeps the full link from wrapping. +for (const width of [375, 480, 481, 520]) { test(`keeps the header on one row at ${width} px`, async ({ page }) => { await page.setViewportSize({ width, height: 640 }) await mockGraphql(page) await page.goto('./') - const { home, report, theme } = header(page) - - const homeBox = await boxOf(home) - for (const box of [await boxOf(report), await boxOf(theme)]) { - expect(box.y).toBeLessThan(homeBox.y + homeBox.height) - } + await expectOneRow(page) }) } diff --git a/frontend/src/components/Banner.css b/frontend/src/components/Banner.css index ae58b27..55699b5 100644 --- a/frontend/src/components/Banner.css +++ b/frontend/src/components/Banner.css @@ -97,8 +97,8 @@ } /* The full-size logo, the link and the switch need about 490 px, so the - smaller logo starts above the 480 px where the header wraps. Otherwise - it would wrap on its own, with the switch between the two rows. */ + smaller logo starts above the 480 px where the link is shortened. + Otherwise the header would wrap on its own. */ @media (max-width: 520px) { .banner__title { gap: 8px; @@ -111,41 +111,10 @@ } } -/* Too narrow for one row, so the logo gets the first row, and the link - and the switch share the second, in the same order as the Tab order. - The link fills the space next to the switch, so both rows line up with - the page's edges, and is as tall as the switch. */ -@media (max-width: 480px) { - .banner__content { - flex-wrap: nowrap; - align-items: flex-end; - gap: 12px; - } - - .banner__nav { - flex-direction: column; - align-items: flex-start; - gap: 12px; - min-width: 0; - } - - .banner__submit { - align-self: stretch; - display: flex; - align-items: center; - justify-content: center; - min-height: 42px; - box-sizing: border-box; - } - - .banner__theme { - flex: 0 0 auto; - } -} - /* A pill with a moon on the left and a sun on the right. The thumb slides behind the icon of the mode in use. */ .banner__theme { + --theme-icon: 34px; position: relative; display: inline-flex; border: 1px solid var(--border); @@ -165,8 +134,8 @@ position: absolute; top: 3px; left: 3px; - width: 34px; - height: 34px; + width: var(--theme-icon); + height: var(--theme-icon); border-radius: 11px; background: var(--accent); box-shadow: var(--shadow); @@ -174,14 +143,14 @@ } .banner__theme[aria-checked='false'] .banner__theme-thumb { - transform: translateX(34px); + transform: translateX(var(--theme-icon)); } .banner__theme-icon { /* Above the thumb, which comes first in the markup. */ position: relative; - width: 34px; - height: 34px; + width: var(--theme-icon); + height: var(--theme-icon); padding: 8px; box-sizing: border-box; fill: none; @@ -216,3 +185,51 @@ transition: none; } } + +/* At phone widths the full header needs about 450 px, so it would wrap + onto two rows. Shortening the link to "Report" and making the logo and + the switch a little smaller fits it on one row from 320 px. The rest of + the link's text stays for screen readers, so its name is still "Report + a sighting" and still starts with the word that shows. The header can + still wrap, so a wider font or larger text never pushes it off screen. */ +@media (max-width: 480px) { + .banner__content, + .banner__nav { + gap: 8px; + } + + .banner__title { + gap: 6px; + font-size: 1.15rem; + } + + .banner__logo { + width: 30px; + height: 27px; + } + + .banner__submit { + padding: 8px 10px; + } + + .banner__submit-more { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; + } + + /* 28 px stays above the 24 px minimum for touch targets. */ + .banner__theme { + --theme-icon: 28px; + } + + .banner__theme-icon { + padding: 6px; + } +} diff --git a/frontend/src/components/Banner.tsx b/frontend/src/components/Banner.tsx index e7a1ad2..da3509c 100644 --- a/frontend/src/components/Banner.tsx +++ b/frontend/src/components/Banner.tsx @@ -62,8 +62,11 @@ function Banner() { UFOund it + {/* Phones show only "Report" (Banner.css), but screen readers + always read the whole name. The space is outside the hidden + part, since some name calculations drop it from there. */} - Report a sighting + Report a sighting {/* A switch, so screen readers say "Dark mode, switch, on" while