From 7be6da9248f0ab5e52bab8a10e980e9eab3cbcc1 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:04:07 +0800 Subject: [PATCH 1/9] Remove search bar footer chips across all modes Co-authored-by: Cursor --- .../master-search-header.tsx | 215 +--------- .../clinical-dashboard/mode-action-popup.tsx | 394 +++++++++++------- tests/ui-accessibility.spec.ts | 17 +- tests/ui-smoke.spec.ts | 10 +- tests/ui-stress.spec.ts | 8 +- tests/ui-tools.spec.ts | 9 +- 6 files changed, 265 insertions(+), 388 deletions(-) diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index f23741b06..dcd62b950 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -16,17 +16,13 @@ import { createPortal } from "react-dom"; import { Activity, - BadgeCheck, CalendarDays, Check, CheckCircle2, ChevronDown, FileText, Filter, - FolderOpen, - GitBranch, Globe2, - ListChecks, Loader2, Menu, MessageSquarePlus, @@ -335,7 +331,6 @@ 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 @@ -1024,144 +1019,6 @@ 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; @@ -1169,23 +1026,10 @@ 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; - // 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 supportsDocumentScope = searchMode === "answer" || searchMode === "documents" || searchMode === "forms"; const composerPlaceholder = usesMobileBottomStyle && searchMode === "differentials" ? "Search a presentation" : queryPlaceholder; @@ -1207,18 +1051,12 @@ export function MasterSearchHeader({ : usesMobileBottomStyle ? cn( "document-mobile-search-edge universal-top-search-edge fixed z-40 mx-auto max-w-3xl sm:z-20 sm:w-full sm:px-4 sm:py-3 lg:max-w-4xl", - // Hero-placement mode-homes (services/forms) portal the composer into - // the hero from sm up. Hide the default (non-portaled) composer at sm+ - // so it never briefly flashes as an overlapping float over the hero - // before the portal activates; the mobile fixed-bottom slot still shows - // below sm. Other homes keep a sticky bar until the portal lifts it. isHeroDesktopComposer ? "sm:hidden" : "sm:sticky sm:top-[calc(4.75rem+env(safe-area-inset-top))]", ) : "universal-top-search-edge sticky top-[calc(4.75rem+env(safe-area-inset-top))] z-20 mx-auto box-border w-full px-3 py-3 sm:px-4", usesBottomComposerPlacement && "answer-footer-search-edge", usesPhoneFooterDock && "answer-footer-search-dock", usesCompactMobileBottomStyle && "document-mobile-search-compact", - usesFooterChipLayout && "flex flex-col items-center gap-2.5", )} > {usesBottomComposerPlacement ? - {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 ? ( + {supportsDocumentScope && !usesScopeSheet && scopeOpen ? (
= 6) return 2; + if (itemCount >= 3) return 2; + return 2; +} + +function estimateIntegratedMenuHeights(itemCount: number, integrated: boolean) { + const rows = Math.ceil(itemCount / integratedActionGridColumns(itemCount)); + const rowHeight = 74; + const rowGap = 8; + const bodyPadding = integrated ? 24 : 20; + const minBodyHeight = rows * rowHeight + Math.max(0, rows - 1) * rowGap + bodyPadding; + const headerHeight = 92; + return { minBodyHeight, minSurfaceHeight: minBodyHeight + headerHeight, headerHeight }; +} + export type ModeActionModeOption = { id: string; label: string; @@ -261,6 +287,8 @@ export function ModeActionPopup({ onPlacementChange, triggerClassName, integrated = false, + integratedChipRow = true, + triggerRef, }: { open: boolean; title: string; @@ -277,6 +305,9 @@ export function ModeActionPopup({ onPlacementChange?: (placement: ModeActionPlacement) => void; triggerClassName?: string; integrated?: boolean; + /** When false, the integrated menu skips the footer chip-row clearance offset. */ + integratedChipRow?: boolean; + triggerRef?: RefObject; }) { const buttonRef = useRef(null); const rootRef = useRef(null); @@ -287,6 +318,7 @@ export function ModeActionPopup({ const [placement, setPlacement] = useState("up"); const [surfaceMaxHeight, setSurfaceMaxHeight] = useState(null); const [bodyMaxHeight, setBodyMaxHeight] = useState(null); + const [integratedSurfaceLayout, setIntegratedSurfaceLayout] = useState(null); const [modeSelectorOpen, setModeSelectorOpen] = useState(false); const canSwitchMode = Boolean(modeOptions?.length && onModeSelect); const selectedModeOption = modeOptions?.find((mode) => mode.id === selectedModeId); @@ -314,7 +346,7 @@ export function ModeActionPopup({ const updatePlacement = useCallback(() => { if (typeof window === "undefined") return; - const anchor = surfaceRef.current?.parentElement ?? rootRef.current?.parentElement ?? rootRef.current; + const anchor = buttonRef.current ?? rootRef.current?.parentElement ?? rootRef.current; if (!anchor) return; const viewport = window.visualViewport; @@ -325,20 +357,53 @@ 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 nextPlacement: ModeActionPlacement = availableBelow > availableAbove + 40 ? "down" : "up"; + const { minBodyHeight, minSurfaceHeight, headerHeight } = estimateIntegratedMenuHeights(items.length, integrated); const detachedUpOffset = 16; - const detachedDownOffset = integrated ? 72 : 14; - const available = - nextPlacement === "up" - ? Math.max(0, availableAbove - detachedUpOffset) - : Math.max(0, availableBelow - detachedDownOffset); + const integratedDownOffset = integratedChipRow ? 58 : 14; + const detachedDownOffset = integrated ? integratedDownOffset : 14; + const spaceAbove = Math.max(0, availableAbove - detachedUpOffset); + const spaceBelow = Math.max(0, availableBelow - detachedDownOffset); + + let nextPlacement: ModeActionPlacement; + if (integrated) { + const canFitAbove = spaceAbove >= minSurfaceHeight; + const canFitBelow = spaceBelow >= minSurfaceHeight; + if (canFitAbove && !canFitBelow) { + nextPlacement = "up"; + } else if (canFitBelow && !canFitAbove) { + nextPlacement = "down"; + } else { + // In-flow hero composers sit above page content; opening upward avoids + // clipping inside scroll containers and the dead space below centred homes. + nextPlacement = spaceBelow > spaceAbove + 80 ? "down" : "up"; + } + } else { + nextPlacement = availableBelow > availableAbove + 40 ? "down" : "up"; + } + + const available = nextPlacement === "up" ? spaceAbove : spaceBelow; const nextSurfaceMaxHeight = Math.max(220, Math.floor(Math.min(available, viewportHeight - edgePadding * 2))); - const nextBodyMaxHeight = Math.max(156, nextSurfaceMaxHeight - 92); + const nextBodyMaxHeight = Math.max(156, nextSurfaceMaxHeight - headerHeight); setPlacement((current) => (current === nextPlacement ? current : nextPlacement)); setSurfaceMaxHeight((current) => (current === nextSurfaceMaxHeight ? current : nextSurfaceMaxHeight)); setBodyMaxHeight((current) => (current === nextBodyMaxHeight ? current : nextBodyMaxHeight)); - }, [integrated]); + + if (integrated) { + const maxSurfaceWidth = Math.min(window.innerWidth - edgePadding * 2, 400); + const surfaceLeft = Math.max(edgePadding, Math.min(rect.left, window.innerWidth - maxSurfaceWidth - edgePadding)); + setIntegratedSurfaceLayout({ + placement: nextPlacement, + left: surfaceLeft, + width: maxSurfaceWidth, + ...(nextPlacement === "up" + ? { bottom: window.innerHeight - rect.top + 14 } + : { top: rect.bottom + integratedDownOffset }), + }); + } else { + setIntegratedSurfaceLayout(null); + } + }, [integrated, integratedChipRow, items.length]); function openWithFocus(index: number) { onBeforeOpen?.(); @@ -457,6 +522,11 @@ export function ModeActionPopup({ updatePlacement(); }, [items.length, open, title, updatePlacement]); + useEffect(() => { + if (open) return; + setIntegratedSurfaceLayout(null); + }, [open]); + useEffect(() => { if (!open) return; onPlacementChange?.(placement); @@ -481,169 +551,189 @@ export function ModeActionPopup({ const surfaceStyle = { "--mode-action-max-height": surfaceMaxHeight ? `${surfaceMaxHeight}px` : undefined, "--mode-action-body-max-height": bodyMaxHeight ? `${bodyMaxHeight}px` : undefined, + ...(integrated && integratedSurfaceLayout + ? { + left: `${integratedSurfaceLayout.left}px`, + width: `${integratedSurfaceLayout.width}px`, + ...(integratedSurfaceLayout.top !== undefined ? { top: `${integratedSurfaceLayout.top}px` } : {}), + ...(integratedSurfaceLayout.bottom !== undefined ? { bottom: `${integratedSurfaceLayout.bottom}px` } : {}), + } + : {}), } as CSSProperties; - return ( - <> - {open ? ( -
-
-
-
- - {modeSelectorOpen && modeOptions?.length ? ( - - ) : null} -
-
-
- -
- + {!integrated ? ( + <> + {placement === "up" ? ( +
+ ) : null; + + return ( + <> + {integrated && open && typeof document !== "undefined" + ? createPortal(actionSurface, document.body) + : actionSurface}