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:
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 C1: the card shows its state and the everyday toggles (watched, save, add to playlist) and a
|
||||
// "…" into the list's ONE menu; download, details, hide and reset live only in that menu. Every view
|
||||
// carries the same set, and a view that shows the "…" twice (the card: in the hover row on the
|
||||
// picture, and beside the title for touch — only one is displayed per device) points both at the
|
||||
// same menu.
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("./navigationContext", () => ({
|
||||
useNavigationActions: () => ({ openChannel: vi.fn() }),
|
||||
}));
|
||||
|
||||
import VideoCard from "./VideoCard";
|
||||
import type { Video } from "../lib/api";
|
||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
beforeEach(cleanup);
|
||||
|
||||
const video = {
|
||||
id: "abc123",
|
||||
title: "Cicada 3301",
|
||||
channel_id: "c1",
|
||||
channel_title: "LEMMiNO",
|
||||
status: "new",
|
||||
saved: false,
|
||||
position_seconds: 40,
|
||||
duration_seconds: 1200,
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
watch_url: "https://www.youtube.com/watch?v=abc123",
|
||||
thumbnail_url: "",
|
||||
} as unknown as Video;
|
||||
|
||||
function renderCard(view: FeedView, menuOpen = false) {
|
||||
const buttonProps = vi.fn(() => ({ "aria-haspopup": "menu" }));
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<VideoCard
|
||||
video={video}
|
||||
view={view}
|
||||
width={0}
|
||||
onState={() => {}}
|
||||
onToggleSave={() => {}}
|
||||
onOpen={() => {}}
|
||||
menuButtonProps={buttonProps}
|
||||
menuOpen={menuOpen}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { buttonProps };
|
||||
}
|
||||
|
||||
const names = () => screen.getAllByRole("button").map((b) => b.getAttribute("aria-label"));
|
||||
|
||||
describe.each(Object.keys(FEED_VIEW_SPEC) as FeedView[])("the %s view's actions", (view) => {
|
||||
it("are the toggles and the '…' — nothing that belongs to the menu", () => {
|
||||
renderCard(view);
|
||||
const all = names();
|
||||
for (const want of ["card.markWatched", "card.saveForLater", "playlists.addToPlaylist"])
|
||||
expect(all).toContain(want);
|
||||
expect(all).toContain("card.menu.more");
|
||||
for (const gone of ["downloads.button.label", "card.hide", "card.resetState", "card.info"])
|
||||
expect(all).not.toContain(gone);
|
||||
});
|
||||
|
||||
it("points every '…' at the menu, and says when it is open", () => {
|
||||
const { buttonProps } = renderCard(view, true);
|
||||
const mores = screen.getAllByRole("button", { name: "card.menu.more" });
|
||||
expect(buttonProps).toHaveBeenCalledWith(video);
|
||||
for (const m of mores) {
|
||||
expect(m.getAttribute("aria-haspopup")).toBe("menu");
|
||||
expect(m.getAttribute("aria-expanded")).toBe("true");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user