From e3f0cd3a6f531b552dbf586aebfc43c83acd0d50 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Jul 2026 18:38:21 +0000 Subject: [PATCH 1/2] Auto-hide phone bottom search dock on scroll down Extend the existing useHideOnScroll signal to slide the fixed bottom search composer off-screen on phones while scrolling down through search results, and restore it on scroll up. Sync main content bottom padding in ClinicalDashboard and GlobalMockupSearchShell so reclaimed space is usable. Adds unit tests for scroll hide evaluation and a Playwright mobile test on forms search results. Co-authored-by: BigSimmo --- src/app/globals.css | 5 ++ src/components/ClinicalDashboard.tsx | 18 ++++--- .../global-mockup-search-shell.tsx | 14 +++-- .../master-search-header.tsx | 41 +++++++++++++-- .../clinical-dashboard/use-hide-on-scroll.ts | 46 +++++++++++------ tests/ui-tools.spec.ts | 16 ++++++ tests/use-hide-on-scroll.test.ts | 51 +++++++++++++++++++ 7 files changed, 158 insertions(+), 33 deletions(-) create mode 100644 tests/use-hide-on-scroll.test.ts 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 ?