|
4 | 4 | */ |
5 | 5 | type PageNumber = number | null; |
6 | 6 |
|
| 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 | + |
7 | 31 | /** |
8 | 32 | * 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. |
11 | 34 | * |
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: |
16 | 38 | * |
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. |
23 | 47 | * |
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. |
29 | 52 | */ |
30 | | -export function pageNumberOptions( |
| 53 | +export function paginationItems( |
31 | 54 | currentPage: number, |
32 | 55 | totalPages: number, |
33 | 56 | /* istanbul ignore next */ |
34 | | - maxPages = 5, |
| 57 | + { boundaryCount = 1, siblingCount = 1 }: Options = {}, |
35 | 58 | ): PageNumber[] { |
36 | 59 | if (totalPages <= 1) { |
37 | 60 | return []; |
38 | 61 | } |
39 | 62 |
|
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); |
42 | 66 |
|
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); |
50 | 77 | } |
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; |
53 | 161 | } |
54 | | - ++increment; |
55 | 162 | } |
56 | 163 |
|
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; |
73 | 165 | } |
0 commit comments