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:
2026-10-03 02:25:15 +02:00
parent c4c442bc5b
commit b40440b825
17 changed files with 360 additions and 63 deletions
@@ -95,4 +95,21 @@ describe("Feed — the count while a new search's count loads", () => {
await waitFor(() => expect(pill().getAttribute("title")).toBe("178,991")); await waitFor(() => expect(pill().getAttribute("title")).toBe("178,991"));
expect(pill().getAttribute("aria-busy")).toBeNull(); 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());
});
});
+22 -4
View File
@@ -7,7 +7,7 @@ import {
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { NEXT_PAGE } from "../lib/nextPage"; import { nextPageOnClick } from "../lib/nextPage";
import { import {
ArrowLeft, ArrowLeft,
Columns2, Columns2,
@@ -557,7 +557,9 @@ export default function Feed({
<div className="text-center pt-4"> <div className="text-center pt-4">
<Button <Button
variant="outline" variant="outline"
onClick={() => void ytQuery.fetchNextPage(NEXT_PAGE)} onClick={() =>
void ytQuery.fetchNextPage(nextPageOnClick(ytQuery.isFetchingNextPage))
}
loading={ytQuery.isFetchingNextPage} loading={ytQuery.isFetchingNextPage}
> >
{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"> <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 {/* The item count is the fixed leading element — a fixed-width pill so a changing number
never shoves the controls beside it (see ItemCount). */} 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 <FilterFoldButton
fold={fold} fold={fold}
label={t("sidebar.filters")} label={t("sidebar.filters")}
@@ -891,7 +897,10 @@ export default function Feed({
onToggleSave={onToggleSave} onToggleSave={onToggleSave}
onResetState={onResetState} onResetState={onResetState}
onOpen={openVideo} 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} isFetching={query.isFetching}
fetchNextPage={fetchNextPage} fetchNextPage={fetchNextPage}
/> />
@@ -910,6 +919,15 @@ export default function Feed({
{isFetchingNextPage && ( {isFetchingNextPage && (
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div> <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> </div>
); );
} }
@@ -563,6 +563,19 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(detail).toHaveBeenCalledTimes(2); 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", () => { it("keeps the details under the video below 1280 px", () => {
wideWindow(false); wideWindow(false);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
+31 -32
View File
@@ -6,6 +6,7 @@ import {
useLayoutEffect, useLayoutEffect,
useRef, useRef,
useState, useState,
type ReactNode,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@@ -606,6 +607,30 @@ export default function PlayerModal({
qc, 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 // 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 // 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 // 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"> <SectionLabel level={3} className="mb-2">
{t("player.description")} {t("player.description")}
</SectionLabel> </SectionLabel>
{detail.isLoading ? ( {description((body) => (
<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 ? (
<div <div
ref={descFade.ref} ref={descFade.ref}
style={descFade.style} style={descFade.style}
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed" className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
> >
{renderDescription(detail.data.description, { {body}
currentId: currentVideoId,
onSeek: seekTo,
onLoadVideo: loadVideo,
t,
})}
</div> </div>
) : ( ))}
<div className="text-sm text-muted">{t("player.noDescription")}</div>
)}
</PopoverSurface> </PopoverSurface>
<Button <Button
variant="ghost" variant="ghost"
@@ -1089,24 +1101,11 @@ export default function PlayerModal({
<SectionLabel level={3} className="mb-2"> <SectionLabel level={3} className="mb-2">
{t("player.description")} {t("player.description")}
</SectionLabel> </SectionLabel>
{detail.isLoading ? ( {description((body) => (
<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 ? (
<div className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed"> <div className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
{renderDescription(detail.data.description, { {body}
currentId: currentVideoId,
onSeek: seekTo,
onLoadVideo: loadVideo,
t,
})}
</div> </div>
) : ( ))}
<div className="text-sm text-muted">{t("player.noDescription")}</div>
)}
</section> </section>
)} )}
</div> </div>
+14 -2
View File
@@ -9,7 +9,7 @@ import {
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { User, X } from "lucide-react"; import { User, X } from "lucide-react";
import { qk } from "../lib/queryKeys"; 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 { healPlexField } from "../lib/plexSearch";
import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort"; import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort";
import type { SortState } from "../lib/columnSort"; 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 // 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). // detached old node after Back and never fire fetchNextPage again (feed stuck at the loaded pages).
const [sentinelEl, setSentinelEl] = useState<HTMLDivElement | null>(null); 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(() => { useEffect(() => {
if (!sentinelEl) return; if (!sentinelEl) return;
const io = new IntersectionObserver( const io = new IntersectionObserver(
@@ -753,6 +756,15 @@ export default function PlexBrowse() {
{isFetchingNextPage && ( {isFetchingNextPage && (
<p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p> <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> </div>
); );
} }
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import videoCard from "./VideoCard.tsx?raw"; import videoCard from "./VideoCard.tsx?raw";
import { import {
MEDIA_BAR,
MEDIA_HOVER_LAYER, MEDIA_HOVER_LAYER,
MEDIA_PICTURE_CONTAINER, MEDIA_PICTURE_CONTAINER,
MEDIA_RESUME_LAYER, MEDIA_RESUME_LAYER,
@@ -24,6 +25,16 @@ describe("a media picture", () => {
["the Play", MEDIA_HOVER_LAYER], ["the Play", MEDIA_HOVER_LAYER],
["the Continue + Restart pair", MEDIA_RESUME_LAYER], ["the Continue + Restart pair", MEDIA_RESUME_LAYER],
])("leaves the hover row its band when narrow: %s", (_, 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 }); draw({ key: "r90", seq: 1 });
expect(host.querySelector("tbody")?.textContent).toContain("r 90"); 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);
});
}); });
+12 -3
View File
@@ -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 // 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 // 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. // 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); 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) { if (reveal && reveal.seq !== revealSeen) {
setRevealSeen(reveal.seq); setRevealSeen(reveal.seq);
const at = sorted.findIndex((row) => rowKey(row) === reveal.key); 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); 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 // 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. // always through the revealed row, which the caller focuses as soon as this frame commits.
const shown = useProgressiveRows( const shown = useProgressiveRows(
paged, paged,
`${safePage}:${size}:${JSON.stringify(sort)}:${JSON.stringify(filters)}:${narrowedBy}`, view,
reveal ? paged.findIndex((row) => rowKey(row) === reveal.key) : -1, reveal && revealView === view ? paged.findIndex((row) => rowKey(row) === reveal.key) : -1,
); );
function toggleSort(key: string) { function toggleSort(key: string) {
@@ -70,4 +70,17 @@ describe("ItemCount", () => {
expect(screen.getByTestId("item-count").getAttribute("aria-busy")).toBe("true"); expect(screen.getByTestId("item-count").getAttribute("aria-busy")).toBe("true");
expect(screen.getByText("5 items").className).toContain("opacity-60"); 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");
});
}); });
+23 -8
View File
@@ -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 // ("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) // "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, // 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, // kept while the new one loads (keepPreviousData) — dimmed, and "updating" for a screen reader (which
// so a broad search's slow count ("a": ~3 s) never reads as the new result. // does not voice aria-busy on plain text), so a broad search's slow count ("a": ~3 s) never reads as
export default function ItemCount({ count, stale = false }: { count?: number; stale?: boolean }) { // 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 { t, i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
// `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just // `count` undefined and not `failed`: a first, un-cached load — keep the fixed box (nothing shifts),
// show the icon until the number arrives. // just show the icon until the number arrives.
const label = const label = failed
count == null ? "—"
: count == null
? "" ? ""
: t("common.itemCount", { : t("common.itemCount", {
count, count,
@@ -31,7 +42,9 @@ export default function ItemCount({ count, stale = false }: { count?: number; st
return ( return (
<span <span
data-testid="item-count" 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} 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" 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")}> <span className={clsx("tabular-nums transition-opacity", stale && "opacity-60")}>
{label} {label}
</span> </span>
{failed && <span className="sr-only">{t("common.countFailed")}</span>}
{stale && !failed && <span className="sr-only">{t("common.countUpdating")}</span>}
</span> </span>
); );
} }
+5 -3
View File
@@ -8,9 +8,11 @@ import CountBadge from "./CountBadge";
// the Settings-style side rail (full-width, left-aligned, icon + label) — from the `sm` breakpoint up. // 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: // 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 // 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 // it is a horizontal row that scrolls. A page whose sub-tab is worth keeping persists it with
// useTabPersistedState (per browser tab, E31 S4b): a reload keeps the user where they were, and a // lib/storage's useTabPersistedState (per browser tab, E31 S4b: Admin Users, Config, Settings, Stats):
// fresh tab opens on the first sub-tab rather than wherever the last visit ended. // 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`, // 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 // ONE Tab stop (the selected tab; roving tabindex), the arrows move and select (either axis, since the
+12 -7
View File
@@ -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"; "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. /** 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. */ * Every picture box carries it: `mediaPictureBox` and the video card's own box. A box that holds a
export const MEDIA_PICTURE_CONTAINER = "@container/pic"; * 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). /** 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 * 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 * 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, * 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 = 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 /** 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. */ * 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. */ /** 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`; * `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, /** 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 * a collection strip) it is one line, cut with an ellipsis, over one meta line — every card on the
+3
View File
@@ -10,6 +10,9 @@
"undo": "Undo", "undo": "Undo",
"redo": "Redo", "redo": "Redo",
"loading": "Loading…", "loading": "Loading…",
"loadMoreFailed": "Couldn't load more",
"countFailed": "Couldn't count the items",
"countUpdating": "updating",
"somethingWrong": "Something went wrong", "somethingWrong": "Something went wrong",
"itemCount_one": "{{formattedCount}} item", "itemCount_one": "{{formattedCount}} item",
"itemCount_other": "{{formattedCount}} items", "itemCount_other": "{{formattedCount}} items",
+3
View File
@@ -10,6 +10,9 @@
"undo": "Visszavonás", "undo": "Visszavonás",
"redo": "Újra", "redo": "Újra",
"loading": "Betöltés…", "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", "somethingWrong": "Hiba történt",
"itemCount_one": "{{formattedCount}} elem", "itemCount_one": "{{formattedCount}} elem",
"itemCount_other": "{{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", () => { it("the feed hands the grid the query's isFetching", () => {
expect(source("Feed.tsx")).toMatch(/isFetching=\{query\.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);
});
}); });
+8 -3
View File
@@ -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 // 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 // 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 // picture). A plain `src={…}` is allowed only where the full cut is the point or the URL is not the
// the proxy's; the SET is pinned, so a new bare one fails here. // 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( const plexFiles = import.meta.glob(
[ [
"../components/{Plex,plex}*.tsx", "../components/{Plex,plex}*.tsx",
@@ -58,7 +60,10 @@ const plexFiles = import.meta.glob(
{ query: "?raw", import: "default", eager: true }, { query: "?raw", import: "default", eager: true },
); );
const ALLOWED = new Set([ 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 "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 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 "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 "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); expect(Object.keys(plexFiles).length).toBeGreaterThan(5);
const bare = new Set<string>(); const bare = new Set<string>();
for (const [path, src] of Object.entries(plexFiles)) 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()}`); bare.add(`${path.split("/").pop()} ${m[1]!.trim()}`);
expect([...bare].filter((b) => !ALLOWED.has(b))).toEqual([]); expect([...bare].filter((b) => !ALLOWED.has(b))).toEqual([]);
}); });