feat(design): a video's one action list (C1) — the card's "…", the menu holds the rest (E28 S3e1)
- useContextMenu gains buttonProps (a row's visible "…" opens the same menu under the button; a second press closes it) and anchor (the element the menu came from, for a panel a row opens). - ui/MoreButton; ActionMenu rows can run inGesture (a clipboard write inside the click, Safari). - useVideoActions: one builder for a video's menu (play / restart, channel, watched, save, add to playlist, download, details, copy title / link, hide, reset, pin), composed by the feed and by Home's rails; the playlists and details panels open anchored beside the card (VideoFollowUp). - The card keeps watched / save / add to playlist and the "…": on the picture on hover (the duration steps aside), in the rows' action column; on touch only a 44px "…" beside the title. Download, hide, reset and the hover info card leave the card. - The title is selectable: a click ending a selection neither plays nor follows the link. - DESIGN.md: how C1 is built + the log row.
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the feed-card hover infobox (E8·S3): the trigger stays closed until opened; on open the
|
||||
// Contract of a video's details panel (E8·S3; opened from the video's menu since E28 C1): the
|
||||
// header (full title + channel) renders IMMEDIATELY from the Video we already hold, while the
|
||||
// description (videoDetail) and the channel's tags (channelDetail.tag_ids resolved against the tag
|
||||
// list) fill in from their on-demand queries; an empty description falls back to the "no description"
|
||||
// line. i18n is key-passthrough and the AnchoredPopover platform is floating-ui (hover mode → open via
|
||||
// focus), so we stub the observers autoUpdate wires and wrap in a FloatingTree + QueryClient.
|
||||
// line. i18n is key-passthrough and the AnchoredPopover platform is floating-ui, so we stub the observers autoUpdate wires and wrap in a FloatingTree + QueryClient.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
@@ -30,7 +29,7 @@ vi.mock("../lib/api", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import VideoInfoCard from "./VideoInfoCard";
|
||||
import VideoDetailsAt from "./VideoInfoCard";
|
||||
import type { Video } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -72,33 +71,31 @@ const video = (over: Partial<Video> = {}): Video =>
|
||||
...over,
|
||||
}) as Video;
|
||||
|
||||
const open = (v: Video = video()) => {
|
||||
const open = (v: Video = video(), onClose = vi.fn()) => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const anchor = document.createElement("div");
|
||||
document.body.appendChild(anchor);
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<VideoInfoCard video={v} className="info-trigger" />
|
||||
<VideoDetailsAt video={v} anchor={anchor} onClose={onClose} />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.focus(screen.getByRole("button", { name: "card.info" }));
|
||||
return { onClose };
|
||||
};
|
||||
|
||||
describe("VideoInfoCard", () => {
|
||||
it("renders a trigger, panel closed until opened (no fetch at rest)", () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<VideoInfoCard video={video()} className="info-trigger" />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "card.info" })).toBeTruthy();
|
||||
expect(screen.queryByText("The full untruncated title")).toBeNull();
|
||||
// `enabled: open` gates the queries — nothing fired while closed.
|
||||
expect(videoDetail).not.toHaveBeenCalled();
|
||||
expect(channelDetail).not.toHaveBeenCalled();
|
||||
describe("VideoDetailsAt", () => {
|
||||
// Escape is measured in the browser: jsdom has no layout, so the anchor reads as hidden and the
|
||||
// layer stack never takes the panel's Escape.
|
||||
it("is a named dialog", async () => {
|
||||
videoDetail.mockResolvedValue({ id: "v1", description: null });
|
||||
channelDetail.mockResolvedValue({ id: "c1", tag_ids: [] });
|
||||
tags.mockResolvedValue([]);
|
||||
open();
|
||||
await screen.findByText("The full untruncated title");
|
||||
// Named by its aria-label (the role query cannot see into the unpositioned, hidden surface).
|
||||
expect(document.querySelector("[role=dialog]")?.getAttribute("aria-label")).toBe("card.info");
|
||||
});
|
||||
|
||||
it("shows the header immediately from the Video, then fills in description + tags on load", async () => {
|
||||
|
||||
Reference in New Issue
Block a user