diff --git a/src/app/globals.css b/src/app/globals.css index b1873a02d..a58de624d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1380,6 +1380,11 @@ summary::-webkit-details-marker { padding-bottom: max(0.45rem, var(--safe-area-bottom)); } + .answer-footer-search-dock[data-scroll-hidden="true"] { + transform: translateY(calc(100% + env(safe-area-inset-bottom))); + pointer-events: none; + } + .answer-footer-search-dock .answer-footer-search-pill { border-color: var(--border-strong); background: var(--surface); diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 018ec0c1d..ce5ceadc4 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1518,6 +1518,7 @@ export function ClinicalDashboard({ const router = useRouter(); const searchParams = useSearchParams(); const mainRef = useRef(null); + const [bottomSearchScrollHidden, setBottomSearchScrollHidden] = useState(false); const composerInputRef = useRef(null); const scrollFrameRef = useRef(null); const navSyncLockRef = useRef(null); @@ -3798,6 +3799,7 @@ export function ClinicalDashboard({ // Phone-only: the header sits above the internally scrolling
, // so hiding must collapse its layout space to hand it to content. hideOnScroll={{ strategy: "collapse", containerRef: mainRef }} + onBottomComposerScrollHiddenChange={setBottomSearchScrollHidden} />
diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index 304496ec3..ced7037da 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -131,6 +131,7 @@ function GlobalMockupSearchShellClient({ const [accountSetupOpen, setAccountSetupOpen] = useState(false); const [recentQueries, setRecentQueries] = useState([]); const [commandScopes, setCommandScopes] = useState([]); + const [bottomSearchScrollHidden, setBottomSearchScrollHidden] = useState(false); const { theme, toggleTheme } = useTheme(); const auth = useAuthSession(); const sidebarIdentity = useMemo(() => deriveSidebarIdentity(auth.session?.user.email), [auth.session?.user.email]); @@ -408,6 +409,7 @@ function GlobalMockupSearchShellClient({ // Phone-only: the document scrolls here and the header is sticky, // so a translate overlay hides it with zero layout shift. hideOnScroll={{ strategy: "overlay" }} + onBottomComposerScrollHiddenChange={setBottomSearchScrollHidden} queryInputAutoFocus={searchParams.get("focus") === "1"} /> @@ -422,11 +424,13 @@ function GlobalMockupSearchShellClient({ "min-w-0 overflow-x-hidden focus:outline-none max-sm:flex-1 sm:min-h-[calc(100dvh-4rem)]", !shouldShowSearchComposer ? "pb-8" - : searchMode === "answer" - ? "pb-[calc(9rem+env(safe-area-inset-bottom))]" - : useCompactBottomSearch - ? "pb-[calc(5.5rem+env(safe-area-inset-bottom))] sm:pb-8" - : "pb-[calc(9rem+env(safe-area-inset-bottom))] sm:pb-8", + : bottomSearchScrollHidden + ? "pb-8 sm:pb-8" + : searchMode === "answer" + ? "pb-[calc(9rem+env(safe-area-inset-bottom))]" + : useCompactBottomSearch + ? "pb-[calc(5.5rem+env(safe-area-inset-bottom))] sm:pb-8" + : "pb-[calc(9rem+env(safe-area-inset-bottom))] sm:pb-8", )} > void; - /** 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. */ + /** 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. */ 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(); @@ -303,12 +307,26 @@ 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); @@ -1183,6 +1201,7 @@ export function MasterSearchHeader({ usesMobileBottomStyle && searchMode === "differentials" ? "Search a presentation" : queryPlaceholder; const usesPhoneFooterDock = usesBottomComposerPlacement && usesPhoneSearchLayout; + const shouldHideBottomOnScroll = Boolean(hideOnScroll && usesMobileBottomStyle && usesPhoneFooterDock); const commandSurfacePlacement = usesBottomComposerPlacement ? "bottom-dock" : "inline"; @@ -1192,6 +1211,8 @@ 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" @@ -1212,6 +1233,8 @@ 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 ?