@automattic/vip-design-system 2.17.0 → 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.
@@ -11,6 +11,7 @@ export interface PaginationProps {
11
11
  onPageChange: (page: number) => void;
12
12
  onItemsPerPageChange: (itemsPerPage: number) => void;
13
13
  hasNextPage?: boolean;
14
+ maxReachablePage?: number;
14
15
  variant?: PaginationVariant;
15
16
  pageSizeOptions?: number[];
16
17
  className?: string;
@@ -18,5 +19,5 @@ export interface PaginationProps {
18
19
  children?: React.ReactNode;
19
20
  }
20
21
  export type PageNumberItem = number | 'ellipsis';
21
- export declare function getPageNumbers(currentPage: number, totalPages?: number, hasNextPage?: boolean): PageNumberItem[];
22
+ export declare function getPageNumbers(currentPage: number, totalPages?: number, hasNextPage?: boolean, maxReachablePage?: number): PageNumberItem[];
22
23
  export declare const Pagination: import("react").ForwardRefExoticComponent<PaginationProps & import("react").RefAttributes<HTMLElement>>;
@@ -1,4 +1,4 @@
1
- var _excluded = ["displayItemsPerPageSelector", "currentPage", "totalItems", "totalPages", "itemsPerPage", "onPageChange", "onItemsPerPageChange", "hasNextPage", "variant", "pageSizeOptions", "className", "sx", "children"];
1
+ var _excluded = ["displayItemsPerPageSelector", "currentPage", "totalItems", "totalPages", "itemsPerPage", "onPageChange", "onItemsPerPageChange", "hasNextPage", "maxReachablePage", "variant", "pageSizeOptions", "className", "sx", "children"];
2
2
  function _extends() { _extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
3
3
  function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; }
4
4
  /** @jsxImportSource theme-ui */
@@ -23,34 +23,60 @@ function range(start, end) {
23
23
  return start + i;
24
24
  });
25
25
  }
26
- export function getPageNumbers(currentPage, totalPages, hasNextPage) {
26
+
27
+ /** Total number of visible items (page numbers + ellipsis indicators) in the pagination bar. */
28
+ var VISIBLE_PAGE_SLOTS = 8;
29
+
30
+ /** When currentPage <= this value, the "near start" layout is used (no leading ellipsis). */
31
+ var NEAR_START_THRESHOLD = 5;
32
+
33
+ /** Pages shown before the current page in the middle layout. */
34
+ var PAGES_BEFORE_CURRENT = 1;
35
+
36
+ /** Pages shown after the current page in the bounded middle layout. */
37
+ var PAGES_AFTER_CURRENT = 2;
38
+ export function getPageNumbers(currentPage, totalPages, hasNextPage, maxReachablePage) {
39
+ var _last;
40
+ // Resolve the last known page
27
41
  var last;
28
- if (totalPages === undefined) {
29
- if (hasNextPage === false) {
30
- last = currentPage;
31
- } else {
32
- last = undefined;
33
- }
34
- } else {
42
+ if (totalPages !== undefined) {
35
43
  last = Math.max(1, Number(totalPages));
44
+ } else if (hasNextPage === false) {
45
+ last = currentPage;
36
46
  }
37
47
  if (last !== undefined && (!Number.isFinite(last) || last < 1)) {
38
48
  return [];
39
49
  }
40
- if (last !== undefined && last <= 8) {
41
- return range(1, last);
50
+
51
+ // Effective end anchor: known last page, or capped reachable page
52
+ var end = (_last = last) != null ? _last : maxReachablePage !== undefined ? Math.max(currentPage, maxReachablePage) : undefined;
53
+
54
+ // Small page count — show all without ellipsis
55
+ if (end !== undefined && end <= VISIBLE_PAGE_SLOTS) {
56
+ return range(1, end);
42
57
  }
43
58
 
44
- // Bounded (last is a known page number)
45
- if (last !== undefined) {
46
- if (currentPage <= 5) return [].concat(range(1, 6), ['ellipsis', last]);
47
- if (currentPage >= last - 4) return [1, 'ellipsis'].concat(range(last - 5, last));
48
- return [1, 'ellipsis'].concat(range(currentPage - 1, currentPage + 2), ['ellipsis', last]);
59
+ // Near start
60
+ if (currentPage <= NEAR_START_THRESHOLD) {
61
+ if (end !== undefined) return [].concat(range(1, NEAR_START_THRESHOLD + 1), ['ellipsis', end]);
62
+ return [].concat(range(1, VISIBLE_PAGE_SLOTS - 1), ['ellipsis']);
49
63
  }
50
64
 
51
- // Open-ended (no known last page)
52
- if (currentPage <= 5) return [].concat(range(1, 7), ['ellipsis']);
53
- return [1, 'ellipsis'].concat(range(currentPage - 1, currentPage + 3), ['ellipsis']);
65
+ // Near end (bounded only — open-ended has no "end zone")
66
+ if (last !== undefined && currentPage >= last - (NEAR_START_THRESHOLD - 1)) {
67
+ return [1, 'ellipsis'].concat(range(last - NEAR_START_THRESHOLD, last));
68
+ }
69
+
70
+ // Middle
71
+ if (end !== undefined) {
72
+ var rangeEnd = Math.min(currentPage + PAGES_AFTER_CURRENT, end);
73
+ var middle = range(currentPage - PAGES_BEFORE_CURRENT, rangeEnd);
74
+ if (rangeEnd >= end) return [1, 'ellipsis'].concat(middle);
75
+ return [1, 'ellipsis'].concat(middle, ['ellipsis', end]);
76
+ }
77
+
78
+ // Fully open-ended middle
79
+ return [1, 'ellipsis'].concat(range(currentPage - PAGES_BEFORE_CURRENT, currentPage + PAGES_AFTER_CURRENT + 1), ['ellipsis']);
54
80
  }
55
81
  var ItemsPerPageSelect = function ItemsPerPageSelect(_ref) {
56
82
  var itemsPerPage = _ref.itemsPerPage,
@@ -76,8 +102,9 @@ var PageNumbers = function PageNumbers(_ref2) {
76
102
  var currentPage = _ref2.currentPage,
77
103
  totalPages = _ref2.totalPages,
78
104
  hasNextPage = _ref2.hasNextPage,
105
+ maxReachablePage = _ref2.maxReachablePage,
79
106
  onPageChange = _ref2.onPageChange;
80
- var pages = getPageNumbers(currentPage, totalPages, hasNextPage);
107
+ var pages = getPageNumbers(currentPage, totalPages, hasNextPage, maxReachablePage);
81
108
  return _jsx(_Fragment, {
82
109
  children: pages.map(function (page, index) {
83
110
  if (page === 'ellipsis') {
@@ -100,14 +127,12 @@ var PageNumbers = function PageNumbers(_ref2) {
100
127
  var CompactPageSelector = function CompactPageSelector(_ref3) {
101
128
  var currentPage = _ref3.currentPage,
102
129
  totalPages = _ref3.totalPages,
130
+ maxReachablePage = _ref3.maxReachablePage,
103
131
  onPageChange = _ref3.onPageChange;
104
132
  var isOpenEnded = totalPages === undefined;
105
- var pageOptions = isOpenEnded ? Array.from({
106
- length: currentPage + 1
107
- }, function (_, i) {
108
- return i + 1;
109
- }) : Array.from({
110
- length: totalPages
133
+ var upperBound = isOpenEnded ? maxReachablePage != null ? maxReachablePage : currentPage + 1 : totalPages;
134
+ var pageOptions = Array.from({
135
+ length: upperBound
111
136
  }, function (_, i) {
112
137
  return i + 1;
113
138
  });
@@ -160,6 +185,7 @@ export var Pagination = /*#__PURE__*/forwardRef(function (_ref4, ref) {
160
185
  onPageChange = _ref4.onPageChange,
161
186
  onItemsPerPageChange = _ref4.onItemsPerPageChange,
162
187
  hasNextPage = _ref4.hasNextPage,
188
+ maxReachablePage = _ref4.maxReachablePage,
163
189
  _ref4$variant = _ref4.variant,
164
190
  variant = _ref4$variant === void 0 ? 'full' : _ref4$variant,
165
191
  _ref4$pageSizeOptions = _ref4.pageSizeOptions,
@@ -194,10 +220,12 @@ export var Pagination = /*#__PURE__*/forwardRef(function (_ref4, ref) {
194
220
  currentPage: currentPage,
195
221
  totalPages: resolvedTotalPages,
196
222
  hasNextPage: hasNextPage,
223
+ maxReachablePage: maxReachablePage,
197
224
  onPageChange: onPageChange
198
225
  }), variant === 'compact' && _jsx(CompactPageSelector, {
199
226
  currentPage: currentPage,
200
227
  totalPages: resolvedTotalPages,
228
+ maxReachablePage: maxReachablePage,
201
229
  onPageChange: onPageChange
202
230
  }), _jsx(Button, {
203
231
  "aria-label": "Previous page",
@@ -13,6 +13,7 @@ export declare const FewPages: Story;
13
13
  export declare const MiddlePage: Story;
14
14
  export declare const CustomPageSizes: Story;
15
15
  export declare const WithItemsPerPageSelector: Story;
16
+ export declare const OpenEndedCursorBased: Story;
16
17
  export declare const OpenEnded: Story;
17
18
  export declare const OpenEndedCompact: Story;
18
19
  export declare const OpenEndedLastPage: Story;
@@ -161,6 +161,59 @@ export var WithItemsPerPageSelector = {
161
161
  });
162
162
  }
163
163
  };
164
+ var CursorBasedPaginationWithState = function CursorBasedPaginationWithState() {
165
+ var _useState5 = useState(1),
166
+ currentPage = _useState5[0],
167
+ setCurrentPage = _useState5[1];
168
+ var _useState6 = useState(20),
169
+ itemsPerPage = _useState6[0],
170
+ setItemsPerPage = _useState6[1];
171
+ var _useState7 = useState(1),
172
+ maxVisited = _useState7[0],
173
+ setMaxVisited = _useState7[1];
174
+ var handlePageChange = function handlePageChange(page) {
175
+ setCurrentPage(page);
176
+ setMaxVisited(function (prev) {
177
+ return Math.max(prev, page);
178
+ });
179
+ };
180
+ var hasNextPage = true; // Simulate always having a next page
181
+ var maxReachablePage = hasNextPage ? maxVisited + 1 : maxVisited;
182
+ return _jsx(Pagination, {
183
+ currentPage: currentPage,
184
+ itemsPerPage: itemsPerPage,
185
+ onPageChange: handlePageChange,
186
+ onItemsPerPageChange: function onItemsPerPageChange(size) {
187
+ setItemsPerPage(size);
188
+ setCurrentPage(1);
189
+ setMaxVisited(1);
190
+ },
191
+ hasNextPage: hasNextPage,
192
+ maxReachablePage: maxReachablePage,
193
+ displayItemsPerPageSelector: true,
194
+ children: _jsxs(Flex, {
195
+ sx: {
196
+ justifyContent: 'center',
197
+ alignItems: 'center',
198
+ verticalAlign: 'middle'
199
+ },
200
+ children: [_jsx(Badge, {
201
+ variant: "gold",
202
+ sx: {
203
+ mr: 2
204
+ },
205
+ children: "DEBUG"
206
+ }), _jsxs(Text, {
207
+ children: ["Page ", currentPage, " \u2014 max reachable: ", maxReachablePage]
208
+ })]
209
+ })
210
+ });
211
+ };
212
+ export var OpenEndedCursorBased = {
213
+ render: function render() {
214
+ return _jsx(CursorBasedPaginationWithState, {});
215
+ }
216
+ };
164
217
  export var OpenEnded = {
165
218
  render: function render() {
166
219
  return _jsx(OpenEndedPaginationWithState, {});
@@ -7,7 +7,6 @@ function _extends() { _extends = Object.assign ? Object.assign.bind() : function
7
7
  import { render, screen } from '@testing-library/react';
8
8
  import userEvent from '@testing-library/user-event';
9
9
  import { axe } from 'jest-axe';
10
- import React from 'react';
11
10
  import '@testing-library/jest-dom';
12
11
  import { Pagination, getPageNumbers } from './Pagination';
13
12
  import { jsx as _jsx } from "theme-ui/jsx-runtime";
@@ -253,6 +252,26 @@ describe('getPageNumbers', function () {
253
252
  expect(getPageNumbers(10, 20)).toEqual([1, 'ellipsis', 9, 10, 11, 12, 'ellipsis', 20]);
254
253
  });
255
254
  });
255
+ describe('getPageNumbers (open-ended with maxReachablePage)', function () {
256
+ it('caps pages to maxReachablePage when near start', function () {
257
+ expect(getPageNumbers(1, undefined, true, 2)).toEqual([1, 2]);
258
+ });
259
+ it('shows all reachable pages when they fit', function () {
260
+ expect(getPageNumbers(3, undefined, true, 4)).toEqual([1, 2, 3, 4]);
261
+ });
262
+ it('shows ellipsis for large reachable ranges', function () {
263
+ expect(getPageNumbers(8, undefined, true, 9)).toEqual([1, 'ellipsis', 7, 8, 9]);
264
+ });
265
+ it('shows both ellipsis when end is far from current page', function () {
266
+ expect(getPageNumbers(8, undefined, true, 15)).toEqual([1, 'ellipsis', 7, 8, 9, 10, 'ellipsis', 15]);
267
+ });
268
+ it('returns all pages when maxReachablePage <= 8', function () {
269
+ expect(getPageNumbers(1, undefined, true, 8)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
270
+ });
271
+ it('does not affect behavior when maxReachablePage is undefined', function () {
272
+ expect(getPageNumbers(1, undefined, true)).toEqual([1, 2, 3, 4, 5, 6, 7, 'ellipsis']);
273
+ });
274
+ });
256
275
  describe('getPageNumbers (open-ended)', function () {
257
276
  it('always returns 8 items when page >= 6', function () {
258
277
  for (var cp = 6; cp <= 20; cp++) {
@@ -272,6 +291,49 @@ describe('getPageNumbers (open-ended)', function () {
272
291
  expect(getPageNumbers(10, undefined, false)).toEqual([1, 'ellipsis', 5, 6, 7, 8, 9, 10]);
273
292
  });
274
293
  });
294
+ describe('<Pagination /> with maxReachablePage', function () {
295
+ var maxReachableProps = {
296
+ currentPage: 1,
297
+ itemsPerPage: 20,
298
+ hasNextPage: true,
299
+ maxReachablePage: 2,
300
+ onPageChange: jest.fn(),
301
+ onItemsPerPageChange: jest.fn()
302
+ };
303
+ it('only renders reachable page buttons', function () {
304
+ render(_jsx(Pagination, _extends({}, maxReachableProps)));
305
+ expect(screen.getByRole('button', {
306
+ name: 'Go to page 1'
307
+ })).toBeInTheDocument();
308
+ expect(screen.getByRole('button', {
309
+ name: 'Go to page 2'
310
+ })).toBeInTheDocument();
311
+ expect(screen.queryByRole('button', {
312
+ name: 'Go to page 3'
313
+ })).not.toBeInTheDocument();
314
+ expect(screen.queryByRole('button', {
315
+ name: 'Go to page 7'
316
+ })).not.toBeInTheDocument();
317
+ });
318
+ it('has no accessibility violations', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee6() {
319
+ var _render3, container;
320
+ return _regeneratorRuntime().wrap(function _callee6$(_context6) {
321
+ while (1) switch (_context6.prev = _context6.next) {
322
+ case 0:
323
+ _render3 = render(_jsx(Pagination, _extends({}, maxReachableProps))), container = _render3.container;
324
+ _context6.t0 = expect;
325
+ _context6.next = 4;
326
+ return axe(container);
327
+ case 4:
328
+ _context6.t1 = _context6.sent;
329
+ (0, _context6.t0)(_context6.t1).toHaveNoViolations();
330
+ case 6:
331
+ case "end":
332
+ return _context6.stop();
333
+ }
334
+ }, _callee6);
335
+ })));
336
+ });
275
337
  describe('<Pagination /> open-ended mode', function () {
276
338
  var openEndedProps = {
277
339
  currentPage: 5,
@@ -296,14 +358,14 @@ describe('<Pagination /> open-ended mode', function () {
296
358
  name: 'Next page'
297
359
  })).toBeDisabled();
298
360
  });
299
- it('calls onPageChange when clicking next in open-ended mode', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee6() {
361
+ it('calls onPageChange when clicking next in open-ended mode', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee7() {
300
362
  var user;
301
- return _regeneratorRuntime().wrap(function _callee6$(_context6) {
302
- while (1) switch (_context6.prev = _context6.next) {
363
+ return _regeneratorRuntime().wrap(function _callee7$(_context7) {
364
+ while (1) switch (_context7.prev = _context7.next) {
303
365
  case 0:
304
366
  user = userEvent.setup();
305
367
  render(_jsx(Pagination, _extends({}, openEndedProps)));
306
- _context6.next = 4;
368
+ _context7.next = 4;
307
369
  return user.click(screen.getByRole('button', {
308
370
  name: 'Next page'
309
371
  }));
@@ -311,9 +373,9 @@ describe('<Pagination /> open-ended mode', function () {
311
373
  expect(openEndedProps.onPageChange).toHaveBeenCalledWith(6);
312
374
  case 5:
313
375
  case "end":
314
- return _context6.stop();
376
+ return _context7.stop();
315
377
  }
316
- }, _callee6);
378
+ }, _callee7);
317
379
  })));
318
380
  it('renders compact variant with "Page" but without "of Y"', function () {
319
381
  render(_jsx(Pagination, _extends({}, openEndedProps, {
@@ -322,32 +384,12 @@ describe('<Pagination /> open-ended mode', function () {
322
384
  expect(screen.getByText('Page')).toBeInTheDocument();
323
385
  expect(screen.queryByText(/of \d+/)).not.toBeInTheDocument();
324
386
  });
325
- it('has no accessibility violations (open-ended full)', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee7() {
326
- var _render3, container;
327
- return _regeneratorRuntime().wrap(function _callee7$(_context7) {
328
- while (1) switch (_context7.prev = _context7.next) {
329
- case 0:
330
- _render3 = render(_jsx(Pagination, _extends({}, openEndedProps))), container = _render3.container;
331
- _context7.t0 = expect;
332
- _context7.next = 4;
333
- return axe(container);
334
- case 4:
335
- _context7.t1 = _context7.sent;
336
- (0, _context7.t0)(_context7.t1).toHaveNoViolations();
337
- case 6:
338
- case "end":
339
- return _context7.stop();
340
- }
341
- }, _callee7);
342
- })));
343
- it('has no accessibility violations (open-ended compact)', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee8() {
387
+ it('has no accessibility violations (open-ended full)', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee8() {
344
388
  var _render4, container;
345
389
  return _regeneratorRuntime().wrap(function _callee8$(_context8) {
346
390
  while (1) switch (_context8.prev = _context8.next) {
347
391
  case 0:
348
- _render4 = render(_jsx(Pagination, _extends({}, openEndedProps, {
349
- variant: "compact"
350
- }))), container = _render4.container;
392
+ _render4 = render(_jsx(Pagination, _extends({}, openEndedProps))), container = _render4.container;
351
393
  _context8.t0 = expect;
352
394
  _context8.next = 4;
353
395
  return axe(container);
@@ -360,4 +402,24 @@ describe('<Pagination /> open-ended mode', function () {
360
402
  }
361
403
  }, _callee8);
362
404
  })));
405
+ it('has no accessibility violations (open-ended compact)', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee9() {
406
+ var _render5, container;
407
+ return _regeneratorRuntime().wrap(function _callee9$(_context9) {
408
+ while (1) switch (_context9.prev = _context9.next) {
409
+ case 0:
410
+ _render5 = render(_jsx(Pagination, _extends({}, openEndedProps, {
411
+ variant: "compact"
412
+ }))), container = _render5.container;
413
+ _context9.t0 = expect;
414
+ _context9.next = 4;
415
+ return axe(container);
416
+ case 4:
417
+ _context9.t1 = _context9.sent;
418
+ (0, _context9.t0)(_context9.t1).toHaveNoViolations();
419
+ case 6:
420
+ case "end":
421
+ return _context9.stop();
422
+ }
423
+ }, _callee9);
424
+ })));
363
425
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/vip-design-system",
3
- "version": "2.17.0",
3
+ "version": "2.17.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/Automattic/vip-design-system"
@@ -154,6 +154,49 @@ export const WithItemsPerPageSelector: Story = {
154
154
  ),
155
155
  };
156
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
+
157
200
  export const OpenEnded: Story = {
158
201
  render: () => <OpenEndedPaginationWithState />,
159
202
  };
@@ -3,7 +3,6 @@
3
3
  import { render, screen } from '@testing-library/react';
4
4
  import userEvent from '@testing-library/user-event';
5
5
  import { axe } from 'jest-axe';
6
- import React from 'react';
7
6
  import '@testing-library/jest-dom';
8
7
 
9
8
  import { Pagination, getPageNumbers } from './Pagination';
@@ -178,6 +177,41 @@ describe( 'getPageNumbers', () => {
178
177
  } );
179
178
  } );
180
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
+
181
215
  describe( 'getPageNumbers (open-ended)', () => {
182
216
  it( 'always returns 8 items when page >= 6', () => {
183
217
  for ( let cp = 6; cp <= 20; cp++ ) {
@@ -210,6 +244,32 @@ describe( 'getPageNumbers (open-ended)', () => {
210
244
  } );
211
245
  } );
212
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
+
213
273
  describe( '<Pagination /> open-ended mode', () => {
214
274
  const openEndedProps = {
215
275
  currentPage: 5,
@@ -30,6 +30,7 @@ export interface PaginationProps {
30
30
  onPageChange: ( page: number ) => void;
31
31
  onItemsPerPageChange: ( itemsPerPage: number ) => void;
32
32
  hasNextPage?: boolean;
33
+ maxReachablePage?: number;
33
34
  variant?: PaginationVariant;
34
35
  pageSizeOptions?: number[];
35
36
  className?: string;
@@ -43,39 +44,72 @@ function range( start: number, end: number ): number[] {
43
44
  return Array.from( { length: end - start + 1 }, ( _, i ) => start + i );
44
45
  }
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
+
46
59
  export function getPageNumbers(
47
60
  currentPage: number,
48
61
  totalPages?: number,
49
- hasNextPage?: boolean
62
+ hasNextPage?: boolean,
63
+ maxReachablePage?: number
50
64
  ): PageNumberItem[] {
65
+ // Resolve the last known page
51
66
  let last: number | undefined;
52
- if ( totalPages === undefined ) {
53
- if ( hasNextPage === false ) {
54
- last = currentPage;
55
- } else {
56
- last = undefined;
57
- }
58
- } else {
67
+ if ( totalPages !== undefined ) {
59
68
  last = Math.max( 1, Number( totalPages ) );
69
+ } else if ( hasNextPage === false ) {
70
+ last = currentPage;
60
71
  }
61
72
 
62
73
  if ( last !== undefined && ( ! Number.isFinite( last ) || last < 1 ) ) {
63
74
  return [];
64
75
  }
65
- if ( last !== undefined && last <= 8 ) {
66
- return range( 1, last );
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 ) ];
67
96
  }
68
97
 
69
- // Bounded (last is a known page number)
70
- if ( last !== undefined ) {
71
- if ( currentPage <= 5 ) return [ ...range( 1, 6 ), 'ellipsis', last ];
72
- if ( currentPage >= last - 4 ) return [ 1, 'ellipsis', ...range( last - 5, last ) ];
73
- return [ 1, 'ellipsis', ...range( currentPage - 1, currentPage + 2 ), 'ellipsis', last ];
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 ];
74
104
  }
75
105
 
76
- // Open-ended (no known last page)
77
- if ( currentPage <= 5 ) return [ ...range( 1, 7 ), 'ellipsis' ];
78
- return [ 1, 'ellipsis', ...range( currentPage - 1, currentPage + 3 ), 'ellipsis' ];
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
+ ];
79
113
  }
80
114
 
81
115
  const ItemsPerPageSelect = ( {
@@ -104,14 +138,16 @@ const PageNumbers = ( {
104
138
  currentPage,
105
139
  totalPages,
106
140
  hasNextPage,
141
+ maxReachablePage,
107
142
  onPageChange,
108
143
  }: {
109
144
  currentPage: number;
110
145
  totalPages?: number;
111
146
  hasNextPage?: boolean;
147
+ maxReachablePage?: number;
112
148
  onPageChange: ( page: number ) => void;
113
149
  } ) => {
114
- const pages = getPageNumbers( currentPage, totalPages, hasNextPage );
150
+ const pages = getPageNumbers( currentPage, totalPages, hasNextPage, maxReachablePage );
115
151
 
116
152
  return (
117
153
  <>
@@ -142,16 +178,17 @@ const PageNumbers = ( {
142
178
  const CompactPageSelector = ( {
143
179
  currentPage,
144
180
  totalPages,
181
+ maxReachablePage,
145
182
  onPageChange,
146
183
  }: {
147
184
  currentPage: number;
148
185
  totalPages?: number;
186
+ maxReachablePage?: number;
149
187
  onPageChange: ( page: number ) => void;
150
188
  } ) => {
151
189
  const isOpenEnded = totalPages === undefined;
152
- const pageOptions = isOpenEnded
153
- ? Array.from( { length: currentPage + 1 }, ( _, i ) => i + 1 )
154
- : Array.from( { length: totalPages }, ( _, i ) => i + 1 );
190
+ const upperBound: number = isOpenEnded ? maxReachablePage ?? currentPage + 1 : totalPages;
191
+ const pageOptions = Array.from( { length: upperBound }, ( _, i ) => i + 1 );
155
192
 
156
193
  return (
157
194
  <Flex sx={ compactTextStyles }>
@@ -187,6 +224,7 @@ export const Pagination = forwardRef< HTMLElement, PaginationProps >(
187
224
  onPageChange,
188
225
  onItemsPerPageChange,
189
226
  hasNextPage,
227
+ maxReachablePage,
190
228
  variant = 'full',
191
229
  pageSizeOptions = [ 20, 50, 100 ],
192
230
  className,
@@ -228,6 +266,7 @@ export const Pagination = forwardRef< HTMLElement, PaginationProps >(
228
266
  currentPage={ currentPage }
229
267
  totalPages={ resolvedTotalPages }
230
268
  hasNextPage={ hasNextPage }
269
+ maxReachablePage={ maxReachablePage }
231
270
  onPageChange={ onPageChange }
232
271
  />
233
272
  ) }
@@ -236,6 +275,7 @@ export const Pagination = forwardRef< HTMLElement, PaginationProps >(
236
275
  <CompactPageSelector
237
276
  currentPage={ currentPage }
238
277
  totalPages={ resolvedTotalPages }
278
+ maxReachablePage={ maxReachablePage }
239
279
  onPageChange={ onPageChange }
240
280
  />
241
281
  ) }