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:
@@ -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,36 +600,24 @@ 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"
|
||||
options={[
|
||||
{ value: "organic", label: t("feed.source.organic") },
|
||||
{ value: "all", label: t("feed.source.all") },
|
||||
{ value: "search", label: t("feed.source.only") },
|
||||
]}
|
||||
selected={[filters.librarySource ?? "organic"]}
|
||||
onChange={(next) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
librarySource: next[0] as FeedFilters["librarySource"],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<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(),
|
||||
<FilterSelect
|
||||
label={t("feed.source.label")}
|
||||
testId="feed-source"
|
||||
options={[
|
||||
{ value: "organic", label: t("feed.source.organic") },
|
||||
{ value: "all", label: t("feed.source.all") },
|
||||
{ value: "search", label: t("feed.source.only") },
|
||||
]}
|
||||
selected={[filters.librarySource ?? "organic"]}
|
||||
onChange={(next) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
librarySource: next[0] as FeedFilters["librarySource"],
|
||||
})
|
||||
: " "}
|
||||
</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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user