Skip to content

Commit 7017b0b

Browse files
committed
feat(pagination): add getPageUrl prop for SEO-friendly anchor links
When getPageUrl is provided, pagination buttons render as <a> elements instead of <button> elements, making pagination links crawlable by search engines. Fixes #791
1 parent 0f526aa commit 7017b0b

File tree

3 files changed

+146
-8
lines changed

3 files changed

+146
-8
lines changed

packages/ui/src/components/Pagination/Pagination.test.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,96 @@ describe("Pagination", () => {
205205
);
206206
});
207207

208+
describe("getPageUrl", () => {
209+
it("should render anchor elements when getPageUrl is provided", () => {
210+
render(
211+
<Pagination
212+
currentPage={2}
213+
onPageChange={() => undefined}
214+
totalPages={5}
215+
getPageUrl={(page) => `/blog?page=${page}`}
216+
/>,
217+
);
218+
219+
const links = screen.getAllByRole("link");
220+
expect(links.length).toBeGreaterThan(0);
221+
});
222+
223+
it("should set correct href on page links", () => {
224+
render(
225+
<Pagination
226+
currentPage={2}
227+
onPageChange={() => undefined}
228+
totalPages={5}
229+
getPageUrl={(page) => `/blog?page=${page}`}
230+
/>,
231+
);
232+
233+
const links = screen.getAllByRole("link");
234+
const hrefs = links.map((link) => link.getAttribute("href"));
235+
236+
expect(hrefs).toContain("/blog?page=1");
237+
expect(hrefs).toContain("/blog?page=3");
238+
});
239+
240+
it("should not render previous as link on first page", () => {
241+
render(
242+
<Pagination
243+
currentPage={1}
244+
onPageChange={() => undefined}
245+
totalPages={5}
246+
getPageUrl={(page) => `/blog?page=${page}`}
247+
/>,
248+
);
249+
250+
const prevButton = previousButton();
251+
expect(prevButton.tagName).toBe("BUTTON");
252+
expect(prevButton).toBeDisabled();
253+
});
254+
255+
it("should not render next as link on last page", () => {
256+
render(
257+
<Pagination
258+
currentPage={5}
259+
onPageChange={() => undefined}
260+
totalPages={5}
261+
getPageUrl={(page) => `/blog?page=${page}`}
262+
/>,
263+
);
264+
265+
const nextBtn = nextButton();
266+
expect(nextBtn.tagName).toBe("BUTTON");
267+
expect(nextBtn).toBeDisabled();
268+
});
269+
270+
it("should render previous and next as links on middle pages", () => {
271+
render(
272+
<Pagination
273+
currentPage={3}
274+
onPageChange={() => undefined}
275+
totalPages={5}
276+
getPageUrl={(page) => `/blog?page=${page}`}
277+
/>,
278+
);
279+
280+
const links = screen.getAllByRole("link");
281+
const hrefs = links.map((link) => link.getAttribute("href"));
282+
283+
expect(hrefs).toContain("/blog?page=2");
284+
expect(hrefs).toContain("/blog?page=4");
285+
});
286+
287+
it("should render buttons when getPageUrl is not provided", () => {
288+
render(<Pagination currentPage={2} onPageChange={() => undefined} totalPages={5} />);
289+
290+
const links = screen.queryAllByRole("link");
291+
expect(links).toHaveLength(0);
292+
293+
const btns = screen.getAllByRole("button");
294+
expect(btns.length).toBeGreaterThan(0);
295+
});
296+
});
297+
208298
it("should throw an error if totalPages is not a positive integer", () => {
209299
expect(() => render(<Pagination currentPage={1} onPageChange={() => undefined} totalPages={-1} />)).toThrow(
210300
"Invalid props: totalPages must be a positive integer",

packages/ui/src/components/Pagination/Pagination.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ export interface BasePaginationProps extends ComponentProps<"nav">, ThemingProps
5555

5656
export interface DefaultPaginationProps extends BasePaginationProps {
5757
layout?: "navigation" | "pagination";
58+
/**
59+
* A function that returns a URL for a given page number. When provided, pagination buttons
60+
* render as `<a>` elements instead of `<button>` elements, improving SEO by making
61+
* pagination links crawlable by search engines.
62+
*/
63+
getPageUrl?: (page: number) => string;
5864
renderPaginationButton?: (props: PaginationButtonProps) => ReactNode;
5965
totalPages: number;
6066
}
@@ -82,6 +88,7 @@ const DefaultPagination = forwardRef<HTMLElement, DefaultPaginationProps>((props
8288
const {
8389
className,
8490
currentPage,
91+
getPageUrl,
8592
layout = "pagination",
8693
nextLabel = "Next",
8794
onPageChange,
@@ -103,12 +110,15 @@ const DefaultPagination = forwardRef<HTMLElement, DefaultPaginationProps>((props
103110
const lastPage = Math.min(Math.max(layout === "pagination" ? currentPage + 2 : currentPage + 4, 5), totalPages);
104111
const firstPage = Math.max(1, lastPage - 4);
105112

113+
const previousPage = Math.max(currentPage - 1, 1);
114+
const nextPage = Math.min(currentPage + 1, totalPages);
115+
106116
function goToNextPage() {
107-
onPageChange(Math.min(currentPage + 1, totalPages));
117+
onPageChange(nextPage);
108118
}
109119

110120
function goToPreviousPage() {
111-
onPageChange(Math.max(currentPage - 1, 1));
121+
onPageChange(previousPage);
112122
}
113123

114124
return (
@@ -119,6 +129,7 @@ const DefaultPagination = forwardRef<HTMLElement, DefaultPaginationProps>((props
119129
className={twMerge(theme.pages.previous.base, showIcon && theme.pages.showIcon)}
120130
onClick={goToPreviousPage}
121131
disabled={currentPage === 1}
132+
href={getPageUrl && currentPage > 1 ? getPageUrl(previousPage) : undefined}
122133
>
123134
{showIcon && <ChevronLeftIcon aria-hidden className={theme.pages.previous.icon} />}
124135
{previousLabel}
@@ -131,6 +142,7 @@ const DefaultPagination = forwardRef<HTMLElement, DefaultPaginationProps>((props
131142
className: twMerge(theme.pages.selector.base, currentPage === page && theme.pages.selector.active),
132143
active: page === currentPage,
133144
onClick: () => onPageChange(page),
145+
href: getPageUrl ? getPageUrl(page) : undefined,
134146
children: page,
135147
})}
136148
</li>
@@ -140,6 +152,7 @@ const DefaultPagination = forwardRef<HTMLElement, DefaultPaginationProps>((props
140152
className={twMerge(theme.pages.next.base, showIcon && theme.pages.showIcon)}
141153
onClick={goToNextPage}
142154
disabled={currentPage === totalPages}
155+
href={getPageUrl && currentPage < totalPages ? getPageUrl(nextPage) : undefined}
143156
>
144157
{nextLabel}
145158
{showIcon && <ChevronRightIcon aria-hidden className={theme.pages.next.icon} />}

packages/ui/src/components/Pagination/PaginationButton.tsx

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { forwardRef, type ComponentProps, type ReactEventHandler, type ReactNode } from "react";
3+
import { forwardRef, type ComponentProps, type ReactEventHandler, type ReactNode, type Ref } from "react";
44
import { get } from "../../helpers/get";
55
import { useResolveTheme } from "../../helpers/resolve-theme";
66
import { twMerge } from "../../helpers/tailwind-merge";
@@ -18,27 +18,45 @@ export interface PaginationButtonProps extends ComponentProps<"button">, Theming
1818
active?: boolean;
1919
children?: ReactNode;
2020
className?: string;
21+
href?: string;
2122
onClick?: ReactEventHandler<HTMLButtonElement>;
2223
}
2324

2425
export interface PaginationPrevButtonProps extends Omit<PaginationButtonProps, "active"> {
2526
disabled?: boolean;
27+
href?: string;
2628
}
2729

28-
export const PaginationButton = forwardRef<HTMLButtonElement, PaginationButtonProps>(
29-
({ active, children, className, onClick, theme: customTheme, clearTheme, applyTheme, ...props }, ref) => {
30+
export const PaginationButton = forwardRef<HTMLButtonElement | HTMLAnchorElement, PaginationButtonProps>(
31+
({ active, children, className, href, onClick, theme: customTheme, clearTheme, applyTheme, ...props }, ref) => {
3032
const provider = useThemeProvider();
3133
const theme = useResolveTheme(
3234
[paginationTheme, provider.theme?.pagination, customTheme],
3335
[get(provider.clearTheme, "pagination"), clearTheme],
3436
[get(provider.applyTheme, "pagination"), applyTheme],
3537
);
3638

39+
const mergedClassName = twMerge(active && theme.pages.selector.active, className);
40+
41+
if (href) {
42+
return (
43+
<a
44+
ref={ref as Ref<HTMLAnchorElement>}
45+
href={href}
46+
className={mergedClassName}
47+
onClick={onClick as unknown as ReactEventHandler<HTMLAnchorElement>}
48+
{...(props as ComponentProps<"a">)}
49+
>
50+
{children}
51+
</a>
52+
);
53+
}
54+
3755
return (
3856
<button
39-
ref={ref}
57+
ref={ref as Ref<HTMLButtonElement>}
4058
type="button"
41-
className={twMerge(active && theme.pages.selector.active, className)}
59+
className={mergedClassName}
4260
onClick={onClick}
4361
{...props}
4462
>
@@ -53,6 +71,7 @@ PaginationButton.displayName = "PaginationButton";
5371
export function PaginationNavigation({
5472
children,
5573
className,
74+
href,
5675
onClick,
5776
disabled = false,
5877
theme: customTheme,
@@ -67,10 +86,26 @@ export function PaginationNavigation({
6786
[get(provider.applyTheme, "pagination"), applyTheme],
6887
);
6988

89+
const mergedClassName = twMerge(disabled && theme.pages.selector.disabled, className);
90+
91+
if (href && !disabled) {
92+
return (
93+
<a
94+
href={href}
95+
className={mergedClassName}
96+
onClick={onClick as unknown as ReactEventHandler<HTMLAnchorElement>}
97+
aria-disabled={disabled}
98+
{...(props as ComponentProps<"a">)}
99+
>
100+
{children}
101+
</a>
102+
);
103+
}
104+
70105
return (
71106
<button
72107
type="button"
73-
className={twMerge(disabled && theme.pages.selector.disabled, className)}
108+
className={mergedClassName}
74109
disabled={disabled}
75110
onClick={onClick}
76111
{...props}

0 commit comments

Comments
 (0)