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
200 changes: 200 additions & 0 deletions app/components/StreamRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,206 @@ describe("StreamRow", () => {
});
});

// ── matchMedia mock ─────────────────────────────────────────────────────────

/** Installs a matchMedia mock that reports the given reduced-motion preference. */
function mockMatchMedia(prefersReduced: boolean) {
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: query.includes("prefers-reduced-motion") ? prefersReduced : false,
media: query,
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
addListener: jest.fn(),
removeListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
}

describe("reduced-motion fallback (Issue #1038)", () => {
afterEach(() => {
// @ts-expect-error reset between tests
delete window.matchMedia;
});

it("sets data-reduced-motion=false on the article element by default", () => {
mockMatchMedia(false);
const { container } = render(<StreamRow stream={baseStream} />);
const article = container.querySelector("article.stream-row");
expect(article).toHaveAttribute("data-reduced-motion", "false");
});

it("sets data-reduced-motion=true when prefers-reduced-motion is active", () => {
mockMatchMedia(true);
const { container } = render(<StreamRow stream={baseStream} />);
const article = container.querySelector("article.stream-row");
expect(article).toHaveAttribute("data-reduced-motion", "true");
});

it("sets data-reduced-motion on the cancel-reveal element", () => {
mockMatchMedia(true);
const cancellableStream: StreamRowData = {
...makeMockStream("active"),
nextAction: "Cancel",
};
const { container } = render(<StreamRow stream={cancellableStream} />);
const reveal = container.querySelector(".stream-row__cancel-reveal");
expect(reveal).toHaveAttribute("data-reduced-motion", "true");
});

it("sets data-reduced-motion on the cancel-reveal to false by default", () => {
mockMatchMedia(false);
const cancellableStream: StreamRowData = {
...makeMockStream("active"),
nextAction: "Cancel",
};
const { container } = render(<StreamRow stream={cancellableStream} />);
const reveal = container.querySelector(".stream-row__cancel-reveal");
expect(reveal).toHaveAttribute("data-reduced-motion", "false");
});

it("applies transition: none to cancel-label when reduced motion is requested", () => {
mockMatchMedia(true);
const cancellableStream: StreamRowData = {
...makeMockStream("active"),
nextAction: "Cancel",
};
const { container } = render(<StreamRow stream={cancellableStream} />);
const label = container.querySelector(".stream-row__cancel-label") as HTMLElement;
expect(label.style.transition).toBe("none");
});

it("does not force transition: none on cancel-label when reduced motion is not requested", () => {
mockMatchMedia(false);
const cancellableStream: StreamRowData = {
...makeMockStream("active"),
nextAction: "Cancel",
};
const { container } = render(<StreamRow stream={cancellableStream} />);
const label = container.querySelector(".stream-row__cancel-label") as HTMLElement;
expect(label.style.transition).toBe("");
});

it("applies transition: none to swipe style when reduced motion is requested", () => {
mockMatchMedia(true);
const cancellableStream: StreamRowData = {
...makeMockStream("active"),
nextAction: "Cancel",
};
const { container } = render(<StreamRow stream={cancellableStream} />);
const article = container.querySelector("article.stream-row") as HTMLElement;

// Simulate a left swipe
fireEvent.touchStart(article, { touches: [{ clientX: 200, clientY: 100 }] });
fireEvent.touchMove(article, { touches: [{ clientX: 50, clientY: 100 }] });

expect(article.style.transition).toBe("none");
});

it("preserves data-status attribute regardless of motion preference", () => {
mockMatchMedia(true);
const { container } = render(<StreamRow stream={baseStream} />);
const article = container.querySelector("article.stream-row");
expect(article).toHaveAttribute("data-status", baseStream.status);
});

it.each(ALL_STATUSES)(
"sets data-reduced-motion on article for status=%s",
(status) => {
mockMatchMedia(true);
const { container } = render(<StreamRow stream={makeMockStream(status)} />);
const article = container.querySelector("article.stream-row");
expect(article).toHaveAttribute("data-reduced-motion", "true");
},
);
});

describe("loading skeleton (Issue #1033)", () => {
afterEach(() => {
// @ts-expect-error reset between tests
delete window.matchMedia;
});

it("renders skeleton when loading is true", () => {
const { container } = render(<StreamRow stream={baseStream} loading={true} />);
const article = container.querySelector("article.stream-row");
expect(article).toHaveClass("stream-row--skeleton");
});

it("applies aria-busy on the article element when loading", () => {
const { container } = render(<StreamRow stream={baseStream} loading={true} />);
const article = container.querySelector("article.stream-row");
expect(article).toHaveAttribute("aria-busy", "true");
});

it("applies aria-label to indicate loading state", () => {
render(<StreamRow stream={baseStream} loading={true} />);
expect(screen.getByLabelText("Stream row is loading")).toBeInTheDocument();
});

it("does not render live content (recipient, action button) when loading", () => {
const { container } = render(<StreamRow stream={baseStream} loading={true} />);
expect(container.querySelector("h2")).toBeNull();
expect(container.querySelector("button")).toBeNull();
expect(container.querySelector(".status-badge")).toBeNull();
expect(container.querySelector(".stream-progress")).toBeNull();
expect(container.querySelector(".stream-row__pattern")).toBeNull();
});

it("renders Skeleton elements inside the row", () => {
const { container } = render(<StreamRow stream={baseStream} loading={true} />);
const skeletons = container.querySelectorAll(".skeleton");
expect(skeletons.length).toBeGreaterThan(0);
});

it("renders skeleton avatar (circle)", () => {
const { container } = render(<StreamRow stream={baseStream} loading={true} />);
const circles = container.querySelectorAll("[style*='border-radius: 50%']");
expect(circles.length).toBeGreaterThan(0);
});

it("skeleton elements are aria-hidden from screen readers", () => {
const { container } = render(<StreamRow stream={baseStream} loading={true} />);
const skeletons = container.querySelectorAll(".skeleton");
skeletons.forEach((sk) => {
expect(sk).toHaveAttribute("aria-hidden", "true");
});
});

it("applies stream-row--compact modifier when density=compact and loading", () => {
const { container } = render(
<StreamRow stream={makeMockStream("paused")} density="compact" loading={true} />
);
const article = container.querySelector("article.stream-row");
expect(article).toHaveClass("stream-row--compact");
expect(article).toHaveClass("stream-row--skeleton");
});

it("renders skeleton for the color stripe placeholder", () => {
const { container } = render(<StreamRow stream={baseStream} loading={true} />);
const stripe = container.querySelector(".stream-row__color-stripe");
expect(stripe).not.toBeNull();
expect(stripe).toHaveAttribute("aria-hidden", "true");
});

it("renders meta section with dt elements in skeleton", () => {
const { container } = render(<StreamRow stream={baseStream} loading={true} />);
const dts = container.querySelectorAll("dt");
expect(dts.length).toBeGreaterThan(0);
});

it("does not render skeleton when loading is false (normal render)", () => {
const { container } = render(<StreamRow stream={baseStream} />);
expect(container.querySelector(".stream-row--skeleton")).toBeNull();
expect(container.querySelector("button")).not.toBeNull();
});

it("does not render skeleton when loading is undefined (normal render)", () => {
const { container } = render(<StreamRow stream={baseStream} />);
expect(container.querySelector(".stream-row--skeleton")).toBeNull();
});
});

describe("swipe to cancel (mobile)", () => {
const cancellableStream: StreamRowData = {
...makeMockStream("active"),
Expand Down
114 changes: 111 additions & 3 deletions app/components/StreamRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import type { StreamPayError } from "../lib/errors/types";
import { LiveRegion } from "../../src/components/LiveRegion";
import { KbdHint } from "../../src/components/KbdHint";
import { colorFromId } from "../utils/colorFromId";
import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion";
import { Skeleton } from "./Skeleton";

const SWIPE_CANCEL_THRESHOLD = 80;
const SWIPE_CANCEL_MAX = 160;
Expand All @@ -40,9 +42,103 @@ export type StreamRowData = {
type StreamRowProps = {
stream: StreamRowData;
density?: "cozy" | "compact";
/**
* When true, renders a themed skeleton placeholder matching the StreamRow
* layout — shimmer blocks for identity, meta, progress bar, and action
* button — while stream data is loading.
* The wrapper carries `aria-busy="true"` and skeleton children are
* `aria-hidden="true"` for screen readers.
*/
loading?: boolean;
};

export function StreamRow({ stream, density = "cozy" }: StreamRowProps) {
/**
* StreamRow renders a single payment stream card with status, progress,
* recipient info, action controls, swipe-to-cancel, and a color-blind-safe
* pattern overlay.
*
* Data attributes exposed for e2e / CSS hooks:
* - `data-status` — stream lifecycle status (active, draft, paused, etc.)
* - `data-reduced-motion` — "true" when the user prefers reduced motion
* (Issue #1038); used to gate swipe transitions and animation fallbacks.
*/

export function StreamRow({ stream, density = "cozy", loading = false }: StreamRowProps) {
// ── Loading skeleton (early return before any hooks) ───────────────────────
if (loading) {
const compact = density === "compact";
return (
<article
className={`stream-row stream-row--skeleton ${compact ? "stream-row--compact" : ""}`.trim()}
aria-busy="true"
aria-label="Stream row is loading"
>
{/* Color stripe placeholder */}
<div className="stream-row__color-stripe" aria-hidden="true">
<Skeleton width="4px" height="100%" />
</div>

{/* Primary section — identity + badge */}
<div className="stream-row__primary">
<div className="stream-row__identity">
{/* Recipient avatar skeleton */}
<Skeleton
width="40px"
height="40px"
circle
aria-hidden="true"
/>
<div style={{ display: "grid", gap: "0.35rem", flex: 1 }}>
{/* Recipient name */}
<Skeleton variant="title" width="65%" />
{/* Schedule */}
<Skeleton variant="text" width="40%" />
</div>
</div>
{/* Status badge skeleton */}
<Skeleton variant="badge" width="5.5rem" height="2rem" />
</div>

{/* Meta section — Rate + Status + Burn-down */}
<div className="stream-row__meta" aria-hidden="true">
<div>
<dt>Rate</dt>
<dd><Skeleton variant="value" width="65%" /></dd>
</div>
<div>
<dt>Status</dt>
<dd><Skeleton variant="badge" width="50%" height="1.25rem" /></dd>
</div>
<div>
<dt>Burn-down</dt>
<dd><Skeleton variant="value" width="55%" /></dd>
</div>
</div>

{/* Stream progress skeleton */}
<div aria-hidden="true" style={{ display: "grid", gap: "0.5rem" }}>
<Skeleton width="100%" height="10px" />
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
}}
>
<Skeleton variant="label" width="4rem" />
<Skeleton variant="text" width="3rem" />
</div>
</div>

{/* Action button skeleton */}
<div className="stream-row__action-wrap">
<Skeleton variant="button" width="7.5rem" height="2.75rem" />
</div>
</article>
);
}

const prefersReducedMotion = usePrefersReducedMotion();
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<StreamPayError | null>(null);
const [isIncidentMode] = useState(false);
Expand Down Expand Up @@ -169,7 +265,10 @@ export function StreamRow({ stream, density = "cozy" }: StreamRowProps) {

const swipeStyle =
canSwipeCancel && swipeOffset !== 0
? { transform: `translateX(${swipeOffset}px)` }
? {
transform: `translateX(${swipeOffset}px)`,
transition: prefersReducedMotion ? "none" : undefined,
}
: undefined;

return (
Expand All @@ -183,6 +282,7 @@ export function StreamRow({ stream, density = "cozy" }: StreamRowProps) {
.filter(Boolean)
.join(" ")}
data-status={stream.status}
data-reduced-motion={prefersReducedMotion ? "true" : "false"}
aria-labelledby={`${stream.id}-recipient`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
Expand All @@ -194,8 +294,16 @@ export function StreamRow({ stream, density = "cozy" }: StreamRowProps) {
className="stream-row__cancel-reveal"
aria-hidden="true"
data-swipe-active={swipeOffset < -SWIPE_CANCEL_THRESHOLD}
data-reduced-motion={prefersReducedMotion ? "true" : "false"}
>
<span className="stream-row__cancel-label">Cancel</span>
<span
className="stream-row__cancel-label"
style={{
transition: prefersReducedMotion ? "none" : undefined,
}}
>
Cancel
</span>
</div>
)}

Expand Down
4 changes: 4 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,10 @@ a:hover {
}

@media (prefers-reduced-motion: reduce) {
.stream-row {
transition: none !important;
}

.stream-row--swiping {
transition: none !important;
}
Expand Down
Loading