Files
siftlode/frontend/src/components/Pager.tsx
T
peter 0559e1a503 refactor(input): R10 S3b — one input recipe, ring focus everywhere
Unify text inputs onto the form.tsx inputCls look (surface bg, rounded-lg, 2px accent
RING focus), replacing the settings-family border-focus style app-wide:
- deleted 3 duplicate local `const inputCls` (KeyGate/SettingsPanel/SetupWizard) → import
  the shared one; a 4th (Welcome, py-2.5) kept local but re-styled to match.
- ~30 inline settings inputs: default full-size fields → inputCls; size variants (pager/
  table-filter/chat textarea/plex/glass-card) keep their width/radius/padding but swap
  bg-card→bg-surface and border-focus→ring-focus. glass-card/bg-transparent bgs left as-is.
- refreshed the form.tsx doc-comment (the old two-style split is gone).

The ring is the more visible / a11y-correct focus cue, matching R9's focus foundation.
2026-08-09 17:43:42 +02:00

127 lines
5.0 KiB
TypeScript

import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight } from "lucide-react";
// 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.
/** How many pages `total` rows make at `size` (`size <= 0` = "All" = one page). */
function pageCount(total: number, size: number): number {
return size <= 0 ? 1 : Math.max(1, Math.ceil(total / size));
}
/** Whether a pager would render anything — the ONE owner of that rule. Callers that wrap the pager
* in their own chrome gate on this instead of re-deriving it: a drifted second copy is exactly what
* left empty control bands behind on tables with nothing to page. */
export function hasPagerContent(total: number, size: number): boolean {
return pageCount(total, size) > 1 || total > 0;
}
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 VirtualGrid's getScrollParent 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">
<button
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")}
</button>
<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>
<button
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" />
</button>
</div>
)}
{total > 0 && (
<label className="flex items-center gap-2 text-muted">
{sizeLabel}
<select
value={size}
onChange={(e) => {
onSizeChange(Number(e.target.value));
scrollListToTop();
}}
className="bg-surface border border-border rounded-md px-1.5 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
>
{pageSizeOptions.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
<option value={0}>{t("datatable.all")}</option>
</select>
</label>
)}
</div>
);
}