Skip to content

Commit 7d6b69a

Browse files
Feat: create Common/Pagination component (#5971)
* Feat: add Common/Pagination initial rendering test and markdown * Feat: add currentPage and pages props to Common/Pagination component * Feat: add disabled logic to previous and next buttons in Common/Pagination component * Feat: create and add initial logic to useGetParsedPages, to manage pages shown in Common/Pagination * Feat: show an ellipsis when there are more than 6 pages in Common/Pagination * Refactor: abstract common test code into a setUpTest function in Common/Pagination tests * Feat: add currentPageSiblingsCount prop, and update useGetPageElements hook with ellipsis logic * Feat: add initial stories for Common/Pagination component * Feat: add aria-current modifier in tailwind.config.ts * Feat: add initial Tailwind styles to all Common/Pagination HTML elements * Refactor: move styles of Common/Pagination component to index.module.css * Feat: add internationalization to Common/Pagination component * Fix: add missing responsive styles in Common/Pagination index.module.css * Refactor: add some minor updates to Common/Pagination component * Refactor: split useGetPageElements code into sub-components, refactor its code, and document it through comments * Feat: add nextJsRouter.mjs mock in __mocks__ directory, and import it within jest.setup.mjs * Feat: add PaginationListItem unit tests, and minor refactors to improve code consistency
1 parent 9671715 commit 7d6b69a

File tree

16 files changed

+620
-11
lines changed

16 files changed

+620
-11
lines changed

.eslintrc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"extends": "plugin:storybook/recommended"
1111
},
1212
{
13-
"files": ["**/__tests__/*.mjs"],
13+
"files": ["**/__tests__/*.mjs", "__mocks__/*.mjs"],
1414
"env": { "jest": true }
1515
},
1616
{

__mocks__/nextJsRouter.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
jest.mock('next/router', () => ({
2+
useRouter() {
3+
return {
4+
isReady: true,
5+
asPath: '/',
6+
};
7+
},
8+
}));

components/Common/ActiveLocalizedLink/__tests__/index.test.mjs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,6 @@ import { IntlProvider } from 'react-intl';
33

44
import ActiveLocalizedLink from '..';
55

6-
jest.mock('next/router', () => ({
7-
useRouter() {
8-
return {
9-
isReady: true,
10-
asPath: '/link',
11-
};
12-
},
13-
}));
14-
156
describe('ActiveLocalizedLink', () => {
167
it('renders as localized link', () => {
178
render(
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.ellipsis {
2+
@apply w-10
3+
px-3
4+
py-2.5
5+
text-neutral-800
6+
dark:text-neutral-200;
7+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import styles from './index.module.css';
2+
3+
const Ellipsis = () => (
4+
<span aria-hidden="true" className={styles.ellipsis}>
5+
...
6+
</span>
7+
);
8+
9+
export default Ellipsis;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { IntlProvider } from 'react-intl';
3+
4+
import PaginationListItem from '@/components/Common/Pagination/PaginationListItem';
5+
6+
function renderPaginationListItem({
7+
url,
8+
pageNumber,
9+
currentPage,
10+
totalPages,
11+
}) {
12+
render(
13+
<IntlProvider>
14+
<PaginationListItem
15+
url={url}
16+
pageNumber={pageNumber}
17+
currentPage={currentPage}
18+
totalPages={totalPages}
19+
/>
20+
</IntlProvider>
21+
);
22+
}
23+
24+
describe('PaginationListItem', () => {
25+
it('Renders the list item correctly, including the corresponding ARIA attributes', () => {
26+
const pageNumber = 1;
27+
const totalPages = 10;
28+
const url = 'http://';
29+
30+
renderPaginationListItem({
31+
url,
32+
currentPage: 1,
33+
pageNumber,
34+
totalPages,
35+
});
36+
37+
const listItem = screen.getByRole('listitem');
38+
39+
expect(listItem).toBeVisible();
40+
expect(listItem).toHaveAttribute('aria-posinset', String(pageNumber));
41+
expect(listItem).toHaveAttribute('aria-setsize', String(totalPages));
42+
43+
expect(screen.getByRole('link')).toHaveAttribute('href', url);
44+
});
45+
46+
it('Assigns aria-current="page" attribute to the link when the current page is equal to the page number', () => {
47+
renderPaginationListItem({
48+
url: 'http://',
49+
currentPage: 1,
50+
pageNumber: 1,
51+
totalPages: 10,
52+
});
53+
54+
expect(screen.getByRole('link')).toHaveAttribute('aria-current', 'page');
55+
});
56+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
.listItem,
2+
.listItem:link,
3+
.listItem:active {
4+
@apply flex
5+
h-10
6+
w-10
7+
items-center
8+
justify-center
9+
rounded
10+
px-3
11+
py-2.5
12+
text-neutral-800
13+
hover:bg-neutral-100
14+
aria-current:bg-green-600
15+
aria-current:text-white
16+
dark:text-neutral-200
17+
hover:dark:bg-neutral-900;
18+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { FC } from 'react';
2+
import { useIntl } from 'react-intl';
3+
4+
import LocalizedLink from '@/components/LocalizedLink';
5+
6+
import styles from './index.module.css';
7+
8+
export type PaginationListItemProps = {
9+
url: string;
10+
pageNumber: number;
11+
// One-based number of the current page
12+
currentPage: number;
13+
totalPages: number;
14+
};
15+
16+
const PaginationListItem: FC<PaginationListItemProps> = ({
17+
url,
18+
pageNumber,
19+
currentPage,
20+
totalPages,
21+
}) => {
22+
const intl = useIntl();
23+
24+
return (
25+
<li key={pageNumber} aria-setsize={totalPages} aria-posinset={pageNumber}>
26+
<LocalizedLink
27+
prefetch={false}
28+
href={url}
29+
aria-label={intl.formatMessage(
30+
{ id: 'components.common.pagination.pageLabel' },
31+
{ pageNumber }
32+
)}
33+
className={styles.listItem}
34+
{...(pageNumber === currentPage && { 'aria-current': 'page' })}
35+
>
36+
<span>{pageNumber}</span>
37+
</LocalizedLink>
38+
</li>
39+
);
40+
};
41+
42+
export default PaginationListItem;
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { IntlProvider } from 'react-intl';
3+
4+
import Pagination from '@/components/Common/Pagination';
5+
6+
function renderPagination({
7+
currentPage = 1,
8+
pages,
9+
currentPageSiblingsCount,
10+
}) {
11+
const parsedPages = new Array(pages).fill({ url: 'page' });
12+
13+
render(
14+
<IntlProvider>
15+
<Pagination
16+
currentPage={currentPage}
17+
pages={parsedPages}
18+
currentPageSiblingsCount={currentPageSiblingsCount}
19+
/>
20+
</IntlProvider>
21+
);
22+
23+
return {
24+
currentPage,
25+
parsedPages,
26+
};
27+
}
28+
29+
describe('Pagination', () => {
30+
describe('Rendering', () => {
31+
it('Renders the navigation buttons even if no pages are passed to it', () => {
32+
renderPagination({ currentPage: 0, pages: 0 });
33+
34+
expect(screen.getByRole('navigation')).toBeVisible();
35+
36+
expect(screen.getByRole('button', { name: /prev/i })).toBeVisible();
37+
38+
expect(screen.getByRole('button', { name: /next/i })).toBeVisible();
39+
});
40+
41+
it('Renders the passed pages and current page', () => {
42+
const { currentPage, parsedPages } = renderPagination({
43+
pages: 4,
44+
});
45+
46+
const pageElements = screen.getAllByRole('link');
47+
48+
expect(pageElements).toHaveLength(parsedPages.length);
49+
50+
pageElements.forEach((page, index) => {
51+
if (index + 1 === currentPage) {
52+
expect(page).toHaveAttribute('aria-current', 'page');
53+
}
54+
55+
expect(page).toBeVisible();
56+
});
57+
});
58+
});
59+
60+
describe('Ellipsis behavior', () => {
61+
it('When the pages size is equal or smaller than currentPageSiblingsCount + 5, all pages are shown', () => {
62+
renderPagination({
63+
pages: 6,
64+
});
65+
66+
expect(screen.queryByText('...')).not.toBeInTheDocument();
67+
68+
const pageElements = screen.getAllByRole('link');
69+
70+
expect(pageElements).toHaveLength(6);
71+
72+
expect(pageElements.map(element => element.textContent)).toEqual([
73+
'1',
74+
'2',
75+
'3',
76+
'4',
77+
'5',
78+
'6',
79+
]);
80+
});
81+
82+
it('Shows left ellipsis when the left sibling of the current page is at least two pages away from the first page', () => {
83+
renderPagination({
84+
currentPage: 5,
85+
pages: 8,
86+
});
87+
88+
expect(screen.getByText('...')).toBeVisible();
89+
90+
const pageElements = screen.getAllByRole('link');
91+
92+
expect(pageElements).toHaveLength(6);
93+
94+
expect(pageElements.map(element => element.textContent)).toEqual([
95+
'1',
96+
'4',
97+
'5',
98+
'6',
99+
'7',
100+
'8',
101+
]);
102+
});
103+
104+
it('Shows right ellipsis when the right sibling of the current page is at least two pages away from the last page', () => {
105+
renderPagination({
106+
currentPage: 3,
107+
pages: 8,
108+
});
109+
110+
expect(screen.getByText('...')).toBeVisible();
111+
112+
const pageElements = screen.getAllByRole('link');
113+
114+
expect(pageElements).toHaveLength(6);
115+
116+
expect(pageElements.map(element => element.textContent)).toEqual([
117+
'1',
118+
'2',
119+
'3',
120+
'4',
121+
'5',
122+
'8',
123+
]);
124+
});
125+
126+
it('Shows right and left ellipses when the current page siblings are both at least two pages away from the first and last pages', () => {
127+
renderPagination({
128+
currentPage: 5,
129+
pages: 10,
130+
});
131+
132+
expect(screen.getAllByText('...')).toHaveLength(2);
133+
134+
const pageElements = screen.getAllByRole('link');
135+
136+
expect(pageElements).toHaveLength(5);
137+
138+
expect(pageElements.map(element => element.textContent)).toEqual([
139+
'1',
140+
'4',
141+
'5',
142+
'6',
143+
'10',
144+
]);
145+
});
146+
});
147+
148+
describe('Navigation buttons', () => {
149+
it('Disables "Previous" button when the currentPage is equal to the first page', () => {
150+
renderPagination({
151+
pages: 2,
152+
});
153+
154+
expect(screen.getByRole('button', { name: /prev/i })).toBeDisabled();
155+
});
156+
157+
it('Disables "Next" button when the currentPage is equal to the last page', () => {
158+
renderPagination({
159+
currentPage: 2,
160+
pages: 2,
161+
});
162+
163+
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
164+
});
165+
});
166+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
.pagination {
2+
@apply grid
3+
items-center
4+
justify-between
5+
gap-y-5
6+
[grid-template-areas:'pages_pages_pages''prev_._next']
7+
md:gap-y-0
8+
md:[grid-template-areas:'prev_pages_next'];
9+
}
10+
11+
.previousButton,
12+
.nextButton {
13+
@apply flex
14+
items-center
15+
gap-2
16+
text-sm;
17+
}
18+
19+
.previousButton {
20+
@apply [grid-area:prev];
21+
}
22+
23+
.nextButton {
24+
@apply [grid-area:next];
25+
}
26+
27+
.arrowIcon {
28+
@apply h-5
29+
shrink-0
30+
text-neutral-600
31+
dark:text-neutral-400;
32+
}
33+
34+
.list {
35+
@apply flex
36+
list-none
37+
justify-center
38+
gap-0.5
39+
[grid-area:pages];
40+
}

0 commit comments

Comments
 (0)