@automattic/vip-design-system 2.16.1 → 2.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/system/NewForm/FormSelect.d.ts +1 -0
- package/build/system/NewForm/FormSelect.js +8 -3
- package/build/system/NewForm/FormSelectArrow.d.ts +1 -0
- package/build/system/NewForm/FormSelectArrow.js +17 -14
- package/build/system/Pagination/Pagination.d.ts +23 -0
- package/build/system/Pagination/Pagination.js +256 -0
- package/build/system/Pagination/Pagination.stories.d.ts +19 -0
- package/build/system/Pagination/Pagination.stories.js +236 -0
- package/build/system/Pagination/Pagination.test.d.ts +2 -0
- package/build/system/Pagination/Pagination.test.js +425 -0
- package/build/system/Pagination/index.d.ts +2 -0
- package/build/system/Pagination/index.js +1 -0
- package/build/system/Pagination/styles.d.ts +9 -0
- package/build/system/Pagination/styles.js +96 -0
- package/build/system/index.d.ts +2 -1
- package/build/system/index.js +2 -0
- package/package.json +1 -1
- package/src/system/NewForm/FormSelect.tsx +8 -2
- package/src/system/NewForm/FormSelectArrow.tsx +24 -19
- package/src/system/Pagination/Pagination.stories.tsx +210 -0
- package/src/system/Pagination/Pagination.test.tsx +324 -0
- package/src/system/Pagination/Pagination.tsx +306 -0
- package/src/system/Pagination/index.ts +2 -0
- package/src/system/Pagination/styles.ts +106 -0
- package/src/system/index.js +2 -0
|
@@ -14,28 +14,33 @@ import { baseControlBorderStyle as borderStyle } from '../Form/Input.styles';
|
|
|
14
14
|
|
|
15
15
|
interface FormSelectArrowProps {
|
|
16
16
|
iconSize?: number;
|
|
17
|
+
separator?: boolean;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
const arrowStyles: ThemeUIStyleObject = {
|
|
20
|
-
position: 'absolute',
|
|
21
|
-
paddingLeft: 2,
|
|
22
|
-
borderLeftWidth: borderStyle.borderWidth,
|
|
23
|
-
borderLeftStyle: borderStyle.borderStyle,
|
|
24
|
-
borderLeftColor: borderStyle.borderColor,
|
|
25
|
-
right: 3,
|
|
26
|
-
top: '7px',
|
|
27
|
-
pointerEvents: 'none',
|
|
28
|
-
svg: {
|
|
29
|
-
fill: borderStyle.borderColor,
|
|
30
|
-
},
|
|
31
|
-
};
|
|
32
|
-
|
|
33
20
|
export const FormSelectArrow = React.forwardRef< SVGSVGElement, FormSelectArrowProps >(
|
|
34
|
-
( { iconSize = 24, ...props }, forwardRef ) =>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
21
|
+
( { iconSize = 24, separator = true, ...props }, forwardRef ) => {
|
|
22
|
+
const arrowStyles: ThemeUIStyleObject = {
|
|
23
|
+
position: 'absolute',
|
|
24
|
+
right: 3,
|
|
25
|
+
top: '7px',
|
|
26
|
+
pointerEvents: 'none',
|
|
27
|
+
svg: {
|
|
28
|
+
fill: borderStyle.borderColor,
|
|
29
|
+
},
|
|
30
|
+
...( separator && {
|
|
31
|
+
paddingLeft: 2,
|
|
32
|
+
borderLeftWidth: borderStyle.borderWidth,
|
|
33
|
+
borderLeftStyle: borderStyle.borderStyle,
|
|
34
|
+
borderLeftColor: borderStyle.borderColor,
|
|
35
|
+
} ),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div ref={ forwardRef as React.RefObject< HTMLDivElement > }>
|
|
40
|
+
<MdExpandMore aria-hidden="true" size={ iconSize } sx={ arrowStyles } { ...props } />
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
39
44
|
);
|
|
40
45
|
|
|
41
46
|
FormSelectArrow.displayName = 'FormSelectArrow';
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/** @jsxImportSource theme-ui */
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Internal dependencies
|
|
7
|
+
*/
|
|
8
|
+
import { Pagination } from './Pagination';
|
|
9
|
+
import { Badge } from '../Badge';
|
|
10
|
+
import { Flex } from '../Flex';
|
|
11
|
+
import { Text } from '../Text';
|
|
12
|
+
|
|
13
|
+
import type { StoryObj, Meta } from '@storybook/react';
|
|
14
|
+
|
|
15
|
+
const meta: Meta< typeof Pagination > = {
|
|
16
|
+
title: 'Pagination',
|
|
17
|
+
component: Pagination,
|
|
18
|
+
parameters: {
|
|
19
|
+
docs: {
|
|
20
|
+
description: {
|
|
21
|
+
component: `
|
|
22
|
+
A Pagination component for navigating paged data.
|
|
23
|
+
|
|
24
|
+
## Variants
|
|
25
|
+
|
|
26
|
+
- **full** (default): Shows individual page number buttons with ellipsis for large page counts.
|
|
27
|
+
- **compact**: Shows a dropdown page selector instead of individual page numbers.
|
|
28
|
+
|
|
29
|
+
## Component Properties
|
|
30
|
+
`,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default meta;
|
|
37
|
+
|
|
38
|
+
type Story = StoryObj< typeof Pagination >;
|
|
39
|
+
|
|
40
|
+
const PaginationWithState = ( {
|
|
41
|
+
initialPage = 1,
|
|
42
|
+
totalItems = 200,
|
|
43
|
+
initialItemsPerPage = 20,
|
|
44
|
+
displayItemsPerPageSelector = false,
|
|
45
|
+
...props
|
|
46
|
+
}: {
|
|
47
|
+
initialPage?: number;
|
|
48
|
+
totalItems?: number;
|
|
49
|
+
initialItemsPerPage?: number;
|
|
50
|
+
variant?: 'full' | 'compact';
|
|
51
|
+
pageSizeOptions?: number[];
|
|
52
|
+
displayItemsPerPageSelector?: boolean;
|
|
53
|
+
} ) => {
|
|
54
|
+
const [ currentPage, setCurrentPage ] = useState( initialPage );
|
|
55
|
+
const [ itemsPerPage, setItemsPerPage ] = useState( initialItemsPerPage );
|
|
56
|
+
const totalPages = Math.ceil( totalItems / itemsPerPage );
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Pagination
|
|
60
|
+
currentPage={ currentPage }
|
|
61
|
+
totalItems={ totalItems }
|
|
62
|
+
itemsPerPage={ itemsPerPage }
|
|
63
|
+
onPageChange={ setCurrentPage }
|
|
64
|
+
displayItemsPerPageSelector={ displayItemsPerPageSelector }
|
|
65
|
+
onItemsPerPageChange={ size => {
|
|
66
|
+
setItemsPerPage( size );
|
|
67
|
+
setCurrentPage( 1 );
|
|
68
|
+
} }
|
|
69
|
+
{ ...props }
|
|
70
|
+
>
|
|
71
|
+
<Flex sx={ { justifyContent: 'center', alignItems: 'center', verticalAlign: 'middle' } }>
|
|
72
|
+
<Badge variant="gold" sx={ { mr: 2 } }>
|
|
73
|
+
DEBUG
|
|
74
|
+
</Badge>
|
|
75
|
+
<Text>
|
|
76
|
+
Page { currentPage } of { totalPages }
|
|
77
|
+
</Text>
|
|
78
|
+
</Flex>
|
|
79
|
+
</Pagination>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const OpenEndedPaginationWithState = ( {
|
|
84
|
+
initialPage = 1,
|
|
85
|
+
initialItemsPerPage = 20,
|
|
86
|
+
hasNextPage,
|
|
87
|
+
...props
|
|
88
|
+
}: {
|
|
89
|
+
initialPage?: number;
|
|
90
|
+
initialItemsPerPage?: number;
|
|
91
|
+
hasNextPage?: boolean;
|
|
92
|
+
variant?: 'full' | 'compact';
|
|
93
|
+
} ) => {
|
|
94
|
+
const [ currentPage, setCurrentPage ] = useState( initialPage );
|
|
95
|
+
const [ itemsPerPage, setItemsPerPage ] = useState( initialItemsPerPage );
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<Pagination
|
|
99
|
+
currentPage={ currentPage }
|
|
100
|
+
itemsPerPage={ itemsPerPage }
|
|
101
|
+
onPageChange={ setCurrentPage }
|
|
102
|
+
onItemsPerPageChange={ size => {
|
|
103
|
+
setItemsPerPage( size );
|
|
104
|
+
setCurrentPage( 1 );
|
|
105
|
+
} }
|
|
106
|
+
hasNextPage={ hasNextPage }
|
|
107
|
+
{ ...props }
|
|
108
|
+
>
|
|
109
|
+
<Flex sx={ { justifyContent: 'center', alignItems: 'center', verticalAlign: 'middle' } }>
|
|
110
|
+
<Badge variant="gold" sx={ { mr: 2 } }>
|
|
111
|
+
DEBUG
|
|
112
|
+
</Badge>
|
|
113
|
+
<Text>Page { currentPage } (open-ended)</Text>
|
|
114
|
+
</Flex>
|
|
115
|
+
</Pagination>
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const Default: Story = {
|
|
120
|
+
render: () => <PaginationWithState />,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const Compact: Story = {
|
|
124
|
+
render: () => <PaginationWithState variant="compact" />,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const FewPages: Story = {
|
|
128
|
+
render: () => <PaginationWithState totalItems={ 200 } initialItemsPerPage={ 10 } />,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const MiddlePage: Story = {
|
|
132
|
+
render: () => (
|
|
133
|
+
<PaginationWithState totalItems={ 500 } initialItemsPerPage={ 10 } initialPage={ 25 } />
|
|
134
|
+
),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const CustomPageSizes: Story = {
|
|
138
|
+
render: () => (
|
|
139
|
+
<PaginationWithState
|
|
140
|
+
totalItems={ 1000 }
|
|
141
|
+
initialItemsPerPage={ 25 }
|
|
142
|
+
pageSizeOptions={ [ 25, 50, 100, 250 ] }
|
|
143
|
+
/>
|
|
144
|
+
),
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const WithItemsPerPageSelector: Story = {
|
|
148
|
+
render: () => (
|
|
149
|
+
<PaginationWithState
|
|
150
|
+
totalItems={ 100 }
|
|
151
|
+
initialItemsPerPage={ 25 }
|
|
152
|
+
displayItemsPerPageSelector={ true }
|
|
153
|
+
/>
|
|
154
|
+
),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const CursorBasedPaginationWithState = () => {
|
|
158
|
+
const [ currentPage, setCurrentPage ] = useState( 1 );
|
|
159
|
+
const [ itemsPerPage, setItemsPerPage ] = useState( 20 );
|
|
160
|
+
const [ maxVisited, setMaxVisited ] = useState( 1 );
|
|
161
|
+
|
|
162
|
+
const handlePageChange = ( page: number ) => {
|
|
163
|
+
setCurrentPage( page );
|
|
164
|
+
setMaxVisited( prev => Math.max( prev, page ) );
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const hasNextPage = true; // Simulate always having a next page
|
|
168
|
+
const maxReachablePage = hasNextPage ? maxVisited + 1 : maxVisited;
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<Pagination
|
|
172
|
+
currentPage={ currentPage }
|
|
173
|
+
itemsPerPage={ itemsPerPage }
|
|
174
|
+
onPageChange={ handlePageChange }
|
|
175
|
+
onItemsPerPageChange={ size => {
|
|
176
|
+
setItemsPerPage( size );
|
|
177
|
+
setCurrentPage( 1 );
|
|
178
|
+
setMaxVisited( 1 );
|
|
179
|
+
} }
|
|
180
|
+
hasNextPage={ hasNextPage }
|
|
181
|
+
maxReachablePage={ maxReachablePage }
|
|
182
|
+
displayItemsPerPageSelector
|
|
183
|
+
>
|
|
184
|
+
<Flex sx={ { justifyContent: 'center', alignItems: 'center', verticalAlign: 'middle' } }>
|
|
185
|
+
<Badge variant="gold" sx={ { mr: 2 } }>
|
|
186
|
+
DEBUG
|
|
187
|
+
</Badge>
|
|
188
|
+
<Text>
|
|
189
|
+
Page { currentPage } — max reachable: { maxReachablePage }
|
|
190
|
+
</Text>
|
|
191
|
+
</Flex>
|
|
192
|
+
</Pagination>
|
|
193
|
+
);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export const OpenEndedCursorBased: Story = {
|
|
197
|
+
render: () => <CursorBasedPaginationWithState />,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export const OpenEnded: Story = {
|
|
201
|
+
render: () => <OpenEndedPaginationWithState />,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export const OpenEndedCompact: Story = {
|
|
205
|
+
render: () => <OpenEndedPaginationWithState variant="compact" />,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export const OpenEndedLastPage: Story = {
|
|
209
|
+
render: () => <OpenEndedPaginationWithState hasNextPage={ false } initialPage={ 15 } />,
|
|
210
|
+
};
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/** @jsxImportSource theme-ui */
|
|
2
|
+
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { axe } from 'jest-axe';
|
|
6
|
+
import '@testing-library/jest-dom';
|
|
7
|
+
|
|
8
|
+
import { Pagination, getPageNumbers } from './Pagination';
|
|
9
|
+
|
|
10
|
+
const defaultProps = {
|
|
11
|
+
currentPage: 1,
|
|
12
|
+
totalItems: 200,
|
|
13
|
+
totalPages: 10,
|
|
14
|
+
itemsPerPage: 20,
|
|
15
|
+
onPageChange: jest.fn(),
|
|
16
|
+
onItemsPerPageChange: jest.fn(),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe( '<Pagination />', () => {
|
|
20
|
+
beforeEach( () => {
|
|
21
|
+
jest.clearAllMocks();
|
|
22
|
+
} );
|
|
23
|
+
|
|
24
|
+
it( 'renders a nav landmark with aria-label', () => {
|
|
25
|
+
render( <Pagination { ...defaultProps } /> );
|
|
26
|
+
|
|
27
|
+
expect( screen.getByRole( 'navigation', { name: 'Pagination' } ) ).toBeInTheDocument();
|
|
28
|
+
} );
|
|
29
|
+
|
|
30
|
+
it( 'renders prev and next arrow buttons', () => {
|
|
31
|
+
render( <Pagination { ...defaultProps } /> );
|
|
32
|
+
|
|
33
|
+
expect( screen.getByRole( 'button', { name: 'Previous page' } ) ).toBeInTheDocument();
|
|
34
|
+
expect( screen.getByRole( 'button', { name: 'Next page' } ) ).toBeInTheDocument();
|
|
35
|
+
} );
|
|
36
|
+
|
|
37
|
+
it( 'disables prev button on first page', () => {
|
|
38
|
+
render( <Pagination { ...defaultProps } currentPage={ 1 } /> );
|
|
39
|
+
|
|
40
|
+
expect( screen.getByRole( 'button', { name: 'Previous page' } ) ).toBeDisabled();
|
|
41
|
+
} );
|
|
42
|
+
|
|
43
|
+
it( 'disables next button on last page', () => {
|
|
44
|
+
render( <Pagination { ...defaultProps } currentPage={ 10 } /> );
|
|
45
|
+
|
|
46
|
+
expect( screen.getByRole( 'button', { name: 'Next page' } ) ).toBeDisabled();
|
|
47
|
+
} );
|
|
48
|
+
|
|
49
|
+
it( 'marks current page with aria-current', () => {
|
|
50
|
+
render( <Pagination { ...defaultProps } currentPage={ 3 } /> );
|
|
51
|
+
|
|
52
|
+
expect( screen.getByRole( 'button', { name: 'Go to page 3' } ) ).toHaveAttribute(
|
|
53
|
+
'aria-current',
|
|
54
|
+
'page'
|
|
55
|
+
);
|
|
56
|
+
} );
|
|
57
|
+
|
|
58
|
+
it( 'does not mark non-current pages with aria-current', () => {
|
|
59
|
+
render( <Pagination { ...defaultProps } currentPage={ 3 } /> );
|
|
60
|
+
|
|
61
|
+
expect( screen.getByRole( 'button', { name: 'Go to page 1' } ) ).not.toHaveAttribute(
|
|
62
|
+
'aria-current'
|
|
63
|
+
);
|
|
64
|
+
} );
|
|
65
|
+
|
|
66
|
+
it( 'calls onPageChange when clicking a page number', async () => {
|
|
67
|
+
const user = userEvent.setup();
|
|
68
|
+
render( <Pagination { ...defaultProps } currentPage={ 1 } /> );
|
|
69
|
+
|
|
70
|
+
await user.click( screen.getByRole( 'button', { name: 'Go to page 2' } ) );
|
|
71
|
+
|
|
72
|
+
expect( defaultProps.onPageChange ).toHaveBeenCalledWith( 2 );
|
|
73
|
+
} );
|
|
74
|
+
|
|
75
|
+
it( 'calls onPageChange when clicking next', async () => {
|
|
76
|
+
const user = userEvent.setup();
|
|
77
|
+
render( <Pagination { ...defaultProps } currentPage={ 3 } /> );
|
|
78
|
+
|
|
79
|
+
await user.click( screen.getByRole( 'button', { name: 'Next page' } ) );
|
|
80
|
+
|
|
81
|
+
expect( defaultProps.onPageChange ).toHaveBeenCalledWith( 4 );
|
|
82
|
+
} );
|
|
83
|
+
|
|
84
|
+
it( 'calls onPageChange when clicking prev', async () => {
|
|
85
|
+
const user = userEvent.setup();
|
|
86
|
+
render( <Pagination { ...defaultProps } currentPage={ 3 } /> );
|
|
87
|
+
|
|
88
|
+
await user.click( screen.getByRole( 'button', { name: 'Previous page' } ) );
|
|
89
|
+
|
|
90
|
+
expect( defaultProps.onPageChange ).toHaveBeenCalledWith( 2 );
|
|
91
|
+
} );
|
|
92
|
+
|
|
93
|
+
it( 'shows ellipsis for large page counts', () => {
|
|
94
|
+
render( <Pagination { ...defaultProps } currentPage={ 10 } totalPages={ 20 } /> );
|
|
95
|
+
|
|
96
|
+
// Intermediate pages are replaced by ellipsis icon
|
|
97
|
+
expect( screen.queryByRole( 'button', { name: 'Go to page 2' } ) ).not.toBeInTheDocument();
|
|
98
|
+
expect( screen.queryByRole( 'button', { name: 'Go to page 3' } ) ).not.toBeInTheDocument();
|
|
99
|
+
} );
|
|
100
|
+
|
|
101
|
+
it( 'does not show ellipsis for small page counts', () => {
|
|
102
|
+
render( <Pagination { ...defaultProps } currentPage={ 1 } totalPages={ 5 } /> );
|
|
103
|
+
|
|
104
|
+
// All pages are rendered when total is small
|
|
105
|
+
expect( screen.getByRole( 'button', { name: 'Go to page 2' } ) ).toBeInTheDocument();
|
|
106
|
+
expect( screen.getByRole( 'button', { name: 'Go to page 3' } ) ).toBeInTheDocument();
|
|
107
|
+
expect( screen.getByRole( 'button', { name: 'Go to page 4' } ) ).toBeInTheDocument();
|
|
108
|
+
expect( screen.getByRole( 'button', { name: 'Go to page 5' } ) ).toBeInTheDocument();
|
|
109
|
+
} );
|
|
110
|
+
|
|
111
|
+
it( 'renders compact variant with Page text', () => {
|
|
112
|
+
render( <Pagination { ...defaultProps } variant="compact" currentPage={ 3 } /> );
|
|
113
|
+
|
|
114
|
+
expect( screen.getByText( 'Page' ) ).toBeInTheDocument();
|
|
115
|
+
expect( screen.getByText( 'of 10' ) ).toBeInTheDocument();
|
|
116
|
+
} );
|
|
117
|
+
|
|
118
|
+
it( 'renders custom pageSizeOptions in the trigger', () => {
|
|
119
|
+
render(
|
|
120
|
+
<Pagination
|
|
121
|
+
{ ...defaultProps }
|
|
122
|
+
displayItemsPerPageSelector
|
|
123
|
+
pageSizeOptions={ [ 5, 25, 75 ] }
|
|
124
|
+
itemsPerPage={ 5 }
|
|
125
|
+
/>
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect( screen.getByRole( 'combobox' ) ).toBeInTheDocument();
|
|
129
|
+
expect( screen.getAllByRole( 'option' ) ).toHaveLength( 3 );
|
|
130
|
+
} );
|
|
131
|
+
|
|
132
|
+
it( 'has no accessibility violations (full variant)', async () => {
|
|
133
|
+
const { container } = render( <Pagination { ...defaultProps } currentPage={ 5 } /> );
|
|
134
|
+
|
|
135
|
+
expect( await axe( container ) ).toHaveNoViolations();
|
|
136
|
+
} );
|
|
137
|
+
|
|
138
|
+
it( 'has no accessibility violations (compact variant)', async () => {
|
|
139
|
+
const { container } = render(
|
|
140
|
+
<Pagination { ...defaultProps } variant="compact" currentPage={ 5 } />
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect( await axe( container ) ).toHaveNoViolations();
|
|
144
|
+
} );
|
|
145
|
+
} );
|
|
146
|
+
|
|
147
|
+
describe( 'getPageNumbers', () => {
|
|
148
|
+
it( 'returns all pages when totalPages <= 8', () => {
|
|
149
|
+
expect( getPageNumbers( 1, 5 ) ).toEqual( [ 1, 2, 3, 4, 5 ] );
|
|
150
|
+
expect( getPageNumbers( 3, 7 ) ).toEqual( [ 1, 2, 3, 4, 5, 6, 7 ] );
|
|
151
|
+
expect( getPageNumbers( 4, 8 ) ).toEqual( [ 1, 2, 3, 4, 5, 6, 7, 8 ] );
|
|
152
|
+
} );
|
|
153
|
+
|
|
154
|
+
it( 'returns single page', () => {
|
|
155
|
+
expect( getPageNumbers( 1, 1 ) ).toEqual( [ 1 ] );
|
|
156
|
+
} );
|
|
157
|
+
|
|
158
|
+
it( 'always returns 8 items when totalPages > 8', () => {
|
|
159
|
+
for ( let cp = 1; cp <= 20; cp++ ) {
|
|
160
|
+
expect( getPageNumbers( cp, 20 ) ).toHaveLength( 8 );
|
|
161
|
+
}
|
|
162
|
+
} );
|
|
163
|
+
|
|
164
|
+
it( 'shows end ellipsis when current page is near start', () => {
|
|
165
|
+
expect( getPageNumbers( 1, 10 ) ).toEqual( [ 1, 2, 3, 4, 5, 6, 'ellipsis', 10 ] );
|
|
166
|
+
expect( getPageNumbers( 3, 10 ) ).toEqual( [ 1, 2, 3, 4, 5, 6, 'ellipsis', 10 ] );
|
|
167
|
+
expect( getPageNumbers( 5, 10 ) ).toEqual( [ 1, 2, 3, 4, 5, 6, 'ellipsis', 10 ] );
|
|
168
|
+
} );
|
|
169
|
+
|
|
170
|
+
it( 'shows start ellipsis when current page is near end', () => {
|
|
171
|
+
expect( getPageNumbers( 9, 10 ) ).toEqual( [ 1, 'ellipsis', 5, 6, 7, 8, 9, 10 ] );
|
|
172
|
+
expect( getPageNumbers( 8, 10 ) ).toEqual( [ 1, 'ellipsis', 5, 6, 7, 8, 9, 10 ] );
|
|
173
|
+
} );
|
|
174
|
+
|
|
175
|
+
it( 'shows both ellipsis when current page is in the middle', () => {
|
|
176
|
+
expect( getPageNumbers( 10, 20 ) ).toEqual( [ 1, 'ellipsis', 9, 10, 11, 12, 'ellipsis', 20 ] );
|
|
177
|
+
} );
|
|
178
|
+
} );
|
|
179
|
+
|
|
180
|
+
describe( 'getPageNumbers (open-ended with maxReachablePage)', () => {
|
|
181
|
+
it( 'caps pages to maxReachablePage when near start', () => {
|
|
182
|
+
expect( getPageNumbers( 1, undefined, true, 2 ) ).toEqual( [ 1, 2 ] );
|
|
183
|
+
} );
|
|
184
|
+
|
|
185
|
+
it( 'shows all reachable pages when they fit', () => {
|
|
186
|
+
expect( getPageNumbers( 3, undefined, true, 4 ) ).toEqual( [ 1, 2, 3, 4 ] );
|
|
187
|
+
} );
|
|
188
|
+
|
|
189
|
+
it( 'shows ellipsis for large reachable ranges', () => {
|
|
190
|
+
expect( getPageNumbers( 8, undefined, true, 9 ) ).toEqual( [ 1, 'ellipsis', 7, 8, 9 ] );
|
|
191
|
+
} );
|
|
192
|
+
|
|
193
|
+
it( 'shows both ellipsis when end is far from current page', () => {
|
|
194
|
+
expect( getPageNumbers( 8, undefined, true, 15 ) ).toEqual( [
|
|
195
|
+
1,
|
|
196
|
+
'ellipsis',
|
|
197
|
+
7,
|
|
198
|
+
8,
|
|
199
|
+
9,
|
|
200
|
+
10,
|
|
201
|
+
'ellipsis',
|
|
202
|
+
15,
|
|
203
|
+
] );
|
|
204
|
+
} );
|
|
205
|
+
|
|
206
|
+
it( 'returns all pages when maxReachablePage <= 8', () => {
|
|
207
|
+
expect( getPageNumbers( 1, undefined, true, 8 ) ).toEqual( [ 1, 2, 3, 4, 5, 6, 7, 8 ] );
|
|
208
|
+
} );
|
|
209
|
+
|
|
210
|
+
it( 'does not affect behavior when maxReachablePage is undefined', () => {
|
|
211
|
+
expect( getPageNumbers( 1, undefined, true ) ).toEqual( [ 1, 2, 3, 4, 5, 6, 7, 'ellipsis' ] );
|
|
212
|
+
} );
|
|
213
|
+
} );
|
|
214
|
+
|
|
215
|
+
describe( 'getPageNumbers (open-ended)', () => {
|
|
216
|
+
it( 'always returns 8 items when page >= 6', () => {
|
|
217
|
+
for ( let cp = 6; cp <= 20; cp++ ) {
|
|
218
|
+
expect( getPageNumbers( cp ) ).toHaveLength( 8 );
|
|
219
|
+
}
|
|
220
|
+
} );
|
|
221
|
+
|
|
222
|
+
it( 'returns near-start pattern for pages 1-5', () => {
|
|
223
|
+
expect( getPageNumbers( 1 ) ).toEqual( [ 1, 2, 3, 4, 5, 6, 7, 'ellipsis' ] );
|
|
224
|
+
expect( getPageNumbers( 5 ) ).toEqual( [ 1, 2, 3, 4, 5, 6, 7, 'ellipsis' ] );
|
|
225
|
+
} );
|
|
226
|
+
|
|
227
|
+
it( 'returns middle pattern with trailing ellipsis for higher pages', () => {
|
|
228
|
+
expect( getPageNumbers( 10 ) ).toEqual( [ 1, 'ellipsis', 9, 10, 11, 12, 13, 'ellipsis' ] );
|
|
229
|
+
} );
|
|
230
|
+
|
|
231
|
+
it( 'excludes forward pages and trailing ellipsis when hasNextPage is false', () => {
|
|
232
|
+
expect( getPageNumbers( 1, undefined, false ) ).toEqual( [ 1 ] );
|
|
233
|
+
expect( getPageNumbers( 5, undefined, false ) ).toEqual( [ 1, 2, 3, 4, 5 ] );
|
|
234
|
+
expect( getPageNumbers( 10, undefined, false ) ).toEqual( [
|
|
235
|
+
1,
|
|
236
|
+
'ellipsis',
|
|
237
|
+
5,
|
|
238
|
+
6,
|
|
239
|
+
7,
|
|
240
|
+
8,
|
|
241
|
+
9,
|
|
242
|
+
10,
|
|
243
|
+
] );
|
|
244
|
+
} );
|
|
245
|
+
} );
|
|
246
|
+
|
|
247
|
+
describe( '<Pagination /> with maxReachablePage', () => {
|
|
248
|
+
const maxReachableProps = {
|
|
249
|
+
currentPage: 1,
|
|
250
|
+
itemsPerPage: 20,
|
|
251
|
+
hasNextPage: true,
|
|
252
|
+
maxReachablePage: 2,
|
|
253
|
+
onPageChange: jest.fn(),
|
|
254
|
+
onItemsPerPageChange: jest.fn(),
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
it( 'only renders reachable page buttons', () => {
|
|
258
|
+
render( <Pagination { ...maxReachableProps } /> );
|
|
259
|
+
|
|
260
|
+
expect( screen.getByRole( 'button', { name: 'Go to page 1' } ) ).toBeInTheDocument();
|
|
261
|
+
expect( screen.getByRole( 'button', { name: 'Go to page 2' } ) ).toBeInTheDocument();
|
|
262
|
+
expect( screen.queryByRole( 'button', { name: 'Go to page 3' } ) ).not.toBeInTheDocument();
|
|
263
|
+
expect( screen.queryByRole( 'button', { name: 'Go to page 7' } ) ).not.toBeInTheDocument();
|
|
264
|
+
} );
|
|
265
|
+
|
|
266
|
+
it( 'has no accessibility violations', async () => {
|
|
267
|
+
const { container } = render( <Pagination { ...maxReachableProps } /> );
|
|
268
|
+
|
|
269
|
+
expect( await axe( container ) ).toHaveNoViolations();
|
|
270
|
+
} );
|
|
271
|
+
} );
|
|
272
|
+
|
|
273
|
+
describe( '<Pagination /> open-ended mode', () => {
|
|
274
|
+
const openEndedProps = {
|
|
275
|
+
currentPage: 5,
|
|
276
|
+
itemsPerPage: 20,
|
|
277
|
+
onPageChange: jest.fn(),
|
|
278
|
+
onItemsPerPageChange: jest.fn(),
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
beforeEach( () => {
|
|
282
|
+
jest.clearAllMocks();
|
|
283
|
+
} );
|
|
284
|
+
|
|
285
|
+
it( 'enables "Next" button when totalPages is omitted', () => {
|
|
286
|
+
render( <Pagination { ...openEndedProps } /> );
|
|
287
|
+
|
|
288
|
+
expect( screen.getByRole( 'button', { name: 'Next page' } ) ).not.toBeDisabled();
|
|
289
|
+
} );
|
|
290
|
+
|
|
291
|
+
it( 'disables "Next" button when hasNextPage is false', () => {
|
|
292
|
+
render( <Pagination { ...openEndedProps } hasNextPage={ false } /> );
|
|
293
|
+
|
|
294
|
+
expect( screen.getByRole( 'button', { name: 'Next page' } ) ).toBeDisabled();
|
|
295
|
+
} );
|
|
296
|
+
|
|
297
|
+
it( 'calls onPageChange when clicking next in open-ended mode', async () => {
|
|
298
|
+
const user = userEvent.setup();
|
|
299
|
+
render( <Pagination { ...openEndedProps } /> );
|
|
300
|
+
|
|
301
|
+
await user.click( screen.getByRole( 'button', { name: 'Next page' } ) );
|
|
302
|
+
|
|
303
|
+
expect( openEndedProps.onPageChange ).toHaveBeenCalledWith( 6 );
|
|
304
|
+
} );
|
|
305
|
+
|
|
306
|
+
it( 'renders compact variant with "Page" but without "of Y"', () => {
|
|
307
|
+
render( <Pagination { ...openEndedProps } variant="compact" /> );
|
|
308
|
+
|
|
309
|
+
expect( screen.getByText( 'Page' ) ).toBeInTheDocument();
|
|
310
|
+
expect( screen.queryByText( /of \d+/ ) ).not.toBeInTheDocument();
|
|
311
|
+
} );
|
|
312
|
+
|
|
313
|
+
it( 'has no accessibility violations (open-ended full)', async () => {
|
|
314
|
+
const { container } = render( <Pagination { ...openEndedProps } /> );
|
|
315
|
+
|
|
316
|
+
expect( await axe( container ) ).toHaveNoViolations();
|
|
317
|
+
} );
|
|
318
|
+
|
|
319
|
+
it( 'has no accessibility violations (open-ended compact)', async () => {
|
|
320
|
+
const { container } = render( <Pagination { ...openEndedProps } variant="compact" /> );
|
|
321
|
+
|
|
322
|
+
expect( await axe( container ) ).toHaveNoViolations();
|
|
323
|
+
} );
|
|
324
|
+
} );
|