+
{activeItem && (
diff --git a/propr-ui/src/components/MobileSearchOverlay.tsx b/propr-ui/src/components/MobileSearchOverlay.tsx
index b36acbe59a..234056da82 100644
--- a/propr-ui/src/components/MobileSearchOverlay.tsx
+++ b/propr-ui/src/components/MobileSearchOverlay.tsx
@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { X } from 'lucide-react';
import GlobalSearch from './GlobalSearch';
+import { useVisualViewportFrame } from '../hooks/useVisualViewportFrame';
interface MobileSearchOverlayProps {
/** Increments on every Search request, so a repeat request refocuses the field. */
@@ -20,6 +21,10 @@ const FOCUSABLE = 'input, button:not([disabled]), [href], [tabindex]:not([tabind
* In the Linux desktop frame the sheet starts below the window-control row
* (desktop.css), so the native controls never cover Close search or the field.
*
+ * An on-screen keyboard shrinks and pans the visual viewport while the layout
+ * viewport keeps its size. The sheet follows the visible area, so the field and
+ * Close search stay on screen and the results palette fits above the keyboard.
+ *
* It closes on Escape, the close button, a backdrop press, or a route change
* (choosing a result), and the header restores focus to where it was.
*/
@@ -28,6 +33,7 @@ const MobileSearchOverlay: React.FC
= ({ request, onCl
const dialogRef = useRef(null);
const location = useLocation();
const openedAt = useRef(location);
+ useVisualViewportFrame(dialogRef);
useEffect(() => {
inputRef.current?.focus();
From 5668a2cccdff742d35b863b4816932a42ba2572d Mon Sep 17 00:00:00 2001
From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com>
Date: Wed, 21 Oct 2026 00:02:59 +0000
Subject: [PATCH 3/3] feat(ai): The WebKit failure was a real focus bug in the
product. The test's timing only exposed it. It's now fixed in
`GlobalHeader.tsx`, the original spec waits for the sheet to be ready, and a
new test reproduces the bug every run.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The WebKit failure was a real focus bug in the product. The test's timing only exposed it. It's now fixed in `GlobalHeader.tsx`, the original spec waits for the sheet to be ready, and a new test reproduces the bug every run.
**Cause.** The sheet focuses its Search field in an effect that runs just after the sheet appears. `closeMobileSearch` moved focus back to the opener as soon as a close was requested. If the backdrop was pressed in the short gap between the sheet appearing and the field taking focus, this happened:
1. The opener was focused (it already was).
2. React ran the pending effect, which focused the Search field.
3. React removed the sheet, so focus fell to `body`.
That matches the WebKit symptom: the sheet was gone and All Repos was "inactive". I reproduced it on demand in both Chromium and WebKit with an in-page probe that presses the backdrop the moment the sheet appears. The focus log showed exactly that order: opener → Search field → `body`. When the sheet was fully open and focused before the press, as in the spec's first two dismissals, focus came back correctly. So your hypothesis was right about the trigger, but the lost focus itself was a product bug, not just a test artefact.
**Fix.** `closeMobileSearch` now only closes the sheet. Focus goes back to the opener after the sheet has been removed (`GlobalHeader.tsx:112`). Nothing the sheet still has pending can take focus after that. A route change still leaves focus to the new page. The keyboard-geometry (IME) handling, the desktop toolbar path, native commands and the sheet itself are unchanged.
**Test changes** (`e2e/global-search-native-zoom.pw.ts`):
- **Readiness wait:** before the third, empty-query backdrop click, the spec now waits until the Search field is visible and focused, as the first opening already did. All existing assertions are kept and in the same order. No timeouts, forced focus, retries or skips were added.
- **New test:** "a backdrop press as the narrow Search sheet appears still returns focus". It presses the backdrop in exactly that gap on every run, checks that the field wasn't focused yet, and expects the sheet gone and the opener focused. It then reopens Search and closes it with Escape.
- **Negative check:** against the original `GlobalHeader.tsx`, the new test fails in both Chromium and WebKit with the operator's error ("Expected: focused / Received: inactive"). With the fix it passes.
**Results:**
- **Unit tests, typecheck, lint:** 112 GlobalHeader/GlobalSearch/MobileBottomNavigation/viewport unit tests pass; `tsc` and lint (`--max-warnings 0`) are clean.
- **Chromium:** all 74 cases in 12 affected specs pass with no retries. They cover the native-zoom, global-search, mobile-menu-search and Search keyboard-viewport specs, the dialog and short-band keyboard-viewport specs, list-search-mobile, dashboard-responsive, and the task-list desktop and search specs.
- **WebKit native-zoom:** 40/40 passed, which is all 4 cases repeated 10 times with no retries.
- **WebKit, wider set:** 17 passed and 2 failed. Both failures are the `mobile-search-keyboard-viewport` cases, which stop at `newCDPSession`, which WebKit doesn't support. Your scoped desktop-WebKit wheel/tap adaptation isn't in this branch, so those two cases didn't run on WebKit here. They did pass in the Chromium run.
This was all headless browser testing with fixture APIs, not an Android/Gboard or signed native check. I didn't capture previews because the change isn't visible on screen; it's where focus ends up.
PR: #3302
Comment by: @integry (ID: 6304371085)
Model: Claude Opus 5.5
---
propr-ui/e2e/global-search-native-zoom.pw.ts | 36 ++++++++++++++++++++
propr-ui/src/components/GlobalHeader.tsx | 11 ++++--
2 files changed, 45 insertions(+), 2 deletions(-)
diff --git a/propr-ui/e2e/global-search-native-zoom.pw.ts b/propr-ui/e2e/global-search-native-zoom.pw.ts
index 86b1e056d0..61d980ede2 100644
--- a/propr-ui/e2e/global-search-native-zoom.pw.ts
+++ b/propr-ui/e2e/global-search-native-zoom.pw.ts
@@ -70,6 +70,8 @@ for (const layout of layouts) {
await expect(opener).toBeFocused();
// Without a query the sheet's own backdrop takes the press, with the same result.
await sendSearch(page);
+ await expect(input).toBeVisible();
+ await expect(input).toBeFocused();
await page.mouse.click(viewport.width - 4, viewport.height - 4);
await expect(sheet).toHaveCount(0);
await expect(opener).toBeFocused();
@@ -87,3 +89,37 @@ for (const layout of layouts) {
await context.close();
});
}
+
+// A press can reach the sheet after it appears but before its field takes focus (the field is
+// focused in an effect after the sheet commits). Closing then must still return focus to where
+// the operator was, not to the field that is being removed. The press is dispatched from a
+// MutationObserver so it lands in exactly that interval on every run.
+test('a backdrop press as the narrow Search sheet appears still returns focus', async ({ browser }) => {
+ const context = await browser.newContext({ viewport: { width: 320, height: 568 }, deviceScaleFactor: 2 });
+ const page = await context.newPage();
+ await openPage(page);
+ const opener = page.locator('main').getByRole('button').first();
+ await opener.focus();
+ const pressedBeforeFieldFocus = await page.evaluate(event => new Promise(resolve => {
+ const observer = new MutationObserver(() => {
+ const sheet = document.querySelector('[data-testid="mobile-search-overlay"]');
+ if (!sheet) return;
+ observer.disconnect();
+ const unfocused = !sheet.contains(document.activeElement);
+ sheet.firstElementChild!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
+ resolve(unfocused);
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+ window.dispatchEvent(new CustomEvent(event, { detail: 'search', cancelable: true }));
+ }), DESKTOP_UI_COMMAND_EVENT);
+ expect(pressedBeforeFieldFocus).toBe(true);
+ await expect(page.getByRole('dialog', { name: 'Search' })).toHaveCount(0);
+ await expect(opener).toBeFocused();
+
+ // The next request opens a usable sheet as usual.
+ await sendSearch(page);
+ await expect(page.getByRole('dialog', { name: 'Search' }).getByRole('combobox', { name: 'Search' })).toBeFocused();
+ await page.keyboard.press('Escape');
+ await expect(opener).toBeFocused();
+ await context.close();
+});
diff --git a/propr-ui/src/components/GlobalHeader.tsx b/propr-ui/src/components/GlobalHeader.tsx
index 2e0bf79321..8d57b0e193 100644
--- a/propr-ui/src/components/GlobalHeader.tsx
+++ b/propr-ui/src/components/GlobalHeader.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useRef, useCallback } from 'react';
+import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ChevronDown } from 'lucide-react';
import { DESKTOP_UI_COMMAND_EVENT } from '../desktop/useDesktopNativeCommands';
@@ -107,11 +107,18 @@ function useSearchRequests(searchInputRef: React.RefObject {
sheetOpenRef.current = false;
setMobileSearch(0);
+ }, []);
+
+ // Restore focus once the sheet has unmounted, not when it is asked to close: a press that
+ // lands right after the sheet appears can close it before its field takes focus, and that
+ // pending focus would otherwise move focus into the field being removed, leaving none.
+ useLayoutEffect(() => {
+ if (mobileSearch) return;
const target = returnFocusRef.current;
returnFocusRef.current = null;
// After a route change the previous element may be gone; leave focus to the new page then.
if (target?.isConnected) target.focus();
- }, []);
+ }, [mobileSearch]);
// Widening past `md` reveals the toolbar field, so the sheet steps aside.
useEffect(() => {