From 1364519e26ead0f67f22fb1b1a1aa447b17dfb27 Mon Sep 17 00:00:00 2001 From: Thor Tveito Date: Thu, 5 Nov 2026 11:10:40 +0100 Subject: [PATCH] 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')