Skip to content
1 change: 1 addition & 0 deletions src/app/globals.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion src/components/ClinicalDashboard.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2443,7 +2443,7 @@
urlDocumentSearchBootstrappedRef.current = true;
void executeSearch(searchText, mode, scopeFilters);
// URL search intentionally runs once when the selected mode can execute.
}, [canRunSearch, answerThreadBootstrapped]);

Check warning on line 2446 in src/components/ClinicalDashboard.tsx

View workflow job for this annotation

GitHub Actions/ verify

React Hook useEffect has missing dependencies: 'executeSearch' and 'scopeFilters'. Either include them or remove the dependency array

useEffect(() => {
const updateHash = () => {
Expand DownExpand Up@@ -3882,7 +3882,9 @@
? // 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"
Expand Down
204 changes: 198 additions & 6 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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());
Expand DownExpand Up@@ -1040,19 +1054,154 @@ 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;
const usesMobileBottomStyle = isMobileBottomComposer && !isDesktopHomeComposer;
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;

Expand DownExpand Up@@ -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",
)}
Expand DownExpand Up@@ -1173,7 +1323,7 @@ export function MasterSearchHeader({
triggerClassName="answer-footer-search-action"
triggerRef={actionMenuTriggerRef}
integrated={usesFooterChipLayout}
integratedChipRow={false}
integratedChipRow={showFooterSearchChips}
dismissIgnoreRefs={[modeMenuRef]}
/>

Expand DownExpand Up@@ -1237,9 +1387,51 @@ export function MasterSearchHeader({
</button>
</div>
</UniversalSearchCommandSurface>
{showFooterSearchChips && (trustFooterChip || hasScopeFooterChip || secondaryFooterChip) ? (
<div className="flex max-w-full flex-wrap items-center justify-center gap-2 px-2">
{trustFooterChip ? (
<button
type="button"
onClick={() => runFooterChipAction(trustFooterChip.actionId)}
className="answer-footer-search-chip"
aria-label={trustFooterChip.ariaLabel}
>
<TrustFooterChipIcon className="h-4 w-4" aria-hidden="true" />
<span className="sm:hidden">{trustFooterChip.shortLabel}</span>
<span className="hidden sm:inline">{trustFooterChip.longLabel}</span>
</button>
) : null}
{hasScopeFooterChip ? (
<button
type="button"
data-testid="scope-trigger"
onClick={openScopePicker}
className="answer-footer-search-chip"
aria-expanded={usesScopeSheet ? scopeSheetOpen : scopeOpen}
aria-label="Open source scope"
>
<Filter className="h-4 w-4" aria-hidden="true" />
<span className="sm:hidden">{selectedDocumentIds.length === 0 ? "Sources" : footerScopeLabel}</span>
<span className="hidden sm:inline">{footerScopeLabel}</span>
</button>
) : null}
{!hasScopeFooterChip && secondaryFooterChip ? (
<button
type="button"
onClick={() => runFooterChipAction(secondaryFooterChip.actionId)}
className="answer-footer-search-chip"
aria-label={secondaryFooterChip.ariaLabel}
>
<SecondaryFooterChipIcon className="h-4 w-4" aria-hidden="true" />
<span className="sm:hidden">{secondaryFooterChip.shortLabel}</span>
<span className="hidden sm:inline">{secondaryFooterChip.longLabel}</span>
</button>
) : null}
</div>
) : 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 ? (
<div
ref={scopePopoverRef}
data-testid="scope-command-popover"
Expand Down
2 changes: 1 addition & 1 deletion src/components/clinical-dashboard/mode-action-popup.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -370,7 +370,7 @@ export function ModeActionPopup({
const edgePadding = 12;
const availableAbove = Math.max(0, rect.top - viewportTop - edgePadding);
const availableBelow = Math.max(0, viewportBottom - rect.bottom - edgePadding);
const { minBodyHeight, minSurfaceHeight, headerHeight } = estimateIntegratedMenuHeights(items.length, integrated);
const { minSurfaceHeight, headerHeight } = estimateIntegratedMenuHeights(items.length, integrated);
const detachedUpOffset = 16;
const integratedDownOffset = integratedChipRow ? 58 : 14;
const detachedDownOffset = integrated ? integratedDownOffset : 14;
Expand Down
6 changes: 5 additions & 1 deletion src/components/use-dismissable-layer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand Down
8 changes: 5 additions & 3 deletions tests/ui-tools.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
});
Expand DownExpand Up@@ -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 });
Expand All@@ -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.
Expand Down
Loading