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/header.spec.ts b/frontend/e2e/header.spec.ts new file mode 100644 index 0000000..a350516 --- /dev/null +++ b/frontend/e2e/header.spec.ts @@ -0,0 +1,67 @@ +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 +} + +// 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(`fits the header 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) + + 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) + + await home.focus() + await page.keyboard.press('Tab') + await expect(report).toBeFocused() + await page.keyboard.press('Tab') + await expect(theme).toBeFocused() + }) +} + +// 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('./') + await expectOneRow(page) + }) +} 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/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/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/components/Banner.css b/frontend/src/components/Banner.css index 912f8b0..55699b5 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 link is shortened. + Otherwise the header would wrap on its own. */ +@media (max-width: 520px) { .banner__title { gap: 8px; font-size: 1.3rem; @@ -111,6 +114,7 @@ /* 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); @@ -130,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); @@ -139,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; @@ -181,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 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 a72e7c1..ff7a178 100644 --- a/frontend/src/components/SearchFilters.tsx +++ b/frontend/src/components/SearchFilters.tsx @@ -1,18 +1,10 @@ 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' 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 @@ -32,13 +24,16 @@ 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)), ] 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 +42,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}` }, @@ -55,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/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') + }) +}) diff --git a/frontend/src/tests/SearchPage.test.tsx b/frontend/src/tests/SearchPage.test.tsx index 2bd403f..e76240a 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') }) @@ -260,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') @@ -314,7 +348,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 +419,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) {