feat(player): a roomier YouTube player, its panel shut on demand
- The full-height previous/next strips beside the card are gone: the panel's transport row and Shift+arrows step on their own, and the video gains the 7rem the strips' gutters took. - Close is one icon in the card's top-right corner (the wide panel's head, the stacked card's title row). - The panel's description is cut to three lines with More / Less (ClampedText now takes the linked text as children). - Wide, the panel shuts to a 2.75rem strip (close, open, previous, next) for a bigger video; it stays shut while the queue steps on, and a video opened from a card starts it open. Focus moves to the counterpart toggle. - The wheel has one meaning per place: over a panel (or the stacked card) with something to scroll it only scrolls, never falling through to the volume at the end; over the video, or a panel that fits, it is the volume. - A long channel name is cut on the panel's line instead of running the 20rem panel into a sideways scroll.
This commit is contained in:
@@ -426,21 +426,34 @@ describe("PlayerModal — the description", () => {
|
||||
|
||||
// C3: the queue's side arrows sit in a gutter of the card's row, not outside it, so no window cuts
|
||||
// them off (outside the card, a 1024 window cut them and a portrait tablet lost them).
|
||||
describe("PlayerModal — the side arrows", () => {
|
||||
it("stand inside the row's gutter", () => {
|
||||
// The full-height strips beside the card were a second Previous / Next that cost the video 7rem of
|
||||
// width (owner): the panel's transport row and Shift+← / → step on their own.
|
||||
describe("PlayerModal — no side strips", () => {
|
||||
it("steps from the panel only, and the card keeps no gutter for strips", () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
const B = vid("B", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
const arrow = [...document.querySelectorAll<HTMLElement>("[aria-label='player.next']")].find(
|
||||
(b) => b.className.includes("w-12"),
|
||||
)!;
|
||||
expect(arrow).toBeTruthy();
|
||||
expect(arrow.className).toMatch(/\bright-0\b/);
|
||||
expect(arrow.className).not.toMatch(/left-full/);
|
||||
expect(arrow.parentElement!.className).toMatch(/sm:px-14/);
|
||||
expect(screen.getAllByRole("button", { name: "player.next" })).toHaveLength(1);
|
||||
expect(screen.getAllByRole("button", { name: "player.previous" })).toHaveLength(1);
|
||||
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
|
||||
expect(row.className).not.toMatch(/px-14/);
|
||||
});
|
||||
|
||||
it("closes from one icon-only button", () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const onClose = vi.fn();
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
|
||||
);
|
||||
const close = screen.getByTestId("player-close");
|
||||
expect(close.getAttribute("aria-label")).toBe("player.close");
|
||||
expect(close.textContent).toBe("");
|
||||
fireEvent.click(close);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -531,9 +544,7 @@ describe("PlayerModal — fits the window's height", () => {
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
|
||||
expect(row.style.maxWidth).toBe(
|
||||
"min(63rem, calc(max(36rem, (100dvh - 3rem - 160px) * 16 / 9) + 7rem))",
|
||||
);
|
||||
expect(row.style.maxWidth).toBe("min(56rem, max(36rem, (100dvh - 3rem - 160px) * 16 / 9))");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -587,13 +598,52 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
// The description is shown outright; there is no ⓘ to open it.
|
||||
expect(await screen.findByText("A long description")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "player.description" })).toBeNull();
|
||||
// The row's width: the video at the window's height, plus the panel and the side strips.
|
||||
expect(card.parentElement!.style.maxWidth).toBe(
|
||||
"calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem + 7rem)",
|
||||
);
|
||||
// The row's width: the video at the window's height, plus the panel.
|
||||
expect(card.parentElement!.style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
|
||||
// A long channel name is cut on the panel's line, not run past its 20rem into a sideways scroll.
|
||||
const name = within(info).getByText("Channel").closest("button, a")!.parentElement!;
|
||||
expect(name.className).toMatch(/\bmin-w-0\b/);
|
||||
expect(name.className).toMatch(/\bflex-1\b/);
|
||||
expect(name.className).not.toMatch(/\bshrink-0\b/);
|
||||
// The description is cut to three lines, with a More once it is cut.
|
||||
expect((await screen.findByText("A long description")).closest(".line-clamp-3")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lets the wheel scroll the panel while it can, and turn the volume everywhere else", async () => {
|
||||
it("shuts the panel to a strip for a bigger video, and keeps it shut as the queue steps on", () => {
|
||||
wideWindow(true);
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
const B = vid("B", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
const row = () =>
|
||||
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
|
||||
expect(screen.queryByTestId("player-info")).toBeNull();
|
||||
const strip = screen.getByTestId("player-strip");
|
||||
// The pressed toggle is gone: focus is on its counterpart, not on <body>.
|
||||
expect(document.activeElement).toBe(
|
||||
within(strip).getByRole("button", { name: "player.expandPanel" }),
|
||||
);
|
||||
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 2.75rem)");
|
||||
// The strip keeps the way out and the stepping.
|
||||
expect(within(strip).getByTestId("player-close")).toBeTruthy();
|
||||
fireEvent.click(within(strip).getByRole("button", { name: "player.next" }));
|
||||
expect(screen.getByTestId("player-strip")).toBeTruthy(); // still shut on the next video
|
||||
fireEvent.click(
|
||||
within(screen.getByTestId("player-strip")).getByRole("button", {
|
||||
name: "player.expandPanel",
|
||||
}),
|
||||
);
|
||||
expect(screen.getByTestId("player-info")).toBeTruthy();
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("button", { name: "player.collapsePanel" }),
|
||||
);
|
||||
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
|
||||
});
|
||||
|
||||
it("gives a panel with something to scroll the wheel for that alone, the volume elsewhere", async () => {
|
||||
wideWindow(true);
|
||||
vi.spyOn(api, "videoDetail").mockResolvedValue({
|
||||
description: "A long description",
|
||||
@@ -614,11 +664,33 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
return e.defaultPrevented; // prevented = the wheel went to the volume
|
||||
};
|
||||
expect(spin(text, 100)).toBe(false); // room below: the panel scrolls
|
||||
expect(spin(text, -100)).toBe(true); // at its top: nothing to scroll up, so the volume
|
||||
expect(spin(text, -100)).toBe(false); // at its top too: one gesture, one meaning (owner)
|
||||
top = 400;
|
||||
expect(spin(text, 100)).toBe(true); // at its end: the volume
|
||||
expect(spin(text, -100)).toBe(false);
|
||||
expect(spin(text, 100)).toBe(false); // and at its end, where it once fell through to the volume
|
||||
expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video
|
||||
// A panel that fits has nothing to scroll: the wheel is the volume there.
|
||||
Object.defineProperty(panel, "scrollHeight", { value: 500, configurable: true });
|
||||
expect(spin(text, 100)).toBe(true);
|
||||
});
|
||||
|
||||
it("stacked: the card scrolls under the wheel, the video in it is still the volume", () => {
|
||||
wideWindow(false);
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
const stage = document.querySelector<HTMLElement>(".player-stage")!;
|
||||
const card = stage.parentElement!;
|
||||
Object.defineProperty(card, "scrollHeight", { value: 900, configurable: true });
|
||||
Object.defineProperty(card, "clientHeight", { value: 500, configurable: true });
|
||||
const spin = (el: Element) => {
|
||||
const e = new WheelEvent("wheel", { deltaY: 100, bubbles: true, cancelable: true });
|
||||
el.dispatchEvent(e);
|
||||
return e.defaultPrevented;
|
||||
};
|
||||
expect(spin(screen.getByRole("heading", { name: "Video A" }))).toBe(false);
|
||||
expect(spin(stage)).toBe(true);
|
||||
});
|
||||
|
||||
// A failed load read "No description." in the always-open panel — an error drawn as empty.
|
||||
|
||||
Reference in New Issue
Block a user