diff --git a/src/app/globals.css b/src/app/globals.css index d0971df7b1..20eb977e70 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -568,6 +568,7 @@ summary::-webkit-details-marker { width: min(100%, clamp(19rem, 90vw, 52rem)); max-width: calc(100vw - 1rem - var(--safe-area-left) - var(--safe-area-right)); margin-inline: auto; + overflow: visible; } .universal-home-search-edge { diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 14ae434f90..de0809e93b 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3882,7 +3882,9 @@ export function ClinicalDashboard({ ? // On tall phones the centred home leans slightly toward the // bottom composer (matches the committed vertical-weighting // guard); short phones skip the bias so content still fits. - "grid w-full place-items-center max-sm:[@media(min-height:800px)]:pt-[5vh]" + // Mobile uses top alignment so the integrated action menu is + // not clipped by the dead space below vertically centred homes. + "grid w-full place-items-center max-sm:place-content-start max-sm:justify-items-center max-sm:pt-[clamp(0.75rem,3vh,2rem)] max-sm:[@media(min-height:800px)]:pt-[5vh]" : activeModeResultKind === "tools" || activeModeResultKind === "favourites" || activeModeResultKind === "differentials" diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 002dcf7dbf..6feda36919 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, @@ -349,6 +353,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 @@ -641,11 +646,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()); @@ -1040,6 +1054,137 @@ 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; + }; + + 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; + } + } + + 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,12 +1192,16 @@ export function MasterSearchHeader({ const usesCompactMobileBottomStyle = usesMobileBottomStyle && mobileBottomSearchVariant === "compact"; const usesBottomComposerPlacement = usesAnswerFooterStyle || (usesMobileBottomStyle && usesPhoneSearchLayout); const usesFooterChipLayout = usesBottomComposerPlacement || isDesktopHomeComposer; - // The visible footer/hero composer chrome is universal; submit semantics still - // come from the active mode. + // Keep footer suggestion chips on tablet/desktop; phones reach the same actions via "+". + const showFooterSearchChips = usesFooterChipLayout && !usesPhoneSearchLayout; const usesSendAffordance = searchMode === "answer" || usesFooterChipLayout; const usesModeIdentityAffordance = usesBottomComposerPlacement && !usesSendAffordance; const ModeIdentityIcon = appModeIcons[searchMode]; - const supportsDocumentScope = searchMode === "answer" || searchMode === "documents" || searchMode === "forms"; + const hasScopeFooterChip = searchMode === "answer" || searchMode === "documents" || searchMode === "forms"; + const trustFooterChip = footerTrustChipFor(searchMode); + const secondaryFooterChip = footerSecondaryChipFor(searchMode); + const TrustFooterChipIcon = trustFooterChip?.icon ?? BadgeCheck; + const SecondaryFooterChipIcon = secondaryFooterChip?.icon ?? ListChecks; const composerPlaceholder = usesMobileBottomStyle && searchMode === "differentials" ? "Search a presentation" : queryPlaceholder; @@ -1083,6 +1232,7 @@ export function MasterSearchHeader({ usesBottomComposerPlacement && "answer-footer-search-edge", usesPhoneFooterDock && "answer-footer-search-dock", usesCompactMobileBottomStyle && "document-mobile-search-compact", + showFooterSearchChips && "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", )} @@ -1173,7 +1323,7 @@ export function MasterSearchHeader({ triggerClassName="answer-footer-search-action" triggerRef={actionMenuTriggerRef} integrated={usesFooterChipLayout} - integratedChipRow={false} + integratedChipRow={showFooterSearchChips} dismissIgnoreRefs={[modeMenuRef]} /> @@ -1237,9 +1387,51 @@ export function MasterSearchHeader({ + {showFooterSearchChips && (trustFooterChip || hasScopeFooterChip || secondaryFooterChip) ? ( +
+ {trustFooterChip ? ( + + ) : null} + {hasScopeFooterChip ? ( + + ) : null} + {!hasScopeFooterChip && secondaryFooterChip ? ( + + ) : null} +
+ ) : null} {/* 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. */} - {supportsDocumentScope && !usesScopeSheet && scopeOpen ? ( + {hasScopeFooterChip && !usesScopeSheet && scopeOpen ? (
restoreFocusRef?.current?.focus({ preventScroll: true })); + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + restoreFocusRef?.current?.focus({ preventScroll: true }); + }); + }); } document.addEventListener("pointerdown", handlePointerDown, true); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 49ada2c4a6..f466e56cc6 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -444,6 +444,7 @@ test.describe("Clinical KB tools launcher", () => { const metrics = await globalSearchComposerMetrics(page); expect(metrics?.position).toBe("fixed"); + await expect(page.locator(".answer-footer-search-chip:visible")).not.toHaveCount(0); await commandSurfaceOpensAbovePill(page); await expectNoPageHorizontalOverflow(page); }); @@ -1004,7 +1005,7 @@ test.describe("Responsive layout guards", () => { }); } - test("prescribing mode home bottom-anchors its content on phones but centres on tablet", async ({ page }) => { + test("prescribing mode home top-aligns on phones but centres on tablet", async ({ page }) => { async function verticalWeighting(width: number) { // Tall viewport exaggerates the free space so the anchor is unambiguous. await page.setViewportSize({ width, height: 900 }); @@ -1019,10 +1020,11 @@ test.describe("Responsive layout guards", () => { }); } - // Phone (< sm): content is pushed toward the bottom, so the gap above exceeds the gap below. + // Phone (< sm): content is top-aligned so integrated action menus are not + // clipped by dead space below vertically centred homes. const phone = await verticalWeighting(375); expect(phone).not.toBeNull(); - expect(phone?.topGap ?? 0).toBeGreaterThan(phone?.bottomGap ?? 0); + expect(phone?.topGap ?? 0).toBeLessThan(phone?.bottomGap ?? 0); // Tablet hero-composer homes include the portaled search shell in the measured // block, so viewport gap balance is looser than phone bottom-anchoring.