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
5 changes: 5 additions & 0 deletions src/app/globals.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand Down
18 changes: 11 additions & 7 deletions src/components/ClinicalDashboard.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1518,6 +1518,7 @@
const router = useRouter();
const searchParams = useSearchParams();
const mainRef = useRef<HTMLElement>(null);
const [bottomSearchScrollHidden, setBottomSearchScrollHidden] = useState(false);
const composerInputRef = useRef<HTMLInputElement>(null);
const scrollFrameRef = useRef<number | null>(null);
const navSyncLockRef = useRef<number | null>(null);
Expand DownExpand Up@@ -3190,7 +3191,7 @@
});
}

function stageAnswerFollowUpDraft(draft: string) {

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

View workflow job for this annotation

GitHub Actions/ verify

'stageAnswerFollowUpDraft' is defined but never used
setQuery(draft);
focusComposerInput();
}
Expand DownExpand Up@@ -3798,6 +3799,7 @@
// 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 }}
onBottomComposerScrollHiddenChange={setBottomSearchScrollHidden}
/>

<main
Expand All@@ -3812,13 +3814,15 @@
? "mb-0"
: "mb-[calc(5.25rem+env(safe-area-inset-bottom))] sm:mb-24"
: hasMobileBottomSearch
? compactMobileBottomSearch
? differentialsCompareAddonActive
? "mb-[calc(8.75rem+env(safe-area-inset-bottom))] sm:mb-0"
: "mb-[calc(5rem+env(safe-area-inset-bottom))] sm:mb-0"
: compactMobileModeHome
? "mb-0"
: "mb-[calc(5.25rem+env(safe-area-inset-bottom))] sm:mb-0"
? bottomSearchScrollHidden
? "mb-0 sm:mb-0"
: compactMobileBottomSearch
? differentialsCompareAddonActive
? "mb-[calc(8.75rem+env(safe-area-inset-bottom))] sm:mb-0"
: "mb-[calc(5rem+env(safe-area-inset-bottom))] sm:mb-0"
: compactMobileModeHome
? "mb-0"
: "mb-[calc(5.25rem+env(safe-area-inset-bottom))] sm:mb-0"
: "mb-0",
)}
>
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,7 @@ function GlobalMockupSearchShellClient({
const [accountSetupOpen, setAccountSetupOpen] = useState(false);
const [recentQueries, setRecentQueries] = useState<string[]>([]);
const [commandScopes, setCommandScopes] = useState<string[]>([]);
const [bottomSearchScrollHidden, setBottomSearchScrollHidden] = useState(false);
const { theme, toggleTheme } = useTheme();
const auth = useAuthSession();
const sidebarIdentity = useMemo(() => deriveSidebarIdentity(auth.session?.user.email), [auth.session?.user.email]);
Expand DownExpand Up@@ -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"}
/>
</div>
Expand All@@ -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",
)}
>
<ClientHydrationBoundary
Expand Down
41 changes: 36 additions & 5 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -206,6 +206,7 @@ export function MasterSearchHeader({
mobileLeadingAction = "menu",
onMobileBack,
hideOnScroll,
onBottomComposerScrollHiddenChange,
}: {
documents: ClinicalDocument[];
documentTotal?: number;
Expand DownExpand Up@@ -258,12 +259,15 @@ 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. */
/** 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<HTMLElement | null> };
/** 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();
Expand DownExpand Up@@ -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<HTMLDivElement | null>(null);
Expand DownExpand Up@@ -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";

Expand All@@ -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"
Expand All@@ -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 ? <div className="answer-footer-search-backdrop" aria-hidden="true" /> : null}
Expand DownExpand Up@@ -1459,6 +1482,14 @@ export function MasterSearchHeader({
},
}
: undefined;
const composerFocusProps = hideOnScroll
? {
onFocusCapture: () => setComposerChromeFocused(true),
onBlurCapture: (event: ReactFocusEvent<HTMLElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setComposerChromeFocused(false);
},
}
: undefined;

const headerAndComposer = (
<>
Expand Down
45 changes: 29 additions & 16 deletions src/components/clinical-dashboard/use-hide-on-scroll.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,27 @@ const topRevealOffset = 8;
// avoid jitter from momentum settling and fractional scroll positions.
const minimumDelta = 4;

/** Pure scroll-direction evaluation used by the hook; exported for unit tests. */
export function computeScrollHideUpdate(params: { offset: number; lastOffset: number; currentlyHidden: boolean }): {
hidden: boolean;
lastOffset: number;
} {
const { offset, lastOffset, currentlyHidden } = params;
// Ignore iOS rubber-band overscroll at the top.
if (offset < 0) return { hidden: currentlyHidden, lastOffset };
const delta = offset - lastOffset;
if (offset <= topRevealOffset) {
return { hidden: false, lastOffset: offset };
}
if (Math.abs(delta) < minimumDelta) {
return { hidden: currentlyHidden, lastOffset };
}
if (delta > 0) {
return { hidden: offset > hideActivationOffset, lastOffset: offset };
}
return { hidden: false, lastOffset: offset };
}

function subscribeToPhoneMedia(onChange: () => void) {
const media = window.matchMedia(phoneMediaQuery);
media.addEventListener("change", onChange);
Expand All@@ -41,9 +62,10 @@ interface UseHideOnScrollOptions {

/**
* 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.
* universal header) and the bottom search dock 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);
Expand All@@ -63,21 +85,12 @@ export function useHideOnScroll({ containerRef, disabled = false }: UseHideOnScr
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);
}
if (Math.abs(delta) < minimumDelta && offset > topRevealOffset) return;
const update = computeScrollHideUpdate({ offset, lastOffset, currentlyHidden: false });
lastOffset = update.lastOffset;
setHidden(update.hidden);
};

const onScroll = () => {
Expand Down
16 changes: 16 additions & 0 deletions tests/ui-tools.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -637,6 +637,22 @@ test.describe("Clinical KB applications launcher", () => {
await expectNoPageHorizontalOverflow(page);
});

test("phone bottom search dock hides while scrolling down on search results", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await gotoLauncher(page, "/forms?q=transport&focus=1&run=1");

await expect(page.getByTestId("form-search-mobile-results")).toBeVisible();
const dock = page.locator("form.answer-footer-search-dock");
await expect(dock).toBeVisible();
await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true");

await page.evaluate(() => window.scrollTo({ top: 120, behavior: "auto" }));
await expect(dock).toHaveAttribute("data-scroll-hidden", "true");

await page.evaluate(() => window.scrollTo({ top: 60, behavior: "auto" }));
await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true");
});

test("mode toggle keeps forms separate from services", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoLauncher(page, "/?mode=answer");
Expand Down
51 changes: 51 additions & 0 deletions tests/use-hide-on-scroll.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";

import { computeScrollHideUpdate } from "@/components/clinical-dashboard/use-hide-on-scroll";

describe("computeScrollHideUpdate", () => {
it("keeps the chrome visible near the top", () => {
expect(computeScrollHideUpdate({ offset: 0, lastOffset: 0, currentlyHidden: true })).toEqual({
hidden: false,
lastOffset: 0,
});
expect(computeScrollHideUpdate({ offset: 8, lastOffset: 20, currentlyHidden: true })).toEqual({
hidden: false,
lastOffset: 8,
});
});

it("hides after scrolling down past the activation offset", () => {
expect(computeScrollHideUpdate({ offset: 80, lastOffset: 10, currentlyHidden: false })).toEqual({
hidden: true,
lastOffset: 80,
});
});

it("stays visible when scrolling down but still within the activation band", () => {
expect(computeScrollHideUpdate({ offset: 40, lastOffset: 10, currentlyHidden: false })).toEqual({
hidden: false,
lastOffset: 40,
});
});

it("reveals again on deliberate scroll up", () => {
expect(computeScrollHideUpdate({ offset: 120, lastOffset: 180, currentlyHidden: true })).toEqual({
hidden: false,
lastOffset: 120,
});
});

it("ignores rubber-band overscroll at the top", () => {
expect(computeScrollHideUpdate({ offset: -12, lastOffset: 4, currentlyHidden: true })).toEqual({
hidden: true,
lastOffset: 4,
});
});

it("ignores sub-threshold deltas", () => {
expect(computeScrollHideUpdate({ offset: 82, lastOffset: 80, currentlyHidden: false })).toEqual({
hidden: false,
lastOffset: 80,
});
});
});
Loading