fix(ui): review round 2 — reveal floor, failed next page, count states, row Play
- DataTable: the reveal floor holds only in the view it was made for (Channels never clears its follow, so every later sort mounted rows up to it) - Feed + Plex browse: a failed next page stops the read-ahead and shows a row that retries, instead of re-requesting it once a round-trip - the YouTube Load more click uses nextPageOnClick; a contract pins NEXT_PAGE to the read-aheads and the click sites to nextPageOnClick - ItemCount: a failed count says so; a stale one says updating to a screen reader - the narrow-picture Play lift applies only where a hover row is there to clear - PlayerModal: one description ladder for the popover and the panel, both tested - plexImage contract reads every img src, any attribute order - Tabs comment names who persists the sub-tab
This commit is contained in:
@@ -95,4 +95,21 @@ describe("Feed — the count while a new search's count loads", () => {
|
||||
await waitFor(() => expect(pill().getAttribute("title")).toBe("178,991"));
|
||||
expect(pill().getAttribute("aria-busy")).toBeNull();
|
||||
});
|
||||
|
||||
it("says the count failed instead of showing the empty box of a first load", async () => {
|
||||
vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never);
|
||||
vi.spyOn(api, "feedSummary").mockImplementation(((f: FeedFilters) =>
|
||||
f.q === "kitchen"
|
||||
? Promise.resolve({ count: 1937, facets: {} })
|
||||
: Promise.reject(new Error("boom"))) as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
const pill = () => screen.getByTestId("item-count");
|
||||
|
||||
const view = render(feed("kitchen", qc));
|
||||
await waitFor(() => expect(pill().getAttribute("title")).toBe("1,937"));
|
||||
view.rerender(feed("a", qc));
|
||||
await waitFor(() => expect(pill().getAttribute("title")).toBe("common.countFailed"));
|
||||
expect(pill().getAttribute("aria-busy")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A next page that fails. Retry is off and the read-ahead re-runs whenever a fetch settles, so a failed
|
||||
// page was requested again once a round-trip, forever, while the end of the list was in view, and
|
||||
// nothing said it had failed (E31 review 2). The read-ahead now stops at a failed page, and a row says
|
||||
// so and retries on a click. VirtualFeed is replaced by what Feed hands it: the read-ahead's gate and a
|
||||
// button standing in for the read-ahead itself.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, fireEvent } 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("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
|
||||
vi.mock("./VirtualFeed", () => ({
|
||||
default: ({
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
}: {
|
||||
hasNextPage: boolean;
|
||||
fetchNextPage: (o: { cancelRefetch: boolean }) => unknown;
|
||||
}) => (
|
||||
<div>
|
||||
<span data-testid="has-next">{String(hasNextPage)}</span>
|
||||
<button onClick={() => void fetchNextPage({ cancelRefetch: false })}>read ahead</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./SelectedViewChip", () => ({ default: () => null }));
|
||||
vi.mock("./ActiveFilterChips", () => ({ default: () => null }));
|
||||
|
||||
import Feed from "./Feed";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
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.restoreAllMocks();
|
||||
});
|
||||
|
||||
const FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
describe("Feed — a next page that fails", () => {
|
||||
it("stops the read-ahead, says so, and loads the page on a retry", async () => {
|
||||
const page = (n: number, next: string | null) => ({
|
||||
items: [
|
||||
{ id: `v${n}`, title: `v${n}`, channel_id: "UCx", published_at: "2026-01-01T00:00:00Z" },
|
||||
],
|
||||
next_cursor: next,
|
||||
});
|
||||
let failNext = true;
|
||||
const feed = vi
|
||||
.spyOn(api, "feed")
|
||||
.mockImplementation(((_f: FeedFilters, cursor: string | null) =>
|
||||
cursor === null
|
||||
? Promise.resolve(page(1, "c2"))
|
||||
: failNext
|
||||
? Promise.reject(new Error("boom"))
|
||||
: Promise.resolve(page(2, null))) as never);
|
||||
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 2, facets: {} } as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Feed
|
||||
filters={FILTERS}
|
||||
setFilters={() => {}}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
canRead
|
||||
ytSearch=""
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTestId("has-next").textContent).toBe("true"));
|
||||
|
||||
fireEvent.click(screen.getByText("read ahead"));
|
||||
await waitFor(() => expect(screen.getByText("common.loadMoreFailed")).toBeTruthy());
|
||||
expect(screen.getByTestId("has-next").textContent).toBe("false"); // the read-ahead stands down
|
||||
const calls = feed.mock.calls.length;
|
||||
|
||||
failNext = false;
|
||||
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
|
||||
await waitFor(() => expect(feed.mock.calls.length).toBe(calls + 1));
|
||||
await waitFor(() => expect(screen.queryByText("common.loadMoreFailed")).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { NEXT_PAGE } from "../lib/nextPage";
|
||||
import { nextPageOnClick } from "../lib/nextPage";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Columns2,
|
||||
@@ -557,7 +557,9 @@ export default function Feed({
|
||||
<div className="text-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void ytQuery.fetchNextPage(NEXT_PAGE)}
|
||||
onClick={() =>
|
||||
void ytQuery.fetchNextPage(nextPageOnClick(ytQuery.isFetchingNextPage))
|
||||
}
|
||||
loading={ytQuery.isFetchingNextPage}
|
||||
>
|
||||
{ytQuery.isFetchingNextPage
|
||||
@@ -710,7 +712,11 @@ export default function Feed({
|
||||
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
|
||||
{/* The item count is the fixed leading element — a fixed-width pill so a changing number
|
||||
never shoves the controls beside it (see ItemCount). */}
|
||||
<ItemCount count={summaryQuery.data?.count} stale={summaryQuery.isPlaceholderData} />
|
||||
<ItemCount
|
||||
count={summaryQuery.data?.count}
|
||||
stale={summaryQuery.isPlaceholderData}
|
||||
failed={summaryQuery.isError && !summaryQuery.data}
|
||||
/>
|
||||
<FilterFoldButton
|
||||
fold={fold}
|
||||
label={t("sidebar.filters")}
|
||||
@@ -891,7 +897,10 @@ export default function Feed({
|
||||
onToggleSave={onToggleSave}
|
||||
onResetState={onResetState}
|
||||
onOpen={openVideo}
|
||||
hasNextPage={!!hasNextPage}
|
||||
// A failed next page stops the read-ahead: retry is off, and re-running on every settle would
|
||||
// re-request it once a round-trip for as long as the end is in view (E31 review 2). The row
|
||||
// below says so and retries on a click.
|
||||
hasNextPage={!!hasNextPage && !query.isFetchNextPageError}
|
||||
isFetching={query.isFetching}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
@@ -910,6 +919,15 @@ export default function Feed({
|
||||
{isFetchingNextPage && (
|
||||
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>
|
||||
)}
|
||||
{query.isFetchNextPageError && !isFetchingNextPage && (
|
||||
<StateMessage
|
||||
tone="error"
|
||||
compact
|
||||
onRetry={() => void fetchNextPage(nextPageOnClick(isFetchingNextPage))}
|
||||
>
|
||||
{t("common.loadMoreFailed")}
|
||||
</StateMessage>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -563,6 +563,19 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
expect(detail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// E31 review 2: the popover below 1280 px said it the same way only by a copy nobody tested.
|
||||
it("says it in the description popover below 1280 px too", async () => {
|
||||
wideWindow(false);
|
||||
vi.spyOn(api, "videoDetail").mockRejectedValue(new Error("boom"));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.description" }));
|
||||
expect(await screen.findByText("player.descriptionFailed")).toBeTruthy();
|
||||
expect(screen.queryByText("player.noDescription")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the details under the video below 1280 px", () => {
|
||||
wideWindow(false);
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -606,6 +607,30 @@ export default function PlayerModal({
|
||||
qc,
|
||||
});
|
||||
|
||||
// The description, said the same way in its two places (the popover under 1280 px, the panel
|
||||
// beside the video above it): loading, failed with a retry, the text, or none. One ladder, so the
|
||||
// two cannot drift (E31 review 2: the failed state was tested in the panel only). `wrap` is each
|
||||
// place's own box for the text.
|
||||
const description = (wrap: (body: ReactNode) => ReactNode) =>
|
||||
detail.isLoading ? (
|
||||
<div className="text-sm text-muted">{t("player.loading")}</div>
|
||||
) : detail.isError && !detail.data ? (
|
||||
<StateMessage tone="error" compact onRetry={() => void detail.refetch()}>
|
||||
{t("player.descriptionFailed")}
|
||||
</StateMessage>
|
||||
) : detail.data?.description ? (
|
||||
wrap(
|
||||
renderDescription(detail.data.description, {
|
||||
currentId: currentVideoId,
|
||||
onSeek: seekTo,
|
||||
onLoadVideo: loadVideo,
|
||||
t,
|
||||
}),
|
||||
)
|
||||
) : (
|
||||
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
||||
);
|
||||
|
||||
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
|
||||
// must not render inside the page scroller: once that scroller is a stacking context (its
|
||||
// edge-fade mask makes it one) this z-overlay is scoped inside it, and the nav rail (z-rail) and the
|
||||
@@ -887,28 +912,15 @@ export default function PlayerModal({
|
||||
<SectionLabel level={3} className="mb-2">
|
||||
{t("player.description")}
|
||||
</SectionLabel>
|
||||
{detail.isLoading ? (
|
||||
<div className="text-sm text-muted">{t("player.loading")}</div>
|
||||
) : detail.isError && !detail.data ? (
|
||||
<StateMessage tone="error" compact onRetry={() => void detail.refetch()}>
|
||||
{t("player.descriptionFailed")}
|
||||
</StateMessage>
|
||||
) : detail.data?.description ? (
|
||||
{description((body) => (
|
||||
<div
|
||||
ref={descFade.ref}
|
||||
style={descFade.style}
|
||||
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
||||
>
|
||||
{renderDescription(detail.data.description, {
|
||||
currentId: currentVideoId,
|
||||
onSeek: seekTo,
|
||||
onLoadVideo: loadVideo,
|
||||
t,
|
||||
})}
|
||||
{body}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
||||
)}
|
||||
))}
|
||||
</PopoverSurface>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1089,24 +1101,11 @@ export default function PlayerModal({
|
||||
<SectionLabel level={3} className="mb-2">
|
||||
{t("player.description")}
|
||||
</SectionLabel>
|
||||
{detail.isLoading ? (
|
||||
<div className="text-sm text-muted">{t("player.loading")}</div>
|
||||
) : detail.isError && !detail.data ? (
|
||||
<StateMessage tone="error" compact onRetry={() => void detail.refetch()}>
|
||||
{t("player.descriptionFailed")}
|
||||
</StateMessage>
|
||||
) : detail.data?.description ? (
|
||||
{description((body) => (
|
||||
<div className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||||
{renderDescription(detail.data.description, {
|
||||
currentId: currentVideoId,
|
||||
onSeek: seekTo,
|
||||
onLoadVideo: loadVideo,
|
||||
t,
|
||||
})}
|
||||
{body}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
||||
)}
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import { User, X } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { mayReadAhead, NEXT_PAGE } from "../lib/nextPage";
|
||||
import { mayReadAhead, NEXT_PAGE, nextPageOnClick } from "../lib/nextPage";
|
||||
import { healPlexField } from "../lib/plexSearch";
|
||||
import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||
import type { SortState } from "../lib/columnSort";
|
||||
@@ -317,7 +317,10 @@ export default function PlexBrowse() {
|
||||
// mount/unmount, so the observer always watches the live node; a plain ref would keep observing the
|
||||
// detached old node after Back and never fire fetchNextPage again (feed stuck at the loaded pages).
|
||||
const [sentinelEl, setSentinelEl] = useState<HTMLDivElement | null>(null);
|
||||
const { hasNextPage, isFetching, isFetchingNextPage, fetchNextPage } = browseQ;
|
||||
const { isFetching, isFetchingNextPage, isFetchNextPageError, fetchNextPage } = browseQ;
|
||||
// A failed next page stops the read-ahead (the row at the bottom retries): the observer is rebuilt
|
||||
// on every settle and would re-request it once a round-trip while the sentinel is in view.
|
||||
const hasNextPage = browseQ.hasNextPage && !isFetchNextPageError;
|
||||
useEffect(() => {
|
||||
if (!sentinelEl) return;
|
||||
const io = new IntersectionObserver(
|
||||
@@ -753,6 +756,15 @@ export default function PlexBrowse() {
|
||||
{isFetchingNextPage && (
|
||||
<p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>
|
||||
)}
|
||||
{isFetchNextPageError && !isFetchingNextPage && (
|
||||
<StateMessage
|
||||
tone="error"
|
||||
compact
|
||||
onRetry={() => void fetchNextPage(nextPageOnClick(isFetchingNextPage))}
|
||||
>
|
||||
{t("common.loadMoreFailed")}
|
||||
</StateMessage>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import videoCard from "./VideoCard.tsx?raw";
|
||||
|
||||
import {
|
||||
MEDIA_BAR,
|
||||
MEDIA_HOVER_LAYER,
|
||||
MEDIA_PICTURE_CONTAINER,
|
||||
MEDIA_RESUME_LAYER,
|
||||
@@ -24,6 +25,16 @@ describe("a media picture", () => {
|
||||
["the Play", MEDIA_HOVER_LAYER],
|
||||
["the Continue + Restart pair", MEDIA_RESUME_LAYER],
|
||||
])("leaves the hover row its band when narrow: %s", (_, layer) => {
|
||||
expect(layer.split(" ")).toContain("@max-[14rem]/pic:pb-10");
|
||||
expect(layer.split(" ")).toContain("@max-[14rem]/pic:pb-(--bar-room)");
|
||||
});
|
||||
|
||||
// E31 review 2: the lift applied to every narrow picture, and the feed's row and tile views (a
|
||||
// 10rem thumbnail, no hover row) showed their Play 1.25rem above centre over an empty band. The band
|
||||
// is now the row's own: set only by a box that holds one.
|
||||
it("lifts it only where a hover row is there to clear", () => {
|
||||
expect(MEDIA_BAR.split(" ")).toContain("media-bar");
|
||||
expect(MEDIA_PICTURE_CONTAINER.split(" ")).toContain("has-[.media-bar]:[--bar-room:2.5rem]");
|
||||
for (const layer of [MEDIA_HOVER_LAYER, MEDIA_RESUME_LAYER])
|
||||
expect(layer).not.toMatch(/pic:pb-\d/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,4 +124,32 @@ describe("DataTable — a long page mounts in steps", () => {
|
||||
draw({ key: "r90", seq: 1 });
|
||||
expect(host.querySelector("tbody")?.textContent).toContain("r 90");
|
||||
});
|
||||
|
||||
// E31 review 2: the reveal floor held for the page's lifetime. Channels never clears its `follow`, so
|
||||
// after one priority pick every later sort or search mounted every row up to that channel at once.
|
||||
it("lets a revealed row go once the reader moves to another view", () => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
const rows = many(200);
|
||||
const draw = (reveal: { key: string; seq: number } | null, narrowedBy: string) =>
|
||||
flushSync(() =>
|
||||
root!.render(
|
||||
<DataTable
|
||||
rows={rows}
|
||||
columns={plain}
|
||||
rowKey={(x) => x.id}
|
||||
pageSize={0}
|
||||
reveal={reveal}
|
||||
narrowedBy={narrowedBy}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
draw(null, "");
|
||||
const follow = { key: "r150", seq: 1 };
|
||||
draw(follow, "");
|
||||
expect(host.querySelector("tbody")?.textContent).toContain("r 150");
|
||||
draw(follow, "s"); // a search: a new view, the same (never cleared) reveal
|
||||
expect(bodyRows()).toBe(FIRST_ROWS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -267,19 +267,28 @@ export default function DataTable<T>({
|
||||
// Adjusted DURING render against the last seq seen (the house's key-change reset), so the frame
|
||||
// that re-sorted the row already shows its new page. Seeded with the mount's seq: arriving with a
|
||||
// stale reveal must not move a page nobody asked to move.
|
||||
const viewOf = (p: number) =>
|
||||
`${p}:${size}:${JSON.stringify(sort)}:${JSON.stringify(filters)}:${narrowedBy}`;
|
||||
const [revealSeen, setRevealSeen] = useState(reveal?.seq);
|
||||
// The view the reveal was made for: the floor below holds only there. A caller keeps its reveal
|
||||
// after the focus (Channels never clears `follow`), and a floor that outlived its view mounted every
|
||||
// row up to that channel on each later sort or search (E31 review 2).
|
||||
const [revealView, setRevealView] = useState<string | null>(null);
|
||||
if (reveal && reveal.seq !== revealSeen) {
|
||||
setRevealSeen(reveal.seq);
|
||||
const at = sorted.findIndex((row) => rowKey(row) === reveal.key);
|
||||
if (at >= 0 && !allRows) setPage(Math.floor(at / size));
|
||||
const target = at >= 0 && !allRows ? Math.floor(at / size) : safePage;
|
||||
if (target !== safePage) setPage(target);
|
||||
setRevealView(viewOf(target));
|
||||
}
|
||||
const view = viewOf(safePage);
|
||||
const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size);
|
||||
// A long page ("All", 100) mounts its first rows at once and the rest in the background — but
|
||||
// always through the revealed row, which the caller focuses as soon as this frame commits.
|
||||
const shown = useProgressiveRows(
|
||||
paged,
|
||||
`${safePage}:${size}:${JSON.stringify(sort)}:${JSON.stringify(filters)}:${narrowedBy}`,
|
||||
reveal ? paged.findIndex((row) => rowKey(row) === reveal.key) : -1,
|
||||
view,
|
||||
reveal && revealView === view ? paged.findIndex((row) => rowKey(row) === reveal.key) : -1,
|
||||
);
|
||||
|
||||
function toggleSort(key: string) {
|
||||
|
||||
@@ -70,4 +70,17 @@ describe("ItemCount", () => {
|
||||
expect(screen.getByTestId("item-count").getAttribute("aria-busy")).toBe("true");
|
||||
expect(screen.getByText("5 items").className).toContain("opacity-60");
|
||||
});
|
||||
|
||||
// E31 review 2: the dim and aria-busy were the only stale cue, and a screen reader voices neither on
|
||||
// plain text; a failed count looked like a first load.
|
||||
it("tells a screen reader a stale count is updating", () => {
|
||||
render(<ItemCount count={5} stale />);
|
||||
expect(text()).toBe("5 itemsupdating");
|
||||
});
|
||||
|
||||
it("says a count that failed, rather than looking like a first load", () => {
|
||||
render(<ItemCount failed />);
|
||||
expect(text()).toBe("—Couldn't count the items");
|
||||
expect(screen.getByTestId("item-count").getAttribute("title")).toBe("Couldn't count the items");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,15 +14,26 @@ const OVERFLOW_AT = 1_000_000;
|
||||
// ("999,999 items") and the content is centred within it. A count of a million or more collapses to
|
||||
// "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural)
|
||||
// so any surface can reuse it for whatever it counts. `stale`: the count is the previous filter's,
|
||||
// kept while the new one loads (keepPreviousData) — dimmed and aria-busy, as AdminLogs dims its lines,
|
||||
// so a broad search's slow count ("a": ~3 s) never reads as the new result.
|
||||
export default function ItemCount({ count, stale = false }: { count?: number; stale?: boolean }) {
|
||||
// kept while the new one loads (keepPreviousData) — dimmed, and "updating" for a screen reader (which
|
||||
// does not voice aria-busy on plain text), so a broad search's slow count ("a": ~3 s) never reads as
|
||||
// the new result. `failed`: the count could not be read — a dash that says so, not the icon-only box
|
||||
// of a first load (E31 review 2).
|
||||
export default function ItemCount({
|
||||
count,
|
||||
stale = false,
|
||||
failed = false,
|
||||
}: {
|
||||
count?: number;
|
||||
stale?: boolean;
|
||||
failed?: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
// `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just
|
||||
// show the icon until the number arrives.
|
||||
const label =
|
||||
count == null
|
||||
// `count` undefined and not `failed`: a first, un-cached load — keep the fixed box (nothing shifts),
|
||||
// just show the icon until the number arrives.
|
||||
const label = failed
|
||||
? "—"
|
||||
: count == null
|
||||
? ""
|
||||
: t("common.itemCount", {
|
||||
count,
|
||||
@@ -31,7 +42,9 @@ export default function ItemCount({ count, stale = false }: { count?: number; st
|
||||
return (
|
||||
<span
|
||||
data-testid="item-count"
|
||||
title={count == null ? undefined : formatNumber(count, lang)}
|
||||
title={
|
||||
failed ? t("common.countFailed") : count == null ? undefined : formatNumber(count, lang)
|
||||
}
|
||||
aria-busy={stale || undefined}
|
||||
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-fg/8 px-3 text-sm whitespace-nowrap text-muted"
|
||||
>
|
||||
@@ -39,6 +52,8 @@ export default function ItemCount({ count, stale = false }: { count?: number; st
|
||||
<span className={clsx("tabular-nums transition-opacity", stale && "opacity-60")}>
|
||||
{label}
|
||||
</span>
|
||||
{failed && <span className="sr-only">{t("common.countFailed")}</span>}
|
||||
{stale && !failed && <span className="sr-only">{t("common.countUpdating")}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ import CountBadge from "./CountBadge";
|
||||
// the Settings-style side rail (full-width, left-aligned, icon + label) — from the `sm` breakpoint up.
|
||||
// Below it a side rail leaves a phone too little width for the content beside it (measured at 390 px:
|
||||
// the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there
|
||||
// it is a horizontal row that scrolls. Callers persist the active tab with lib/storage's
|
||||
// useTabPersistedState (per browser tab, E31 S4b): a reload keeps the user where they were, and a
|
||||
// fresh tab opens on the first sub-tab rather than wherever the last visit ended.
|
||||
// it is a horizontal row that scrolls. A page whose sub-tab is worth keeping persists it with
|
||||
// lib/storage's useTabPersistedState (per browser tab, E31 S4b: Admin Users, Config, Settings, Stats):
|
||||
// a reload keeps the user where they were, and a fresh tab opens on the first sub-tab. Not every
|
||||
// caller: the channel manager's view is account-wide (ChannelsProvider), and Downloads, a channel page
|
||||
// and the player settings start on their first tab each time.
|
||||
//
|
||||
// An ARIA tab list (E28 review F3), not a row of toggle buttons: `tablist` / `tab` / `aria-selected`,
|
||||
// ONE Tab stop (the selected tab; roving tabindex), the arrows move and select (either axis, since the
|
||||
|
||||
@@ -19,23 +19,28 @@ export const MEDIA_BADGE =
|
||||
"pointer-events-none absolute rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums";
|
||||
|
||||
/** The picture box as a size container (`pic`), so what sits on the picture can answer its width.
|
||||
* Every picture box carries it: `mediaPictureBox` and the video card's own box. */
|
||||
export const MEDIA_PICTURE_CONTAINER = "@container/pic";
|
||||
* Every picture box carries it: `mediaPictureBox` and the video card's own box. A box that holds a
|
||||
* hover row (MEDIA_BAR) also sets `--bar-room`, the band the row needs: the narrow-picture rule
|
||||
* below lifts the Play by it, and a box with no row (the feed's row and tile views, a Plex card
|
||||
* without actions) lifts it by nothing (E31 review 2: it sat off-centre there). */
|
||||
export const MEDIA_PICTURE_CONTAINER = "@container/pic has-[.media-bar]:[--bar-room:2.5rem]";
|
||||
|
||||
/** The hover layer over the picture: a dim that carries the Play (mouse only, like the hover row).
|
||||
* On a narrow picture the Play centres in the band ABOVE the hover row instead of on the whole
|
||||
* picture: the picture's height follows the card's width, but the Play and the row grow with the
|
||||
* text size, so at 1.3 a 242px thumbnail put the 63px Play 13px into the 37px row (E31 S5,
|
||||
* measured). The cut is in rem, so it moves with the text size too: a picture under 14rem wide. */
|
||||
* measured). The cut is in rem, so it moves with the text size too: a picture under 14rem wide.
|
||||
* Only where a row is there to clear (`--bar-room`, MEDIA_PICTURE_CONTAINER). */
|
||||
export const MEDIA_HOVER_LAYER =
|
||||
"reveal-hover absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 @max-[14rem]/pic:pb-10";
|
||||
"reveal-hover absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 @max-[14rem]/pic:pb-(--bar-room)";
|
||||
|
||||
/** The layer that holds a started title's Continue + Restart pair over the picture: a sibling of the
|
||||
* picture's button, never inside it (C5). Narrow, it leaves the hover row its band as the Play does. */
|
||||
export const MEDIA_RESUME_LAYER = `${HOVER_REVEAL} pointer-events-none absolute inset-0 z-base flex items-center justify-center gap-2 *:pointer-events-auto @max-[14rem]/pic:pb-10`;
|
||||
export const MEDIA_RESUME_LAYER = `${HOVER_REVEAL} pointer-events-none absolute inset-0 z-base flex items-center justify-center gap-2 *:pointer-events-auto @max-[14rem]/pic:pb-(--bar-room)`;
|
||||
|
||||
/** Where the card's hover row (watched, save, add, "…") docks: the picture's bottom-right. */
|
||||
export const MEDIA_BAR = `${HOVER_REVEAL} absolute bottom-2.5 right-1.5 z-base`;
|
||||
/** Where the card's hover row (watched, save, add, "…") docks: the picture's bottom-right.
|
||||
* `media-bar` marks it for the picture box's `--bar-room` (MEDIA_PICTURE_CONTAINER). */
|
||||
export const MEDIA_BAR = `${HOVER_REVEAL} media-bar absolute bottom-2.5 right-1.5 z-base`;
|
||||
|
||||
/** The title's type, one size per density. On a SHELF (any horizontal strip: Home, the Plex rails,
|
||||
* a collection strip) it is one line, cut with an ellipsis, over one meta line — every card on the
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"loading": "Loading…",
|
||||
"loadMoreFailed": "Couldn't load more",
|
||||
"countFailed": "Couldn't count the items",
|
||||
"countUpdating": "updating",
|
||||
"somethingWrong": "Something went wrong",
|
||||
"itemCount_one": "{{formattedCount}} item",
|
||||
"itemCount_other": "{{formattedCount}} items",
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"undo": "Visszavonás",
|
||||
"redo": "Újra",
|
||||
"loading": "Betöltés…",
|
||||
"loadMoreFailed": "Nem sikerült betölteni a továbbiakat",
|
||||
"countFailed": "Nem sikerült megszámolni az elemeket",
|
||||
"countUpdating": "frissül",
|
||||
"somethingWrong": "Hiba történt",
|
||||
"itemCount_one": "{{formattedCount}} elem",
|
||||
"itemCount_other": "{{formattedCount}} elem",
|
||||
|
||||
@@ -29,4 +29,34 @@ describe("an infinite list's read-ahead", () => {
|
||||
it("the feed hands the grid the query's isFetching", () => {
|
||||
expect(source("Feed.tsx")).toMatch(/isFetching=\{query\.isFetching\}/);
|
||||
});
|
||||
|
||||
// Retry is off, so a failed page settles at once; a read-ahead that re-runs on the settle would
|
||||
// request it again once a round-trip while the end is in view (E31 review 2).
|
||||
it("stands down at a failed next page", () => {
|
||||
expect(source("Feed.tsx")).toMatch(
|
||||
/hasNextPage=\{!!hasNextPage && !query\.isFetchNextPageError\}/,
|
||||
);
|
||||
expect(source("PlexBrowse.tsx")).toMatch(
|
||||
/const hasNextPage = browseQ\.hasNextPage && !isFetchNextPageError;/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// A click joins an in-flight next page but cancels a background refetch (nextPageOnClick): through
|
||||
// NEXT_PAGE it would join the refetch and load nothing. NEXT_PAGE is only for the read-aheads, so a
|
||||
// click site that goes back to it fails here (E31 review 2: the YouTube "Load more" was missed).
|
||||
describe("a click that loads the next page", () => {
|
||||
const READ_AHEADS = new Set(["ui/VirtualGrid.tsx", "PlexBrowse.tsx"]);
|
||||
it("uses nextPageOnClick; NEXT_PAGE only in the read-aheads", () => {
|
||||
const users = Object.entries(files)
|
||||
.filter(([, src]) => /fetchNextPage\(NEXT_PAGE\)/.test(src as string))
|
||||
.map(([path]) => path.replace("../components/", ""));
|
||||
expect(users.sort()).toEqual([...READ_AHEADS].sort());
|
||||
for (const [file, sites] of [
|
||||
["AdminLogs.tsx", 2],
|
||||
["Feed.tsx", 2],
|
||||
["PlexBrowse.tsx", 1],
|
||||
] as const)
|
||||
expect(source(file).match(/fetchNextPage\(nextPageOnClick\(/g)?.length, file).toBe(sites);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,8 +47,10 @@ it("names each backdrop cut by the width the proxy cuts it at", () => {
|
||||
|
||||
// Every small Plex picture asks for its card-size cut through cardImage (E31 S4e, swept in S5: the
|
||||
// playlist rows, the search box and the collection picker still fetched the 400px poster for a 32px
|
||||
// picture). A bare `src={…thumb}` is allowed only where the full cut is the point or the URL is not
|
||||
// the proxy's; the SET is pinned, so a new bare one fails here.
|
||||
// picture). A plain `src={…}` is allowed only where the full cut is the point or the URL is not the
|
||||
// proxy's; the SET is pinned, so a new one fails here. Every <img>'s src counts, wherever it sits
|
||||
// among the attributes and whatever its variable is called: the first cut of this test read only a
|
||||
// leading `src={…thumb…}` and missed two of the three sites it was written for (E31 review 2).
|
||||
const plexFiles = import.meta.glob(
|
||||
[
|
||||
"../components/{Plex,plex}*.tsx",
|
||||
@@ -58,7 +60,10 @@ const plexFiles = import.meta.glob(
|
||||
{ query: "?raw", import: "default", eager: true },
|
||||
);
|
||||
const ALLOWED = new Set([
|
||||
"plexCards.tsx pic.src", // cardImage's own src (srcSet/sizes beside it): the card cut
|
||||
"plexCards.tsx c.thumb", // cast photos: the person-image route, not the image proxy
|
||||
"PlexBrowse.tsx p.photo", // the person filter chip's headshot: the person-image route
|
||||
"plexViews.tsx d.photo", // the person page's headshot: the same
|
||||
"PlexInfo.tsx c.thumb", // the same cast photos on the detail page
|
||||
"PlexInfo.tsx detail.thumb", // the detail page's own poster: the full cut is the point
|
||||
"plexDetailUi.tsx thumb", // the show/season header poster: the same
|
||||
@@ -69,7 +74,7 @@ it("leaves no small Plex picture on the full cut", () => {
|
||||
expect(Object.keys(plexFiles).length).toBeGreaterThan(5);
|
||||
const bare = new Set<string>();
|
||||
for (const [path, src] of Object.entries(plexFiles))
|
||||
for (const m of (src as string).matchAll(/<img\s+src=\{([^}]*thumb[^}]*)\}/g))
|
||||
for (const m of (src as string).matchAll(/<img\b[^>]*?\bsrc=\{([^}]*)\}/g))
|
||||
bare.add(`${path.split("/").pop()} ${m[1]!.trim()}`);
|
||||
expect([...bare].filter((b) => !ALLOWED.has(b))).toEqual([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user