feat(frontend): add Pagination primitive
This commit is contained in:
@@ -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 A–B 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user