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
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()
})
77 changes: 77 additions & 0 deletions frontend/src/components/SearchFilters.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
34 changes: 30 additions & 4 deletions frontend/src/components/SearchFilters.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<HTMLButtonElement>(null)
const shapes = [
...new Set([...(data?.shapes ?? []), filters.shape].filter(Boolean)),
]
Expand All @@ -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 (
<>
<fieldset className="search-filters" aria-busy={loading}>
<button
ref={toggleRef}
type="button"
className="search-filters__toggle"
aria-expanded={open}
aria-controls="filter-fields"
onClick={() => setOpen(!open)}
>
Filters
</button>
<fieldset
id="filter-fields"
className={`search-filters${open ? '' : ' search-filters--collapsed'}`}
aria-busy={loading}
>
<legend>Filters</legend>
<div className="search-bar search-filters__controls">
<div className="search-bar__field">
Expand Down
12 changes: 8 additions & 4 deletions frontend/src/components/SightingList.css
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@

@media (max-width: 640px) {
.sighting-results {
margin-top: 32px;
margin-top: 24px;
}

.sighting-results__header h1 {
Expand All @@ -246,6 +246,7 @@
.sighting-list__item {
gap: 16px;
grid-template-columns: 140px minmax(0, 1fr);
padding: 16px 0;
}

.sighting-list__item .sighting-map,
Expand All @@ -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;
}
}
32 changes: 32 additions & 0 deletions frontend/src/tests/SearchPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading