Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <main>,
// so hiding must collapse its layout space to hand it to content.
hideOnScroll={{ strategy: "collapse", containerRef: mainRef }}
/>

<main
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,11 @@ function GlobalMockupSearchShellClient({
) : null}

<div className="flex min-h-dvh min-w-0 flex-col">
<div className={cn(!mobileChromeVisible && "hidden lg:block")}>
{/* 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. */}
<div className={mobileChromeVisible ? "max-sm:contents" : "hidden lg:block"}>
<MasterSearchHeader
documents={[]}
documentTotal={0}
Expand Down Expand Up @@ -369,6 +373,9 @@ function GlobalMockupSearchShellClient({
searchComposerVisible={shouldShowSearchComposer}
desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined}
heroComposerFromTablet={isStandaloneModeHome}
// Phone-only: the document scrolls here and the header is sticky,
// so a translate overlay hides it with zero layout shift.
hideOnScroll={{ strategy: "overlay" }}
/>
</div>

Expand Down
73 changes: 72 additions & 1 deletion src/components/clinical-dashboard/master-search-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -208,6 +211,7 @@ export function MasterSearchHeader({
heroComposerFromTablet = false,
mobileLeadingAction = "menu",
onMobileBack,
hideOnScroll,
}: {
documents: ClinicalDocument[];
documentTotal?: number;
Expand Down Expand Up @@ -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<HTMLElement | null> };
}) {
const visibleAppModeOptions = defaultVisibleAppModeOptions;
const trimmedQuery = query.trim();
Expand Down Expand Up @@ -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<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -1361,13 +1381,32 @@ export function MasterSearchHeader({
);
}

return (
const hideStrategy = hideOnScroll?.strategy;
const chromeFocusProps = hideOnScroll
? {
onFocusCapture: () => setHeaderChromeFocused(true),
onBlurCapture: (event: ReactFocusEvent<HTMLElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setHeaderChromeFocused(false);
},
}
: undefined;

const headerAndComposer = (
<>
<header
id="search"
className={cn(
"edge-glass-header universal-header sticky top-0 z-30 border-b border-[color:var(--border)] py-2 pt-[max(0.5rem,env(safe-area-inset-top))] text-[color:var(--text)] backdrop-blur-xl",
// Overlay hide-on-scroll (phones): the header is sticky over document
// scroll, so a plain translate reveals the content already flowing
// beneath it with zero layout shift. No transform is applied while
// visible so the fixed-position mobile mode menu keeps the viewport
// as its containing block.
hideStrategy === "overlay" &&
"max-sm:transition-transform max-sm:duration-200 max-sm:ease-out motion-reduce:transition-none",
hideStrategy === "overlay" && headerChromeHidden && "max-sm:-translate-y-full",
)}
{...(hideStrategy === "overlay" ? chromeFocusProps : undefined)}
>
<div
className={cn(
Expand Down Expand Up @@ -1552,4 +1591,36 @@ export function MasterSearchHeader({
) : null}
</>
);

if (hideStrategy === "collapse") {
// Collapse hide-on-scroll (phones): the host renders the header above an
// internally scrolling element, so hiding must also release the header's
// layout space. A 1fr -> 0fr grid row animates the collapse without any
// height measurement; the bottom-anchored inner track makes the chrome
// slide up out of the viewport top. Fixed-position composers (answer
// footer, mobile bottom search) escape the wrapper naturally because it
// never carries a transform, and everything is inert from sm up.
return (
<div
className={cn(
"max-sm:grid max-sm:transition-[grid-template-rows] max-sm:duration-200 max-sm:ease-out motion-reduce:transition-none",
headerChromeHidden ? "max-sm:[grid-template-rows:0fr]" : "max-sm:[grid-template-rows:1fr]",
)}
{...chromeFocusProps}
>
<div
className={cn(
"max-sm:flex max-sm:min-h-0 max-sm:flex-col max-sm:justify-end",
// Clip only while hiding so the edge-glass-header gradient that
// extends below the header keeps painting when the chrome is shown.
headerChromeHidden && "max-sm:overflow-hidden",
)}
>
{headerAndComposer}
</div>
</div>
);
}

return headerAndComposer;
}
98 changes: 98 additions & 0 deletions src/components/clinical-dashboard/use-hide-on-scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"use client";

import { useEffect, useState, useSyncExternalStore, type RefObject } from "react";

// Matches phoneSearchLayoutMediaQuery in master-search-header.tsx — the repo's
// phone/tablet seam. Hide-on-scroll only ever runs below the sm breakpoint.
const phoneMediaQuery = "(max-width: 639px)";

// Scroll offset (px) that must be passed before the chrome may hide; roughly
// the header's own height so it never vanishes while still fully in view.
const hideActivationOffset = 56;
// Offset (px) at or below which the chrome is always shown.
const topRevealOffset = 8;
// Minimum per-event delta (px) before we treat movement as intentional, to
// avoid jitter from momentum settling and fractional scroll positions.
const minimumDelta = 4;

function subscribeToPhoneMedia(onChange: () => void) {
const media = window.matchMedia(phoneMediaQuery);
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}

function readPhoneMedia() {
return window.matchMedia(phoneMediaQuery).matches;
}

function readPhoneMediaServer() {
return false;
}

interface UseHideOnScrollOptions {
/**
* Element that owns the scrolling. When omitted the window/document scroll
* position is observed instead.
*/
containerRef?: RefObject<HTMLElement | null>;
/** Disables the behavior entirely (state resets to visible). */
disabled?: boolean;
}

/**
* Tracks scroll direction on phones and reports when top chrome (the
* universal header) should hide to maximise content space. Hidden while
* scrolling down past the header, shown again on any deliberate scroll up or
* when near the top. Inert (always visible) above the phone breakpoint.
*/
export function useHideOnScroll({ containerRef, disabled = false }: UseHideOnScrollOptions): boolean {
const [hidden, setHidden] = useState(false);
const isPhone = useSyncExternalStore(subscribeToPhoneMedia, readPhoneMedia, readPhoneMediaServer);
const active = isPhone && !disabled;

useEffect(() => {
if (!active) return;

const container = containerRef?.current ?? null;
const target: HTMLElement | Window = container ?? window;
const readOffset = () => (container ? container.scrollTop : window.scrollY);

let lastOffset = readOffset();
let frame = 0;

const evaluate = () => {
frame = 0;
const offset = readOffset();
// Ignore iOS rubber-band overscroll at the top.
if (offset < 0) return;
const delta = offset - lastOffset;
if (offset <= topRevealOffset) {
lastOffset = offset;
setHidden(false);
return;
}
if (Math.abs(delta) < minimumDelta) return;
lastOffset = offset;
if (delta > 0) {
if (offset > hideActivationOffset) setHidden(true);
} else {
setHidden(false);
}
};

const onScroll = () => {
if (frame) return;
frame = window.requestAnimationFrame(evaluate);
};

target.addEventListener("scroll", onScroll, { passive: true });
return () => {
target.removeEventListener("scroll", onScroll);
if (frame) window.cancelAnimationFrame(frame);
// Leaving the phone breakpoint (or unmounting) always restores the chrome.
setHidden(false);
};
}, [active, containerRef]);

return active && hidden;
}