diff --git a/frontend/src/lib/useScrollFade.dom.test.tsx b/frontend/src/lib/useScrollFade.dom.test.tsx new file mode 100644 index 00000000..090eee25 --- /dev/null +++ b/frontend/src/lib/useScrollFade.dom.test.tsx @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { render, cleanup, fireEvent } from "@testing-library/react"; +import { useScrollFade } from "./useScrollFade"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; +}); +afterEach(cleanup); + +function Scroller({ fadeTop = true }: { fadeTop?: boolean }) { + const fade = useScrollFade(20, fadeTop); + const { ref } = fade; + return
; +} + +/** A scroller 100 px tall over 300 px of content, scrolled to `top`. */ +function scrolledTo(el: HTMLElement, top: number) { + Object.defineProperties(el, { + clientHeight: { configurable: true, value: 100 }, + scrollHeight: { configurable: true, value: 300 }, + scrollTop: { configurable: true, value: top, writable: true }, + }); + fireEvent.scroll(el); +} + +// iPad WebKit kept a scrolled container's mask stuck to the content: at the bottom of a scrolled page +// the last strip (a table's pager) sat under a dark band. With no edge to fade there is no mask. +describe("useScrollFade", () => { + it("masks the edge that is clipped", () => { + const { getByTestId } = render(); + const el = getByTestId("s"); + scrolledTo(el, 0); + expect(el.style.maskImage).toContain("calc(100% - 20px)"); + }); + + it("sets no mask once nothing fades, and keeps the layer it made", () => { + const { getByTestId } = render(); + const el = getByTestId("s"); + scrolledTo(el, 200); + expect(el.style.maskImage).toBe(""); + expect(el.style.isolation).toBe("isolate"); + }); +}); diff --git a/frontend/src/lib/useScrollFade.ts b/frontend/src/lib/useScrollFade.ts index d64d879c..5da6b32f 100644 --- a/frontend/src/lib/useScrollFade.ts +++ b/frontend/src/lib/useScrollFade.ts @@ -18,7 +18,12 @@ export function useScrollFade( style: CSSProperties; } { const { ref, start, end } = useScrollEdges("y"); - const mask = `linear-gradient(to bottom, transparent, #000 ${start && fadeTop ? fadePx : 0}px, #000 calc(100% - ${ + // No edge to fade, no mask at all: iPad WebKit kept a scrolled container's mask stuck to the + // content, so at the bottom of a scrolled page the last strip (a table's pager) sat under a dark + // band. `isolation` keeps the stacking context the mask made, so nothing inside changes layer. + const top = start && fadeTop; + if (!top && !end) return { ref, style: { isolation: "isolate" } }; + const mask = `linear-gradient(to bottom, transparent, #000 ${top ? fadePx : 0}px, #000 calc(100% - ${ end ? fadePx : 0 }px), transparent)`; return { ref, style: { maskImage: mask, WebkitMaskImage: mask } };