feat(design): one media card, and Home's hero and shelves as cards (E28 S3h)
- ui/MediaCard + mediaCardStyles: one frame for every video, Plex title and Home tile; the meta line pinned to the bottom edge, the length a badge on the picture, the media Play only where the click plays, the hover row at the picture's bottom-right (the owner's pick C). - Shelf density: on any strip a one-line title over one meta line; mixed Home shelves are landscape; rails turn content-visibility off (a card's reserved height had become the strip's). Home slot 232px, so the Play and the hover row no longer touch; a landscape shelf takes the small Play. - Home hero is a card (hover row, the whole menu, a touch "…"), three quarters wide and centred; channel / playlist tiles get Play, open and "…"; a show tile acts on its next episode, ⓘ opens the show. - Plex preview: a ~0.9 s mouse rest opens a passive panel with the whole title, facts and synopsis (popover `pointerOnly`); the synopsis and title tooltips are gone. - Compact list row B: the title leads, "…" at the end, toggles over the date on hover. - Media-info layout B (collections, crew, cast beside the facts sidebar; an empty column is not drawn); filtering values are chips there and on the show page; "Cast" over the cast-only strips. - Browse tiles hover like a card; prose capped by the `measure` utility.
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is
|
||||
// key-passthrough, so the asserted names are the i18n keys.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -13,6 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
|
||||
import { iconButtonClass } from "./ui/buttonStyles";
|
||||
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
|
||||
|
||||
const movie = (status?: string): PlexCard => ({
|
||||
@@ -65,21 +66,103 @@ describe("PlexPosterCard a11y", () => {
|
||||
expect(screen.getByRole("button", { name: "plex.markUnwatched" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reveals the synopsis + rating on the hover scrim when the card carries them", () => {
|
||||
// E28 S3h: the one media card — the year and the rating are the meta line under the title (pinned
|
||||
// to the frame's bottom), the length a badge on the picture. The synopsis is NOT the picture's
|
||||
// tooltip any more: it lives in the card's preview (below).
|
||||
it("carries the year and rating in its meta line, the length on the picture", () => {
|
||||
render(
|
||||
<PlexPosterCard
|
||||
card={{ ...movie(), year: 2023, rating: 7.5, summary: "A quiet synopsis." }}
|
||||
card={{
|
||||
...movie(),
|
||||
year: 2023,
|
||||
rating: 7.5,
|
||||
duration_seconds: 6780,
|
||||
summary: "A quiet synopsis.",
|
||||
}}
|
||||
onOpen={vi.fn()}
|
||||
onToggleWatched={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("A quiet synopsis.")).toBeTruthy();
|
||||
expect(screen.getByText("2023")).toBeTruthy();
|
||||
expect(screen.getByText("7.5")).toBeTruthy(); // rating.toFixed(1)
|
||||
expect(screen.getByText("1h 53m")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: movie().title }).getAttribute("title")).toBeNull();
|
||||
});
|
||||
|
||||
it("omits the scrim when the card has no synopsis or rating", () => {
|
||||
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} onToggleWatched={vi.fn()} />);
|
||||
expect(screen.queryByText(/synopsis/i)).toBeNull();
|
||||
it("draws no meta line when the card has neither a year nor a rating", () => {
|
||||
const { container } = render(
|
||||
<PlexPosterCard card={{ ...movie(), year: null, rating: null }} onOpen={vi.fn()} />,
|
||||
);
|
||||
expect(container.querySelector(".mt-auto")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The owner's pick C (2026-09-27): the synopsis shows when the MOUSE rests on the card — not on a
|
||||
// sweep across the grid, and not on the keyboard walk (a panel at every stop).
|
||||
describe("PlexPosterCard preview", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
const card = { ...movie(), title: "A long title", year: 2020, summary: "A quiet synopsis." };
|
||||
const root = () =>
|
||||
screen.getByRole("button", { name: "A long title" }).closest(".cv-poster") as HTMLElement;
|
||||
|
||||
it("opens once the mouse has rested on the card, with the whole title and the synopsis", () => {
|
||||
vi.useFakeTimers();
|
||||
render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
|
||||
fireEvent.mouseEnter(root());
|
||||
act(() => vi.advanceTimersByTime(600));
|
||||
expect(screen.queryByText("A quiet synopsis.")).toBeNull(); // a sweep opens nothing
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(screen.getByText("A quiet synopsis.")).toBeTruthy();
|
||||
expect(screen.getAllByText("A long title")).toHaveLength(2); // the card's and the preview's
|
||||
});
|
||||
|
||||
it("does not open on keyboard focus", () => {
|
||||
vi.useFakeTimers();
|
||||
render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
|
||||
fireEvent.focus(screen.getByRole("button", { name: "A long title" }));
|
||||
act(() => vi.advanceTimersByTime(2000));
|
||||
expect(screen.queryByText("A quiet synopsis.")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Home's shelves: a show that plays on is a card for its NEXT episode (the owner, 2026-09-27).
|
||||
describe("PlexPosterCard with a next episode", () => {
|
||||
const show: PlexCard = { id: "sh", type: "show", title: "Show", thumb: "", season_count: 6 };
|
||||
const next: PlexCard = { ...episode(), season_number: 6, episode_number: 16 };
|
||||
const playGlyph = (c: HTMLElement) => c.querySelector(".reveal-hover.inset-0 svg");
|
||||
|
||||
it("draws the Play, names the episode, and acts on it; the ⓘ opens the show", () => {
|
||||
const onToggleWatched = vi.fn();
|
||||
const onAdd = vi.fn();
|
||||
const onInfo = vi.fn();
|
||||
const { container } = render(
|
||||
<PlexPosterCard
|
||||
card={show}
|
||||
next={next}
|
||||
onOpen={vi.fn()}
|
||||
onToggleWatched={onToggleWatched}
|
||||
onAdd={onAdd}
|
||||
onInfo={onInfo}
|
||||
/>,
|
||||
);
|
||||
expect(playGlyph(container)).not.toBeNull();
|
||||
expect(screen.getByText("plex.nextUp")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched" }));
|
||||
expect(onToggleWatched).toHaveBeenCalledWith(next);
|
||||
// The episode's own add ("Add episode"), not the show's.
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.playlist.addEpisode" }));
|
||||
expect(onAdd).toHaveBeenCalledWith(next);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.openShow" }));
|
||||
expect(onInfo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays a plain show card without one: no Play, no toggles, no ⓘ", () => {
|
||||
const { container } = render(
|
||||
<PlexPosterCard card={show} onOpen={vi.fn()} onToggleWatched={vi.fn()} onInfo={vi.fn()} />,
|
||||
);
|
||||
expect(playGlyph(container)).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "plex.openShow" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "plex.markWatched" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,3 +260,36 @@ describe("a Plex card's caption and a drag", () => {
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// On a shelf (Home, the Plex rails, a collection strip) the title is one line, so every card on the
|
||||
// strip is the same height; a landscape shelf picture is short, so it takes the smaller Play, clear
|
||||
// of the hover row under it (E28 S3h: at the large size they touched on a 232px slot).
|
||||
describe("PlexPosterCard on a shelf", () => {
|
||||
const titleOf = () => screen.getByText("M");
|
||||
const play = (c: HTMLElement) =>
|
||||
c.querySelector("span[aria-hidden] > svg.lucide-play")!.parentElement!;
|
||||
|
||||
it("cuts the title to one line; a grid card keeps two", () => {
|
||||
const { unmount } = render(<PlexPosterCard card={movie()} onOpen={vi.fn()} shelf />);
|
||||
expect(titleOf().className).toContain("truncate");
|
||||
unmount();
|
||||
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} />);
|
||||
expect(titleOf().className).toContain("line-clamp-2");
|
||||
});
|
||||
|
||||
it("only a landscape SHELF card takes the smaller Play", () => {
|
||||
const large = iconButtonClass({ variant: "media", size: "lg" });
|
||||
const cases: [boolean, boolean, string][] = [
|
||||
[true, true, iconButtonClass({ variant: "media", size: "md" })],
|
||||
[true, false, large], // a poster on a shelf is tall enough
|
||||
[false, true, large], // a landscape card in a grid is wide enough
|
||||
];
|
||||
for (const [shelf, wide, want] of cases) {
|
||||
const { container, unmount } = render(
|
||||
<PlexPosterCard card={movie()} onOpen={vi.fn()} shelf={shelf} wide={wide} />,
|
||||
);
|
||||
expect(play(container).className, `shelf=${shelf} wide=${wide}`).toBe(want);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user