From a37da150089605123682e0c02ccd4009bbc2c306 Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 30 Sep 2026 22:08:56 +0100 Subject: [PATCH] fix(search): keep the phone results toolbar inside the screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search summary ("SW196AR · within 1 mile Edit") ran about 40px off the right edge on every phone, cutting "Edit" in half, and the filter row bled past the screen with it (reported on an iPhone 17, 402px). The phone toolbar is a column flex container, but it kept flex-wrap: wrap from the desktop rule. In a wrapping flex container each line is as wide as its widest item's content, not the container, so the summary's ~410px of text stretched the line past a 370px toolbar. nowrap makes stretch mean the container's width. Verified on staging by injecting the rule: nothing in the toolbar passes the right edge at 360 or 402px. The E2E overflow check could not see this: .main clips on x, so scrollWidth stayed equal to the viewport. The phone journey now measures the toolbar's own right edges, and runs at 402px as well. It also waits for the bottom sheet's slide-up to finish before measuring, and measures the Map/List button again after the sheet pushes it up; both assertions read a mid-animation or stale box before. Co-Authored-By: Claude Opus 5.5 --- e2e/tests/journeys.spec.ts | 30 +++++++++++++++++++--- nextjs-app/components/FilterBar.module.css | 8 ++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index cdae421..9dabf5d 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -576,7 +576,8 @@ test('a desktop postcode search opens on the map with the list beside it', async await expect(page.locator('.sc-popup')).toContainText(name); }); -for (const width of [360, 390, 430]) { +// 402 is the iPhone 17, where the toolbar overflowed (see below). +for (const width of [360, 390, 402, 430]) { test(`a phone opens on the map, with the list a tap away, at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 800 }); await searchByName(page, 'B1 1BB'); @@ -597,13 +598,34 @@ for (const width of [360, 390, 430]) { await page.locator('.sc-pin').first().dispatchEvent('click'); const sheet = page.locator('[class*="bottomSheet"]'); await expect(sheet).toBeVisible(); - const sheetBox = (await sheet.boundingBox())!; - expect(sheetBox.y + sheetBox.height).toBeLessThanOrEqual(barTop); - expect(sheetBox.y).toBeGreaterThanOrEqual(fabBox.y + fabBox.height); + // The sheet slides up over 0.3s; measure where it comes to rest, not a + // frame of the animation (which reads as below the tab bar). + await expect.poll(async () => { + const box = (await sheet.boundingBox())!; + return Math.round(box.y + box.height); + }).toBeLessThanOrEqual(barTop); + // The dock is anchored at the bottom, so the sheet pushes the button up: + // measure the button again now that the sheet is in. + const fabNow = (await toList.boundingBox())!; + expect((await sheet.boundingBox())!.y).toBeGreaterThanOrEqual(fabNow.y + fabNow.height); // MOBILE.md: no horizontal overflow, and 44px targets in the new chrome. expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)) .toBe(0); + // scrollWidth alone cannot see this page's overflow: .main clips on x, so + // the search summary ran 40px off an iPhone 17 screen with scrollWidth + // still equal to the viewport. Measure the toolbar's own edges instead; + // the controls row scrolls by design, so only its box is held to the edge. + const offscreen = await page.evaluate(() => { + const toolbar = document.querySelector('[class*="resultsToolbar"]'); + return [...(toolbar?.querySelectorAll('*') ?? [])] + .filter((el) => (el as HTMLElement).offsetParent + && !el.parentElement?.closest('[class*="controlsRow"]')) + .map((el) => ({ el: (el.className?.toString() || el.tagName).slice(0, 40), + right: Math.round(el.getBoundingClientRect().right) })) + .filter((o) => o.right > window.innerWidth); + }); + expect(offscreen).toEqual([]); const small = await page.evaluate(() => { const toolbar = document.querySelector('[class*="resultsToolbar"]'); const fabEl = document.querySelector('[class*="viewFab"]'); diff --git a/nextjs-app/components/FilterBar.module.css b/nextjs-app/components/FilterBar.module.css index 0df34a8..611b03c 100644 --- a/nextjs-app/components/FilterBar.module.css +++ b/nextjs-app/components/FilterBar.module.css @@ -522,8 +522,16 @@ * everything else, so it must never be the chip scrolled out of sight. */ @media (max-width: 640px) { + /* + * nowrap matters as much as column. The desktop rule wraps, and in a + * wrapping flex container each line is as wide as its widest item's content, + * not the container: the search summary ("SW196AR · within 1 mile Edit", + * about 410px) stretched the line, and the controls row with it, 40px past a + * 402px iPhone 17 screen. Single-line, stretch means the container's width. + */ .filterBar:not(.heroMode) { flex-direction: column; + flex-wrap: nowrap; align-items: stretch; gap: 0.625rem; } -- 2.54.0