feat(toolbar): fixed-width ItemCount pill, lead the toolbar with it, align chrome to the cards

- New ItemCount: a generic, FIXED-WIDTH count pill (glass, non-interactive) — "192,050 items"
  (neutral noun, dynamic plural), min-w-[9rem] so a changing number never shifts the controls
  beside it; a million or more collapses to "1M+" with the exact figure in the title tooltip.
- Feed toolbar: ItemCount is the fixed leading element now; dropped the mx-1 separators so every
  gap is a uniform 1.5 (Content-types↔Source used to be wider than Watch-state↔Content-types).
- Chrome-to-card alignment (app-wide, via the shared Header + PageShell): the floating header and
  the toolbar band now span exactly the card grid's width — left-4, and a right edge of
  1rem + the scrollbar gutter (measured once into --sbw), so the fixed chrome lines up with the
  scrolled cards instead of running ~15px past them. Wraps cleanly to multiple rows on mobile.

Renames the count testid feed-result-count → item-count (E2Es updated).
This commit is contained in:
2026-08-21 20:33:55 +02:00
parent c3820a0b44
commit 45d8b47366
10 changed files with 88 additions and 34 deletions
+1 -1
View File
@@ -111,7 +111,7 @@ test.describe("channel view", () => {
test("the channel chrome stays fixed while the video grid scrolls", async ({ page }) => {
await openApp(page);
await page.getByTestId("video-card-channel").first().click();
const count = page.getByTestId("feed-result-count");
const count = page.getByTestId("item-count");
const title = page.getByTestId("channel-title");
await expect(count).toBeVisible();
await expect(page.getByTestId("video-card").first()).toBeVisible();
+2 -2
View File
@@ -8,7 +8,7 @@ test.describe("feed", () => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
const count = page.getByTestId("feed-result-count");
const count = page.getByTestId("item-count");
await expect(count).toHaveText(/\d/);
const baseline = await count.innerText();
@@ -55,7 +55,7 @@ test.describe("feed", () => {
// pins in the shell's toolbar band above the scroller, so it stays put while the cards scroll.
test("the feed toolbar stays pinned while cards scroll", async ({ page }) => {
await openApp(page);
const count = page.getByTestId("feed-result-count");
const count = page.getByTestId("item-count");
await expect(count).toBeVisible();
const topBefore = await count.evaluate((el) => el.getBoundingClientRect().top);
+13
View File
@@ -136,6 +136,19 @@ export default function App() {
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
}, [meQuery.data?.id]);
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
// that gutter via scrollbar-gutter: stable). It's a device constant, so a one-shot mount measure is enough.
useEffect(() => {
const probe = document.createElement("div");
probe.style.cssText =
"position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll;scrollbar-gutter:stable";
document.body.appendChild(probe);
const w = probe.offsetWidth - probe.clientWidth;
document.body.removeChild(probe);
document.documentElement.style.setProperty("--sbw", `${w}px`);
}, []);
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
+5 -13
View File
@@ -41,6 +41,7 @@ import { PageToolbar } from "./PageShell";
import { useWizard } from "./WizardProvider";
import SortControl from "./SortControl";
import FilterSelect from "./FilterSelect";
import ItemCount from "./ItemCount";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
@@ -570,6 +571,9 @@ export default function Feed({
const toolbar = (
<div className="pb-1.5">
<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} />
{/* Watch state is single-select (always exactly one); content types are an independent
multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
<FilterSelect
@@ -596,8 +600,6 @@ export default function Feed({
of this one channel's videos (librarySource is pinned to "all"), so the selector would
only offer a near-empty, confusing slice — hide it here. */}
{!channelScoped && (
<>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<FilterSelect
label={t("feed.source.label")}
testId="feed-source"
@@ -614,18 +616,8 @@ export default function Feed({
})
}
/>
</>
)}
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<span data-testid="feed-result-count" className="text-sm text-muted whitespace-nowrap">
{summaryQuery.data
? t("feed.videoCount", {
count: summaryQuery.data.count,
formattedCount: summaryQuery.data.count.toLocaleString(),
})
: " "}
</span>
<div className="ml-auto flex items-center gap-2">
<div className="ml-auto flex items-center gap-1.5">
<SortControl
options={sortOptions}
sort={{ key: sortKey, dir: sortDir }}
+3 -1
View File
@@ -89,7 +89,9 @@ const Header = forwardRef<
// slim and/or no filter sidebar is present — no hard-coded offset.
<div
ref={ref}
className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex flex-wrap items-center gap-x-3 gap-y-2"
// left-4 / right = 1rem + the scrollbar gutter: the header spans exactly the card grid's width
// (left card's left edge to right card's right edge), matching the toolbar band below it.
className="pointer-events-none absolute top-3 left-4 right-[calc(1rem+var(--sbw))] z-chrome flex flex-wrap items-center gap-x-3 gap-y-2"
>
{search}
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
+35
View File
@@ -0,0 +1,35 @@
import { useTranslation } from "react-i18next";
import { Layers } from "lucide-react";
// At a million and above we stop printing the exact figure — six digits is the readable ceiling, so
// the pill shows "1M+" and the tooltip carries the precise number.
const OVERFLOW_AT = 1_000_000;
// A compact, FIXED-WIDTH count pill for a toolbar: the filtered item count in the same glass pill as
// the filter dropdowns, minus the chevron and the interactivity. The fixed width is the point — a
// changing number must never shove the controls beside it, so the box is sized to the widest value
// ("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.
export default function ItemCount({ count }: { count?: number }) {
const { t } = useTranslation();
// `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
? ""
: t("common.itemCount", {
count,
formattedCount: count >= OVERFLOW_AT ? "1M+" : count.toLocaleString(),
});
return (
<span
data-testid="item-count"
title={count?.toLocaleString()}
className="glass-card inline-flex shrink-0 items-center justify-center gap-1.5 rounded-full border border-border px-3 py-1 text-sm text-muted min-w-[9rem] whitespace-nowrap"
>
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
<span className="tabular-nums">{label}</span>
</span>
);
}
+3 -1
View File
@@ -103,7 +103,9 @@ export default function PageShell({
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
>
{banners}
<div ref={setToolbarSlot} className="shrink-0" />
{/* pr-[--sbw]: the toolbar band isn't the scroller, so without this it would run the
scrollbar-gutter's width past the scrolled cards on the right. Pad it back to card width. */}
<div ref={setToolbarSlot} className="shrink-0 pr-[var(--sbw)]" />
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
{children}
</PageScroller>
+2
View File
@@ -12,6 +12,8 @@
"loading": "Loading…",
"language": "Language",
"somethingWrong": "Something went wrong.",
"itemCount_one": "{{formattedCount}} item",
"itemCount_other": "{{formattedCount}} items",
"accessRequestsTitle": "Access requests",
"accessRequestsMessage": "{{count}} pending — review them on the Users page.",
"accessRequestsReview": "Review",
+2
View File
@@ -12,6 +12,8 @@
"loading": "Betöltés…",
"language": "Nyelv",
"somethingWrong": "Hiba történt.",
"itemCount_one": "{{formattedCount}} elem",
"itemCount_other": "{{formattedCount}} elem",
"accessRequestsTitle": "Hozzáférési kérések",
"accessRequestsMessage": "{{count}} függőben — nézd át a Felhasználók oldalon.",
"accessRequestsReview": "Áttekintés",
+6
View File
@@ -98,6 +98,12 @@
place since App and Playlists both reference it. */
--hdr-h: 4.25rem;
/* Width of the app scroller's scrollbar gutter (scrollbar-gutter: stable reserves it on the right).
Measured once at startup (App) and written here; the floating header and the toolbar band pad
their right edge by it so the fixed chrome lines up with the scrolled cards, not 15px past them.
Defaults to 0 for overlay-scrollbar platforms where the gutter is empty. */
--sbw: 0px;
/* ===== Glass tunables — every value that materially drives the look lives here so it can be
tuned in ONE place. ===== */
/* GLOBAL baseline = "Adaptive": solid-enough dark glass that reads well on content-less chrome