Owner's pick from the S7 mockups (2026-09-19): a short fixed set is a SegmentedToggle — the live search's result count, the table's rows per page, a download profile's mode (on its own row, so the three worded choices fit a phone) and audio format — and a longer set or the user's own list is a FilterSelect: the profile's quality (seven heights) and the download dialog's profile. A lint rule refuses a native <select> from now on (the canonical "FE control" row). The px-frozen size rules also caught up with a fraction: `text-[9.5px]` had slipped past the integer-only pattern in the Glass tuner, which is rem now.
117 lines
4.6 KiB
TypeScript
117 lines
4.6 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
import { hasPagerContent, pageCount } from "./pagerContent";
|
|
import { FocusSafeButton } from "./FocusSafeButton";
|
|
import SegmentedToggle from "./SegmentedToggle";
|
|
|
|
// The Prev / page-input / Next + "N per page" cluster, lifted out of DataTable so a non-table view
|
|
// (the channel manager's card layout) can paginate the same way. Controlled: the caller owns page +
|
|
// size and slices its own rows; this only renders the controls and reports changes. `sizeLabel` lets
|
|
// the caller say "Rows per page" or "Cards per page". Returns null when there's nothing to page.
|
|
|
|
export default function Pager({
|
|
page,
|
|
size,
|
|
total,
|
|
pageSizeOptions,
|
|
onPageChange,
|
|
onSizeChange,
|
|
sizeLabel,
|
|
}: {
|
|
/** Current page (0-based). */
|
|
page: number;
|
|
/** Page size; `<= 0` means "All" (one page). */
|
|
size: number;
|
|
/** Total item count across all pages. */
|
|
total: number;
|
|
pageSizeOptions: number[];
|
|
onPageChange: (page: number) => void;
|
|
onSizeChange: (size: number) => void;
|
|
sizeLabel: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const totalPages = pageCount(total, size);
|
|
const safePage = Math.min(page, totalPages - 1);
|
|
|
|
// Local text for the editable "jump to page" box (committed on Enter/blur).
|
|
const [pageInput, setPageInput] = useState(String(safePage + 1));
|
|
useEffect(() => setPageInput(String(safePage + 1)), [safePage]);
|
|
|
|
// Turning the page — or resizing it, which re-slices from a different first item — swaps the whole
|
|
// list, so put the reader at its top; landing mid-list in a page you haven't seen is disorienting.
|
|
// The page scroller is the app's <main> (PageScroller documents itself as the ONE scroller; the
|
|
// same implicit contract BackToTop and lib/listPaging's scrollParent (VirtualGrid, listNav) already rely on).
|
|
function scrollListToTop() {
|
|
document.querySelector("main")?.scrollTo({ top: 0 });
|
|
}
|
|
function goToPage(p: number) {
|
|
onPageChange(p);
|
|
scrollListToTop();
|
|
}
|
|
function commitPageInput() {
|
|
const n = parseInt(pageInput, 10);
|
|
if (!isNaN(n)) goToPage(Math.min(totalPages - 1, Math.max(0, n - 1)));
|
|
else setPageInput(String(safePage + 1));
|
|
}
|
|
|
|
if (!hasPagerContent(total, size)) return null;
|
|
return (
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center gap-2">
|
|
<FocusSafeButton
|
|
onClick={() => goToPage(Math.max(0, safePage - 1))}
|
|
disabled={safePage === 0}
|
|
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
{t("datatable.pager.prev")}
|
|
</FocusSafeButton>
|
|
<span className="text-muted flex items-center gap-1.5">
|
|
{t("datatable.pager.pageLabel")}
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={totalPages}
|
|
value={pageInput}
|
|
onChange={(e) => setPageInput(e.target.value)}
|
|
onBlur={commitPageInput}
|
|
onKeyDown={(e) => e.key === "Enter" && commitPageInput()}
|
|
aria-label={t("datatable.pager.pageLabel")}
|
|
className="w-14 bg-surface border border-border rounded-md px-1.5 py-1 text-xs text-center tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
/>
|
|
{t("datatable.pager.ofTotal", { total: totalPages })}
|
|
</span>
|
|
<FocusSafeButton
|
|
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
|
|
disabled={safePage >= totalPages - 1}
|
|
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
|
>
|
|
{t("datatable.pager.next")}
|
|
<ChevronRight className="w-4 h-4" />
|
|
</FocusSafeButton>
|
|
</div>
|
|
)}
|
|
{total > 0 && (
|
|
<div className="flex items-center gap-2 text-muted">
|
|
<span aria-hidden="true">{sizeLabel}</span>
|
|
{/* A handful of fixed sizes plus "All": segmented, the house control for a short fixed set. */}
|
|
<SegmentedToggle
|
|
ariaLabel={sizeLabel}
|
|
value={String(size)}
|
|
onChange={(v) => {
|
|
onSizeChange(Number(v));
|
|
scrollListToTop();
|
|
}}
|
|
options={[
|
|
...pageSizeOptions.map((n) => ({ value: String(n), label: String(n) })),
|
|
{ value: "0", label: t("datatable.all") },
|
|
]}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|