Files
triggerdotdev--trigger.dev/apps/webapp/app/components/primitives/Pagination.tsx
T
2026-07-13 13:32:57 +08:00

239 lines
6.7 KiB
TypeScript

import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { ChevronLeftIcon } from "@heroicons/react/24/solid";
import { Link, useLocation } from "@remix-run/react";
import { cn } from "~/utils/cn";
import { LinkButton } from "./Buttons";
/** Pass `hasNextPage` when the total page count is unknown; use `showPageNumbers={false}` in that case. */
export function PaginationControls({
currentPage,
totalPages,
hasNextPage,
showPageNumbers = true,
}: {
currentPage: number;
totalPages: number;
/** When set, navigation uses this instead of `totalPages`. */
hasNextPage?: boolean;
showPageNumbers?: boolean;
}) {
const location = useLocation();
const hasNextMode = hasNextPage !== undefined;
const showPagination = hasNextMode ? currentPage > 1 || hasNextPage : totalPages > 1;
const nextDisabled = hasNextMode ? !hasNextPage : currentPage === totalPages;
if (!showPagination) {
return null;
}
return (
<nav className="flex items-center gap-1" aria-label="Pagination">
{showPageNumbers ? (
<>
<LinkButton
to={pageUrl(location, currentPage - 1)}
variant="secondary/small"
LeadingIcon={ChevronLeftIcon}
shortcut={{ key: "j" }}
tooltip="Previous"
disabled={currentPage === 1}
className={cn("px-2", currentPage > 1 ? "group" : "")}
/>
{calculatePageLinks(currentPage, totalPages).map((page, i) => (
<PageLinkComponent page={page} key={i} location={location} />
))}
<LinkButton
to={pageUrl(location, currentPage + 1)}
variant="secondary/small"
TrailingIcon={ChevronRightIcon}
shortcut={{ key: "k" }}
tooltip="Next"
disabled={nextDisabled}
className={cn("px-2", !nextDisabled ? "group" : "")}
/>
</>
) : (
<div className="flex items-center">
<div className={cn("peer/prev order-1", currentPage === 1 && "pointer-events-none")}>
<LinkButton
to={pageUrl(location, currentPage - 1)}
variant="secondary/small"
LeadingIcon={ChevronLeftIcon}
shortcut={{ key: "j" }}
tooltip="Previous"
disabled={currentPage === 1}
className={cn(
"flex items-center rounded-r-none border-r-0 pl-2 pr-2.25",
currentPage === 1 && "cursor-not-allowed opacity-50"
)}
/>
</div>
<div
className={cn(
"order-2 h-6 w-px bg-surface-control transition-colors peer-hover/next:bg-surface-control-hover peer-hover/prev:bg-surface-control-hover",
currentPage === 1 && nextDisabled && "opacity-30"
)}
/>
<div className={cn("peer/next order-3", nextDisabled && "pointer-events-none")}>
<LinkButton
to={pageUrl(location, currentPage + 1)}
variant="secondary/small"
TrailingIcon={ChevronRightIcon}
shortcut={{ key: "k" }}
tooltip="Next"
disabled={nextDisabled}
className={cn(
"flex items-center rounded-l-none border-l-0 pl-2.25 pr-2",
nextDisabled && "cursor-not-allowed opacity-50"
)}
/>
</div>
</div>
)}
</nav>
);
}
function pageUrl(location: ReturnType<typeof useLocation>, page: number): string {
const search = new URLSearchParams(location.search);
search.set("page", String(page));
return location.pathname + "?" + search.toString();
}
const baseClass =
"flex items-center justify-center border border-transparent min-w-6 h-6 px-1 text-xs font-medium transition text-text-dimmed rounded-sm focus-visible:focus-custom";
const unselectedClass = "hover:bg-tertiary hover:text-text-bright";
const selectedClass =
"border-border-bright bg-tertiary text-text-bright hover:bg-surface-control/50";
function PageLinkComponent({
page,
location,
}: {
page: PageLink;
location: ReturnType<typeof useLocation>;
}) {
if (page.type === "specific") {
return (
<Link
to={pageUrl(location, page.page)}
className={cn(baseClass, page.isCurrent ? selectedClass : unselectedClass)}
>
{page.page}
</Link>
);
} else {
return <span className={baseClass}>...</span>;
}
}
type PageLink = EllipsisPageLink | SpecificPageLink;
type EllipsisPageLink = {
type: "ellipses";
};
type SpecificPageLink = {
type: "specific";
page: number;
isCurrent: boolean;
};
// If there are less than or equal to 6 pages, just show all the pages.
// If there are more than 5 pages, show the first 3, the current page, and the last 3.
function calculatePageLinks(currentPage: number, totalPages: number): Array<PageLink> {
const pageLinks: Array<PageLink> = [];
if (totalPages <= 10) {
for (let i = 1; i <= totalPages; i++) {
pageLinks.push({
type: "specific",
page: i,
isCurrent: i === currentPage,
});
}
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 4; i++) {
pageLinks.push({
type: "specific",
page: i,
isCurrent: i === currentPage,
});
}
pageLinks.push({
type: "ellipses",
});
for (let i = totalPages - 3; i <= totalPages; i++) {
pageLinks.push({
type: "specific",
page: i,
isCurrent: i === currentPage,
});
}
} else if (currentPage >= totalPages - 3) {
for (let i = 1; i <= 3; i++) {
pageLinks.push({
type: "specific",
page: i,
isCurrent: i === currentPage,
});
}
pageLinks.push({
type: "ellipses",
});
for (let i = totalPages - 4; i <= totalPages; i++) {
pageLinks.push({
type: "specific",
page: i,
isCurrent: i === currentPage,
});
}
} else {
for (let i = 1; i <= 3; i++) {
pageLinks.push({
type: "specific",
page: i,
isCurrent: i === currentPage,
});
}
pageLinks.push({
type: "ellipses",
});
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pageLinks.push({
type: "specific",
page: i,
isCurrent: i === currentPage,
});
}
pageLinks.push({
type: "ellipses",
});
for (let i = totalPages - 2; i <= totalPages; i++) {
pageLinks.push({
type: "specific",
page: i,
isCurrent: i === currentPage,
});
}
}
}
return pageLinks;
}