feat(frontend): add Pagination primitive

This commit is contained in:
Tyler
2026-06-19 19:37:01 -06:00
parent daccb2d223
commit 6af787614e
+133
View File
@@ -0,0 +1,133 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
type PaginationProps = {
/** Current 1-indexed page number. */
page: number;
/** Items per page. */
pageSize: number;
/** Total item count across all pages. */
total: number;
/** Called with a clamped, 1-indexed page number. */
onPageChange: (page: number) => void;
className?: string;
};
function buildPageList(current: number, last: number): (number | "…")[] {
if (last <= 7) return Array.from({ length: last }, (_, i) => i + 1);
const out: (number | "…")[] = [1];
const start = Math.max(2, current - 1);
const end = Math.min(last - 1, current + 1);
if (start > 2) out.push("…");
for (let i = start; i <= end; i++) out.push(i);
if (end < last - 1) out.push("…");
out.push(last);
return out;
}
/**
* Tabular-num pagination.
* Voice: hairline-bordered Prev/Next + numbered pages, the active page
* echoing the nav-active style (filled muted tile with a left accent
* tick). Eyebrow shows "Page X of Y" + "Showing AB of N".
*/
export function Pagination({
page,
pageSize,
total,
onPageChange,
className,
}: PaginationProps) {
const last = Math.max(1, Math.ceil(total / pageSize));
if (last <= 1) return null;
const safePage = Math.min(Math.max(1, page), last);
const start = (safePage - 1) * pageSize + 1;
const end = Math.min(total, start + pageSize - 1);
const pages = buildPageList(safePage, last);
return (
<div
className={cn(
"flex items-center justify-between gap-3 mt-4 pt-4",
"border-t border-border/40 text-xs",
className,
)}
>
<div className="flex items-center gap-3 text-muted-foreground">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em]">
Page {safePage} of {last}
</span>
<span className="num">
Showing {start.toLocaleString()}{end.toLocaleString()} of{" "}
{total.toLocaleString()}
</span>
</div>
<nav className="flex items-center gap-1" aria-label="Pagination">
<button
type="button"
onClick={() => onPageChange(Math.max(1, safePage - 1))}
disabled={safePage <= 1}
aria-label="Previous page"
className={cn(
"h-7 w-7 rounded-md flex items-center justify-center",
"text-muted-foreground hover:bg-muted/60",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:opacity-40 disabled:pointer-events-none",
)}
>
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden />
</button>
{pages.map((p, i) =>
p === "…" ? (
<span
key={`e-${i}`}
className="px-1 text-muted-foreground num select-none"
aria-hidden
>
</span>
) : (
<button
key={p}
type="button"
onClick={() => onPageChange(p)}
aria-current={p === safePage ? "page" : undefined}
aria-label={`Go to page ${p}`}
className={cn(
"relative h-7 min-w-7 px-2 rounded-md num text-xs",
"flex items-center justify-center transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
p === safePage
? "bg-muted/60 text-foreground"
: "hover:bg-muted/40 text-muted-foreground",
)}
>
{p === safePage ? (
<span
aria-hidden
className="absolute left-0 top-1 bottom-1 w-px bg-accent rounded"
/>
) : null}
{p}
</button>
),
)}
<button
type="button"
onClick={() => onPageChange(Math.min(last, safePage + 1))}
disabled={safePage >= last}
aria-label="Next page"
className={cn(
"h-7 w-7 rounded-md flex items-center justify-center",
"text-muted-foreground hover:bg-muted/60",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:opacity-40 disabled:pointer-events-none",
)}
>
<ChevronRight className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden />
</button>
</nav>
</div>
);
}