diff --git a/components/navigation/PageSectionNav.tsx b/components/navigation/PageSectionNav.tsx index 9ab24d5..9289a3d 100644 --- a/components/navigation/PageSectionNav.tsx +++ b/components/navigation/PageSectionNav.tsx @@ -26,6 +26,7 @@ type PageSectionNavProps = { export function PageSectionNav({ items, className, offset = 96 }: PageSectionNavProps) { const [activeId, setActiveId] = useState(null); const [isSticky, setIsSticky] = useState(false); + const [canScrollLeft, setCanScrollLeft] = useState(false); const [canScrollRight, setCanScrollRight] = useState(false); const observerRef = useRef(null); const navRef = useRef(null); @@ -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); @@ -181,17 +185,26 @@ export function PageSectionNav({ items, className, offset = 96 }: PageSectionNav >
{/* 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. */}
{items.map(({ id, label }) => { diff --git a/tests/e2e/interactive-content.spec.ts b/tests/e2e/interactive-content.spec.ts index 71ae5af..a54933f 100644 --- a/tests/e2e/interactive-content.spec.ts +++ b/tests/e2e/interactive-content.spec.ts @@ -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 diff --git a/tests/integration/page-section-nav.test.tsx b/tests/integration/page-section-nav.test.tsx new file mode 100644 index 0000000..122ffd1 --- /dev/null +++ b/tests/integration/page-section-nav.test.tsx @@ -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(); + 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(); + 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"); + // 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(); + 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(); + 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(); + const el = strip(); + setScrollMetrics(el, { scrollWidth: 400, clientWidth: 400 }); + expect(el.className).not.toContain("mask-image"); + }); +});