From 297b9fb02af5cfef9cf54f0b60b070f70e9a848e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:36:09 +0800 Subject: [PATCH 1/5] fix(mobile): prevent integrated action menu clipping on mode homes --- src/app/globals.css | 1 + src/components/ClinicalDashboard.tsx | 4 +- .../master-search-header.tsx | 1 + .../clinical-dashboard/mode-action-popup.tsx | 147 ++++++++++++++---- 4 files changed, 124 insertions(+), 29 deletions(-) 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 a0c6575a84..ee5d7a7c3f 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1179,6 +1179,7 @@ export function MasterSearchHeader({ triggerClassName="answer-footer-search-action" triggerRef={scopeSummaryRef} integrated={usesFooterChipLayout} + integratedChipRow={showFooterSearchChips} /> {/* The clear button is a flex sibling (not absolutely positioned): the diff --git a/src/components/clinical-dashboard/mode-action-popup.tsx b/src/components/clinical-dashboard/mode-action-popup.tsx index 9dd64da107..b75e46a6a6 100644 --- a/src/components/clinical-dashboard/mode-action-popup.tsx +++ b/src/components/clinical-dashboard/mode-action-popup.tsx @@ -10,6 +10,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, type Ref, } from "react"; +import { createPortal } from "react-dom"; import { BadgeCheck, Check, @@ -39,6 +40,30 @@ import { cn, chatComposerIconButton } from "@/components/ui-primitives"; export type ModeActionSetId = "answer" | "documents" | "services" | "favourites" | "tools" | "differentials"; export type ModeActionPlacement = "up" | "down"; +type IntegratedSurfaceLayout = { + placement: ModeActionPlacement; + left: number; + width: number; + top?: number; + bottom?: number; +}; + +function integratedActionGridColumns(itemCount: number) { + if (itemCount >= 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; @@ -272,6 +297,7 @@ export function ModeActionPopup({ triggerClassName, triggerRef, integrated = false, + integratedChipRow = true, }: { open: boolean; title: string; @@ -289,6 +315,8 @@ export function ModeActionPopup({ triggerClassName?: string; triggerRef?: Ref; integrated?: boolean; + /** When false, the integrated menu skips the footer chip-row clearance offset. */ + integratedChipRow?: boolean; }) { const buttonRef = useRef(null); const rootRef = useRef(null); @@ -299,6 +327,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); @@ -326,7 +355,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; @@ -337,20 +366,56 @@ 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?.(); @@ -469,6 +534,11 @@ export function ModeActionPopup({ updatePlacement(); }, [items.length, open, title, updatePlacement]); + useEffect(() => { + if (open) return; + setIntegratedSurfaceLayout(null); + }, [open]); + useEffect(() => { if (!open) return; onPlacementChange?.(placement); @@ -493,26 +563,41 @@ 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 ? ( -
+ const integratedDownOffsetClass = integratedChipRow ? "top-[calc(100%+3.65rem)]" : "top-[calc(100%+0.875rem)]"; + + const actionSurface = open ? ( +
) : null}
- ) : null} + ) : null; + + return ( + <> + {integrated && open && typeof document !== "undefined" + ? createPortal(actionSurface, document.body) + : actionSurface}
- {modeSelectorOpen && modeOptions?.length ? ( - - ) : null} -
-
-
- -
- + +
+ {!integrated ? ( + <> + {placement === "up" ? ( +