Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions packages/ui/src/components/Pagination/Pagination.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,96 @@ describe("Pagination", () => {
);
});

describe("getPageUrl", () => {
it("should render anchor elements when getPageUrl is provided", () => {
render(
<Pagination
currentPage={2}
onPageChange={() => undefined}
totalPages={5}
getPageUrl={(page) => `/blog?page=${page}`}
/>,
);

const links = screen.getAllByRole("link");
expect(links.length).toBeGreaterThan(0);
});

it("should set correct href on page links", () => {
render(
<Pagination
currentPage={2}
onPageChange={() => undefined}
totalPages={5}
getPageUrl={(page) => `/blog?page=${page}`}
/>,
);

const links = screen.getAllByRole("link");
const hrefs = links.map((link) => link.getAttribute("href"));

expect(hrefs).toContain("/blog?page=1");
expect(hrefs).toContain("/blog?page=3");
});

it("should not render previous as link on first page", () => {
render(
<Pagination
currentPage={1}
onPageChange={() => undefined}
totalPages={5}
getPageUrl={(page) => `/blog?page=${page}`}
/>,
);

const prevButton = previousButton();
expect(prevButton.tagName).toBe("BUTTON");
expect(prevButton).toBeDisabled();
});

it("should not render next as link on last page", () => {
render(
<Pagination
currentPage={5}
onPageChange={() => undefined}
totalPages={5}
getPageUrl={(page) => `/blog?page=${page}`}
/>,
);

const nextBtn = nextButton();
expect(nextBtn.tagName).toBe("BUTTON");
expect(nextBtn).toBeDisabled();
});

it("should render previous and next as links on middle pages", () => {
render(
<Pagination
currentPage={3}
onPageChange={() => undefined}
totalPages={5}
getPageUrl={(page) => `/blog?page=${page}`}
/>,
);

const links = screen.getAllByRole("link");
const hrefs = links.map((link) => link.getAttribute("href"));

expect(hrefs).toContain("/blog?page=2");
expect(hrefs).toContain("/blog?page=4");
});

it("should render buttons when getPageUrl is not provided", () => {
render(<Pagination currentPage={2} onPageChange={() => undefined} totalPages={5} />);

const links = screen.queryAllByRole("link");
expect(links).toHaveLength(0);

const btns = screen.getAllByRole("button");
expect(btns.length).toBeGreaterThan(0);
});
});

it("should throw an error if totalPages is not a positive integer", () => {
expect(() => render(<Pagination currentPage={1} onPageChange={() => undefined} totalPages={-1} />)).toThrow(
"Invalid props: totalPages must be a positive integer",
Expand Down
17 changes: 15 additions & 2 deletions packages/ui/src/components/Pagination/Pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export interface BasePaginationProps extends ComponentProps<"nav">, ThemingProps

export interface DefaultPaginationProps extends BasePaginationProps {
layout?: "navigation" | "pagination";
/**
* A function that returns a URL for a given page number. When provided, pagination buttons
* render as `<a>` elements instead of `<button>` elements, improving SEO by making
* pagination links crawlable by search engines.
*/
getPageUrl?: (page: number) => string;
renderPaginationButton?: (props: PaginationButtonProps) => ReactNode;
totalPages: number;
}
Expand Down Expand Up @@ -82,6 +88,7 @@ const DefaultPagination = forwardRef<HTMLElement, DefaultPaginationProps>((props
const {
className,
currentPage,
getPageUrl,
layout = "pagination",
nextLabel = "Next",
onPageChange,
Expand All @@ -103,12 +110,15 @@ const DefaultPagination = forwardRef<HTMLElement, DefaultPaginationProps>((props
const lastPage = Math.min(Math.max(layout === "pagination" ? currentPage + 2 : currentPage + 4, 5), totalPages);
const firstPage = Math.max(1, lastPage - 4);

const previousPage = Math.max(currentPage - 1, 1);
const nextPage = Math.min(currentPage + 1, totalPages);

function goToNextPage() {
onPageChange(Math.min(currentPage + 1, totalPages));
onPageChange(nextPage);
}

function goToPreviousPage() {
onPageChange(Math.max(currentPage - 1, 1));
onPageChange(previousPage);
}

return (
Expand All @@ -119,6 +129,7 @@ const DefaultPagination = forwardRef<HTMLElement, DefaultPaginationProps>((props
className={twMerge(theme.pages.previous.base, showIcon && theme.pages.showIcon)}
onClick={goToPreviousPage}
disabled={currentPage === 1}
href={getPageUrl && currentPage > 1 ? getPageUrl(previousPage) : undefined}
>
{showIcon && <ChevronLeftIcon aria-hidden className={theme.pages.previous.icon} />}
{previousLabel}
Expand All @@ -131,6 +142,7 @@ const DefaultPagination = forwardRef<HTMLElement, DefaultPaginationProps>((props
className: twMerge(theme.pages.selector.base, currentPage === page && theme.pages.selector.active),
active: page === currentPage,
onClick: () => onPageChange(page),
href: getPageUrl ? getPageUrl(page) : undefined,
children: page,
})}
</li>
Expand All @@ -140,6 +152,7 @@ const DefaultPagination = forwardRef<HTMLElement, DefaultPaginationProps>((props
className={twMerge(theme.pages.next.base, showIcon && theme.pages.showIcon)}
onClick={goToNextPage}
disabled={currentPage === totalPages}
href={getPageUrl && currentPage < totalPages ? getPageUrl(nextPage) : undefined}
>
{nextLabel}
{showIcon && <ChevronRightIcon aria-hidden className={theme.pages.next.icon} />}
Expand Down
53 changes: 41 additions & 12 deletions packages/ui/src/components/Pagination/PaginationButton.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { forwardRef, type ComponentProps, type ReactEventHandler, type ReactNode } from "react";
import { forwardRef, type ComponentProps, type ReactEventHandler, type ReactNode, type Ref } from "react";
import { get } from "../../helpers/get";
import { useResolveTheme } from "../../helpers/resolve-theme";
import { twMerge } from "../../helpers/tailwind-merge";
Expand All @@ -18,27 +18,45 @@ export interface PaginationButtonProps extends ComponentProps<"button">, Theming
active?: boolean;
children?: ReactNode;
className?: string;
href?: string;
onClick?: ReactEventHandler<HTMLButtonElement>;
}

export interface PaginationPrevButtonProps extends Omit<PaginationButtonProps, "active"> {
disabled?: boolean;
href?: string;
}

export const PaginationButton = forwardRef<HTMLButtonElement, PaginationButtonProps>(
({ active, children, className, onClick, theme: customTheme, clearTheme, applyTheme, ...props }, ref) => {
export const PaginationButton = forwardRef<HTMLButtonElement | HTMLAnchorElement, PaginationButtonProps>(
({ active, children, className, href, onClick, theme: customTheme, clearTheme, applyTheme, ...props }, ref) => {
const provider = useThemeProvider();
const theme = useResolveTheme(
[paginationTheme, provider.theme?.pagination, customTheme],
[get(provider.clearTheme, "pagination"), clearTheme],
[get(provider.applyTheme, "pagination"), applyTheme],
);

const mergedClassName = twMerge(active && theme.pages.selector.active, className);

if (href) {
return (
<a
ref={ref as Ref<HTMLAnchorElement>}
href={href}
className={mergedClassName}
onClick={onClick as unknown as ReactEventHandler<HTMLAnchorElement>}
{...(props as ComponentProps<"a">)}
>
{children}
</a>
);
}

return (
<button
ref={ref}
ref={ref as Ref<HTMLButtonElement>}
type="button"
className={twMerge(active && theme.pages.selector.active, className)}
className={mergedClassName}
onClick={onClick}
{...props}
>
Expand All @@ -53,6 +71,7 @@ PaginationButton.displayName = "PaginationButton";
export function PaginationNavigation({
children,
className,
href,
onClick,
disabled = false,
theme: customTheme,
Expand All @@ -67,14 +86,24 @@ export function PaginationNavigation({
[get(provider.applyTheme, "pagination"), applyTheme],
);

const mergedClassName = twMerge(disabled && theme.pages.selector.disabled, className);

if (href && !disabled) {
return (
<a
href={href}
className={mergedClassName}
onClick={onClick as unknown as ReactEventHandler<HTMLAnchorElement>}
aria-disabled={disabled}
{...(props as ComponentProps<"a">)}
>
{children}
</a>
);
}

return (
<button
type="button"
className={twMerge(disabled && theme.pages.selector.disabled, className)}
disabled={disabled}
onClick={onClick}
{...props}
>
<button type="button" className={mergedClassName} disabled={disabled} onClick={onClick} {...props}>
{children}
</button>
);
Expand Down
Loading