fix: review round 1 — row press after a rename, related reset, dead items on removal, honest Prev/Next

- A press that begins while a field in the row is edited only ends the editing (rowOpen latches it
  at pointerdown); the card layout's row press is now tested.
- A related video reset in the player forgets the session's stored position.
- Removing the playing video passes over known-dead items and steps the way it went.
- Prev/Next off only because of dead items stay focusable and say why (HU/EN).
- The docked bar's scroller leaves room for focus rings.
- A focus-opened About card is not closed by its own scroll into view.
- EN "Clear watch progress" for the video reset, distinct from a page's "Reset view".
- The signed-out title read goes to the page yt-dlp read; the retitle writes are conditional.
- Tests: the cap of three errors, the bar's own Fullscreen, the group glyph, the backfill's reads.
This commit is contained in:
2026-11-01 05:36:59 +01:00
parent 245c0a2019
commit c910cf2f20
25 changed files with 411 additions and 63 deletions
@@ -2,7 +2,8 @@
//
// The About cell's card opened the instant a pointer crossed the cell, so scrolling the table over
// a column of them popped card after card. It waits for the pointer to rest on the cell now, and a
// scroll puts an open one away.
// scroll puts an open one away — unless the keyboard's focus opened it, whose own scroll into view
// would put it away the moment it opened.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, act, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -66,4 +67,14 @@ describe("AboutCell — the hover card", () => {
await wait(50);
expect(card()).toBeNull();
});
it("stays when the focus that opened it scrolls its cell into view", async () => {
const cell = mount();
act(() => cell.focus());
await wait(10);
expect(card()).not.toBeNull();
act(() => void window.dispatchEvent(new Event("scroll")));
await wait(50);
expect(card()).not.toBeNull();
});
});
+8 -2
View File
@@ -25,17 +25,23 @@ const ABOUT_REST_MS = 500;
export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) {
const preview = (text ?? "").trim();
const [open, setOpen] = useState(false);
// Opened by the keyboard's focus: the scroll that brings a focused cell into view is that focus's
// own, and would put the card away the moment it opened — so only a pointer's card goes on a scroll.
const [byFocus, setByFocus] = useState(false);
const pop = useAnchoredPopover({
open: open && !!preview,
onOpenChange: setOpen,
onOpenChange: (next, _event, reason) => {
setByFocus(next && reason === "focus");
setOpen(next);
},
mode: "hover",
offset: 6,
// Opens once the pointer rests on the cell, not as it crosses one: scrolling the table under a
// still pointer, or moving down the column, used to pop card after card. The close grace lets
// the cursor reach the card; a scroll puts an open one away.
hoverDelay: { open: ABOUT_REST_MS, close: 160 },
dismissOnScroll: true,
dismissOnScroll: !byFocus,
// Cap the panel to the space between the trigger and the viewport edge (publishes --fit-max-h) so a
// long About never runs its top off-screen — at high browser zoom the short viewport made a fixed
// max-height overflow above the top edge, where the scroll container can't reach.
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// "Reset view" on a half-watched video. The optimistic layer overrode only
// "Clear watch progress" on a half-watched video. The optimistic layer overrode only
// the STATUS, so the card kept its resume position — its progress bar and its Continue button — until
// the next refetch (found in a UAT). The grid is replaced by a list that shows what Feed
// hands it: each video's position, and a button that fires the reset.
@@ -85,6 +85,8 @@ async function renderFeed(channelScoped = false) {
const trigger = (name: string) =>
screen.getAllByRole("button").find((b) => b.getAttribute("aria-label")?.startsWith(`${name}:`))!;
const sortTrigger = () => trigger("feed.sortLabel");
// The group's glyph, not the trigger's own chevron: the glyph sits in its aria-hidden wrapper.
const glyph = (el: HTMLElement) => el.querySelector('[aria-hidden="true"] > svg');
describe("Feed toolbar", () => {
it.each([
@@ -94,7 +96,7 @@ describe("Feed toolbar", () => {
await renderFeed();
const pill = screen.getByTestId(id);
expect(pill.textContent).not.toContain(word);
expect(pill.querySelector("svg")).not.toBeNull();
expect(glyph(pill)).not.toBeNull();
expect(pill.getAttribute("title")).toBe(word);
expect(pill.getAttribute("aria-label")).toMatch(new RegExp(`^${word}: `));
});
@@ -103,6 +105,7 @@ describe("Feed toolbar", () => {
await renderFeed();
const sort = sortTrigger();
expect(sort.textContent).not.toContain("feed.sortLabel");
expect(glyph(sort)).not.toBeNull();
expect(sort.getAttribute("title")).toBe("feed.sortLabel");
});
@@ -1433,6 +1433,24 @@ describe("Files · a press on the row plays it", () => {
expect(screen.queryByText("playing 1")).toBeNull();
});
it("opens nothing for the click on the row that ends its rename", async () => {
mocks.files = [file(1)];
mocks.folders = [YT];
mount();
const cell = sizeCellOf("Clip 1");
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 1")));
fireEvent.click(menuItem("files.actions.rename"));
const field = await screen.findByRole("textbox", { name: "files.actions.rename" });
// A click elsewhere on the row: the press begins with the field focused, its mousedown blurs
// the field (which saves the name), and the click lands on the row after that.
fireEvent.pointerDown(cell);
act(() => field.blur());
expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull();
fireEvent.click(cell, { detail: 1 });
await act(async () => {});
expect(screen.queryByText("playing 1")).toBeNull();
});
it("opens nothing for the click that ends selecting the row's words", async () => {
mocks.files = [file(1)];
mocks.folders = [YT];
@@ -53,6 +53,8 @@ async function mount() {
return screen.findByTestId("plex-show");
}
// The group's glyph, not the trigger's own chevron: the glyph sits in its aria-hidden wrapper.
const glyph = (el: HTMLElement) => el.querySelector('[aria-hidden="true"] > svg');
const sortTrigger = () =>
screen
.getAllByRole("button")
@@ -63,7 +65,7 @@ describe("PlexBrowse toolbar", () => {
const pill = await mount();
// The key of a value ("plex.filter.showOpt.all") holds the group's key: test the visible text.
expect(pill.textContent).toBe("plex.filter.showOpt.all");
expect(pill.querySelector("svg")).not.toBeNull();
expect(glyph(pill)).not.toBeNull();
expect(pill.getAttribute("title")).toBe("plex.filter.show");
expect(pill.getAttribute("aria-label")).toMatch(/^plex\.filter\.show: /);
});
@@ -72,6 +74,7 @@ describe("PlexBrowse toolbar", () => {
const pill = await mount();
const sort = sortTrigger();
expect(sort.textContent).not.toContain("plex.filter.sort");
expect(glyph(sort)).not.toBeNull();
expect(sort.getAttribute("title")).toBe("plex.filter.sort");
expect(pill.parentElement!.contains(sort)).toBe(true);
});