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
4 changes: 4 additions & 0 deletions docs/branch-review-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -1248,3 +1248,7 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie
| 2026-07-28 | PR #1267 / dependabot/github-actions (merged) | 359b0e1a21a489978f00df8075ebcd906d57de4b | open-pr-merge-sweep | MERGED. Allowlisted anthropics/claude-code-action be7b93b (v1.0.183 peeled tag) in github-action-pins.mjs; pin check PASS. | hosted-pr-required,static,unit,check-github-action-pins,tag-peel-verify |
| 2026-07-28 | PR #1351 / audit-design-fixes-clean (merged) | c2ec2d75553e7718fd89194cbc8362d9905151d3 | open-pr-merge-sweep | MERGED after EmptyState actions= fix, DocumentViewerRail re-home, review-thread clearance. Supersedes prior LEFT OPEN handoff row. | hosted-pr-required,static,build,production-ui,merge-tree-clean |
| 2026-07-28 | PR #1352 / execute-audit-remediation-fixes (closed) | dcdcb5b2b0546eddbb5bfd6efdb68122be2eabaa | open-pr-merge-sweep | CLOSED as superseded junk: stale continuation of merged #1305 with real merge-tree conflicts. Supersedes prior SKIP-CLOSE 403 row; closed via ManagePullRequest. | merge-tree-conflicts,gh-pr-1305-MERGED,manage-pr-close |
| 2026-07-29 | origin/main | 02144ab6e5f2040110cc5e15cfcd15455c2a0615 | cross-page PWA search composer click-close defect | P1 confirmed; explicit gesture-only focus release implemented locally | manual PWA/browser repro; focused Vitest 48/48; typecheck |
| 2026-07-29 | codex/search-composer-focus-pwa | f9a14e8e38a568d8675c4154597f73340161e4ea | current diff vs origin/main: PWA search composer focus | P1 fix reviewed; passive viewport scroll no longer blurs search | focused Vitest 48/48; typecheck; ledger guard; phone and PR plans inspected |
| 2026-07-29 | codex/search-composer-focus-pwa | 5c6a75c21833dc31d4f8658cec15154f58918a36 | PR #1373 unresolved review comment remediation | P2 test race and keyboard/scrollbar intent gaps fixed | focused Vitest 48/48; typecheck; diff check |
| 2026-07-29 | codex/search-composer-focus-pwa | 3f7cd1e4069b7ef8f8b18519adfb0dba0dc349a8 | PR #1373 CircleCI lint remediation | Deterministic unused locator warning removed | CircleCI format passed; lint root cause captured; focused Vitest 48/48; typecheck |
9 changes: 0 additions & 9 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -659,12 +659,6 @@ function GlobalStandaloneSearchShellBody({

function handleMainScroll(event: UIEvent<HTMLDivElement>) {
const target = event.currentTarget;
// Scrolling the result canvas while the dock input is focused (user
// retapped the pill) must release the focus pin before the hide reporter
// runs; otherwise both chrome edges stay locked for the whole session.
if (target.scrollTop > 8 && inputRef.current && document.activeElement === inputRef.current) {
inputRef.current.blur();
}
chromeScrollHide.reportScroll({
offset: target.scrollTop,
maxOffset: Math.max(0, target.scrollHeight - target.clientHeight),
Expand All @@ -688,9 +682,6 @@ function GlobalStandaloneSearchShellBody({
const target = event.target;
if (!(target instanceof HTMLElement) || !main.contains(target)) return;
if (target.scrollHeight <= target.clientHeight + 1) return;
if (target.scrollTop > 8 && inputRef.current && document.activeElement === inputRef.current) {
inputRef.current.blur();
}
reportChromeScrollHideRef.current({
offset: target.scrollTop,
maxOffset: Math.max(0, target.scrollHeight - target.clientHeight),
Expand Down
51 changes: 43 additions & 8 deletions src/components/clinical-dashboard/use-hide-on-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,20 +448,38 @@ export function useDocumentScrollHideReporter(
useEffect(() => {
let frame = 0;

const releaseComposerFocus = () => {
const input = focusInputRef?.current;
if (!input || document.activeElement !== input) return;
if (!window.matchMedia(phoneMediaQuery).matches) return;
input.blur();
};

const releaseComposerFocusOnOutsideScrollIntent = (event: Event) => {
const input = focusInputRef?.current;
if (!input || document.activeElement !== input) return;
if (!window.matchMedia(phoneMediaQuery).matches) return;
// Keyboard opening, focus scrolling, and viewport settling can all emit
// `scroll` without a user scroll gesture. Only explicit wheel, touch, or
// pointer activity outside the composer may dismiss the active input.
// Gestures that start inside the composer retain its swipe threshold.
const composer = input.closest("form");
if (event.target instanceof Node && composer?.contains(event.target)) return;
releaseComposerFocus();
};

const releaseComposerFocusOnKeyboardScrollIntent = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
if (event.key !== "PageDown" && event.key !== "PageUp") return;
releaseComposerFocus();
};

const evaluate = () => {
frame = 0;
const scrollingElement = document.scrollingElement ?? document.documentElement;
const maxOffset = Math.max(0, scrollingElement.scrollHeight - window.innerHeight);
if (maxOffset <= 0) return;
const offset = window.scrollY;
if (
window.matchMedia(phoneMediaQuery).matches &&
offset > topRevealOffset &&
focusInputRef?.current &&
document.activeElement === focusInputRef.current
) {
focusInputRef.current.blur();
}
reportScroll({
offset,
maxOffset,
Expand All @@ -482,8 +500,25 @@ export function useDocumentScrollHideReporter(
};

window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("wheel", releaseComposerFocusOnOutsideScrollIntent, {
capture: true,
passive: true,
});
window.addEventListener("touchmove", releaseComposerFocusOnOutsideScrollIntent, {
capture: true,
passive: true,
});
window.addEventListener("pointerdown", releaseComposerFocusOnOutsideScrollIntent, {
capture: true,
passive: true,
});
window.addEventListener("keydown", releaseComposerFocusOnKeyboardScrollIntent, true);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("wheel", releaseComposerFocusOnOutsideScrollIntent, true);
window.removeEventListener("touchmove", releaseComposerFocusOnOutsideScrollIntent, true);
window.removeEventListener("pointerdown", releaseComposerFocusOnOutsideScrollIntent, true);
window.removeEventListener("keydown", releaseComposerFocusOnKeyboardScrollIntent, true);
if (frame) window.cancelAnimationFrame(frame);
};
}, [collapseMetricsRoot, focusInputRef, reportScroll]);
Expand Down
17 changes: 10 additions & 7 deletions tests/header-scroll-hide-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,10 +289,14 @@ describe("shared header hide/reveal wiring", () => {
expect(hookSource).toContain("headerRelease + phoneSafeAreaRelease + reserveRelease");
});

it("only blurs the focused dock input on phone document scroll", () => {
expect(hookSource).toMatch(
/window\.matchMedia\(phoneMediaQuery\)\.matches\s*&&\s*offset > topRevealOffset[\s\S]*?focusInputRef\.current\.blur\(\)/,
);
it("only blurs the focused dock input for explicit outside scroll intent", () => {
expect(hookSource).toContain('window.addEventListener("wheel", releaseComposerFocusOnOutsideScrollIntent');
expect(hookSource).toContain('window.addEventListener("touchmove", releaseComposerFocusOnOutsideScrollIntent');
expect(hookSource).toContain('window.addEventListener("pointerdown", releaseComposerFocusOnOutsideScrollIntent');
expect(hookSource).toContain('window.addEventListener("keydown", releaseComposerFocusOnKeyboardScrollIntent');
expect(hookSource).toContain('const composer = input.closest("form")');
expect(hookSource).toContain('event.key !== "PageDown" && event.key !== "PageUp"');
expect(hookSource).not.toMatch(/const offset = window\.scrollY;[\s\S]{0,300}?\.blur\(\)/);
});

it("rebases the reporter when a host swaps its scroll geometry", () => {
Expand Down Expand Up @@ -335,9 +339,8 @@ describe("shared header hide/reveal wiring", () => {
expect(shellSource).toContain("focus: !trimmedQuery");
expect(shellSource).toContain("queryInputAutoFocus={requestedFocus && !hasSubmittedModeSearch}");
expect(shellSource).toContain("if (hasSubmittedModeSearch)");
expect(shellSource).toContain(
"if (target.scrollTop > 8 && inputRef.current && document.activeElement === inputRef.current)",
);
expect(shellSource).not.toContain("target.scrollTop > 8 && inputRef.current");
expect(hookSource).toContain("releaseComposerFocusOnOutsideScrollIntent");
expect(behaviourDocSource).toContain("Do not carry composer focus into submitted result views");
});

Expand Down
58 changes: 58 additions & 0 deletions tests/ui-phone-scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,64 @@ test("compiled standalone PWA rules bind full-height footer chrome to the inner
expect(hidden.mainScrollTop, "the PWA footer must follow the inner scroll owner").toBeGreaterThan(120);
});

for (const scrollOwner of ["browser document", "standalone PWA main"] as const) {
test(`${scrollOwner} search keeps focus through passive scroll settling and releases it on a user gesture`, async ({
page,
}) => {
await page.setViewportSize(phoneViewport);
await gotoPhoneSurface(page, "/services?q=clinic&run=1&focus=1");
if (scrollOwner === "standalone PWA main") {
expect(await forceCompiledStandalonePhoneCss(page), "compiled CSS must expose standalone rules").toBeGreaterThan(
0,
);
}
await addPhoneScrollRunway(page);

const input = page.getByTestId("global-search-input");
await expect(input).toBeVisible({ timeout: 20_000 });
await page.evaluate((owner) => {
const main = document.getElementById("main-content");
const target = owner === "standalone PWA main" ? main : (document.scrollingElement ?? document.documentElement);
if (!target) throw new Error("search focus proof did not find its scroll owner");
target.scrollTop = 40;
(owner === "standalone PWA main" ? main : window)?.dispatchEvent(new Event("scroll"));
}, scrollOwner);

await input.click();
await expect(input).toBeFocused();

// iOS keyboard opening, scrollIntoView(), and viewport settling can emit a
// passive scroll notification without any user scroll intent. It must not
// close the keyboard or make the composer eligible to hide.
await page.evaluate((owner) => {
const target = owner === "standalone PWA main" ? document.getElementById("main-content") : window;
target?.dispatchEvent(new Event("scroll"));
}, scrollOwner);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
await expect(input).toBeFocused();

// Keyboard scrolling carries explicit user intent without a wheel/touch
// event. Release focus first, then prove that the active owner can hide the
// shared chrome as it scrolls through the result canvas.
await input.dispatchEvent("keydown", { key: "PageDown" });
await expect(input).not.toBeFocused();
await page.evaluate((owner) => {
const main = document.getElementById("main-content");
const target = owner === "standalone PWA main" ? main : (document.scrollingElement ?? document.documentElement);
if (!target) throw new Error("keyboard scroll proof did not find its scroll owner");
target.scrollTop = 720;
(owner === "standalone PWA main" ? main : window)?.dispatchEvent(new Event("scroll"));
}, scrollOwner);
await expect(page.getByTestId("universal-header-collapse")).toHaveAttribute("data-scroll-hidden", "true");
await expect(page.locator("form.answer-footer-search-dock")).toHaveAttribute("data-scroll-hidden", "true");
});
}

for (const footerCase of standalonePageOwnedFooterRoutes) {
test(`standalone ${footerCase.name} is frame-owned and stays anchored while main scrolls`, async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
Expand Down
59 changes: 59 additions & 0 deletions tests/use-hide-on-scroll.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import { useDocumentScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll";

describe("useDocumentScrollHideReporter composer focus", () => {
afterEach(() => {
document.body.replaceChildren();
vi.restoreAllMocks();
});

it("preserves focus through passive scroll settling and releases it on an outside gesture", async () => {
vi.spyOn(window, "matchMedia").mockImplementation(
(query) =>
({
matches: query === "(max-width: 639px)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}) satisfies MediaQueryList,
);
const form = document.createElement("form");
const input = document.createElement("input");
const results = document.createElement("main");
form.append(input);
document.body.append(form, results);
input.focus();

vi.spyOn(document.documentElement, "scrollHeight", "get").mockReturnValue(1_200);
vi.spyOn(window, "innerHeight", "get").mockReturnValue(600);
vi.spyOn(window, "scrollY", "get").mockReturnValue(40);

renderHook(() => useDocumentScrollHideReporter(vi.fn(), null, { current: input }));

window.dispatchEvent(new Event("scroll"));
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
await waitFor(() => expect(input).toHaveFocus());
Comment thread
BigSimmo marked this conversation as resolved.

// Movement that begins inside the composer keeps its existing swipe
// threshold in MasterSearchHeader rather than dismissing on finger jitter.
input.dispatchEvent(new Event("touchmove", { bubbles: true }));
expect(input).toHaveFocus();

input.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "PageDown" }));
expect(input).not.toHaveFocus();

input.focus();
results.dispatchEvent(new Event("pointerdown", { bubbles: true }));
expect(input).not.toHaveFocus();

input.focus();
results.dispatchEvent(new Event("touchmove", { bubbles: true }));
expect(input).not.toHaveFocus();
});
});
Loading