Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
66 changes: 52 additions & 14 deletions packages/ui/src/components/Pagination/Pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,43 @@ export interface BasePaginationProps extends ComponentProps<"nav">, ThemingProps
layout?: "navigation" | "pagination" | "table";
currentPage: number;
nextLabel?: string;
onPageChange: (page: number) => void;
previousLabel?: string;
showIcons?: boolean;
}

export interface DefaultPaginationProps extends BasePaginationProps {
interface DefaultPaginationSharedProps extends BasePaginationProps {
layout?: "navigation" | "pagination";
renderPaginationButton?: (props: PaginationButtonProps) => ReactNode;
totalPages: number;
}

/**
* Client-side pagination: uses `onPageChange` callback for navigation.
*/
interface ClientSidePaginationProps extends DefaultPaginationSharedProps {
onPageChange: (page: number) => void;
getPageUrl?: never;
}

/**
* Anchor-based pagination: uses `getPageUrl` to render `<a>` elements for SEO.
* `onPageChange` is optional — if omitted, anchor links handle navigation natively.
*/
interface AnchorPaginationProps extends DefaultPaginationSharedProps {
/**
* 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;
onPageChange?: (page: number) => void;
}

export type DefaultPaginationProps = ClientSidePaginationProps | AnchorPaginationProps;

export interface TablePaginationProps extends BasePaginationProps {
layout: "table";
onPageChange: (page: number) => void;
itemsPerPage: number;
totalItems: number;
}
Expand All @@ -82,6 +107,7 @@ const DefaultPagination = forwardRef<HTMLElement, DefaultPaginationProps>((props
const {
className,
currentPage,
getPageUrl,
layout = "pagination",
nextLabel = "Next",
onPageChange,
Expand All @@ -103,14 +129,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 +151,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: theme.pages.selector.base,
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
127 changes: 85 additions & 42 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,72 @@ 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;
};

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

/**
* Union type for PaginationNavigation props — used as the re-exported
* `PaginationNavigation` type in index.ts for consumers who need the type
* (not the component).
*/
export type PaginationNavigation = PaginationNavigationAsButton | PaginationNavigationAsAnchor;

export type PaginationPrevButtonProps = PaginationNavigation;

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 @@ -50,34 +88,39 @@ export const PaginationButton = forwardRef<HTMLButtonElement, PaginationButtonPr

PaginationButton.displayName = "PaginationButton";

export function PaginationNavigation({
children,
className,
onClick,
disabled = false,
theme: customTheme,
clearTheme,
applyTheme,
...props
}: PaginationPrevButtonProps) {
const provider = useThemeProvider();
const theme = useResolveTheme(
[paginationTheme, provider.theme?.pagination, customTheme],
[get(provider.clearTheme, "pagination"), clearTheme],
[get(provider.applyTheme, "pagination"), applyTheme],
);

return (
<button
type="button"
className={twMerge(disabled && theme.pages.selector.disabled, className)}
disabled={disabled}
onClick={onClick}
{...props}
>
{children}
</button>
);
}
export const PaginationNavigation = forwardRef<HTMLButtonElement | HTMLAnchorElement, PaginationPrevButtonProps>(
({ children, className, disabled = false, 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(disabled && theme.pages.selector.disabled, className);

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

const { href: _, ...buttonProps } = props as PaginationNavigationAsButton;
return (
<button
ref={ref as Ref<HTMLButtonElement>}
type="button"
className={mergedClassName}
disabled={disabled}
{...buttonProps}
>
{children}
</button>
);
},
);

PaginationNavigation.displayName = "PaginationNavigation";
Loading