From 213388bb08436338138fb8e8907faf0edd57382c Mon Sep 17 00:00:00 2001 From: Thor Tveito Date: Thu, 5 Nov 2026 11:28:07 +0100 Subject: [PATCH] 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 {