From cc2f93e338cd21b0edcd3871f7125ba3a6ce9c6b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:12:31 +0800 Subject: [PATCH] feat(mobile): hide universal header on scroll to maximise phone screen space On screens below 640px the universal header now hides once the user scrolls down and returns as soon as they scroll up, keeping content edge-to-edge. - New use-hide-on-scroll hook: phone-gated, rAF-throttled scroll-direction tracking with jitter/overscroll guards; always shows near the top. - MasterSearchHeader gains an opt-in hideOnScroll prop with two strategies: 'overlay' translates the sticky header away (document-scroll shells, zero layout shift); 'collapse' releases the header's layout space via a measurement-free 1fr->0fr grid-row animation (dashboard, where
scrolls internally). - Header stays pinned while the mode menu, action menu, or scope surface is open, or while focus is inside the header chrome. - Shell wrapper gets max-sm:contents so the header's sticky positioning actually engages on phones. - Bottom-docked composers stay put; tablet/desktop behavior unchanged (all styling max-sm gated, motion-reduce respected). --- src/components/ClinicalDashboard.tsx | 3 + .../global-mockup-search-shell.tsx | 9 +- .../master-search-header.tsx | 73 +++++++++++++- .../clinical-dashboard/use-hide-on-scroll.ts | 98 +++++++++++++++++++ 4 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 src/components/clinical-dashboard/use-hide-on-scroll.ts diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 49ba42517..e5b3e2118 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -5546,6 +5546,9 @@ export function ClinicalDashboard({ mobileSearchPlacement={hasMobileBottomSearch ? "bottom" : "default"} desktopHomeComposerSlotId={desktopHomeComposerSlotId} heroComposerFromTablet={Boolean(desktopHomeComposerSlotId)} + // 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 }} />
-
+ {/* max-sm:contents lets the header's own `sticky top-0` engage against + the document scroll on phones (a plain wrapper div otherwise caps + its sticking range at its own height), which the phone + hide-on-scroll overlay relies on. */} +
diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 99cbde6b4..4041e4254 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -7,8 +7,10 @@ import { useMemo, useRef, useState, + type FocusEvent as ReactFocusEvent, type KeyboardEvent as ReactKeyboardEvent, type Ref, + type RefObject, } from "react"; import { createPortal } from "react-dom"; @@ -45,6 +47,7 @@ import { import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; +import { useHideOnScroll } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { ModeActionPopup, modeActionItemsFor, @@ -208,6 +211,7 @@ export function MasterSearchHeader({ heroComposerFromTablet = false, mobileLeadingAction = "menu", onMobileBack, + hideOnScroll, }: { documents: ClinicalDocument[]; documentTotal?: number; @@ -247,6 +251,12 @@ export function MasterSearchHeader({ heroComposerFromTablet?: boolean; mobileLeadingAction?: "menu" | "back"; onMobileBack?: () => 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. */ + hideOnScroll?: { strategy: "overlay" | "collapse"; containerRef?: RefObject }; }) { const visibleAppModeOptions = defaultVisibleAppModeOptions; const trimmedQuery = query.trim(); @@ -279,6 +289,16 @@ export function MasterSearchHeader({ const [usesScopeSheet, setUsesScopeSheet] = useState(false); const [usesPhoneSearchLayout, setUsesPhoneSearchLayout] = useState(false); const [desktopHomeComposerActive, setDesktopHomeComposerActive] = useState(false); + // Phone-only hide-on-scroll: never hide while a header-owned surface is open + // or while focus sits inside the header chrome (keyboard users must not tab + // into invisible controls). + const [headerChromeFocused, setHeaderChromeFocused] = useState(false); + const scrollHidden = useHideOnScroll({ + containerRef: hideOnScroll?.containerRef, + disabled: !hideOnScroll, + }); + const headerChromeHidden = + scrollHidden && !modeMenuOpen && !actionMenuOpen && !scopeOpen && !scopeSheetOpen && !headerChromeFocused; // 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); @@ -1361,13 +1381,32 @@ export function MasterSearchHeader({ ); } - return ( + const hideStrategy = hideOnScroll?.strategy; + const chromeFocusProps = hideOnScroll + ? { + onFocusCapture: () => setHeaderChromeFocused(true), + onBlurCapture: (event: ReactFocusEvent) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setHeaderChromeFocused(false); + }, + } + : undefined; + + const headerAndComposer = ( <>