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
13 changes: 10 additions & 3 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
WifiOff,
Wrench,
} from "lucide-react";
import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type CSSProperties, type UIEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type DocumentDeleteResult } from "@/components/DocumentManagementActions";
import { extractSafetyFindings } from "@/lib/clinical-safety";
import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity";
Expand All @@ -38,8 +38,8 @@
primaryControl,
textMuted,
toneInfo,
toneSuccess,

Check warning on line 41 in src/components/ClinicalDashboard.tsx

View workflow job for this annotation

GitHub Actions / verify

'toneSuccess' is defined but never used
toneWarning,

Check warning on line 42 in src/components/ClinicalDashboard.tsx

View workflow job for this annotation

GitHub Actions / verify

'toneWarning' is defined but never used
} from "@/components/ui-primitives";
import { useAuthSession } from "@/lib/supabase/client";
import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog";
Expand Down Expand Up @@ -77,6 +77,7 @@
import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status";
import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-map-model";
import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header";
import { useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll";
import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context";
import { answerRecovery, errorCopy } from "@/lib/ui-copy";
import { applicationsLauncherItemCount } from "@/components/applications-launcher-page";
Expand Down Expand Up @@ -669,6 +670,7 @@
const router = useRouter();
const searchParams = useSearchParams();
const mainRef = useRef<HTMLElement>(null);
const phoneScrollHide = useScrollHideReporter();
const [bottomSearchScrollHidden, setBottomSearchScrollHidden] = useState(false);
const composerInputRef = useRef<HTMLInputElement>(null);
const scrollFrameRef = useRef<number | null>(null);
Expand Down Expand Up @@ -2653,6 +2655,11 @@
});
}

function handleMainScroll(event: UIEvent<HTMLElement>) {
scheduleActiveSectionSync();
phoneScrollHide.reportScroll(event.currentTarget.scrollTop);
}

async function copyText(action: string, text: string) {
let copied = false;
try {
Expand Down Expand Up @@ -3115,15 +3122,15 @@
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 }}
hideOnScroll={{ strategy: "collapse", scrollHidden: phoneScrollHide.hidden, containerRef: mainRef }}
onBottomComposerScrollHiddenChange={setBottomSearchScrollHidden}
/>

<main
id="main-content"
ref={mainRef}
tabIndex={-1}
onScroll={scheduleActiveSectionSync}
onScroll={handleMainScroll}
className={cn(
"min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain [-webkit-overflow-scrolling:touch] focus:outline-none",
searchMode === "answer"
Expand Down
28 changes: 20 additions & 8 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,13 @@ export function MasterSearchHeader({
* 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<HTMLElement | null> };
hideOnScroll?: {
strategy: "overlay" | "collapse";
containerRef?: RefObject<HTMLElement | null>;
scrollContainer?: HTMLElement | null;
/** Parent-owned hidden state for hosts that report scroll via React `onScroll`. */
scrollHidden?: boolean;
};
/** Fired when the phone bottom search dock enters or leaves the scroll-hidden state. */
onBottomComposerScrollHiddenChange?: (hidden: boolean) => void;
}) {
Expand Down Expand Up @@ -288,10 +294,13 @@ export function MasterSearchHeader({
// into invisible controls).
const [headerChromeFocused, setHeaderChromeFocused] = useState(false);
const [composerChromeFocused, setComposerChromeFocused] = useState(false);
const scrollHidden = useHideOnScroll({
const internalScrollHidden = useHideOnScroll({
containerRef: hideOnScroll?.containerRef,
scrollContainer: hideOnScroll?.scrollContainer,
disabled: !hideOnScroll,
});
const scrollHidden =
hideOnScroll?.scrollHidden !== undefined ? hideOnScroll.scrollHidden || internalScrollHidden : internalScrollHidden;
const headerChromeHidden =
scrollHidden && !modeMenuOpen && !actionMenuOpen && !scopeOpen && !scopeSheetOpen && !headerChromeFocused;
const phoneBottomSearchDockActive =
Expand Down Expand Up @@ -1336,12 +1345,15 @@ export function MasterSearchHeader({
<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.
"edge-glass-header universal-header 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",
// Collapse hosts keep the header above an internally scrolling <main>, so
// sticky is unnecessary on phones and fights the 0fr grid collapse by
// pinning the bar inside the viewport. Overlay hosts need sticky so the
// header rides document scroll and can translate away with zero layout shift.
hideStrategy === "collapse" ? "max-sm:relative sm:sticky sm:top-0" : "sticky top-0",
// Overlay hide-on-scroll (phones): a plain translate reveals the content
// already flowing beneath it. 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",
Expand Down
119 changes: 96 additions & 23 deletions src/components/clinical-dashboard/use-hide-on-scroll.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useState, useSyncExternalStore, type RefObject } from "react";
import { useCallback, useEffect, useRef, 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.
Expand Down Expand Up @@ -50,12 +50,58 @@ function readPhoneMediaServer() {
return false;
}

function usePhoneScrollHideActive(disabled = false) {
const isPhone = useSyncExternalStore(subscribeToPhoneMedia, readPhoneMedia, readPhoneMediaServer);
return isPhone && !disabled;
}

/**
* Imperative scroll-offset reporter for hosts that already own a React `onScroll`
* handler on the scrolling element (for example ClinicalDashboard `<main>`).
*/
export function useScrollHideReporter(disabled = false) {
const [hidden, setHidden] = useState(false);
const hiddenRef = useRef(false);
const lastOffsetRef = useRef(0);
const active = usePhoneScrollHideActive(disabled);

const reportScroll = useCallback(
(offset: number) => {
if (!active || offset < 0) return;
const lastOffset = lastOffsetRef.current;
const delta = offset - lastOffset;
if (Math.abs(delta) < minimumDelta && offset > topRevealOffset) return;
const update = computeScrollHideUpdate({
offset,
lastOffset,
currentlyHidden: hiddenRef.current,
});
lastOffsetRef.current = update.lastOffset;
hiddenRef.current = update.hidden;
setHidden(update.hidden);
},
[active],
);

useEffect(() => {
if (active) return undefined;
hiddenRef.current = false;
lastOffsetRef.current = 0;
const frame = window.requestAnimationFrame(() => setHidden(false));
return () => window.cancelAnimationFrame(frame);
}, [active]);

return { hidden: active && hidden, reportScroll };
}

interface UseHideOnScrollOptions {
/**
* Element that owns the scrolling. When omitted the window/document scroll
* position is observed instead.
*/
containerRef?: RefObject<HTMLElement | null>;
/** Resolved scroll container; preferred over containerRef when the host sets it via callback ref. */
scrollContainer?: HTMLElement | null;
/** Disables the behavior entirely (state resets to visible). */
disabled?: boolean;
}
Expand All @@ -67,45 +113,72 @@ interface UseHideOnScrollOptions {
* 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;
export function useHideOnScroll({
containerRef,
scrollContainer = null,
disabled = false,
}: UseHideOnScrollOptions): boolean {
const { hidden, reportScroll } = useScrollHideReporter(disabled);
const active = usePhoneScrollHideActive(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;
let attachedTarget: HTMLElement | Window | null = null;
let attachFrame = 0;
let disposed = false;

const resolveContainer = () => scrollContainer ?? containerRef?.current ?? null;

const readOffset = () => {
const container = resolveContainer();
return container ? container.scrollTop : window.scrollY;
};

const evaluate = () => {
frame = 0;
const offset = readOffset();
if (offset < 0) return;
const delta = offset - lastOffset;
if (Math.abs(delta) < minimumDelta && offset > topRevealOffset) return;
const update = computeScrollHideUpdate({ offset, lastOffset, currentlyHidden: false });
lastOffset = update.lastOffset;
setHidden(update.hidden);
reportScroll(readOffset());
};

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

target.addEventListener("scroll", onScroll, { passive: true });
const attach = () => {
const container = resolveContainer();
if (containerRef && !container) return false;

const target: HTMLElement | Window = container ?? window;
if (target === attachedTarget) return true;

attachedTarget?.removeEventListener("scroll", onScroll);
attachedTarget = target;
target.addEventListener("scroll", onScroll, { passive: true });
reportScroll(readOffset());
return true;
};

const waitForContainer = () => {
if (disposed) return;
if (attach()) return;
attachFrame = window.requestAnimationFrame(waitForContainer);
};

if (containerRef) {
waitForContainer();
} else {
attach();
}

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

return active && hidden;
return hidden;
}
Loading