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
16 changes: 12 additions & 4 deletions src/app/globals.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -746,14 +746,16 @@ summary::-webkit-details-marker {
--footer-scrim-height: max(7rem, calc(var(--safe-area-bottom) + 5.5rem));
}

/* In-flow above the search pill inside the phone dock so scroll-hide
translateY moves the Compare bar with the composer (no viewport-fixed jump). */
.differentials-mobile-compare-fab {
pointer-events: none;
position: fixed;
inset-inline: max(1rem, var(--safe-area-left)) max(1rem, var(--safe-area-right));
bottom: max(5.35rem, calc(var(--safe-area-bottom) + 5.05rem));
z-index: 50;
position: relative;
z-index: 10;
display: flex;
width: 100%;
justify-content: center;
margin-bottom: 0.5rem;
}

.differentials-mobile-compare-fab__button {
Expand DownExpand Up@@ -1846,6 +1848,12 @@ summary::-webkit-details-marker {
pointer-events: none;
}

/* Compare addon adds in-flow chrome above the pill; clear a little extra so
the Compare bar does not leave a 1px/subpixel peep on the viewport edge. */
.answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge[data-footer-addon="differentials-compare"][data-scroll-hidden="true"] {
transform: translateY(calc(100% + 0.75rem + env(safe-area-inset-bottom)));
}

/* Document viewer bottom composer mirrors the dock's hide-on-scroll. Unlike
the full-bleed dock it floats with a bottom gap (.floating-composer-edge,
bottom: max(0.75rem, safe-area)), so the exit transform must also clear
Expand Down
6 changes: 2 additions & 4 deletions src/components/clinical-dashboard/differentials-home.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,10 +113,8 @@ const candidateIconBySlug: Array<[string, LucideIcon]> = [
];

/**
* Mobile/tablet floating compare action. Portals into the search composer's
* addon slot so it stays anchored beside the active result controls, but
* renders as a self-contained floating pill so it reads as a batch-selection
* action rather than composer chrome.
* Mobile/tablet compare action. Portals into the search composer's addon slot
* so it sits above the search pill as dock chrome and hides/reveals with it.
*/
function DifferentialsMobileCompareBar({
selectedCount,
Expand Down
4 changes: 2 additions & 2 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -288,8 +288,8 @@ function GlobalStandaloneSearchShellClient({
: "calc(9rem + var(--safe-area-bottom))";
// Release the large bottom reserve only when the phone bottom composer is
// actually hidden (MasterSearchHeader's bottomComposerHidden). Header-only
// scroll-hide, pinned compare addons, open menus/sheets, and composer focus
// keep the full reserve so content does not slide under a still-visible dock.
// scroll-hide, open menus/sheets, and composer focus keep the full reserve
// so content does not slide under a still-visible dock.
// Safari's bottom safe-area inset includes its translucent browser toolbar.
// Reusing that inset after the app composer hides recreates a toolbar-sized
// blank band, so the hidden state intentionally keeps only a small content
Expand Down
14 changes: 6 additions & 8 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -325,9 +325,9 @@ export function MasterSearchHeader({
searchComposerVisible &&
!desktopHomeComposerSlotId &&
(isAnswerFooterComposer || mobileSearchPlacement === "bottom");
const bottomComposerScrollHiddenActive = Boolean(
hideOnScroll && phoneBottomSearchDockActive && !mobileBottomSearchAddonSlotId,
);
// Compare addon chrome lives inside the phone dock; hide/reveal with it so
// the search pill and Compare selected bar reclaim space together.
const bottomComposerScrollHiddenActive = Boolean(hideOnScroll && phoneBottomSearchDockActive);
const bottomComposerHidden =
bottomComposerScrollHiddenActive &&
scrollHidden &&
Expand DownExpand Up@@ -1200,11 +1200,9 @@ export function MasterSearchHeader({
const ModeIdentityIcon = appModeIcons[searchMode];
const hasScopeFooterChip = searchMode === "answer" || searchMode === "documents" || searchMode === "forms";
const usesPhoneFooterDock = usesBottomComposerPlacement && usesPhoneSearchLayout;
// A differential comparison is a persistent batch action: hiding its host
// dock on downward scroll makes the CTA slide under mobile browser chrome
// and disables pointer events just when users finish reviewing the list.
// Keep that dock pinned while the header can still collapse independently.
const shouldHideBottomOnScroll = Boolean(hideOnScroll && usesPhoneFooterDock && !mobileBottomSearchAddonSlotId);
// Differentials compare addon is dock chrome (search pill + Compare bar).
// Hide/reveal the whole dock together; do not pin for the addon slot.
const shouldHideBottomOnScroll = Boolean(hideOnScroll && usesPhoneFooterDock);
// Phone submitted non-answer result docks reserve pill-only scroll
// clearance (ClinicalDashboard / global-search-shell <main> padding via
// mobileComposerReserve), so an extra notice line would push the fixed
Expand Down
98 changes: 71 additions & 27 deletions tests/ui-tools.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1451,7 +1451,8 @@ test.describe("Clinical KB tools launcher", () => {
await expectNoPageHorizontalOverflow(page);
});

test("mobile differential compare action stays tappable while results scroll", async ({ page }) => {
test("mobile differential compare dock hides on scroll down and stays tappable when revealed", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await mockAnswerDashboardApi(page);
await mockDifferentialCatalogApi(page);
await page.route(/\/api\/search(?:\?.*)?$/, async (route) => {
Expand DownExpand Up@@ -1491,45 +1492,88 @@ test.describe("Clinical KB tools launcher", () => {
await expect(page.locator("#differentials-mobile-compare-addon-slot")).toHaveCount(1);
await expect(compareAction).toBeVisible();
await expect(compareAction).toContainText("Compare selected");

await mainContent.evaluate((element) => element.scrollTo({ top: 700, behavior: "instant" }));
await expect.poll(() => mainContent.evaluate((element) => element.scrollTop)).toBeGreaterThan(100);
await page.waitForTimeout(300);

await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true");
const mainPaddingBottom = await mainContent.evaluate((element) =>
Number.parseFloat(window.getComputedStyle(element).paddingBottom),
);
const dockHeight = await dock.evaluate((element) => element.getBoundingClientRect().height);
expect(mainPaddingBottom).toBeGreaterThanOrEqual(dockHeight);

await mainContent.evaluate((element) => element.scrollTo({ top: element.scrollHeight, behavior: "instant" }));
const lastResultBottom = await page
.getByTestId("differential-status-badge")
.last()
.evaluate(
(element) =>
element.closest("article")?.getBoundingClientRect().bottom ?? element.getBoundingClientRect().bottom,
);
const dockTop = await dock.evaluate((element) => element.getBoundingClientRect().top);
expect(lastResultBottom).toBeLessThanOrEqual(dockTop);

const compareGeometry = await compareAction.evaluate((element) => {
// Compare lives in the dock addon slot above the search pill.
const revealedGeometry = await compareAction.evaluate((element) => {
const rect = element.getBoundingClientRect();
const dockRect = element.closest("form")?.getBoundingClientRect();
const centreX = rect.left + rect.width / 2;
const centreY = rect.top + rect.height / 2;
const hit = document.elementFromPoint(centreX, centreY);
return {
top: rect.top,
bottom: rect.bottom,
dockTop: dockRect?.top ?? null,
dockBottom: dockRect?.bottom ?? null,
viewportHeight: window.innerHeight,
receivesPointer: hit === element || element.contains(hit),
};
});
expect(compareGeometry.top).toBeGreaterThanOrEqual(0);
expect(compareGeometry.bottom).toBeLessThanOrEqual(compareGeometry.viewportHeight);
expect(compareGeometry.receivesPointer).toBe(true);
expect(compareGeometry.bottom).toBeLessThan(dockTop);
expect(revealedGeometry.dockTop).not.toBeNull();
expect(revealedGeometry.top).toBeGreaterThanOrEqual(revealedGeometry.dockTop!);
expect(revealedGeometry.bottom).toBeLessThanOrEqual(revealedGeometry.dockBottom!);
expect(revealedGeometry.receivesPointer).toBe(true);

const mainPaddingBottom = await mainContent.evaluate((element) =>
Number.parseFloat(window.getComputedStyle(element).paddingBottom),
);
const dockHeight = await dock.evaluate((element) => element.getBoundingClientRect().height);
expect(mainPaddingBottom).toBeGreaterThanOrEqual(dockHeight);

// Ensure enough scroll room for hide thresholds even with a short result list.
await page.evaluate(() => {
const container = document.getElementById("main-content");
if (!container) return;
const spacer = document.createElement("div");
spacer.id = "test-scroll-spacer";
spacer.style.height = "2000px";
spacer.style.minHeight = "2000px";
spacer.style.display = "block";
container.appendChild(spacer);
});

await expect(async () => {
await input.blur();
await expect(input).not.toBeFocused({ timeout: 1_000 });
await scrollPrimarySurface(page, 0);
for (const offset of [40, 80, 120, 160, 200]) {
await scrollPrimarySurface(page, offset);
}
await expect(dock).toHaveAttribute("data-scroll-hidden", "true", { timeout: 1_000 });
}).toPass({ timeout: 15_000 });

await expect
.poll(async () => dock.evaluate((node) => window.getComputedStyle(node).transform !== "none"))
.toBe(true);
await expect
.poll(async () => mainContent.evaluate((node) => Number.parseFloat(window.getComputedStyle(node).paddingBottom)))
.toBeLessThanOrEqual(13);

// Wait for the hide transition to finish so the in-dock Compare bar is fully
// off-screen (translateY(100%) parks the dock top on the viewport bottom edge).
await expect
.poll(async () =>
compareAction.evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
viewportHeight: window.innerHeight,
offscreen: rect.top >= window.innerHeight - 1,
};
}),
)
.toMatchObject({ offscreen: true });

await scrollPrimarySurface(page, 60);
await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true");
await expect(compareAction).toBeVisible();
const restoredPointer = await compareAction.evaluate((element) => {
const rect = element.getBoundingClientRect();
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
return hit === element || element.contains(hit);
});
expect(restoredPointer).toBe(true);
await expectNoPageHorizontalOverflow(page);

// The result cards and compare bar remain in their non-desktop layout up
Expand Down
Loading