diff --git a/frontend/e2e/header.spec.ts b/frontend/e2e/header.spec.ts index 2dfaa4b..a350516 100644 --- a/frontend/e2e/header.spec.ts +++ b/frontend/e2e/header.spec.ts @@ -16,10 +16,20 @@ async function boxOf(locator: ReturnType) { 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(`puts the report link and theme switch on one row at 320 px (${colorScheme})`, async ({ + test(`fits the header on one row at 320 px (${colorScheme})`, async ({ page, }) => { await page.setViewportSize({ width: 320, height: 640 }) @@ -28,23 +38,15 @@ for (const colorScheme of ['light', 'dark'] as const) { 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) + 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) - // The Tab order follows the rows. await home.focus() await page.keyboard.press('Tab') await expect(report).toBeFocused() @@ -53,18 +55,13 @@ for (const colorScheme of ['light', 'dark'] as const) { }) } -// Just above the two-row layout, the smaller logo keeps the header from -// wrapping on its own. -for (const width of [481, 520]) { +// 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('./') - 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) - } + await expectOneRow(page) }) } diff --git a/frontend/src/components/Banner.css b/frontend/src/components/Banner.css index ae58b27..55699b5 100644 --- a/frontend/src/components/Banner.css +++ b/frontend/src/components/Banner.css @@ -97,8 +97,8 @@ } /* 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. */ + 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; @@ -111,41 +111,10 @@ } } -/* 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 { + --theme-icon: 34px; position: relative; display: inline-flex; border: 1px solid var(--border); @@ -165,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); @@ -174,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; @@ -216,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