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
35 changes: 25 additions & 10 deletions src/components/clinical-dashboard/use-hide-on-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const minimumDelta = 4;
// chrome at a direction change.
const hideIntentDistance = 24;
const revealIntentDistance = 12;
// How close to the bottom edge (px) counts as "pinned to the bottom". When the
// offset is this near the maximum, an upward reading is the viewport growing
// under a collapsing header rather than a real scroll, so it must not reveal.
const bottomClampTolerance = 1;

type ScrollDirection = "down" | "up" | null;
export interface ScrollMetrics {
Expand Down Expand Up @@ -65,16 +69,27 @@ export function computeScrollHideUpdate(params: {
return { hidden: false, lastOffset: offset, direction: null, directionTravel: 0 };
}

// Collapsing in-flow chrome grows the scroll viewport. At the bottom the
// browser clamps scrollTop to the new maximum and emits an apparent upward
// scroll even though the user is still moving down. Keep the chrome hidden
// and rebase intent so that layout feedback cannot start a hide/show loop.
if (
currentlyHidden &&
maxOffset !== undefined &&
lastOffset > maxOffset + minimumDelta &&
Math.abs(offset - maxOffset) <= 1
) {
// Collapsing in-flow chrome grows the scroll viewport: as the header hands its
// height back to the content, the browser clamps scrollTop to the new, smaller
// maximum and emits an apparent upward scroll even though the user is moving
// down or holding at the bottom. A collapse animates over several frames, so
// this clamp repeats frame after frame; if any frame's phantom "up" reveals
// the chrome, the viewport shrinks again and a hide/reveal scroll-bounce
// begins. While the chrome is hidden and the offset stays pinned to the bottom
// edge, treat every upward reading as layout feedback — hold the hidden state
// and rebase intent so only a genuine upward scroll (one that pulls the offset
// clear of the bottom) can reveal. This intentionally does not depend on the
// previous offset's relationship to the maximum, which the browser's per-frame
// clamping makes unreliable during the collapse.
//
// The bottom test is deliberately one-sided (`offset >= maxOffset - tol`, not
// `|offset - maxOffset| <= tol`): iOS rubber-band overscroll at the bottom can
// report a scrollTop *past* the maximum, and while the content springs back
// the reading moves up. That is still the bottom edge, not a scroll away from
// it, so it must hold hidden too — mirroring the `offset < 0` guard that holds
// state through top overscroll. A symmetric window would instead reveal the
// chrome mid-rubber-band, reintroducing the flicker this guard removes.
if (currentlyHidden && maxOffset !== undefined && offset < lastOffset && offset >= maxOffset - bottomClampTolerance) {
return { hidden: true, lastOffset: offset, direction: null, directionTravel: 0 };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand Down
86 changes: 86 additions & 0 deletions tests/use-hide-on-scroll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,92 @@ describe("computeScrollHideUpdate", () => {
});
});

it("holds the chrome hidden across a multi-frame collapse clamp at the bottom", () => {
// As the collapsing header hands its height back to the scroll container,
// maxOffset shrinks frame by frame and the browser clamps scrollTop to it,
// so each reading looks like a small upward scroll while the offset stays
// pinned to the (moving) bottom edge. Include a frame whose shrink is only
// `minimumDelta` — the case the previous `lastOffset > maxOffset + delta`
// guard let slip through and reveal the chrome, starting the bounce.
const clampFrames = [1000, 996, 988, 980, 974, 970, 968, 940, 928];
let state = {
hidden: true,
lastOffset: clampFrames[0],
direction: "down" as "down" | "up" | null,
directionTravel: 160,
};
for (const offset of clampFrames.slice(1)) {
state = computeScrollHideUpdate({
offset,
lastOffset: state.lastOffset,
// offset is pinned to the shrinking bottom edge each frame.
maxOffset: offset,
currentlyHidden: state.hidden,
direction: state.direction,
directionTravel: state.directionTravel,
});
expect(state.hidden).toBe(true);
expect(state.lastOffset).toBe(offset);
}

// Once the collapse settles, a genuine upward scroll that pulls the offset
// clear of the bottom still reveals the chrome.
const revealed = computeScrollHideUpdate({
offset: 900,
lastOffset: state.lastOffset,
maxOffset: 928,
currentlyHidden: state.hidden,
direction: state.direction,
directionTravel: state.directionTravel,
});
expect(revealed.hidden).toBe(false);
expect(revealed.direction).toBe("up");
});

it("does not reveal on a small phantom clamp when the offset stays pinned to the bottom", () => {
// Single frame: a 4px upward clamp while glued to the bottom edge. The old
// guard required the previous offset to sit more than `minimumDelta` above
// the new maximum, which fails here, so the phantom read reached the reveal
// path. It must be treated as layout feedback instead.
expect(
computeScrollHideUpdate({
offset: 968,
lastOffset: 972,
maxOffset: 968,
currentlyHidden: true,
direction: "down",
directionTravel: 120,
}),
).toEqual({
hidden: true,
lastOffset: 968,
direction: null,
directionTravel: 0,
});
});

it("holds the chrome hidden while a bottom rubber-band overscroll springs back", () => {
// iOS reports a scrollTop past the maximum during bottom overscroll; as the
// content springs back the reading moves up but is still the bottom edge.
// A symmetric `|offset - maxOffset| <= tolerance` window would fall through
// here and reveal the chrome mid-rubber-band — the one-sided test holds it.
expect(
computeScrollHideUpdate({
offset: 930,
lastOffset: 962,
maxOffset: 900,
currentlyHidden: true,
direction: "down",
directionTravel: 120,
}),
).toEqual({
hidden: true,
lastOffset: 930,
direction: null,
directionTravel: 0,
});
});

it("ignores an upward clamp caused by the viewport growing at the bottom", () => {
const clamped = computeScrollHideUpdate({
offset: 900,
Expand Down