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,209 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 C1: a video's ONE action list. The right-click and the card's "…" open the same menu, which
|
||||
// holds everything the card no longer shows (download, details, copy, hide, reset, pin) beside the
|
||||
// everyday toggles, and each row fires the same handler the card's own buttons do.
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { Video } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
comfortPins: () => Promise.resolve({ items: [] }),
|
||||
downloadIndex: () => Promise.resolve({}),
|
||||
playlists: () => Promise.resolve([]),
|
||||
},
|
||||
};
|
||||
});
|
||||
const copyText = vi.fn((_: string) => Promise.resolve(true));
|
||||
vi.mock("../lib/clipboard", () => ({ copyText: (s: string) => copyText(s) }));
|
||||
|
||||
import { VideoMenu } from "./VideoMenu";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// The surface is visibility:hidden in jsdom (floating-ui never positions), so an item has no
|
||||
// accessible name there: matched on its text, as ContextMenu.dom.test.tsx does.
|
||||
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
||||
const item = (label: string) => {
|
||||
const found = screen
|
||||
.queryAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === label);
|
||||
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
|
||||
return found;
|
||||
};
|
||||
|
||||
const video = (extra: Partial<Video>): Video =>
|
||||
({
|
||||
id: "v1",
|
||||
title: "A video",
|
||||
channel_id: "c1",
|
||||
channel_title: "A channel",
|
||||
watch_url: "https://youtu.be/v1",
|
||||
status: "new",
|
||||
saved: false,
|
||||
position_seconds: 0,
|
||||
...extra,
|
||||
}) as Video;
|
||||
|
||||
function Feed({
|
||||
v,
|
||||
handlers,
|
||||
}: {
|
||||
v: Video;
|
||||
handlers: Parameters<typeof VideoMenu>[0]["handlers"];
|
||||
}) {
|
||||
const menu = useContextMenu<Video>();
|
||||
return (
|
||||
<>
|
||||
<div {...menu.triggerProps(v)}>card</div>
|
||||
<MoreButton
|
||||
label="more"
|
||||
expanded={menu.open && menu.target?.id === v.id}
|
||||
menuProps={menu.buttonProps(v)}
|
||||
/>
|
||||
<VideoMenu menu={menu} handlers={handlers} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function setup(v: Video, { demo = false } = {}) {
|
||||
const handlers = {
|
||||
onState: vi.fn(),
|
||||
onToggleSave: vi.fn(),
|
||||
onResetState: vi.fn(),
|
||||
onOpen: vi.fn(),
|
||||
};
|
||||
const qc = new QueryClient();
|
||||
qc.setQueryData(qk.me(), { is_demo: demo });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<Feed v={v} handlers={handlers} />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { handlers };
|
||||
}
|
||||
const rightClick = () => fireEvent.contextMenu(screen.getByText("card"));
|
||||
const more = () => screen.getByRole("button", { name: "more" });
|
||||
|
||||
describe("a video's menu", () => {
|
||||
it("lists the whole action list for a fresh video, in its groups", () => {
|
||||
setup(video({}));
|
||||
rightClick();
|
||||
expect(labels()).toEqual([
|
||||
"card.play",
|
||||
"card.menu.openChannel",
|
||||
"card.markWatched",
|
||||
"card.saveForLater",
|
||||
"playlists.addToPlaylist",
|
||||
"downloads.button.label",
|
||||
"card.info",
|
||||
"common.copy.title",
|
||||
"common.copy.link",
|
||||
"card.hide",
|
||||
"home.pin.add",
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers Continue and the restart on a half-watched one, the way back and the reset", async () => {
|
||||
const { handlers } = setup(video({ position_seconds: 30, saved: true }));
|
||||
rightClick();
|
||||
expect(labels()).toContain("card.restartTitle");
|
||||
expect(labels()).toContain("card.menu.unsave");
|
||||
expect(labels()).toContain("card.resetState");
|
||||
fireEvent.click(item("card.restartTitle"));
|
||||
await vi.waitFor(() => expect(handlers.onOpen).toHaveBeenLastCalledWith(expect.anything(), 0));
|
||||
});
|
||||
|
||||
it("each toggle fires the card's own handler", async () => {
|
||||
const { handlers } = setup(video({ status: "watched" }));
|
||||
rightClick();
|
||||
fireEvent.click(item("card.menu.unmarkWatched"));
|
||||
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "new"));
|
||||
rightClick();
|
||||
fireEvent.click(item("card.hide"));
|
||||
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "hidden"));
|
||||
});
|
||||
|
||||
it("copies the link INSIDE the click, before the menu closes (Safari's gesture rule)", () => {
|
||||
setup(video({}));
|
||||
rightClick();
|
||||
fireEvent.click(item("common.copy.link"));
|
||||
// Synchronously: a row that went through closeThenAct would run a task later.
|
||||
expect(copyText).toHaveBeenCalledWith("https://youtu.be/v1");
|
||||
expect(labels()).toEqual([]);
|
||||
});
|
||||
|
||||
it("opens the playlists panel from the menu, anchored where the menu came from", async () => {
|
||||
setup(video({}));
|
||||
rightClick();
|
||||
fireEvent.click(item("playlists.addToPlaylist"));
|
||||
expect(await screen.findByText("playlists.noneYet")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has no download row on the demo account", () => {
|
||||
setup(video({}), { demo: true });
|
||||
rightClick();
|
||||
expect(labels()).not.toContain("downloads.button.label");
|
||||
});
|
||||
|
||||
it("the '…' opens the same menu, says so, and a second press closes it", () => {
|
||||
setup(video({}));
|
||||
expect(more().getAttribute("aria-haspopup")).toBe("menu");
|
||||
expect(more().getAttribute("aria-expanded")).toBe("false");
|
||||
fireEvent.click(more());
|
||||
expect(labels()).toContain("card.hide");
|
||||
expect(more().getAttribute("aria-expanded")).toBe("true");
|
||||
// The press lands outside the panel, which floating-ui's dismiss closes on; the click after
|
||||
// it must not open it again.
|
||||
fireEvent.pointerDown(more());
|
||||
fireEvent.mouseDown(more());
|
||||
fireEvent.click(more(), { detail: 1 });
|
||||
expect(labels()).toEqual([]);
|
||||
});
|
||||
|
||||
it("a keyboard click on the '…' always opens, whatever an earlier press left behind", () => {
|
||||
setup(video({}));
|
||||
fireEvent.click(more(), { detail: 1 });
|
||||
// A press on the open row's "…" that never became a click (released elsewhere)…
|
||||
fireEvent.pointerDown(more());
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
// …then Enter on the button: a click with no pointer (detail 0).
|
||||
fireEvent.click(more(), { detail: 0 });
|
||||
expect(labels()).toContain("card.hide");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user