@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
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/** @jsxImportSource theme-ui */
|
|
2
|
+
|
|
3
|
+
import classNames from 'classnames';
|
|
4
|
+
import { forwardRef } from 'react';
|
|
5
|
+
import { BiDotsHorizontalRounded } from 'react-icons/bi';
|
|
6
|
+
import { MdChevronLeft, MdChevronRight } from 'react-icons/md';
|
|
7
|
+
import { Flex, ThemeUIStyleObject } from 'theme-ui';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
containerStyles,
|
|
11
|
+
navigationStyles,
|
|
12
|
+
pageButtonStyles,
|
|
13
|
+
activePageButtonStyles,
|
|
14
|
+
arrowButtonStyles,
|
|
15
|
+
compactTextStyles,
|
|
16
|
+
} from './styles';
|
|
17
|
+
import { Box } from '../Box';
|
|
18
|
+
import { Button } from '../Button';
|
|
19
|
+
import { Select } from '../NewForm';
|
|
20
|
+
import { Text } from '../Text';
|
|
21
|
+
|
|
22
|
+
export type PaginationVariant = 'full' | 'compact';
|
|
23
|
+
|
|
24
|
+
export interface PaginationProps {
|
|
25
|
+
displayItemsPerPageSelector?: boolean;
|
|
26
|
+
currentPage: number;
|
|
27
|
+
totalItems?: number;
|
|
28
|
+
totalPages?: number;
|
|
29
|
+
itemsPerPage: number;
|
|
30
|
+
onPageChange: ( page: number ) => void;
|
|
31
|
+
onItemsPerPageChange: ( itemsPerPage: number ) => void;
|
|
32
|
+
hasNextPage?: boolean;
|
|
33
|
+
maxReachablePage?: number;
|
|
34
|
+
variant?: PaginationVariant;
|
|
35
|
+
pageSizeOptions?: number[];
|
|
36
|
+
className?: string;
|
|
37
|
+
sx?: ThemeUIStyleObject;
|
|
38
|
+
children?: React.ReactNode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type PageNumberItem = number | 'ellipsis';
|
|
42
|
+
|
|
43
|
+
function range( start: number, end: number ): number[] {
|
|
44
|
+
return Array.from( { length: end - start + 1 }, ( _, i ) => start + i );
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Total number of visible items (page numbers + ellipsis indicators) in the pagination bar. */
|
|
48
|
+
const VISIBLE_PAGE_SLOTS = 8;
|
|
49
|
+
|
|
50
|
+
/** When currentPage <= this value, the "near start" layout is used (no leading ellipsis). */
|
|
51
|
+
const NEAR_START_THRESHOLD = 5;
|
|
52
|
+
|
|
53
|
+
/** Pages shown before the current page in the middle layout. */
|
|
54
|
+
const PAGES_BEFORE_CURRENT = 1;
|
|
55
|
+
|
|
56
|
+
/** Pages shown after the current page in the bounded middle layout. */
|
|
57
|
+
const PAGES_AFTER_CURRENT = 2;
|
|
58
|
+
|
|
59
|
+
export function getPageNumbers(
|
|
60
|
+
currentPage: number,
|
|
61
|
+
totalPages?: number,
|
|
62
|
+
hasNextPage?: boolean,
|
|
63
|
+
maxReachablePage?: number
|
|
64
|
+
): PageNumberItem[] {
|
|
65
|
+
// Resolve the last known page
|
|
66
|
+
let last: number | undefined;
|
|
67
|
+
if ( totalPages !== undefined ) {
|
|
68
|
+
last = Math.max( 1, Number( totalPages ) );
|
|
69
|
+
} else if ( hasNextPage === false ) {
|
|
70
|
+
last = currentPage;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if ( last !== undefined && ( ! Number.isFinite( last ) || last < 1 ) ) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Effective end anchor: known last page, or capped reachable page
|
|
78
|
+
const end =
|
|
79
|
+
last ??
|
|
80
|
+
( maxReachablePage !== undefined ? Math.max( currentPage, maxReachablePage ) : undefined );
|
|
81
|
+
|
|
82
|
+
// Small page count — show all without ellipsis
|
|
83
|
+
if ( end !== undefined && end <= VISIBLE_PAGE_SLOTS ) {
|
|
84
|
+
return range( 1, end );
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Near start
|
|
88
|
+
if ( currentPage <= NEAR_START_THRESHOLD ) {
|
|
89
|
+
if ( end !== undefined ) return [ ...range( 1, NEAR_START_THRESHOLD + 1 ), 'ellipsis', end ];
|
|
90
|
+
return [ ...range( 1, VISIBLE_PAGE_SLOTS - 1 ), 'ellipsis' ];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Near end (bounded only — open-ended has no "end zone")
|
|
94
|
+
if ( last !== undefined && currentPage >= last - ( NEAR_START_THRESHOLD - 1 ) ) {
|
|
95
|
+
return [ 1, 'ellipsis', ...range( last - NEAR_START_THRESHOLD, last ) ];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Middle
|
|
99
|
+
if ( end !== undefined ) {
|
|
100
|
+
const rangeEnd = Math.min( currentPage + PAGES_AFTER_CURRENT, end );
|
|
101
|
+
const middle = range( currentPage - PAGES_BEFORE_CURRENT, rangeEnd );
|
|
102
|
+
if ( rangeEnd >= end ) return [ 1, 'ellipsis', ...middle ];
|
|
103
|
+
return [ 1, 'ellipsis', ...middle, 'ellipsis', end ];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Fully open-ended middle
|
|
107
|
+
return [
|
|
108
|
+
1,
|
|
109
|
+
'ellipsis',
|
|
110
|
+
...range( currentPage - PAGES_BEFORE_CURRENT, currentPage + PAGES_AFTER_CURRENT + 1 ),
|
|
111
|
+
'ellipsis',
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const ItemsPerPageSelect = ( {
|
|
116
|
+
itemsPerPage,
|
|
117
|
+
pageSizeOptions,
|
|
118
|
+
onItemsPerPageChange,
|
|
119
|
+
}: {
|
|
120
|
+
itemsPerPage: number;
|
|
121
|
+
pageSizeOptions: number[];
|
|
122
|
+
onItemsPerPageChange: ( size: number ) => void;
|
|
123
|
+
} ) => (
|
|
124
|
+
<Select
|
|
125
|
+
id="items-per-page"
|
|
126
|
+
aria-label="Items per page"
|
|
127
|
+
separator={ false }
|
|
128
|
+
value={ itemsPerPage }
|
|
129
|
+
options={ pageSizeOptions.map( size => ( {
|
|
130
|
+
value: size,
|
|
131
|
+
label: `${ size.toString() } / page`,
|
|
132
|
+
} ) ) }
|
|
133
|
+
onChange={ option => onItemsPerPageChange( Number( option?.value ) ) }
|
|
134
|
+
/>
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const PageNumbers = ( {
|
|
138
|
+
currentPage,
|
|
139
|
+
totalPages,
|
|
140
|
+
hasNextPage,
|
|
141
|
+
maxReachablePage,
|
|
142
|
+
onPageChange,
|
|
143
|
+
}: {
|
|
144
|
+
currentPage: number;
|
|
145
|
+
totalPages?: number;
|
|
146
|
+
hasNextPage?: boolean;
|
|
147
|
+
maxReachablePage?: number;
|
|
148
|
+
onPageChange: ( page: number ) => void;
|
|
149
|
+
} ) => {
|
|
150
|
+
const pages = getPageNumbers( currentPage, totalPages, hasNextPage, maxReachablePage );
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<>
|
|
154
|
+
{ pages.map( ( page, index ) => {
|
|
155
|
+
if ( page === 'ellipsis' ) {
|
|
156
|
+
return <BiDotsHorizontalRounded key={ `ellipsis-${ index }` } />;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const isActive = page === currentPage;
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<Button
|
|
163
|
+
key={ page }
|
|
164
|
+
type="button"
|
|
165
|
+
onClick={ () => onPageChange( page ) }
|
|
166
|
+
aria-label={ `Go to page ${ page }` }
|
|
167
|
+
aria-current={ isActive ? 'page' : undefined }
|
|
168
|
+
sx={ isActive ? activePageButtonStyles : pageButtonStyles }
|
|
169
|
+
>
|
|
170
|
+
{ page }
|
|
171
|
+
</Button>
|
|
172
|
+
);
|
|
173
|
+
} ) }
|
|
174
|
+
</>
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const CompactPageSelector = ( {
|
|
179
|
+
currentPage,
|
|
180
|
+
totalPages,
|
|
181
|
+
maxReachablePage,
|
|
182
|
+
onPageChange,
|
|
183
|
+
}: {
|
|
184
|
+
currentPage: number;
|
|
185
|
+
totalPages?: number;
|
|
186
|
+
maxReachablePage?: number;
|
|
187
|
+
onPageChange: ( page: number ) => void;
|
|
188
|
+
} ) => {
|
|
189
|
+
const isOpenEnded = totalPages === undefined;
|
|
190
|
+
const upperBound: number = isOpenEnded ? maxReachablePage ?? currentPage + 1 : totalPages;
|
|
191
|
+
const pageOptions = Array.from( { length: upperBound }, ( _, i ) => i + 1 );
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<Flex sx={ compactTextStyles }>
|
|
195
|
+
<Text as="span" sx={ { fontSize: 2, color: 'heading', mb: 0 } }>
|
|
196
|
+
Page
|
|
197
|
+
</Text>
|
|
198
|
+
<Select
|
|
199
|
+
id="page"
|
|
200
|
+
aria-label="Page"
|
|
201
|
+
separator={ false }
|
|
202
|
+
value={ currentPage }
|
|
203
|
+
onChange={ option => onPageChange( Number( option?.value ) ) }
|
|
204
|
+
options={ pageOptions.map( page => ( { value: page, label: page.toString() } ) ) }
|
|
205
|
+
sx={ { minWidth: '70px', mx: 1 } }
|
|
206
|
+
/>
|
|
207
|
+
{ ! isOpenEnded && (
|
|
208
|
+
<Text as="span" sx={ { fontSize: 2, color: 'heading', mb: 0 } }>
|
|
209
|
+
of { totalPages }
|
|
210
|
+
</Text>
|
|
211
|
+
) }
|
|
212
|
+
</Flex>
|
|
213
|
+
);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
export const Pagination = forwardRef< HTMLElement, PaginationProps >(
|
|
217
|
+
(
|
|
218
|
+
{
|
|
219
|
+
displayItemsPerPageSelector = false,
|
|
220
|
+
currentPage,
|
|
221
|
+
totalItems,
|
|
222
|
+
totalPages,
|
|
223
|
+
itemsPerPage,
|
|
224
|
+
onPageChange,
|
|
225
|
+
onItemsPerPageChange,
|
|
226
|
+
hasNextPage,
|
|
227
|
+
maxReachablePage,
|
|
228
|
+
variant = 'full',
|
|
229
|
+
pageSizeOptions = [ 20, 50, 100 ],
|
|
230
|
+
className,
|
|
231
|
+
sx,
|
|
232
|
+
children,
|
|
233
|
+
...rest
|
|
234
|
+
},
|
|
235
|
+
ref
|
|
236
|
+
) => {
|
|
237
|
+
const resolvedTotalPages =
|
|
238
|
+
totalPages ??
|
|
239
|
+
( totalItems !== undefined ? Math.ceil( totalItems / itemsPerPage ) : undefined );
|
|
240
|
+
|
|
241
|
+
const isFirstPage = currentPage <= 1;
|
|
242
|
+
const isLastPage =
|
|
243
|
+
resolvedTotalPages !== undefined ? currentPage >= resolvedTotalPages : hasNextPage === false;
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<nav
|
|
247
|
+
ref={ ref }
|
|
248
|
+
aria-label="Pagination"
|
|
249
|
+
className={ classNames( 'vip-pagination-component', className ) }
|
|
250
|
+
sx={ { ...containerStyles, ...sx } }
|
|
251
|
+
{ ...rest }
|
|
252
|
+
>
|
|
253
|
+
<Box>
|
|
254
|
+
{ displayItemsPerPageSelector && (
|
|
255
|
+
<ItemsPerPageSelect
|
|
256
|
+
itemsPerPage={ itemsPerPage }
|
|
257
|
+
pageSizeOptions={ pageSizeOptions }
|
|
258
|
+
onItemsPerPageChange={ onItemsPerPageChange }
|
|
259
|
+
/>
|
|
260
|
+
) }
|
|
261
|
+
</Box>
|
|
262
|
+
<Box sx={ { flex: 1 } }>{ children }</Box>
|
|
263
|
+
<Flex sx={ navigationStyles }>
|
|
264
|
+
{ variant === 'full' && (
|
|
265
|
+
<PageNumbers
|
|
266
|
+
currentPage={ currentPage }
|
|
267
|
+
totalPages={ resolvedTotalPages }
|
|
268
|
+
hasNextPage={ hasNextPage }
|
|
269
|
+
maxReachablePage={ maxReachablePage }
|
|
270
|
+
onPageChange={ onPageChange }
|
|
271
|
+
/>
|
|
272
|
+
) }
|
|
273
|
+
|
|
274
|
+
{ variant === 'compact' && (
|
|
275
|
+
<CompactPageSelector
|
|
276
|
+
currentPage={ currentPage }
|
|
277
|
+
totalPages={ resolvedTotalPages }
|
|
278
|
+
maxReachablePage={ maxReachablePage }
|
|
279
|
+
onPageChange={ onPageChange }
|
|
280
|
+
/>
|
|
281
|
+
) }
|
|
282
|
+
|
|
283
|
+
<Button
|
|
284
|
+
aria-label="Previous page"
|
|
285
|
+
disabled={ isFirstPage }
|
|
286
|
+
onClick={ () => onPageChange( currentPage - 1 ) }
|
|
287
|
+
sx={ { ...arrowButtonStyles, ml: 4 } }
|
|
288
|
+
>
|
|
289
|
+
<MdChevronLeft size={ 20 } />
|
|
290
|
+
</Button>
|
|
291
|
+
|
|
292
|
+
<Button
|
|
293
|
+
aria-label="Next page"
|
|
294
|
+
disabled={ isLastPage }
|
|
295
|
+
onClick={ () => onPageChange( currentPage + 1 ) }
|
|
296
|
+
sx={ arrowButtonStyles }
|
|
297
|
+
>
|
|
298
|
+
<MdChevronRight size={ 20 } />
|
|
299
|
+
</Button>
|
|
300
|
+
</Flex>
|
|
301
|
+
</nav>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
Pagination.displayName = 'Pagination';
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { ThemeUIStyleObject } from 'theme-ui';
|
|
2
|
+
|
|
3
|
+
export const containerStyles: ThemeUIStyleObject = {
|
|
4
|
+
display: 'flex',
|
|
5
|
+
alignItems: 'center',
|
|
6
|
+
justifyContent: 'space-between',
|
|
7
|
+
flexFlow: 'row wrap',
|
|
8
|
+
gap: 3,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const navigationStyles: ThemeUIStyleObject = {
|
|
12
|
+
display: 'flex',
|
|
13
|
+
alignItems: 'center',
|
|
14
|
+
gap: 1,
|
|
15
|
+
justifySelf: 'flex-end',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const pageButtonStyles: ThemeUIStyleObject = {
|
|
19
|
+
minWidth: '32px',
|
|
20
|
+
height: '32px',
|
|
21
|
+
display: 'inline-flex',
|
|
22
|
+
alignItems: 'center',
|
|
23
|
+
justifyContent: 'center',
|
|
24
|
+
borderRadius: 1,
|
|
25
|
+
border: 'none',
|
|
26
|
+
bg: 'transparent',
|
|
27
|
+
color: 'heading',
|
|
28
|
+
cursor: 'pointer',
|
|
29
|
+
fontSize: 2,
|
|
30
|
+
fontFamily: 'body',
|
|
31
|
+
px: 2,
|
|
32
|
+
borderBottom: '3px solid',
|
|
33
|
+
borderColor: 'transparent',
|
|
34
|
+
'&:hover': {
|
|
35
|
+
bg: 'button.tertiary.background.hover',
|
|
36
|
+
},
|
|
37
|
+
'&:focus-visible': {
|
|
38
|
+
outline: '2px solid',
|
|
39
|
+
outlineColor: 'primary',
|
|
40
|
+
outlineOffset: '-2px',
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const activePageButtonStyles: ThemeUIStyleObject = {
|
|
45
|
+
...pageButtonStyles,
|
|
46
|
+
borderColor: 'button.display.background.default',
|
|
47
|
+
borderRadius: 0,
|
|
48
|
+
fontWeight: 'bold',
|
|
49
|
+
'&:hover': {
|
|
50
|
+
bg: 'button.tertiary.background.hover',
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const arrowButtonStyles: ThemeUIStyleObject = {
|
|
55
|
+
...pageButtonStyles,
|
|
56
|
+
'&:disabled, &[aria-disabled="true"]': {
|
|
57
|
+
bg: 'button.tertiary.background.disabled',
|
|
58
|
+
cursor: 'not-allowed',
|
|
59
|
+
opacity: 0.4,
|
|
60
|
+
pointerEvents: 'none',
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const ellipsisStyles: ThemeUIStyleObject = {
|
|
65
|
+
minWidth: '32px',
|
|
66
|
+
height: '32px',
|
|
67
|
+
display: 'inline-flex',
|
|
68
|
+
alignItems: 'center',
|
|
69
|
+
justifyContent: 'center',
|
|
70
|
+
color: 'muted',
|
|
71
|
+
fontSize: 2,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const compactTextStyles: ThemeUIStyleObject = {
|
|
75
|
+
display: 'inline-flex',
|
|
76
|
+
alignItems: 'center',
|
|
77
|
+
gap: 1,
|
|
78
|
+
fontSize: 2,
|
|
79
|
+
color: 'heading',
|
|
80
|
+
whiteSpace: 'nowrap',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const compactTriggerStyles: ThemeUIStyleObject = {
|
|
84
|
+
display: 'inline-flex',
|
|
85
|
+
alignItems: 'center',
|
|
86
|
+
gap: 1,
|
|
87
|
+
border: '1px solid',
|
|
88
|
+
borderColor: 'button.tertiary.border.default',
|
|
89
|
+
bg: 'transparent',
|
|
90
|
+
color: 'heading',
|
|
91
|
+
cursor: 'pointer',
|
|
92
|
+
fontSize: 2,
|
|
93
|
+
fontFamily: 'body',
|
|
94
|
+
fontWeight: 'bold',
|
|
95
|
+
px: 2,
|
|
96
|
+
borderRadius: 1,
|
|
97
|
+
'&:hover': {
|
|
98
|
+
bg: 'button.tertiary.background.hover',
|
|
99
|
+
borderColor: 'button.tertiary.background.hover',
|
|
100
|
+
},
|
|
101
|
+
'&:focus-visible': {
|
|
102
|
+
outline: '2px solid',
|
|
103
|
+
outlineColor: 'primary',
|
|
104
|
+
outlineOffset: '-2px',
|
|
105
|
+
},
|
|
106
|
+
};
|
package/src/system/index.js
CHANGED
|
@@ -50,6 +50,7 @@ import * as Form from './NewForm';
|
|
|
50
50
|
import { NewTooltip } from './NewTooltip';
|
|
51
51
|
import { Notice } from './Notice';
|
|
52
52
|
import { OptionRow } from './OptionRow';
|
|
53
|
+
import { Pagination } from './Pagination';
|
|
53
54
|
import { Progress } from './Progress';
|
|
54
55
|
import { ScreenReaderText } from './ScreenReaderText';
|
|
55
56
|
import { Skeleton } from './Skeleton';
|
|
@@ -98,6 +99,7 @@ export {
|
|
|
98
99
|
Flex,
|
|
99
100
|
Notice,
|
|
100
101
|
OptionRow,
|
|
102
|
+
Pagination,
|
|
101
103
|
Heading,
|
|
102
104
|
Hr,
|
|
103
105
|
Input,
|