Skip to content

Commit 2d6d3a4

Browse files
committed
Revise selection of pagination items
Revise the calculation of the set of page numbers so that the number of pagination items (page numbers and elided-page markers) is consistent regardless of the current page. If each item is rendered at a consistent width, this keeps the controls in the same place as the user navigates through the page list, avoiding mis-clicks due to pages jumping around.
1 parent 8778164 commit 2d6d3a4

File tree

4 files changed

+271
-72
lines changed

4 files changed

+271
-72
lines changed

src/components/navigation/Pagination.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import classnames from 'classnames';
22
import type { ComponentChildren } from 'preact';
33

4-
import { pageNumberOptions } from '../../util/pagination';
4+
import { paginationItems } from '../../util/pagination';
55
import { ArrowLeftIcon, ArrowRightIcon } from '../icons';
66
import Button from '../input/Button';
77

@@ -75,7 +75,7 @@ export default function Pagination({
7575
// Pages are 1-indexed
7676
const hasNextPage = currentPage < totalPages;
7777
const hasPreviousPage = currentPage > 1;
78-
const pageNumbers = pageNumberOptions(currentPage, totalPages);
78+
const pageNumbers = paginationItems(currentPage, totalPages);
7979

8080
const changePageTo = (pageNumber: number, element: HTMLElement) => {
8181
onChangePage(pageNumber);

src/components/navigation/test/Pagination-test.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Pagination, { $imports } from '../Pagination';
44

55
describe('Pagination', () => {
66
let fakeOnChangePage;
7-
let fakePageNumberOptions;
7+
let fakePaginationItems;
88

99
const findButton = (wrapper, title) =>
1010
wrapper.find('button').filterWhere(n => n.props().title === title);
@@ -22,10 +22,10 @@ describe('Pagination', () => {
2222

2323
beforeEach(() => {
2424
fakeOnChangePage = sinon.stub();
25-
fakePageNumberOptions = sinon.stub().returns([1, 2, 3, 4, null, 10]);
25+
fakePaginationItems = sinon.stub().returns([1, 2, 3, 4, null, 10]);
2626

2727
$imports.$mock({
28-
'../../util/pagination': { pageNumberOptions: fakePageNumberOptions },
28+
'../../util/pagination': { paginationItems: fakePaginationItems },
2929
});
3030
});
3131

@@ -103,7 +103,7 @@ describe('Pagination', () => {
103103

104104
describe('page number buttons', () => {
105105
it('should render buttons for each page number available', () => {
106-
fakePageNumberOptions.returns([1, 2, 3, 4, null, 10]);
106+
fakePaginationItems.returns([1, 2, 3, 4, null, 10]);
107107
const wrapper = createComponent();
108108

109109
[1, 2, 3, 4, 10].forEach(pageNumber => {
@@ -116,7 +116,7 @@ describe('Pagination', () => {
116116
});
117117

118118
it('should invoke the onChangePage callback when page number button clicked', () => {
119-
fakePageNumberOptions.returns([1, 2, 3, 4, null, 10]);
119+
fakePaginationItems.returns([1, 2, 3, 4, null, 10]);
120120
const wrapper = createComponent();
121121

122122
[1, 2, 3, 4, 10].forEach(pageNumber => {

src/util/pagination.ts

Lines changed: 139 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4,70 +4,162 @@
44
*/
55
type PageNumber = number | null;
66

7+
type ElidedRange = {
8+
/** Position of elided range item. */
9+
index: number;
10+
/** Number of items in this elided range. */
11+
count: number;
12+
/** Next value to consume from this elided range. */
13+
value: number;
14+
};
15+
16+
export type Options = {
17+
/**
18+
* Number of pages to display at the start and end, including the start/end
19+
* page.
20+
*
21+
* This must be >= 1.
22+
*/
23+
boundaryCount?: number;
24+
25+
/**
26+
* Number of pages to display before and after the current page.
27+
*/
28+
siblingCount?: number;
29+
};
30+
731
/**
832
* Determine the set of (pagination) page numbers that should be provided to
9-
* a user, given the current page the user is on, the total number of pages
10-
* available, and the number of individual page options desired.
33+
* a user.
1134
*
12-
* The first, last and current pages will always be included in the returned
13-
* results. Additional pages adjacent to the current page will be added until
14-
* `maxPages` is reached. Gaps in the sequence of pages are represented by
15-
* `null` values.
35+
* The result includes a mixture of page numbers that should be shown, plus
36+
* `null` values indicating elided page numbers. The goals of the selection
37+
* are:
1638
*
17-
* @example
18-
* pageNumberOptions(1, 10, 5) => [1, 2, 3, 4, null, 10]
19-
* pageNumberOptions(3, 10, 5) => [1, 2, 3, 4, null, 10]
20-
* pageNumberOptions(6, 10, 5) => [1, null, 5, 6, 7, null, 10]
21-
* pageNumberOptions(9, 10, 5) => [1, null, 7, 8, 9, 10]
22-
* pageNumberOptions(2, 3, 5) => [1, 2, 3]
39+
* - To always provide page numbers for the first, last and current pages.
40+
* Additional adjacent pages are provided according to the `boundaryCount`
41+
* and `siblingCount` options.
42+
* - To try and keep the number of pagination items consistent as the current
43+
* page changes. If each item is rendered with approximately the same width,
44+
* this keeps the overall width of the pagination component and the location
45+
* of child controls consistent as the user navigates. This helps to avoid
46+
* mis-clicks due to controls moving around under the cursor.
2347
*
24-
* @param currentPage - The currently-visible/-active page of results.
25-
* Note that pages are 1-indexed
26-
* @param maxPages - The maximum number of numbered pages to make available
27-
* @return Set of navigation page options to show. `null` values represent gaps
28-
* in the sequence of pages, to be represented later as ellipses (...)
48+
* @param currentPage - The 1-based currently-visible/-active page number.
49+
* @param totalPages - The total number of pages
50+
* @param options - Options for the number of pages to show at the boundary and
51+
* around the current page.
2952
*/
30-
export function pageNumberOptions(
53+
export function paginationItems(
3154
currentPage: number,
3255
totalPages: number,
3356
/* istanbul ignore next */
34-
maxPages = 5,
57+
{ boundaryCount = 1, siblingCount = 1 }: Options = {},
3558
): PageNumber[] {
3659
if (totalPages <= 1) {
3760
return [];
3861
}
3962

40-
// Start with first, last and current page. Use a set to avoid dupes.
41-
const pageNumbers = new Set([1, currentPage, totalPages]);
63+
currentPage = Math.max(1, Math.min(currentPage, totalPages));
64+
boundaryCount = Math.max(boundaryCount, 1);
65+
siblingCount = Math.max(siblingCount, 0);
4266

43-
// Fill out the `pageNumbers` with additional pages near the currentPage,
44-
// if available
45-
let increment = 1;
46-
while (pageNumbers.size < Math.min(totalPages, maxPages)) {
47-
// Build the set "outward" from the currently-active page
48-
if (currentPage + increment <= totalPages) {
49-
pageNumbers.add(currentPage + increment);
67+
const pageNumbers: PageNumber[] = [];
68+
const beforeCurrent = currentPage - 1;
69+
const afterCurrent = totalPages - currentPage;
70+
71+
const elideBeforeCurrent = boundaryCount + siblingCount < beforeCurrent;
72+
let elideBefore: ElidedRange | null = null;
73+
74+
if (elideBeforeCurrent) {
75+
for (let page = 1; page <= boundaryCount; page++) {
76+
pageNumbers.push(page);
5077
}
51-
if (currentPage - increment >= 1) {
52-
pageNumbers.add(currentPage - increment);
78+
79+
elideBefore = {
80+
index: pageNumbers.length,
81+
count: currentPage - siblingCount - boundaryCount,
82+
// Last value in elided range, as we expand backwards
83+
value: currentPage - siblingCount - 1,
84+
};
85+
pageNumbers.push(null);
86+
87+
for (let page = currentPage - siblingCount; page < currentPage; page++) {
88+
pageNumbers.push(page);
89+
}
90+
} else {
91+
for (let page = 1; page < currentPage; page++) {
92+
pageNumbers.push(page);
93+
}
94+
}
95+
96+
pageNumbers.push(currentPage);
97+
98+
const elideAfterCurrent = boundaryCount + siblingCount < afterCurrent;
99+
let elideAfter: ElidedRange | null = null;
100+
101+
if (elideAfterCurrent) {
102+
for (
103+
let page = currentPage + 1;
104+
page <= currentPage + siblingCount;
105+
page++
106+
) {
107+
pageNumbers.push(page);
108+
}
109+
110+
elideAfter = {
111+
index: pageNumbers.length,
112+
count: totalPages - boundaryCount + 1 - (currentPage + siblingCount),
113+
// First value in elided range, as we expand forwards
114+
value: currentPage + siblingCount + 1,
115+
};
116+
pageNumbers.push(null);
117+
118+
for (
119+
let page = totalPages - boundaryCount + 1;
120+
page <= totalPages;
121+
page++
122+
) {
123+
pageNumbers.push(page);
124+
}
125+
} else {
126+
for (let page = currentPage + 1; page <= totalPages; page++) {
127+
pageNumbers.push(page);
128+
}
129+
}
130+
131+
// Calculate the maximum number of items we will show for the total number
132+
// of pages and options.
133+
const maxItems = Math.min(
134+
// First and last pages
135+
2 * boundaryCount +
136+
// Pages adjacent to current page
137+
2 * siblingCount +
138+
// Current page, indicators for elided pages before and after current.
139+
3,
140+
totalPages,
141+
);
142+
143+
// To keep the number of items consistent as the current page changes,
144+
// expand the elided ranges until we reach the maximum.
145+
while (
146+
pageNumbers.length < maxItems &&
147+
(elideAfter?.count || elideBefore?.count)
148+
) {
149+
if (elideAfter && elideAfter.count > 0) {
150+
// Expand ahead of current page if possible, starting with numbers closest
151+
// to current page.
152+
pageNumbers.splice(elideAfter.index, 0, elideAfter.value);
153+
++elideAfter.index;
154+
++elideAfter.value;
155+
--elideAfter.count;
156+
} else if (elideBefore) {
157+
// Otherwise expand behind, starting with numbers closest to current page.
158+
pageNumbers.splice(elideBefore.index + 1, 0, elideBefore.value);
159+
--elideBefore.value;
160+
--elideBefore.count;
53161
}
54-
++increment;
55162
}
56163

57-
const pageOptions: PageNumber[] = [];
58-
59-
// Construct a numerically-sorted array with `null` entries inserted
60-
// between non-sequential entries
61-
[...pageNumbers]
62-
.sort((a, b) => a - b)
63-
.forEach((page, idx, arr) => {
64-
if (idx > 0 && page - arr[idx - 1] > 1) {
65-
// Two page entries are non-sequential. Push a `null` value between
66-
// them to indicate the gap, which will later be represented as an
67-
// ellipsis
68-
pageOptions.push(null);
69-
}
70-
pageOptions.push(page);
71-
});
72-
return pageOptions;
164+
return pageNumbers;
73165
}

0 commit comments

Comments
 (0)