From 9ad4a308c4841ee1ce67a0006278f4674730ebe7 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:59:48 +0800 Subject: [PATCH 1/4] fix(ui): hide answer footer chips; scope via + menu after main sync --- scripts/capture-chrome-parity.ts | 4 - .../master-search-header.tsx | 272 ++++++++++++++---- tests/ui-accessibility.spec.ts | 18 +- tests/ui-smoke.spec.ts | 53 +--- 4 files changed, 245 insertions(+), 102 deletions(-) diff --git a/scripts/capture-chrome-parity.ts b/scripts/capture-chrome-parity.ts index 98e0a00c30..567a937173 100644 --- a/scripts/capture-chrome-parity.ts +++ b/scripts/capture-chrome-parity.ts @@ -82,10 +82,6 @@ const selectorGroups: Array<{ key: string; selector: string; pseudo?: string }> { key: "composer-pill-children", selector: 'form:has([data-testid="global-search-input"]) > div > *' }, { key: "composer-input", selector: '[data-testid="global-search-input"]', pseudo: "::placeholder" }, { key: "composer-buttons", selector: 'form:has([data-testid="global-search-input"]) button' }, - { key: "evidence-chip", selector: 'button[aria-label="Open evidence-backed answer sources"]' }, - { key: "evidence-chip-icon", selector: 'button[aria-label="Open evidence-backed answer sources"] svg' }, - { key: "scope-chip", selector: 'button[aria-label="Open source scope"]' }, - { key: "scope-chip-icon", selector: 'button[aria-label="Open source scope"] svg' }, { key: "viewer-header", selector: "main header, body > div > header", pseudo: "::after" }, { key: "viewer-composer", selector: 'form:has(input[placeholder^="Search or answer"])' }, { key: "viewer-composer-children", selector: 'form:has(input[placeholder^="Search or answer"]) > *' }, diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index a0c6575a84..62f68cb830 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -16,13 +16,17 @@ import { createPortal } from "react-dom"; import { Activity, + BadgeCheck, CalendarDays, Check, CheckCircle2, ChevronDown, FileText, Filter, + FolderOpen, + GitBranch, Globe2, + ListChecks, Loader2, Menu, MessageSquarePlus, @@ -206,7 +210,6 @@ export function MasterSearchHeader({ mobileLeadingAction = "menu", onMobileBack, hideOnScroll, - onBottomComposerScrollHiddenChange, }: { documents: ClinicalDocument[]; documentTotal?: number; @@ -262,15 +265,12 @@ export function MasterSearchHeader({ heroComposerFromTablet?: boolean; mobileLeadingAction?: "menu" | "back"; onMobileBack?: () => void; - /** Phone-only hide-on-scroll for the universal header and bottom search dock. - * "overlay" translates the sticky header away (host scrolls the document, - * content already flows beneath); "collapse" also releases the header's - * layout space (host keeps the header above an internally scrolling element). - * The phone bottom search composer hides in sync on search-mode pages. - * `containerRef` points at the scrolling element; omit it to observe window scroll. */ + /** Phone-only hide-on-scroll for the universal header. "overlay" translates + * the sticky header away (host scrolls the document, content already flows + * beneath); "collapse" also releases the header's layout space (host keeps + * the header above an internally scrolling element). `containerRef` points + * at the scrolling element; omit it to observe window scroll. */ hideOnScroll?: { strategy: "overlay" | "collapse"; containerRef?: RefObject }; - /** Fired when the phone bottom search dock enters or leaves the scroll-hidden state. */ - onBottomComposerScrollHiddenChange?: (hidden: boolean) => void; }) { const visibleAppModeOptions = defaultVisibleAppModeOptions; const trimmedQuery = query.trim(); @@ -310,26 +310,12 @@ export function MasterSearchHeader({ // or while focus sits inside the header chrome (keyboard users must not tab // into invisible controls). const [headerChromeFocused, setHeaderChromeFocused] = useState(false); - const [composerChromeFocused, setComposerChromeFocused] = useState(false); const scrollHidden = useHideOnScroll({ containerRef: hideOnScroll?.containerRef, disabled: !hideOnScroll, }); const headerChromeHidden = scrollHidden && !modeMenuOpen && !actionMenuOpen && !scopeOpen && !scopeSheetOpen && !headerChromeFocused; - const bottomComposerScrollHiddenActive = Boolean(hideOnScroll && isMobileBottomComposer && usesPhoneSearchLayout); - const bottomComposerHidden = - bottomComposerScrollHiddenActive && - scrollHidden && - !actionMenuOpen && - !commandDropdownOpen && - !scopeOpen && - !scopeSheetOpen && - !composerChromeFocused; - - useEffect(() => { - onBottomComposerScrollHiddenChange?.(bottomComposerHidden); - }, [bottomComposerHidden, onBottomComposerScrollHiddenChange]); // Stable, header-owned element the composer is portaled into; we move it in and // out of the page-owned slot rather than portaling into the slot directly. const [desktopHomeComposerHost, setDesktopHomeComposerHost] = useState(null); @@ -349,6 +335,7 @@ export function MasterSearchHeader({ [documentById, selectedDocumentIds], ); const scopeSummary = selectedDocumentIds.length === 0 ? "All documents" : `${selectedDocumentIds.length} scoped`; + const footerScopeLabel = selectedDocumentIds.length === 0 ? "All sources" : `${selectedDocumentIds.length} scoped`; const scopePreview = useMemo( () => selectedDocuments @@ -695,7 +682,6 @@ export function MasterSearchHeader({ ); let frame: number | null = null; let retryTimeout: number | null = null; - let portalRetryCount = 0; const syncTarget = () => { if (retryTimeout !== null) { window.clearTimeout(retryTimeout); @@ -703,16 +689,14 @@ export function MasterSearchHeader({ } const slot = mediaQuery.matches ? document.getElementById(desktopHomeComposerSlotId) : null; if (slot) { - portalRetryCount = 0; if (host.parentNode !== slot) slot.appendChild(host); setDesktopHomeComposerHost(host); setDesktopHomeComposerActive(true); } else { host.parentNode?.removeChild(host); setDesktopHomeComposerActive(false); - if (mediaQuery.matches && portalRetryCount < 24) { - portalRetryCount += 1; - retryTimeout = window.setTimeout(syncTarget, Math.min(40 * portalRetryCount, 400)); + if (mediaQuery.matches) { + retryTimeout = window.setTimeout(syncTarget, 50); } } }; @@ -1040,6 +1024,144 @@ export function MasterSearchHeader({ ); } + // "open-evidence" is the one footer-chip action that isn't already a mode-action + // id — every other chip dispatches through the existing runModeAction handler + // (the same dispatcher the "+" action menu already uses for these ids). + type FooterChipActionId = ModeActionId | "open-evidence"; + + type FooterActionChip = { + icon: typeof Search; + shortLabel: string; + longLabel: string; + actionId: FooterChipActionId; + ariaLabel: string; + }; + + // The first ("trust") chip on the universal small-screen footer. Every mode gets + // one, mirroring Answer's "Evidence-based" chip in tone, each wired to a real + // action from that mode's own action menu rather than being decorative. + function footerTrustChipFor(mode: AppModeId): FooterActionChip | null { + switch (mode) { + case "answer": + return { + icon: ListChecks, + shortLabel: "Evidence", + longLabel: "Evidence-based", + actionId: "open-evidence", + ariaLabel: "Open evidence-backed answer sources", + }; + case "documents": + return { + icon: BadgeCheck, + shortLabel: "Indexed", + longLabel: "Fully indexed", + actionId: "documents-collections", + ariaLabel: "Open the indexed document library", + }; + case "forms": + return { + icon: BadgeCheck, + shortLabel: "Library", + longLabel: "Form library", + actionId: "forms-records", + ariaLabel: "Open the form library", + }; + case "services": + return { + icon: BadgeCheck, + shortLabel: "Verified", + longLabel: "Verified directory", + actionId: "services-records", + ariaLabel: "Browse verified service records", + }; + case "favourites": + return { + icon: BadgeCheck, + shortLabel: "Trusted", + longLabel: "Trusted picks", + actionId: "favourites-browse", + ariaLabel: "Browse trusted favourites", + }; + case "differentials": + return { + icon: ListChecks, + shortLabel: "Evidence", + longLabel: "Evidence-linked", + actionId: "differentials-evidence", + ariaLabel: "Review cited differential evidence", + }; + case "prescribing": + return { + icon: ShieldCheck, + shortLabel: "Safety", + longLabel: "Safety-checked", + actionId: "medication-safety", + ariaLabel: "Review contraindications and cautions", + }; + case "tools": + return { + icon: BadgeCheck, + shortLabel: "Curated", + longLabel: "Curated registry", + actionId: "tools-browse", + ariaLabel: "Browse the curated tools registry", + }; + default: + return null; + } + } + + // The second footer chip. Answer/Documents/Forms use the shared document-scope + // trigger instead (see hasScopeFooterChip below) since scope is a real, existing + // concept for those three modes. Tools has no genuine second action yet, so it + // intentionally ships with a single chip rather than an invented one. + function footerSecondaryChipFor(mode: AppModeId): FooterActionChip | null { + switch (mode) { + case "services": + return { + icon: ListChecks, + shortLabel: "Pathways", + longLabel: "Pathways", + actionId: "services-pathways", + ariaLabel: "Browse referral pathways", + }; + case "favourites": + return { + icon: FolderOpen, + shortLabel: "Sets", + longLabel: "Sets", + actionId: "favourites-sets", + ariaLabel: "Open saved sets", + }; + case "differentials": + return { + icon: GitBranch, + shortLabel: "Criteria", + longLabel: "Criteria", + actionId: "differentials-criteria", + ariaLabel: "Compare distinguishing criteria", + }; + case "prescribing": + return { + icon: Activity, + shortLabel: "Monitor", + longLabel: "Monitoring", + actionId: "medication-monitoring", + ariaLabel: "Review the monitoring schedule", + }; + default: + return null; + } + } + + function runFooterChipAction(actionId: FooterChipActionId) { + if (actionId === "open-evidence") { + onOpenEvidence?.(); + return; + } + runModeAction(actionId); + } + function renderSearchComposer(placement: "default" | "desktop-home") { const isDesktopHomeComposer = placement === "desktop-home"; const usesAnswerFooterStyle = isAnswerFooterComposer && !isDesktopHomeComposer; @@ -1047,17 +1169,27 @@ export function MasterSearchHeader({ const usesCompactMobileBottomStyle = usesMobileBottomStyle && mobileBottomSearchVariant === "compact"; const usesBottomComposerPlacement = usesAnswerFooterStyle || (usesMobileBottomStyle && usesPhoneSearchLayout); const usesFooterChipLayout = usesBottomComposerPlacement || isDesktopHomeComposer; + // Compact search views drop the chip row on phones so the pill can sit + // flush with the bottom edge; the same actions stay reachable via the + // integrated "+" menu. + const showFooterSearchChips = usesFooterChipLayout && !usesCompactMobileBottomStyle && searchMode !== "answer"; // The visible footer/hero composer chrome is universal; submit semantics still // come from the active mode. const usesSendAffordance = searchMode === "answer" || usesFooterChipLayout; const usesModeIdentityAffordance = usesBottomComposerPlacement && !usesSendAffordance; const ModeIdentityIcon = appModeIcons[searchMode]; const hasScopeFooterChip = searchMode === "answer" || searchMode === "documents" || searchMode === "forms"; + const trustFooterChip = footerTrustChipFor(searchMode); + const secondaryFooterChip = footerSecondaryChipFor(searchMode); + // Fallback icons here are never rendered — both are only used inside a JSX guard + // on the corresponding chip being non-null — but keep the icon variables typed as + // components (not `| null`) so the JSX below type-checks without a cast. + const TrustFooterChipIcon = trustFooterChip?.icon ?? BadgeCheck; + const SecondaryFooterChipIcon = secondaryFooterChip?.icon ?? ListChecks; const composerPlaceholder = usesMobileBottomStyle && searchMode === "differentials" ? "Search a presentation" : queryPlaceholder; const usesPhoneFooterDock = usesBottomComposerPlacement && usesPhoneSearchLayout; - const shouldHideBottomOnScroll = Boolean(hideOnScroll && usesMobileBottomStyle && usesPhoneFooterDock); const commandSurfacePlacement = usesBottomComposerPlacement ? "bottom-dock" : "inline"; @@ -1067,8 +1199,6 @@ export function MasterSearchHeader({ data-footer-variant={usesPhoneFooterDock ? (usesCompactMobileBottomStyle ? "compact" : "default") : undefined} data-footer-addon={usesPhoneFooterDock && mobileBottomSearchAddonSlotId ? "differentials-compare" : undefined} data-command-open={usesBottomComposerPlacement && commandDropdownOpen ? "true" : undefined} - data-scroll-hidden={shouldHideBottomOnScroll && bottomComposerHidden ? "true" : undefined} - {...(shouldHideBottomOnScroll ? composerFocusProps : undefined)} className={cn( isDesktopHomeComposer ? "universal-home-search-edge mx-auto w-full" @@ -1089,8 +1219,6 @@ export function MasterSearchHeader({ usesPhoneFooterDock && "answer-footer-search-dock", usesCompactMobileBottomStyle && "document-mobile-search-compact", usesFooterChipLayout && "flex flex-col items-center gap-2.5", - shouldHideBottomOnScroll && - "max-sm:transition-transform max-sm:duration-200 max-sm:ease-out motion-reduce:transition-none", )} > {usesBottomComposerPlacement ? - {/* Scope popover is a form sibling so the "+" menu's "Set scope" action can - open it even when the footer chip row is not shown. */} + {hasScopeFooterChip && !showFooterSearchChips ? ( + + ) : null} + {showFooterSearchChips && (trustFooterChip || hasScopeFooterChip || secondaryFooterChip) ? ( +
+ {trustFooterChip ? ( + + ) : null} + {hasScopeFooterChip ? ( + + ) : null} + {!hasScopeFooterChip && secondaryFooterChip ? ( + + ) : null} +
+ ) : null} + {/* Rendered as a sibling of the chip row (not nested inside it) so the "+" + menu's "Set scope" action still opens this popover on screens where the + chip row itself is hidden (documents/forms desktop widths) — the popover + still anchors correctly since the form stays position:fixed/sticky there. */} {hasScopeFooterChip && !usesScopeSheet && scopeOpen ? (
setComposerChromeFocused(true), - onBlurCapture: (event: ReactFocusEvent) => { - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setComposerChromeFocused(false); - }, - } - : undefined; const headerAndComposer = ( <> @@ -1510,11 +1688,7 @@ export function MasterSearchHeader({ {searchComposerVisible ? ( <> - {desktopHomeComposerActive && desktopHomeComposerHost - ? null - : desktopHomeComposerSlotId - ? null - : renderSearchComposer("default")} + {desktopHomeComposerActive && desktopHomeComposerHost ? null : renderSearchComposer("default")} {desktopHomeComposerActive && desktopHomeComposerHost ? createPortal(renderSearchComposer("desktop-home"), desktopHomeComposerHost) : null} diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index f177658af2..cab9cb2385 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -55,17 +55,19 @@ async function expectDashboardUsable(page: Page) { } async function openScopeControl(page: Page) { - const actionMenu = page.getByRole("button", { name: "Open answer options" }); + const trigger = page.getByRole("button", { name: "Open answer options" }); + const menu = page.getByTestId("daily-actions-menu"); await expect(async () => { - await actionMenu.click(); - const actionsMenu = page.getByTestId("daily-actions-menu"); - await expect(actionsMenu).toBeVisible({ timeout: uiAssertionTimeoutMs }); - await actionsMenu.getByRole("menuitem", { name: /^Scope\b/ }).click(); - await expect(page.getByTestId("scope-command-popover")).toBeVisible({ - timeout: uiAssertionTimeoutMs, - }); + if (await menu.isVisible().catch(() => false)) return; + await trigger.click(); + await expect(menu).toBeVisible({ timeout: uiAssertionTimeoutMs }); }).toPass({ timeout: 10_000 }); + + await menu.getByRole("menuitem", { name: "Scope", exact: true }).click(); + await expect(page.locator('[data-testid="scope-command-popover"]:visible')).toBeVisible({ + timeout: uiAssertionTimeoutMs, + }); } test.describe("Clinical KB accessibility media smoke", () => { diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 02e77c13d5..c9476804e6 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -515,34 +515,10 @@ async function expectDomIntegrity(page: Page, options: { mobileNav?: boolean; mo } } -// Scope opens from the command surface after answer submit and from the "+" menu on mode homes. -async function openScopeControl(page: Page) { - await page.keyboard.press("Escape"); - await page.keyboard.press("Escape"); - await page - .getByRole("listbox", { name: /search suggestions/i }) - .waitFor({ state: "hidden", timeout: 5_000 }) - .catch(() => undefined); - - const composer = page.locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible').first(); - - await expect(async () => { - await composer.click(); - const scopeOption = page.getByRole("option", { name: /Scope sources/i }); - if (await scopeOption.isVisible({ timeout: 2_000 }).catch(() => false)) { - await scopeOption.click(); - } else { - const actionMenu = page.getByRole("button", { name: "Open answer options" }); - await expect(actionMenu).toBeVisible(); - await actionMenu.click(); - const actionsMenu = page.getByTestId("daily-actions-menu"); - await expect(actionsMenu).toBeVisible({ timeout: uiAssertionTimeoutMs }); - await actionsMenu.getByRole("menuitem", { name: /^Scope\b/ }).click(); - } - await expect(page.getByTestId("scope-command-popover")).toBeVisible({ - timeout: uiAssertionTimeoutMs, - }); - }).toPass({ timeout: 15_000 }); +// The document-scope control lives in the footer composer. It opens a desktop +// popover or mobile sheet depending on the current viewport. +function scopeTrigger(page: Page) { + return page.locator('[data-testid="scope-trigger"]:visible'); } async function expectMinTouchTarget(locator: Locator, minSize = 44) { @@ -743,6 +719,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByTestId("scope-command-popover")).toBeHidden(); await expect(page.getByTestId("scope-prompts-drawer")).toHaveCount(0); await expect(page.getByTestId("mobile-scope-popover")).toHaveCount(0); + await expect(page.getByRole("button", { name: "lithium level timing" })).toBeVisible(); await expect(page.getByRole("button", { name: "Search documents" })).toBeVisible(); await expect(page.getByRole("button", { name: "Upload document" })).toBeVisible(); await expectDomIntegrity(page, { mobileNav: viewport.width <= 768 }); @@ -1049,14 +1026,9 @@ test.describe("Clinical KB UI smoke coverage", () => { // First open — use robust retry helper to handle async state update timing. await openDailyActions(page); - await expect(async () => { - if (await dailyActionsMenu.isVisible().catch(() => false)) { - // Top-left avoids integrated menu panels that can intercept center clicks in CI. - await page.mouse.click(8, 8); - } - await expect(dailyActionsMenu).toHaveCount(0); - await expect(dailyActionsTrigger).toHaveAttribute("aria-expanded", "false"); - }).toPass({ timeout: 10_000 }); + await page.mouse.click(640, 430); + await expect(dailyActionsMenu).toHaveCount(0); + await expect(dailyActionsTrigger).toHaveAttribute("aria-expanded", "false"); // Second open - verify opening the mode menu closes the daily actions surface. await openDailyActions(page); @@ -1287,8 +1259,9 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(evidenceSheet).toHaveCount(0); await expect(evidenceDrawer).toBeFocused(); - await openScopeControl(page); - const scopePopover = page.getByTestId("scope-command-popover"); + const dailyActions = await openDailyActions(page); + await dailyActions.getByRole("menuitem", { name: "Scope", exact: true }).click(); + const scopePopover = page.locator('[data-testid="scope-command-popover"]:visible'); await expect(scopePopover).toBeVisible(); const scopeFilter = scopePopover.locator('[data-testid="document-scope-filter"]'); await expect(scopeFilter).toBeVisible(); @@ -1310,7 +1283,6 @@ test.describe("Clinical KB UI smoke coverage", () => { expect(popoverMetrics.height).toBeLessThanOrEqual(Math.ceil(popoverMetrics.viewportHeight * 0.72)); await page.keyboard.press("Escape"); await expect(scopePopover).toBeHidden(); - await expect(page.getByTestId("global-search-input")).toBeFocused(); await expectNoPageHorizontalOverflow(page); }); @@ -1626,9 +1598,8 @@ test.describe("Clinical KB UI smoke coverage", () => { await mockDemoApi(page); await gotoApp(page, "/favourites?q=lithium%20set"); - const globalSearchInput = page.getByRole("combobox", { name: /Search saved favourites/ }); + const globalSearchInput = visibleQuestionInput(page); await expect(page.getByRole("button", { name: "Mode Favourites" })).toBeVisible(); - await expect(globalSearchInput).toBeVisible({ timeout: 30_000 }); await expect(globalSearchInput).toHaveAttribute("placeholder", "Search favourites..."); await expect(globalSearchInput).toHaveValue("lithium set"); await expect(page.getByTestId("favourites-hub")).toBeVisible(); From c33664a3e17dda8445229ed2f38bffbeb58c94ea Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:09:05 +0800 Subject: [PATCH 2/4] fix(ci): restore bottom-composer callback prop and drop stale integratedChipRow Align MasterSearchHeader with main after chip removal: wire onBottomComposerScrollHiddenChange for typecheck and remove the integratedChipRow prop that ModeActionPopup no longer accepts. --- .gitignore | 1 + .../clinical-dashboard/master-search-header.tsx | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b2313c9645..d0afa73df2 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ next-env.d.ts # agent/QA artifacts .codex-screenshots/ /worktrees/ +.worktrees/ /docs/mockups/ # design/UX review scratch dumps (favourites-review, tools-page-review, etc.) — never commit artifacts/ diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 62f68cb830..a27211a367 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -210,6 +210,7 @@ export function MasterSearchHeader({ mobileLeadingAction = "menu", onMobileBack, hideOnScroll, + onBottomComposerScrollHiddenChange, }: { documents: ClinicalDocument[]; documentTotal?: number; @@ -271,6 +272,8 @@ export function MasterSearchHeader({ * the header above an internally scrolling element). `containerRef` points * at the scrolling element; omit it to observe window scroll. */ hideOnScroll?: { strategy: "overlay" | "collapse"; containerRef?: RefObject }; + /** Fired when the phone bottom search dock enters or leaves the scroll-hidden state. */ + onBottomComposerScrollHiddenChange?: (hidden: boolean) => void; }) { const visibleAppModeOptions = defaultVisibleAppModeOptions; const trimmedQuery = query.trim(); @@ -316,6 +319,19 @@ export function MasterSearchHeader({ }); const headerChromeHidden = scrollHidden && !modeMenuOpen && !actionMenuOpen && !scopeOpen && !scopeSheetOpen && !headerChromeFocused; + const bottomComposerScrollHiddenActive = Boolean(hideOnScroll && isMobileBottomComposer && usesPhoneSearchLayout); + const bottomComposerHidden = + bottomComposerScrollHiddenActive && + scrollHidden && + !actionMenuOpen && + !commandDropdownOpen && + !scopeOpen && + !scopeSheetOpen && + !headerChromeFocused; + + useEffect(() => { + onBottomComposerScrollHiddenChange?.(bottomComposerHidden); + }, [bottomComposerHidden, onBottomComposerScrollHiddenChange]); // Stable, header-owned element the composer is portaled into; we move it in and // out of the page-owned slot rather than portaling into the slot directly. const [desktopHomeComposerHost, setDesktopHomeComposerHost] = useState(null); @@ -1306,7 +1322,6 @@ export function MasterSearchHeader({ onPlacementChange={setActionMenuPlacement} triggerClassName="answer-footer-search-action" integrated={usesFooterChipLayout} - integratedChipRow={showFooterSearchChips} /> {/* The clear button is a flex sibling (not absolutely positioned): the From 2f5762099f5622707d516ecc13b82a3311656001 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:27:22 +0800 Subject: [PATCH 3/4] fix(ui): restore action menu focus after scope dismiss Defer focus restoration until after the scope popover unmounts, and use double requestAnimationFrame in dismissable layers so Escape reliably returns focus to the answer options trigger. --- .../clinical-dashboard/master-search-header.tsx | 11 ++++++++++- src/components/use-dismissable-layer.ts | 6 +++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 002dcf7dbf..aee03b76fe 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -641,11 +641,20 @@ export function MasterSearchHeader({ } } + const restoreActionMenuFocusRef = useRef(false); const closeScope = useCallback((restoreFocus = false) => { + restoreActionMenuFocusRef.current = restoreFocus; setScopeOpen(false); - if (restoreFocus) actionMenuTriggerRef.current?.focus(); }, []); + useEffect(() => { + if (scopeOpen || !restoreActionMenuFocusRef.current) return; + restoreActionMenuFocusRef.current = false; + window.requestAnimationFrame(() => { + actionMenuTriggerRef.current?.focus({ preventScroll: true }); + }); + }, [scopeOpen]); + const closeScopeSheet = useCallback(() => { setScopeSheetOpen(false); window.requestAnimationFrame(() => actionMenuTriggerRef.current?.focus()); diff --git a/src/components/use-dismissable-layer.ts b/src/components/use-dismissable-layer.ts index 05285b4177..7832940834 100644 --- a/src/components/use-dismissable-layer.ts +++ b/src/components/use-dismissable-layer.ts @@ -38,7 +38,11 @@ export function useDismissableLayer({ if (event.key !== "Escape") return; event.preventDefault(); onDismiss("escape"); - window.requestAnimationFrame(() => restoreFocusRef?.current?.focus({ preventScroll: true })); + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + restoreFocusRef?.current?.focus({ preventScroll: true }); + }); + }); } document.addEventListener("pointerdown", handlePointerDown, true); From 0a5f3013fac0514e49283459a7e7c0be781b4322 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:29:18 +0800 Subject: [PATCH 4/4] test(ui-smoke): retry scope dismiss focus assertion for CI timing --- tests/ui-smoke.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 0593c8ba90..0683393677 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1311,7 +1311,9 @@ test.describe("Clinical KB UI smoke coverage", () => { expect(popoverMetrics.height).toBeLessThanOrEqual(Math.ceil(popoverMetrics.viewportHeight * 0.72)); await page.keyboard.press("Escape"); await expect(scopePopover).toBeHidden(); - await expect(page.getByRole("button", { name: "Open answer options" })).toBeFocused(); + await expect(async () => { + await expect(page.getByRole("button", { name: "Open answer options" })).toBeFocused(); + }).toPass({ timeout: 5_000 }); await expectNoPageHorizontalOverflow(page); });