Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
43 changes: 31 additions & 12 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,14 +110,20 @@ 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);
}

const previousHref = getPageUrl && currentPage > 1 ? getPageUrl(previousPage) : undefined;
const nextHref = getPageUrl && currentPage < totalPages ? getPageUrl(nextPage) : undefined;

return (
<nav ref={ref} className={twMerge(theme.base, className)} {...restProps}>
<ul className={theme.pages.base}>
Expand All @@ -119,27 +132,33 @@ const DefaultPagination = forwardRef<HTMLElement, DefaultPaginationProps>((props
className={twMerge(theme.pages.previous.base, showIcon && theme.pages.showIcon)}
onClick={goToPreviousPage}
disabled={currentPage === 1}
{...(previousHref ? { href: previousHref } : {})}
>
{showIcon && <ChevronLeftIcon aria-hidden className={theme.pages.previous.icon} />}
{previousLabel}
</PaginationNavigation>
</li>
{layout === "pagination" &&
range(firstPage, lastPage).map((page: number) => (
<li aria-current={page === currentPage ? "page" : undefined} key={page}>
{renderPaginationButton({
className: twMerge(theme.pages.selector.base, currentPage === page && theme.pages.selector.active),
active: page === currentPage,
onClick: () => onPageChange(page),
children: page,
})}
</li>
))}
range(firstPage, lastPage).map((page: number) => {
const pageHref = getPageUrl ? getPageUrl(page) : undefined;
return (
<li aria-current={page === currentPage ? "page" : undefined} key={page}>
{renderPaginationButton({
className: twMerge(theme.pages.selector.base, currentPage === page && theme.pages.selector.active),
active: page === currentPage,
onClick: () => onPageChange(page),
...(pageHref ? { href: pageHref } : {}),
children: page,
})}
</li>
);
})}
<li>
<PaginationNavigation
className={twMerge(theme.pages.next.base, showIcon && theme.pages.showIcon)}
onClick={goToNextPage}
disabled={currentPage === totalPages}
{...(nextHref ? { href: nextHref } : {})}
>
{nextLabel}
{showIcon && <ChevronRightIcon aria-hidden className={theme.pages.next.icon} />}
Expand Down
78 changes: 57 additions & 21 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 ReactNode, type Ref } from "react";
import { get } from "../../helpers/get";
import { useResolveTheme } from "../../helpers/resolve-theme";
import { twMerge } from "../../helpers/tailwind-merge";
Expand All @@ -14,34 +14,65 @@ export interface PaginationButtonTheme {
disabled: string;
}

export interface PaginationButtonProps extends ComponentProps<"button">, ThemingProps<PaginationButtonTheme> {
interface PaginationButtonBaseProps extends ThemingProps<PaginationButtonTheme> {
active?: boolean;
children?: ReactNode;
className?: string;
onClick?: ReactEventHandler<HTMLButtonElement>;
}

export interface PaginationPrevButtonProps extends Omit<PaginationButtonProps, "active"> {
type PaginationButtonAsButton = PaginationButtonBaseProps &
Omit<ComponentProps<"button">, keyof PaginationButtonBaseProps> & {
href?: never;
};

type PaginationButtonAsAnchor = PaginationButtonBaseProps &
Omit<ComponentProps<"a">, keyof PaginationButtonBaseProps> & {
href: string;
};

export type PaginationButtonProps = PaginationButtonAsButton | PaginationButtonAsAnchor;

interface PaginationNavigationBaseProps extends ThemingProps<PaginationButtonTheme> {
children?: ReactNode;
className?: string;
disabled?: boolean;
}

export const PaginationButton = forwardRef<HTMLButtonElement, PaginationButtonProps>(
({ active, children, className, onClick, theme: customTheme, clearTheme, applyTheme, ...props }, ref) => {
type PaginationNavigationAsButton = PaginationNavigationBaseProps &
Omit<ComponentProps<"button">, keyof PaginationNavigationBaseProps> & {
href?: never;
};

type PaginationNavigationAsAnchor = PaginationNavigationBaseProps &
Omit<ComponentProps<"a">, keyof PaginationNavigationBaseProps> & {
href: string;
};

export type PaginationPrevButtonProps = PaginationNavigationAsButton | PaginationNavigationAsAnchor;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

export const PaginationButton = forwardRef<HTMLButtonElement | HTMLAnchorElement, PaginationButtonProps>(
({ active, children, className, 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" in props && props.href) {
const { href, ...anchorProps } = props;
return (
<a ref={ref as Ref<HTMLAnchorElement>} href={href} className={mergedClassName} {...anchorProps}>
{children}
</a>
);
}

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

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

if ("href" in props && props.href && !disabled) {
const { href, ...anchorProps } = props;
return (
<a href={href} className={mergedClassName} {...anchorProps}>
{children}
</a>
);
}

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