Skip to content

Commit 557f5df

Browse files
author
Julien Bouquillon
authored
feat: add Pagination component (#42)
feat: add Pagination component
1 parent 0c24e87 commit 557f5df

File tree

4 files changed

+257
-1
lines changed

4 files changed

+257
-1
lines changed

COMPONENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
- [ ] Modal
3131
- [x] Main navigation
3232
- [x] Tabs
33-
- [ ] Pagination
33+
- [x] Pagination
3434
- [x] Display
3535
- [ ] Share
3636
- [x] Footer

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@
137137
"./Stepper": "./dist/Stepper.js",
138138
"./SkipLinks": "./dist/SkipLinks.js",
139139
"./Quote": "./dist/Quote.js",
140+
"./Pagination": "./dist/Pagination.js",
140141
"./Notice": "./dist/Notice.js",
141142
"./Highlight": "./dist/Highlight.js",
142143
"./Header": "./dist/Header/index.js",

src/Pagination.tsx

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import React, { memo, forwardRef } from "react";
2+
import { symToStr } from "tsafe/symToStr";
3+
import { assert } from "tsafe/assert";
4+
import type { Equals } from "tsafe";
5+
6+
import { fr } from "./fr";
7+
import { cx } from "./tools/cx";
8+
import { createComponentI18nApi } from "./i18n/i18n";
9+
import { RegisteredLinkProps, getLink } from "./link";
10+
11+
export type PaginationProps = {
12+
className?: string;
13+
count: number;
14+
defaultPage?: number;
15+
classes?: Partial<Record<"root" | "list" | "link", string>>;
16+
showFirstLast?: boolean;
17+
getPageLinkProps: (pageNumber: number) => RegisteredLinkProps;
18+
};
19+
20+
// naive page slicing
21+
const getPaginationParts = ({ count, defaultPage }: { count: number; defaultPage: number }) => {
22+
const maxVisiblePages = 10;
23+
const slicesSize = 4;
24+
// first n pages
25+
if (count <= maxVisiblePages) {
26+
return Array.from({ length: count }, (_, v) => ({
27+
number: v + 1,
28+
active: defaultPage === v + 1
29+
}));
30+
}
31+
// last n pages
32+
if (defaultPage > count - maxVisiblePages) {
33+
return Array.from({ length: maxVisiblePages }, (_, v) => {
34+
const pageNumber = count - (maxVisiblePages - v) + 1;
35+
return {
36+
number: pageNumber,
37+
active: defaultPage === pageNumber
38+
};
39+
});
40+
}
41+
// slices
42+
return [
43+
...Array.from({ length: slicesSize }, (_, v) => {
44+
if (defaultPage > slicesSize) {
45+
const pageNumber = v + defaultPage;
46+
return { number: pageNumber, active: defaultPage === pageNumber };
47+
}
48+
return { number: v + 1, active: defaultPage === v + 1 };
49+
}),
50+
{ number: null, active: false },
51+
...Array.from({ length: slicesSize }, (_, v) => {
52+
const pageNumber = count - (slicesSize - v) + 1;
53+
return {
54+
number: pageNumber,
55+
active: defaultPage === pageNumber
56+
};
57+
})
58+
];
59+
};
60+
61+
/** @see <https://react-dsfr-components.etalab.studio/?path=/docs/components-pagination> */
62+
export const Pagination = memo(
63+
forwardRef<HTMLDivElement, PaginationProps>((props, ref) => {
64+
const {
65+
className,
66+
count,
67+
defaultPage = 1,
68+
showFirstLast = true,
69+
getPageLinkProps,
70+
classes = {},
71+
...rest
72+
} = props;
73+
74+
assert<Equals<keyof typeof rest, never>>();
75+
76+
const { t } = useTranslation();
77+
78+
const { Link } = getLink();
79+
80+
const parts = getPaginationParts({ count, defaultPage });
81+
82+
return (
83+
<nav
84+
role="navigation"
85+
className={cx(fr.cx("fr-pagination"), classes.root, className)}
86+
aria-label={t("aria-label")}
87+
ref={ref}
88+
>
89+
<ul className={cx(fr.cx("fr-pagination__list"), classes.list)}>
90+
{showFirstLast && (
91+
<li>
92+
<Link
93+
{...(count > 0 && defaultPage > 1 && getPageLinkProps(1))}
94+
className={cx(
95+
fr.cx("fr-pagination__link", "fr-pagination__link--first"),
96+
classes.link,
97+
getPageLinkProps(1).className
98+
)}
99+
aria-disabled={count > 0 && defaultPage > 1 ? true : undefined}
100+
role="link"
101+
>
102+
{t("first page")}
103+
</Link>
104+
</li>
105+
)}
106+
<li>
107+
<Link
108+
className={cx(
109+
fr.cx(
110+
"fr-pagination__link",
111+
"fr-pagination__link--prev",
112+
"fr-pagination__link--lg-label"
113+
),
114+
classes.link
115+
)}
116+
{...(defaultPage > 1 && getPageLinkProps(defaultPage - 1))}
117+
aria-disabled={defaultPage <= 1 ? true : undefined}
118+
role="link"
119+
>
120+
{t("previous page")}
121+
</Link>
122+
</li>
123+
{parts.map(part => (
124+
<li key={part.number}>
125+
{part.number === null ? (
126+
<a className={cx(fr.cx("fr-pagination__link"), classes.link)}>
127+
...
128+
</a>
129+
) : (
130+
<Link
131+
className={cx(fr.cx("fr-pagination__link"), classes.link)}
132+
aria-current={part.active ? true : undefined}
133+
title={`Page ${part.number}`}
134+
{...getPageLinkProps(part.number)}
135+
>
136+
{part.number}
137+
</Link>
138+
)}
139+
</li>
140+
))}
141+
<li>
142+
<Link
143+
className={cx(
144+
fr.cx(
145+
"fr-pagination__link",
146+
"fr-pagination__link--next",
147+
"fr-pagination__link--lg-label"
148+
),
149+
classes.link
150+
)}
151+
{...(defaultPage < count && getPageLinkProps(defaultPage + 1))}
152+
aria-disabled={defaultPage < count ? true : undefined}
153+
role="link"
154+
>
155+
{t("next page")}
156+
</Link>
157+
</li>
158+
{showFirstLast && (
159+
<li>
160+
<Link
161+
className={cx(
162+
fr.cx("fr-pagination__link", "fr-pagination__link--last"),
163+
classes.link
164+
)}
165+
{...(defaultPage < count && getPageLinkProps(count))}
166+
aria-disabled={defaultPage < count ? true : undefined}
167+
>
168+
{t("last page")}
169+
</Link>
170+
</li>
171+
)}
172+
</ul>
173+
</nav>
174+
);
175+
})
176+
);
177+
178+
Pagination.displayName = symToStr({ Pagination });
179+
180+
const { useTranslation, addPaginationTranslations } = createComponentI18nApi({
181+
"componentName": symToStr({ Pagination }),
182+
"frMessages": {
183+
"first page": "Première page",
184+
"previous page": "Page précédente",
185+
"next page": "Page suivante",
186+
"last page": "Dernière page",
187+
"aria-label": "Pagination"
188+
}
189+
});
190+
191+
addPaginationTranslations({
192+
"lang": "en",
193+
"messages": {
194+
"first page": "First page",
195+
"previous page": "Previous page",
196+
"next page": "Next page",
197+
"last page": "Last page",
198+
"aria-label": "Pagination"
199+
}
200+
});
201+
202+
export { addPaginationTranslations };
203+
204+
export default Pagination;

stories/Pagination.stories.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Pagination } from "../dist/Pagination";
2+
import { sectionName } from "./sectionName";
3+
import { getStoryFactory } from "./getStory";
4+
5+
const { meta, getStory } = getStoryFactory({
6+
sectionName,
7+
"wrappedComponent": { Pagination },
8+
"description": `
9+
- [See DSFR documentation](//www.systeme-de-design.gouv.fr/elements-d-interface/composants/pagination)
10+
- [See DSFR demos](//main--ds-gouv.netlify.app/example/component/pagination/)
11+
- [See source code](//github.com/codegouvfr/react-dsfr/blob/main/src/Pagination.tsx)`,
12+
"disabledProps": ["lang"]
13+
});
14+
15+
export default meta;
16+
17+
export const Default = getStory({
18+
count: 100,
19+
defaultPage: 2,
20+
showFirstLast: true,
21+
getPageLinkProps: pageNumber => ({ href: `/page/${pageNumber}` })
22+
});
23+
24+
export const SummaryWithNoPage = getStory({
25+
count: 0,
26+
getPageLinkProps: pageNumber => ({ href: `/page/${pageNumber}` })
27+
});
28+
29+
export const SummaryWithSinglePage = getStory({
30+
count: 1,
31+
getPageLinkProps: pageNumber => ({ href: `/page/${pageNumber}` })
32+
});
33+
34+
export const SummaryWith132Pages = getStory({
35+
count: 132,
36+
defaultPage: 42,
37+
getPageLinkProps: pageNumber => ({ href: `/page/${pageNumber}` })
38+
});
39+
40+
export const SummaryWithoutShowFirstLast = getStory({
41+
count: 45,
42+
defaultPage: 42,
43+
showFirstLast: false,
44+
getPageLinkProps: pageNumber => ({ href: `/page/${pageNumber}` })
45+
});
46+
47+
export const SummaryWithLastPage = getStory({
48+
count: 24,
49+
defaultPage: 24,
50+
getPageLinkProps: pageNumber => ({ href: `/page/${pageNumber}` })
51+
});

0 commit comments

Comments
 (0)