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: 24 additions & 11 deletions components/navigation/PageSectionNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type PageSectionNavProps = {
export function PageSectionNav({ items, className, offset = 96 }: PageSectionNavProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const [isSticky, setIsSticky] = useState(false);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const navRef = useRef<HTMLElement>(null);
Expand Down Expand Up @@ -85,13 +86,16 @@ export function PageSectionNav({ items, className, offset = 96 }: PageSectionNav
}, []);

// Single-row strip at every width: overflow scrolls horizontally inside
// the nav, so the right-edge fade only makes sense while content actually
// extends past the visible edge — and lifts when scrolled to the end.
// the nav. Edge fades only make sense where content is actually clipped:
// the right fade lifts when scrolled to the end, and a subtler left
// affordance appears only once pills have scrolled off the start edge.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const update = () =>
const update = () => {
setCanScrollRight(el.scrollWidth - el.scrollLeft - el.clientWidth > 4);
setCanScrollLeft(el.scrollLeft > 4);
};
update();
el.addEventListener("scroll", update, { passive: true });
const ro = new ResizeObserver(update);
Expand Down Expand Up @@ -181,17 +185,26 @@ export function PageSectionNav({ items, className, offset = 96 }: PageSectionNav
>
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
{/* Always one row — overflow scrolls inside the strip at every
width. The right-edge mask fade appears only while content
actually extends past the edge, so a partially visible next pill
reads as an intentional scroll affordance. scroll-px keeps a
comfortable inset when the browser scrolls a focused pill into
view. */}
width. The clip region bleeds 12px into the page gutter
(-mx-3/px-3), so at rest the first pill still aligns with the
content inset while its focus ring and approaching pills get
runway inside the clip; -mt-1/pt-1 gives the same headroom for
focus outlines above the pills. scroll-px-3 makes
focus-into-view land pills at that same inset. Edge fades are a
narrow fixed 28px mask — enough to hint that content continues,
never enough to wash a pill out — applied only at edges that
actually have clipped content. */}
<div
ref={containerRef}
className={cn(
"scrollbar-hide flex flex-nowrap gap-2 overflow-x-auto scroll-px-4 pb-2",
canScrollRight &&
"[mask-image:linear-gradient(to_right,black_85%,transparent)]"
"scrollbar-hide -mx-3 -mt-1 flex flex-nowrap gap-2 overflow-x-auto scroll-px-3 px-3 pb-2 pt-1",
canScrollLeft && canScrollRight
? "[mask-image:linear-gradient(to_right,transparent,black_28px,black_calc(100%_-_28px),transparent)]"
: canScrollRight
? "[mask-image:linear-gradient(to_right,black_calc(100%_-_28px),transparent)]"
: canScrollLeft
? "[mask-image:linear-gradient(to_right,transparent,black_28px)]"
: ""
)}
>
{items.map(({ id, label }) => {
Expand Down
40 changes: 40 additions & 0 deletions tests/e2e/interactive-content.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,46 @@ test("section nav stays one scrollable row at every width", async ({ page }) =>
}
});

// Edge fades are a scroll affordance, not decoration: a narrow fade only at
// edges that actually have clipped content, so pills never wash out at rest.
// Computed maskImage serializes "to right" as 90deg, black as
// rgb(0, 0, 0), and transparent as rgba(0, 0, 0, 0); the first/last stops
// tell which edges fade.
test("section nav fades only edges that have clipped content", async ({ page }) => {
await hydratedGoto(page, "/plan-your-trip");
// Narrow viewport guarantees the strip overflows.
await page.setViewportSize({ width: 390, height: 800 });
const row = page.locator('nav[aria-label="On this page"] > div > div');

const mask = () =>
row.evaluate((el) => getComputedStyle(el).maskImage);
const scrollRow = (x: number) =>
row.evaluate((el, v) => {
el.scrollLeft = v;
el.dispatchEvent(new Event("scroll"));
}, x);

// Start: a right fade hints at more content; the left edge is untouched.
// The 28px stop pins the fade to a fixed narrow band — a regression to a
// width-proportional fade would wash out the last pill again.
await expect
.poll(mask, "right-only fade at scroll start")
.toMatch(/^linear-gradient\(90deg, rgb\(0, 0, 0\).*rgba\(0, 0, 0, 0\)\)$/);
await expect.poll(mask, "fixed 28px fade stop").toContain("28px");

// Mid-scroll: both edges fade.
await scrollRow(200);
await expect
.poll(mask, "both edges fade mid-scroll")
.toMatch(/^linear-gradient\(90deg, rgba\(0, 0, 0, 0\).*rgba\(0, 0, 0, 0\)\)$/);

// End: the right fade lifts; only the left affordance remains.
await scrollRow(9999);
await expect
.poll(mask, "left-only fade at scroll end")
.toMatch(/^linear-gradient\(90deg, rgba\(0, 0, 0, 0\).*rgb\(0, 0, 0\) 28px\)$/);
});

test("the dive log still renders its UI when Firestore is unreachable", async ({ page }) => {
// With the backend fully blocked (fixture default), the SDK resolves from an
// empty offline cache — the page must render its empty state rather than
Expand Down
85 changes: 85 additions & 0 deletions tests/integration/page-section-nav.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { PageSectionNav } from "@/components/navigation/PageSectionNav";

const ITEMS = Array.from({ length: 10 }, (_, i) => ({
id: `section-${i}`,
label: `Section ${i}`,
}));

// jsdom has no layout — scrollWidth/clientWidth are 0 and nothing actually
// clips. Mock the strip's metrics, then fire a scroll event so the
// component recomputes its edge-fade state.
function strip(): HTMLElement {
return document.querySelector(
'nav[aria-label="On this page"] [class*="overflow-x-auto"]'
) as HTMLElement;
}

function setScrollMetrics(
el: HTMLElement,
{ scrollWidth = 800, clientWidth = 400, scrollLeft = 0 } = {}
) {
Object.defineProperty(el, "scrollWidth", { configurable: true, value: scrollWidth });
Object.defineProperty(el, "clientWidth", { configurable: true, value: clientWidth });
Object.defineProperty(el, "scrollLeft", {
configurable: true,
writable: true,
value: scrollLeft,
});
fireEvent.scroll(el);
}

describe("PageSectionNav edge treatment", () => {
it("stays a single scrollable row and keeps pills readable at rest", () => {
render(<PageSectionNav items={ITEMS} />);
const el = strip();
expect(el.className).toContain("flex-nowrap");
expect(el.className).toContain("overflow-x-auto");
// Inset inside the clip region so pills never sit flush on the clip edge.
expect(el.className).toContain("px-3");
// Focus scroll-into-view lands pills at that same inset.
expect(el.className).toContain("scroll-px-3");

const first = screen.getByRole("button", { name: "Section 0" });
first.focus();
expect(first).toHaveFocus();
});

it("shows only a right-edge fade at the start position", () => {
render(<PageSectionNav items={ITEMS} />);
const el = strip();
setScrollMetrics(el, { scrollLeft: 0 });
// Mask present, but the gradient starts opaque — nothing fades left.
expect(el.className).toContain("mask-image:linear-gradient(to_right,black");
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
// Fixed 28px stop, not a width-proportional fade that washes out pills.
expect(el.className).toContain("black_calc(100%_-_28px)");
});

it("shows both edge affordances while scrolled mid-strip", () => {
render(<PageSectionNav items={ITEMS} />);
const el = strip();
setScrollMetrics(el, { scrollLeft: 150 });
// Gradient opens transparent (left fade) and still closes transparent.
expect(el.className).toContain("to_right,transparent,black");
expect(el.className).toMatch(/transparent\)\]/);
// Both fades are the fixed 28px stops.
expect(el.className).toContain("black_28px,black_calc(100%_-_28px)");
});

it("drops the right fade at the end but keeps the left affordance", () => {
render(<PageSectionNav items={ITEMS} />);
const el = strip();
setScrollMetrics(el, { scrollLeft: 400 });
// Left fade only: opens transparent, closes opaque — nothing fades right.
expect(el.className).toContain("to_right,transparent,black_28px");
expect(el.className).not.toMatch(/transparent\)\]/);
});

it("paints no fades when the strip does not overflow", () => {
render(<PageSectionNav items={ITEMS} />);
const el = strip();
setScrollMetrics(el, { scrollWidth: 400, clientWidth: 400 });
expect(el.className).not.toContain("mask-image");
});
});
Loading