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