E5 S1: shared Carousel primitive + Plex card-art serializer

Wave 5a building blocks under the Seerr-inspired Plex enrichment (E5).

Carousel (frontend/src/components/Carousel.tsx): the one horizontal-rail
component, replacing the hand-rolled `flex overflow-x-auto` strips and the
home of the future Plex-Discover rows. Edge-fade mask, hover/focus scroll
buttons that appear only on the clipped side (useHScroll — the X-axis twin
of useScrollFade, same watch-the-children discipline), native scrollbar
hidden behind .no-scrollbar, reduced-motion-aware paging. First consumers:
CastStrip + RelatedStrip now scroll inside it (dedup + free affordances).
New i18n: common.seeAll / scrollLeft / scrollRight (en+hu).

Card-art serializer (backend/app/plex/cards.py): movie_card + show_card now
emit `art` (the wide backdrop, /api/plex/image/{rk}?variant=art) so rails and
the crossfading hero get a landscape image with no per-item fetch — gated on
art_key so an art-less item stays null and falls back to the poster. FE type
PlexCard.art?.

Tests: Carousel.dom.test (children/header/see-all, clipped-edge buttons +
paging), test_plex_cards (art present/absent by art_key). Added a
ResizeObserver stub to PlexPersonView.dom.test (it renders CastStrip, now a
Carousel consumer).

Gate: tsc x3 / eslint / prettier / knip / vitest 324 green; ruff green;
backend test lane (docker-compose.test.yml) green on the plex card tests.
This commit is contained in:
2026-08-20 08:50:47 +02:00
parent df60bc5ff5
commit fca3fa13ef
9 changed files with 328 additions and 7 deletions
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
//
// Contract of the shared horizontal rail. jsdom lays out nothing (scrollWidth/clientWidth are 0), so
// the overflow-driven scroll buttons only appear once we feed the scroller fake metrics and fire a
// scroll — which is exactly the affordance under test: a button shows ONLY on the clipped edge, and
// clicking it pages the scroller. i18n is key-passthrough, so asserted names are the i18n keys.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
import { Carousel } from "./Carousel";
beforeAll(() => {
// useHScroll wires a ResizeObserver (watch-the-children); jsdom ships none.
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
afterEach(cleanup);
function setMetrics(
el: HTMLElement,
m: { scrollWidth: number; clientWidth: number; scrollLeft: number },
) {
Object.defineProperty(el, "scrollWidth", { configurable: true, value: m.scrollWidth });
Object.defineProperty(el, "clientWidth", { configurable: true, value: m.clientWidth });
Object.defineProperty(el, "scrollLeft", {
configurable: true,
writable: true,
value: m.scrollLeft,
});
}
describe("Carousel", () => {
it("renders children and an optional header with a see-all action", () => {
const onSeeAll = vi.fn();
render(
<Carousel title="Recently added" onSeeAll={onSeeAll}>
<div>card-a</div>
<div>card-b</div>
</Carousel>,
);
expect(screen.getByText("card-a")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Recently added" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "common.seeAll" }));
expect(onSeeAll).toHaveBeenCalledOnce();
});
it("omits the header when no title is given and names the scroll region", () => {
render(
<Carousel ariaLabel="Cast">
<div>x</div>
</Carousel>,
);
expect(screen.queryByRole("heading")).toBeNull();
expect(screen.getByRole("group", { name: "Cast" })).toBeTruthy();
});
it("shows only the clipped edge's button and pages the scroller on click", () => {
render(
<Carousel ariaLabel="rail">
<div>x</div>
</Carousel>,
);
const scroller = screen.getByRole("group", { name: "rail" });
const scrollBy = vi.fn();
scroller.scrollBy = scrollBy as unknown as typeof scroller.scrollBy;
// At the left boundary with more content to the right → only the right button.
setMetrics(scroller, { scrollWidth: 1000, clientWidth: 300, scrollLeft: 0 });
fireEvent.scroll(scroller);
expect(screen.queryByRole("button", { name: "common.scrollLeft" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "common.scrollRight" }));
expect(scrollBy).toHaveBeenCalled();
// Scrolled into the middle → both edges clipped → both buttons.
setMetrics(scroller, { scrollWidth: 1000, clientWidth: 300, scrollLeft: 350 });
fireEvent.scroll(scroller);
expect(screen.getByRole("button", { name: "common.scrollLeft" })).toBeTruthy();
expect(screen.getByRole("button", { name: "common.scrollRight" })).toBeTruthy();
});
});
+166
View File
@@ -0,0 +1,166 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
// Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing
// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
// Plex-Discover rows. Gives every consumer the same affordances for free: edge fade, hover/focus scroll
// buttons that appear only on the side that actually has more content, a native scrollbar hidden behind
// `.no-scrollbar`, and reduced-motion-aware paging. Purely presentational — the caller owns the cards.
// Prefer an instant jump when the viewer asked for reduced motion; smooth otherwise. Guarded for jsdom,
// where matchMedia is often absent (the button paging is still exercised — it just uses "auto" there).
function scrollBehavior(): ScrollBehavior {
const reduce =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
return reduce ? "auto" : "smooth";
}
// Horizontal scroll-affordance: tracks whether the scroller is clipped on the left / right so the rail
// can show a button + fade only where there's more to see. The X-axis twin of useScrollFade — same
// node-state callback ref (re-runs on attach) and same watch-the-children discipline (a rail whose
// cards load in later grows scrollWidth without ever firing a scroll event), and it also hands back the
// element so the buttons can scroll it.
function useHScroll(): {
ref: (node: HTMLElement | null) => void;
el: HTMLElement | null;
left: boolean;
right: boolean;
} {
const [el, setEl] = useState<HTMLElement | null>(null);
const [edge, setEdge] = useState({ left: false, right: false });
useEffect(() => {
if (!el) return;
const update = () => {
const left = el.scrollLeft > 1;
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
setEdge((prev) => (prev.left === left && prev.right === right ? prev : { left, right }));
};
update();
el.addEventListener("scroll", update, { passive: true });
const ro = new ResizeObserver(update);
ro.observe(el);
for (const child of Array.from(el.children)) ro.observe(child);
const mo = new MutationObserver((records) => {
for (const rec of records) {
for (const node of Array.from(rec.addedNodes))
if (node instanceof Element) ro.observe(node);
for (const node of Array.from(rec.removedNodes))
if (node instanceof Element) ro.unobserve(node);
}
update();
});
mo.observe(el, { childList: true });
return () => {
el.removeEventListener("scroll", update);
ro.disconnect();
mo.disconnect();
};
}, [el]);
return { ref: setEl, el, left: edge.left, right: edge.right };
}
const FADE_PX = 28;
export function Carousel({
title,
onSeeAll,
seeAllLabel,
ariaLabel,
gap = "gap-3",
className,
children,
}: {
/** Optional built-in header (rails). Omit for a bare scroller inside a card that owns its own heading. */
title?: ReactNode;
/** When set, a "see all" affordance appears in the header (needs `title`). */
onSeeAll?: () => void;
seeAllLabel?: string;
/** Names the scroll region for AT; falls back to the string `title` when that's a plain string. */
ariaLabel?: string;
/** Tailwind gap class for the scroller (e.g. "gap-4"). */
gap?: string;
className?: string;
children: ReactNode;
}) {
const { t } = useTranslation();
const { ref, el, left, right } = useHScroll();
const page = useCallback(
(dir: -1 | 1) => {
if (!el) return;
// ~one viewport-worth, but never a hair scroll on a narrow rail (or 0 in a zero-width jsdom box).
const amount = Math.max(Math.round(el.clientWidth * 0.85), 200);
el.scrollBy({ left: dir * amount, behavior: scrollBehavior() });
},
[el],
);
// Fade only the clipped edge — no fade at a boundary, none when it all fits (matches useScrollFade).
const mask = `linear-gradient(to right, transparent, #000 ${left ? FADE_PX : 0}px, #000 calc(100% - ${
right ? FADE_PX : 0
}px), transparent)`;
const label = ariaLabel ?? (typeof title === "string" ? title : undefined);
const arrowBtn =
"absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
"bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " +
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75";
return (
<div className={clsx("group/car relative", className)}>
{title != null && (
<div className="mb-3 flex items-baseline gap-2">
<h2 className="text-sm font-semibold">{title}</h2>
{onSeeAll && (
<button
onClick={onSeeAll}
className="ml-auto inline-flex items-center gap-0.5 text-xs text-muted transition hover:text-accent"
>
{seeAllLabel ?? t("common.seeAll")}
<ChevronRight className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
<div className="relative">
{left && (
<button
type="button"
onClick={() => page(-1)}
aria-label={t("common.scrollLeft")}
className={clsx(arrowBtn, "left-1")}
>
<ChevronLeft className="h-5 w-5" />
</button>
)}
<div
ref={ref}
className={clsx("no-scrollbar flex overflow-x-auto pb-1", gap)}
style={{ maskImage: mask, WebkitMaskImage: mask }}
role="group"
aria-label={label}
>
{children}
</div>
{right && (
<button
type="button"
onClick={() => page(1)}
aria-label={t("common.scrollRight")}
className={clsx(arrowBtn, "right-1")}
>
<ChevronRight className="h-5 w-5" />
</button>
)}
</div>
</div>
);
}
@@ -5,10 +5,23 @@
// filter gesture only when no onOpenPerson is wired) and PlexPersonView's render (headshot/name hero,
// the Movies/Shows sections, and the "filter library by this person" action). i18n is key-passthrough
// and the api is mocked so the query resolves synchronously in the test.
import { describe, it, expect, afterEach, vi } from "vitest";
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
beforeAll(() => {
// CastStrip now scrolls inside a Carousel, whose useHScroll wires a ResizeObserver; jsdom ships none.
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
+5 -4
View File
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Tv2 } from "lucide-react";
import { type CSSProperties } from "react";
import { formatRuntime } from "../lib/format";
import { Carousel } from "./Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
@@ -301,7 +302,7 @@ export function CastStrip({
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
<div className="flex gap-4 overflow-x-auto pb-2">
<Carousel gap="gap-4" ariaLabel={t("plex.info.cast")}>
{cast.map((c, i) => {
const inner = (
<>
@@ -345,7 +346,7 @@ export function CastStrip({
</div>
);
})}
</div>
</Carousel>
</section>
);
}
@@ -364,7 +365,7 @@ export function RelatedStrip({
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
>
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
<div className="flex gap-3 overflow-x-auto pb-2">
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")}>
{related.map((r) => (
<button
key={r.id}
@@ -389,7 +390,7 @@ export function RelatedStrip({
</div>
</button>
))}
</div>
</Carousel>
</section>
);
}