@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.
@@ -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,2 @@
1
+ export { Pagination, getPageNumbers } from './Pagination';
2
+ export type { PaginationProps, PaginationVariant, PageNumberItem } from './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
+ };
@@ -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,