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:
@@ -2,9 +2,9 @@
|
||||
//
|
||||
// Below `md` a table is a list of cards, its columns flowing one after another. A column marked
|
||||
// `cardRow` takes a line of its own there — a row's lone "…" otherwise landed at the end of one
|
||||
// card's line and on the next line of another.
|
||||
// card's line and on the next line of another. A card is its row's press surface too, as the row is.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup, screen } from "@testing-library/react";
|
||||
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
@@ -43,4 +43,21 @@ describe("DataTable cards", () => {
|
||||
expect(screen.getByText("more").parentElement!.className).toContain("basis-full");
|
||||
expect(screen.getByText("1 MB").parentElement!.className).not.toContain("basis-full");
|
||||
});
|
||||
|
||||
it("opens its object from a tap on the card, and leaves its control's tap to the control", () => {
|
||||
narrow(false);
|
||||
const open = vi.fn();
|
||||
render(
|
||||
<DataTable
|
||||
rows={[{ id: "a" }]}
|
||||
columns={columns}
|
||||
rowKey={(r) => r.id}
|
||||
rowOpen={() => open}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("1 MB"), { detail: 1 });
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByText("more"), { detail: 1 });
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
|
||||
|
||||
## What lives here
|
||||
|
||||
| area | components |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
|
||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
||||
| area | components |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `unavailable` (an off control's props: with a reason it stays focusable, `aria-disabled` and the reason in its tooltip; without one, `disabled`), `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
|
||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
||||
|
||||
## The size contract (E10 S1c)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
useInteractions,
|
||||
useFloatingNodeId,
|
||||
safePolygon,
|
||||
type OpenChangeReason,
|
||||
type Placement,
|
||||
type UseRoleProps,
|
||||
} from "@floating-ui/react";
|
||||
@@ -32,7 +33,8 @@ type PopoverMode = "menu" | "listbox" | "hover" | "dialog" | "combobox";
|
||||
|
||||
export interface AnchoredPopoverOptions {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean, event?: Event) => void;
|
||||
/** `reason`: what opened or closed it (floating-ui's: "hover", "focus", "ancestor-scroll", …). */
|
||||
onOpenChange: (open: boolean, event?: Event, reason?: OpenChangeReason) => void;
|
||||
/** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards);
|
||||
* "combobox" is a typeable input + listbox where focus stays in the field (use via <Combobox>). */
|
||||
mode?: PopoverMode;
|
||||
|
||||
@@ -9,18 +9,25 @@ import { endsTextSelection, movedPastSlop } from "../../lib/textSelection";
|
||||
// - a click that ends a text selection, or a press that travelled (a drag across the row's padding):
|
||||
// DESIGN.md "Text stays selectable" — no play or open fires;
|
||||
// - an event from a portal (a popover a cell opened): React bubbles it through the row, the DOM does
|
||||
// not, so only a target the row really contains counts.
|
||||
// not, so only a target the row really contains counts;
|
||||
// - a press that began while a field inside the row held the focus (its rename): that press ends the
|
||||
// editing — its mousedown blurs the field, which saves — and does nothing more. Read at pointerdown,
|
||||
// because by the click the blur has already put the row back as it is when nothing is edited.
|
||||
// The long-press menu's trailing click is swallowed by its own trigger (`useContextMenu`), before it
|
||||
// reaches the row.
|
||||
|
||||
// A field the focus can be editing in.
|
||||
const FIELD = 'input, textarea, select, [contenteditable="true"]';
|
||||
|
||||
// What counts as a control of its own. `[tabindex]` covers a focusable cell (a hover card's trigger);
|
||||
// the row's own roving tab stop is the row itself, which the containment check below leaves out.
|
||||
const CONTROL =
|
||||
'a, button, input, select, textarea, label, summary, [role="button"], [role="menuitem"], [role="checkbox"], [role="switch"], [contenteditable="true"], [tabindex]';
|
||||
|
||||
// Where each row's press began, for the drag slop. Per element: the table row and its card twin are
|
||||
// two elements, and a press on one must not be measured against the other.
|
||||
const pressedAt = new WeakMap<Element, { x: number; y: number }>();
|
||||
// Where each row's press began, for the drag slop, and whether it began while a field inside the row
|
||||
// was being edited. Per element: the table row and its card twin are two elements, and a press on one
|
||||
// must not be measured against the other.
|
||||
const pressedAt = new WeakMap<Element, { x: number; y: number; editing: boolean }>();
|
||||
|
||||
type Handlers = {
|
||||
onPointerDown?: (e: PointerEvent<HTMLElement>) => void;
|
||||
@@ -39,13 +46,18 @@ export function withRowOpen(
|
||||
return {
|
||||
...base,
|
||||
onPointerDown: (e: PointerEvent<HTMLElement>) => {
|
||||
pressedAt.set(e.currentTarget, { x: e.clientX, y: e.clientY });
|
||||
const row = e.currentTarget;
|
||||
const focused = document.activeElement;
|
||||
const editing = !!focused && row.contains(focused) && focused.matches(FIELD);
|
||||
pressedAt.set(row, { x: e.clientX, y: e.clientY, editing });
|
||||
onPointerDown?.(e);
|
||||
},
|
||||
onClick: (e: MouseEvent<HTMLElement>) => {
|
||||
onClick?.(e);
|
||||
if (e.defaultPrevented || !claims(e)) return;
|
||||
open();
|
||||
const claimed = !e.defaultPrevented && claims(e);
|
||||
// One press, one click: a later keyboard click (no press) is not judged by this one.
|
||||
pressedAt.delete(e.currentTarget);
|
||||
if (claimed) open();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -56,5 +68,6 @@ function claims(e: MouseEvent<HTMLElement>): boolean {
|
||||
if (!(target instanceof Element) || !row.contains(target)) return false;
|
||||
const control = target.closest(CONTROL);
|
||||
if (control && control !== row && row.contains(control)) return false;
|
||||
return !endsTextSelection(e) && !movedPastSlop(pressedAt.get(row) ?? null, e);
|
||||
const pressed = pressedAt.get(row) ?? null;
|
||||
return !pressed?.editing && !endsTextSelection(e) && !movedPastSlop(pressed, e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/** An unavailable control's props. With a reason it stays focusable (`aria-disabled`, which
|
||||
* `FocusSafeButton` keeps inert to a click, and the reason in its tooltip — DESIGN.md "Unavailable
|
||||
* with a reason stays focusable"); without one it is plainly off. */
|
||||
export function unavailable(disabled: boolean | undefined, reason: string | undefined) {
|
||||
if (!disabled) return {};
|
||||
return reason ? { "aria-disabled": true as const } : { disabled: true };
|
||||
}
|
||||
@@ -1050,6 +1050,35 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
expect(lastStart()?.start ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
// A reset from the related row is where it starts now: the position this session stored for it
|
||||
// (which a plain pick resumes from) is forgotten with it, not replayed and checkpointed back.
|
||||
it("wide: a related video reset from its row starts at 0 on a plain pick", async () => {
|
||||
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
|
||||
video_id: id,
|
||||
position_seconds: id === "rel2" ? 500 : 77,
|
||||
}));
|
||||
const reset = vi.spyOn(api, "clearState").mockResolvedValue(undefined as never);
|
||||
clock.current = 77;
|
||||
await playRel2(() => Promise.resolve());
|
||||
clock.current = 500;
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
await settle();
|
||||
const list = screen.getByTestId("player-related");
|
||||
const links = () => within(list).getAllByRole("link", { name: "Video rel2" });
|
||||
fireEvent.contextMenu(links()[0]!.closest("[data-video-id]")!);
|
||||
fireEvent.click(
|
||||
screen
|
||||
.queryAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "card.resetState")!,
|
||||
);
|
||||
await waitFor(() => expect(reset).toHaveBeenCalledWith("rel2"));
|
||||
await settle();
|
||||
fireEvent.click(titleOf(links()));
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("rel2"));
|
||||
expect(lastStart()?.start ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
// Its row stays in the list while it plays; a new player for the same video is never built (one
|
||||
// per video), so Restart there is a seek.
|
||||
it("wide: Restart on the related video already playing seeks it to 0", async () => {
|
||||
@@ -1654,9 +1683,10 @@ describe("PlayerModal — the docked bar's fullscreen", () => {
|
||||
it("keeps the way out in sight: glyphs only, and out of the scrolling part", () => {
|
||||
maximised(true);
|
||||
const out = screen.getByRole("button", { name: "player.exitMaximize" });
|
||||
const fulls = screen.getAllByLabelText("player.fullscreen");
|
||||
const full = fulls[fulls.length - 1]!;
|
||||
const bar = out.parentElement!;
|
||||
// The windowed panel keeps a Fullscreen of its own too; this is the bar's.
|
||||
const full = screen.getAllByLabelText("player.fullscreen").find((b) => bar.contains(b))!;
|
||||
expect(full).toBeTruthy();
|
||||
// The windowed row keeps its own Mark watched; this is the bar's.
|
||||
const watched = screen
|
||||
.getAllByRole("button", { name: "player.markWatched" })
|
||||
@@ -1898,15 +1928,88 @@ describe("PlayerModal — stepping over a video that cannot play", () => {
|
||||
expect(opened("B")).toBe(1);
|
||||
});
|
||||
|
||||
it("Previous is off when only dead videos are before you", async () => {
|
||||
// Off, and saying why: the counter shows a video before this one, so a dark button alone would not
|
||||
// tell (DESIGN "Unavailable with a reason stays focusable").
|
||||
it("Previous is off, with its reason, when only dead videos are before you", async () => {
|
||||
await open(0);
|
||||
fail(); // opened on it, auto-advance off: it stays, and it is known dead now
|
||||
await settle();
|
||||
expect(lastStart()?.videoId).toBe("A");
|
||||
next();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
for (const b of screen.getAllByRole("button", { name: "player.previous" }))
|
||||
const prevs = screen.getAllByRole("button", { name: "player.previous" });
|
||||
for (const b of prevs) {
|
||||
expect((b as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(b.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(b.getAttribute("title")).toContain("player.noPlayableBefore");
|
||||
}
|
||||
prev();
|
||||
await settle();
|
||||
expect(lastStart()?.videoId).toBe("B");
|
||||
// Next has playable C ahead: on, and no reason.
|
||||
for (const b of screen.getAllByRole("button", { name: "player.next" }))
|
||||
expect(b.getAttribute("title")).not.toContain("player.noPlayableAfter");
|
||||
});
|
||||
|
||||
it("Previous at the queue's start is plainly off: the counter says why", async () => {
|
||||
await open(0);
|
||||
for (const b of screen.getAllByRole("button", { name: "player.previous" })) {
|
||||
expect((b as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(b.getAttribute("title")).not.toContain("player.noPlayableBefore");
|
||||
}
|
||||
});
|
||||
|
||||
// Removing the playing video passes over the ones known dead, the way a step does, and goes back
|
||||
// when nothing playable followed it.
|
||||
it("removing the last video goes back over a dead one to the one that plays", async () => {
|
||||
renderModal(
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A, B, C]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={vi.fn()}
|
||||
onRemoveFromList={async () => true}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
next();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
fail();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
|
||||
fireEvent.click(trash("C"));
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
expect(opened("B")).toBe(1);
|
||||
});
|
||||
|
||||
// The player gives up after three in a row: it stays on the third's error, and all three are
|
||||
// remembered — a later step passes over them.
|
||||
it("three dead in a row stop on the third, and a step then passes over all three", async () => {
|
||||
const [D, E] = [vid("D", 0), vid("E", 0)];
|
||||
renderModal(
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A, B, C, D, E]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
next();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
fail();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
|
||||
fail();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("D"));
|
||||
fail();
|
||||
await settle();
|
||||
expect(lastStart()?.videoId).toBe("D");
|
||||
prev();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
next();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("E"));
|
||||
expect([opened("B"), opened("C"), opened("D")]).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it("a video opened dead with auto-advance off stays on its error", async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { PlayerDescription, PlayerTransportControls } from "./playerModalControls";
|
||||
import { unavailable } from "../../components/ui/unavailable";
|
||||
import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../../lib/playerModes";
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -702,11 +703,15 @@ export default function PlayerModal({
|
||||
const goNext = () => step(1);
|
||||
const canPrev = !!queue && stepIndex(queue, index, -1, dead) >= 0;
|
||||
const canNext = !!queue && stepIndex(queue, index, 1, dead) >= 0;
|
||||
// Off with items still left that way: only ones YouTube refused are there, which the counter alone
|
||||
// does not tell. At the queue's ends the counter does.
|
||||
const prevReason = queue && index > 0 ? t("player.noPlayableBefore") : undefined;
|
||||
const nextReason = queue && index < queue.length - 1 ? t("player.noPlayableAfter") : undefined;
|
||||
// "Remove from this playlist", offered right where you are watching the thing you want gone. The
|
||||
// parent owns the removal (API + caches + the toast whose Undo puts it back); the player owns what happens NEXT, because it
|
||||
// is the only side that knows which item is playing — its queue is frozen at mount, so the parent
|
||||
// cannot tell. Step to the item that followed (clamped when the last one goes), and close when the
|
||||
// list runs out.
|
||||
// cannot tell. Step to the item that followed (the one before when the last one goes), passing over
|
||||
// the ones known unplayable, and close when the list runs out.
|
||||
//
|
||||
// Guarded against a double press by a REF, not by disabling the button: a trigger disabled for the
|
||||
// removal's lifetime was still disabled when focus came back to it (it once waited on a confirm) —
|
||||
@@ -723,19 +728,19 @@ export default function PlayerModal({
|
||||
// Decide from where the player is NOW, not where it was when the button was pressed: prev/next
|
||||
// and auto-advance keep working while the request runs, and a cursor from before the await
|
||||
// threw the viewer back onto a video they had already moved past.
|
||||
const { queue: q, index: i } = navRef.current;
|
||||
const { queue: q, index: i, dead: d } = navRef.current;
|
||||
if ((q?.[i] ?? video).id !== victim.id) {
|
||||
// They moved on — take the removed item out of the queue and leave playback where it is.
|
||||
setQueue((cur) => cur?.filter((v) => v.id !== victim.id));
|
||||
return;
|
||||
}
|
||||
const next = afterRemoval(q, i, victim.id);
|
||||
const next = afterRemoval(q, i, victim.id, d);
|
||||
if (!next) {
|
||||
onClose(); // that was the last one
|
||||
return;
|
||||
}
|
||||
setQueue(next.queue);
|
||||
stepTo(next.playingId, 1); // the item that followed: a dead one there carries on forward
|
||||
stepTo(next.playingId, next.way); // one that cannot play after all carries on the same way
|
||||
};
|
||||
|
||||
// Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s.
|
||||
@@ -830,6 +835,9 @@ export default function PlayerModal({
|
||||
// Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
|
||||
// be refused.
|
||||
const relatedOn = useMe().related_enabled;
|
||||
// A video reset from the related list starts over: the position this session stored for it would
|
||||
// otherwise resume a plain pick there, and the player's close would patch it back into the lists.
|
||||
const forgetPosition = (id: string) => void sessionPosRef.current.delete(id);
|
||||
const playRelated = (v: Video, startAt: number | null) => {
|
||||
setRelatedOpen(false);
|
||||
// The video already playing (its row stays in the list): a new player for the same id would
|
||||
@@ -1208,8 +1216,10 @@ export default function PlayerModal({
|
||||
>
|
||||
{/* Glyphs only, each named by its label and tooltip. The controls scroll when a
|
||||
narrow window cannot hold them; the way out (fullscreen, leave the big view)
|
||||
stays outside that part, in sight: on an iPad it had scrolled off the right. */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-x-3 overflow-x-auto">
|
||||
stays outside that part, in sight: on an iPad it had scrolled off the right.
|
||||
The bar's full height and a little side room: a scroller clips what overflows
|
||||
it, the focus ring drawn outside each control included. */}
|
||||
<div className="-mx-1 flex min-w-0 flex-1 items-center gap-x-3 self-stretch overflow-x-auto px-1">
|
||||
{hasQueue && (
|
||||
<>
|
||||
<PlayerTransportControls
|
||||
@@ -1218,6 +1228,8 @@ export default function PlayerModal({
|
||||
queueLength={queue!.length}
|
||||
canPrev={canPrev}
|
||||
canNext={canNext}
|
||||
prevReason={prevReason}
|
||||
nextReason={nextReason}
|
||||
autoMode={autoMode}
|
||||
loopMode={loopMode}
|
||||
onPrev={goPrev}
|
||||
@@ -1372,18 +1384,18 @@ export default function PlayerModal({
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={goPrev}
|
||||
disabled={!canPrev}
|
||||
{...unavailable(!canPrev, prevReason)}
|
||||
label={t("player.previous")}
|
||||
title={`${t("player.previous")} · Shift+←`}
|
||||
title={`${t("player.previous")} · Shift+←${!canPrev && prevReason ? ` · ${prevReason}` : ""}`}
|
||||
>
|
||||
<SkipBack />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={goNext}
|
||||
disabled={!canNext}
|
||||
{...unavailable(!canNext, nextReason)}
|
||||
label={t("player.next")}
|
||||
title={`${t("player.next")} · Shift+→`}
|
||||
title={`${t("player.next")} · Shift+→${!canNext && nextReason ? ` · ${nextReason}` : ""}`}
|
||||
>
|
||||
<SkipForward />
|
||||
</IconButton>
|
||||
@@ -1419,6 +1431,8 @@ export default function PlayerModal({
|
||||
queueLength={queue!.length}
|
||||
canPrev={canPrev}
|
||||
canNext={canNext}
|
||||
prevReason={prevReason}
|
||||
nextReason={nextReason}
|
||||
autoMode={autoMode}
|
||||
loopMode={loopMode}
|
||||
onPrev={goPrev}
|
||||
@@ -1467,7 +1481,12 @@ export default function PlayerModal({
|
||||
maxWidth="max-w-sm"
|
||||
initialFocus="close"
|
||||
>
|
||||
<RelatedVideos videoId={active.id} onPlay={playRelated} onWrite={onWrite} />
|
||||
<RelatedVideos
|
||||
videoId={active.id}
|
||||
onPlay={playRelated}
|
||||
onWrite={onWrite}
|
||||
onReset={forgetPosition}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{(navigated || related) && (
|
||||
@@ -1723,6 +1742,7 @@ export default function PlayerModal({
|
||||
videoId={active.id}
|
||||
onPlay={playRelated}
|
||||
onWrite={onWrite}
|
||||
onReset={forgetPosition}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -54,12 +54,15 @@ export function RelatedVideos({
|
||||
videoId,
|
||||
onPlay,
|
||||
onWrite,
|
||||
onReset,
|
||||
}: {
|
||||
videoId: string;
|
||||
/** `startAt` null resumes the video where it was left; a number (Restart's 0) starts there. */
|
||||
onPlay: (v: Video, startAt: number | null) => void;
|
||||
/** Each write this list makes, for the host's player session (see PlayerModal `onWrite`). */
|
||||
onWrite?: (write: Promise<unknown>) => void;
|
||||
/** A video's view was reset from its row: the host forgets where its session left it. */
|
||||
onReset?: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -107,6 +110,7 @@ export function RelatedVideos({
|
||||
};
|
||||
|
||||
const onResetState = (id: string) => {
|
||||
onReset?.(id);
|
||||
const seq = begin(id, { status: "new", position_seconds: 0 });
|
||||
const write = resetVideoState(qc, id);
|
||||
onWrite?.(write);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { VideoDetail } from "../../lib/api";
|
||||
import type { AutoMode, LoopMode } from "../../lib/playerModes";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { StateMessage } from "../../components/ui/QueryState";
|
||||
import { unavailable } from "../../components/ui/unavailable";
|
||||
import { ClampedText } from "../../components/ui/ClampedText";
|
||||
import { renderDescription } from "./descriptionLinks";
|
||||
|
||||
@@ -28,6 +29,8 @@ export function PlayerTransportControls({
|
||||
queueLength,
|
||||
canPrev,
|
||||
canNext,
|
||||
prevReason,
|
||||
nextReason,
|
||||
autoMode,
|
||||
loopMode,
|
||||
onPrev,
|
||||
@@ -41,6 +44,11 @@ export function PlayerTransportControls({
|
||||
/** Whether there is a playable item that way (one YouTube refused is passed over). */
|
||||
canPrev: boolean;
|
||||
canNext: boolean;
|
||||
/** Why Previous / Next are off when items are left that way (only ones YouTube refused): the
|
||||
* button stays focusable and its tooltip says so. Unset at the queue's ends, which explain
|
||||
* themselves. */
|
||||
prevReason?: string;
|
||||
nextReason?: string;
|
||||
autoMode: AutoMode;
|
||||
loopMode: LoopMode;
|
||||
onPrev: () => void;
|
||||
@@ -62,6 +70,7 @@ export function PlayerTransportControls({
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
reason?: string;
|
||||
lit?: boolean;
|
||||
};
|
||||
const step: Control[] = [
|
||||
@@ -72,6 +81,7 @@ export function PlayerTransportControls({
|
||||
title: `${t("player.previous")} · Shift+←`,
|
||||
onClick: onPrev,
|
||||
disabled: !canPrev,
|
||||
reason: canPrev ? undefined : prevReason,
|
||||
},
|
||||
{
|
||||
key: "next",
|
||||
@@ -80,6 +90,7 @@ export function PlayerTransportControls({
|
||||
title: `${t("player.next")} · Shift+→`,
|
||||
onClick: onNext,
|
||||
disabled: !canNext,
|
||||
reason: canNext ? undefined : nextReason,
|
||||
},
|
||||
];
|
||||
// Persistent playback settings — cycle on click, saved to your account.
|
||||
@@ -107,10 +118,10 @@ export function PlayerTransportControls({
|
||||
key={c.key}
|
||||
variant={docked ? "overlay" : undefined}
|
||||
onClick={c.onClick}
|
||||
disabled={c.disabled}
|
||||
{...unavailable(c.disabled, c.reason)}
|
||||
lit={c.lit}
|
||||
label={c.name}
|
||||
title={c.title}
|
||||
title={c.reason ? `${c.title} · ${c.reason}` : c.title}
|
||||
>
|
||||
{c.glyph}
|
||||
</IconButton>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"saveForLater": "Save for later",
|
||||
"unhide": "Unhide",
|
||||
"hide": "Hide",
|
||||
"resetState": "Reset view",
|
||||
"resetState": "Clear watch progress",
|
||||
"thisChannel": "This channel",
|
||||
"continueTitle": "Continue where you left off",
|
||||
"continue": "Continue",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"back": "Back",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"noPlayableBefore": "No video before this one can play here",
|
||||
"noPlayableAfter": "No video after this one can play here",
|
||||
"autoAdvance": {
|
||||
"label": "Auto",
|
||||
"hint": "When a video ends, what plays next. Click to cycle Off, Next, Previous, Random. Saved to your account.",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"back": "Vissza",
|
||||
"previous": "Előző",
|
||||
"next": "Következő",
|
||||
"noPlayableBefore": "Előtte nincs itt lejátszható videó",
|
||||
"noPlayableAfter": "Utána nincs itt lejátszható videó",
|
||||
"autoAdvance": {
|
||||
"label": "Automatikus",
|
||||
"hint": "A videó végén mi jöjjön. Kattintásra vált: Ki, Következő, Előző, Véletlen. A fiókodhoz mentve.",
|
||||
|
||||
@@ -10,6 +10,27 @@ const v = (id: string): Video => ({ id }) as Video;
|
||||
const ids = (q: Video[]) => q.map((x) => x.id);
|
||||
|
||||
describe("afterRemoval", () => {
|
||||
it("passes over a follower known dead, on the way it went", () => {
|
||||
const q = [v("a"), v("b"), v("c"), v("d")];
|
||||
const next = afterRemoval(q, 1, "b", new Set(["c"]));
|
||||
expect(next).toMatchObject({ playingId: "d", way: 1 });
|
||||
});
|
||||
|
||||
it("goes back over the dead ones when nothing playable followed", () => {
|
||||
const q = [v("a"), v("b"), v("c")];
|
||||
expect(afterRemoval(q, 2, "c", new Set(["b"]))).toMatchObject({ playingId: "a", way: -1 });
|
||||
// The last one going, nothing dead: the one before it, and that is a step back.
|
||||
expect(afterRemoval(q, 2, "c")).toMatchObject({ playingId: "b", way: -1 });
|
||||
});
|
||||
|
||||
it("with none left that can play, takes the follower all the same", () => {
|
||||
const q = [v("a"), v("b"), v("c")];
|
||||
expect(afterRemoval(q, 0, "a", new Set(["b", "c"]))).toMatchObject({
|
||||
playingId: "b",
|
||||
way: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("plays the item that followed the one you removed", () => {
|
||||
const q = [v("a"), v("b"), v("c")];
|
||||
const next = afterRemoval(q, 1, "b");
|
||||
|
||||
@@ -12,21 +12,30 @@ export interface AfterRemoval {
|
||||
queue: Video[];
|
||||
/** The item to play next. */
|
||||
playingId: string;
|
||||
/** Which way that item lies from the removed one: what it carries on along if it cannot play. */
|
||||
way: 1 | -1;
|
||||
}
|
||||
|
||||
/** `null` means nothing is left — the caller closes the player.
|
||||
*
|
||||
* The next item is the one that FOLLOWED the removed one (that is what "delete and keep going"
|
||||
* means when you are working down a list), clamped to the end when the last item goes. */
|
||||
* means when you are working down a list), the one before it when the last item goes. A video known
|
||||
* to be unplayable (`dead`, see `stepIndex`) is passed over, not loaded again: the nearest playable
|
||||
* one after, else before. None playable: the follower all the same, where the player shows why. */
|
||||
export function afterRemoval(
|
||||
queue: Video[] | undefined,
|
||||
index: number,
|
||||
victimId: string,
|
||||
dead: ReadonlySet<string> = new Set(),
|
||||
): AfterRemoval | null {
|
||||
const rest = (queue ?? []).filter((v) => v.id !== victimId);
|
||||
if (rest.length === 0) return null;
|
||||
const next = rest[Math.min(Math.max(index, 0), rest.length - 1)]!;
|
||||
return { queue: rest, playingId: next.id };
|
||||
// The removed item's slot: what followed it now stands there (past the end when it was the last).
|
||||
const at = Math.min(Math.max(index, 0), rest.length);
|
||||
const pick = (j: number, way: 1 | -1) => ({ queue: rest, playingId: rest[j]!.id, way });
|
||||
for (let j = at; j < rest.length; j++) if (!dead.has(rest[j]!.id)) return pick(j, 1);
|
||||
for (let j = at - 1; j >= 0; j--) if (!dead.has(rest[j]!.id)) return pick(j, -1);
|
||||
return at < rest.length ? pick(at, 1) : pick(rest.length - 1, -1);
|
||||
}
|
||||
|
||||
/** The nearest item from `index` going `dir` that is not known to be unplayable, or -1 when there is
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user