feat(channels): pager into the chip row, Blocked as its own tab

Address manager UAT:
- The DataTable controls row (status chips + pager) now portals into the
  fixed band via a new controlsInBand prop, so it stays put above the
  sticky header instead of scrolling away — and the pager fills the empty
  space next to the status chips, on one row.
- The tab bar is always fixed chrome now (not just on Subscriptions), so
  it doesn't jump when switching tabs; on Subscriptions the stats/intro/
  tags ride with it.
- Blocked channels moves off the bottom of the Subscriptions list into its
  own 'Blocked' tab (with an empty state), alongside Subscriptions and
  Discover.

Verified with a 90-channel test account: everything above the rows stays
fixed while the rows scroll under the sticky header. Suite 15/15, tsc +
knip clean.
This commit is contained in:
2026-07-18 05:34:07 +02:00
parent dcadbe346d
commit 867daa0dcf
5 changed files with 101 additions and 73 deletions
+18 -1
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { PageToolbar } from "./PageShell";
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
@@ -84,6 +85,8 @@ export default function DataTable<T>({
rowClassName,
controlsPosition = "bottom",
controlsLeading,
controlsInBand = false,
controlsBandClassName,
externalFilter,
resetFiltersToken,
}: {
@@ -97,6 +100,11 @@ export default function DataTable<T>({
rowClassName?: (row: T) => string;
controlsPosition?: "top" | "bottom" | "both";
controlsLeading?: ReactNode;
// Render the controls row (controlsLeading + pager) into the shell's fixed toolbar band instead
// of inline, so it stays put above the sticky header while the rows scroll. `controlsBandClassName`
// aligns it with the page's fixed chrome (e.g. the same max-width wrapper).
controlsInBand?: boolean;
controlsBandClassName?: string;
// Set a column's filter from outside (e.g. "focus this channel" deep-links here). Applied
// whenever its value changes; the user can still clear it from the header afterwards.
externalFilter?: { key: string; value: string } | null;
@@ -344,9 +352,18 @@ export default function DataTable<T>({
</div>
) : null;
const topControls =
controlsInBand && controls ? (
<PageToolbar>
<div className={controlsBandClassName}>{controls}</div>
</PageToolbar>
) : (
controls
);
return (
<div>
{(controlsPosition === "top" || controlsPosition === "both") && controls}
{(controlsPosition === "top" || controlsPosition === "both") && topControls}
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
<div className="hidden md:block">
<table className="w-full border-collapse text-sm">