Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion frontend/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
{
Expand Down
67 changes: 67 additions & 0 deletions frontend/e2e/header.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Page['locator']>) {
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)
})
}
68 changes: 68 additions & 0 deletions frontend/e2e/mobile-search.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
2 changes: 1 addition & 1 deletion frontend/e2e/submit-sighting.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/components/BackLink.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
24 changes: 24 additions & 0 deletions frontend/src/components/BackLink.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Link className="back-link" to={to}>
<span aria-hidden="true">← </span>
{children}
</Link>
)
}

export default BackLink
64 changes: 58 additions & 6 deletions frontend/src/components/Banner.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -130,23 +134,23 @@
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);
transition: transform 0.2s;
}

.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;
Expand Down Expand Up @@ -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;
}
}
5 changes: 4 additions & 1 deletion frontend/src/components/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,11 @@ function Banner() {
<span className="banner__ufo">UFO</span>und it
</span>
</Link>
{/* 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. */}
<Link className="banner__submit" to="/submit">
Report a sighting
Report <span className="banner__submit-more">a sighting</span>
</Link>
</nav>
{/* A switch, so screen readers say "Dark mode, switch, on" while
Expand Down
Loading
Loading