Skip to content

Commit 06ca92c

Browse files
authored
add pagination element (#86)
* add pagination element * allow styling the pagination links * version * export pagination * add aria label; require href
1 parent 2984326 commit 06ca92c

File tree

6 files changed

+698
-3
lines changed

6 files changed

+698
-3
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openstax/ui-components",
3-
"version": "1.16.0-pre2",
3+
"version": "1.16.2",
44
"license": "MIT",
55
"source": "./src/index.ts",
66
"types": "./dist/index.d.ts",

src/components/Pagination.spec.tsx

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { render } from '@testing-library/react';
2+
import { Pagination, LinkForPage } from "./Pagination";
3+
4+
describe('Pagination', () => {
5+
let root: HTMLElement;
6+
7+
beforeEach(() => {
8+
root = document.createElement('main');
9+
root.id = 'root';
10+
document.body.append(root);
11+
});
12+
13+
it('matches snapshot', () => {
14+
render(<Pagination
15+
currentPage={1} totalPages={10}
16+
Page={({ page, current }) =>
17+
<LinkForPage page={page} current={current} href="#" />
18+
}
19+
/>, {container: root});
20+
expect(document.body).toMatchSnapshot();
21+
});
22+
23+
it('matches snapshot with dividers', () => {
24+
render(<Pagination
25+
currentPage={5} totalPages={10} showFromEnd={1} showFromCurrent={1}
26+
Page={({ page, current }) =>
27+
<LinkForPage page={page} current={current} href="#" />
28+
}
29+
/>, {container: root});
30+
expect(document.body).toMatchSnapshot();
31+
});
32+
33+
it('grows to min size', () => {
34+
render(<Pagination
35+
currentPage={1} totalPages={10} showFromEnd={1} showFromCurrent={1}
36+
Page={({ page, current }) =>
37+
<LinkForPage page={page} current={current} href="#" />
38+
}
39+
/>, {container: root});
40+
expect(document.body).toMatchSnapshot();
41+
});
42+
43+
it('grows to min size from back', () => {
44+
render(<Pagination
45+
currentPage={10} totalPages={10} showFromEnd={1} showFromCurrent={1}
46+
Page={({ page, current }) =>
47+
<LinkForPage page={page} current={current} href="#" />
48+
}
49+
/>, {container: root});
50+
expect(document.body).toMatchSnapshot();
51+
});
52+
53+
it('noops', () => {
54+
render(<Pagination
55+
currentPage={1} totalPages={1}
56+
Page={({ page, current }) =>
57+
<LinkForPage page={page} current={current} href="#" />
58+
}
59+
/>, {container: root});
60+
expect(document.body).toMatchSnapshot();
61+
});
62+
});

src/components/Pagination.stories.tsx

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import React from "react";
2+
import { Pagination, LinkForPage } from "./Pagination";
3+
4+
export const Examples = () => {
5+
const [currentPage, setCurrentPage] = React.useState(1);
6+
return <div>
7+
<h2>Default settings</h2>
8+
<Pagination
9+
currentPage={currentPage} totalPages={10}
10+
Page={({ page, current }) =>
11+
<LinkForPage page={page} current={current} onClick={() => setCurrentPage(page)} href="#" />
12+
}
13+
/>
14+
15+
<h2>Showing only one link from the end</h2>
16+
<Pagination
17+
currentPage={currentPage} totalPages={10} showFromEnd={1}
18+
Page={({ page, current }) =>
19+
<LinkForPage page={page} current={current} onClick={() => setCurrentPage(page)} href="#" />
20+
}
21+
/>
22+
23+
<h2>Showing zero links from the end</h2>
24+
<Pagination
25+
currentPage={currentPage} totalPages={10} showFromEnd={0}
26+
Page={({ page, current }) =>
27+
<LinkForPage page={page} current={current} onClick={() => setCurrentPage(page)} href="#" />
28+
}
29+
/>
30+
31+
<h2>Showing only one link from the end and current</h2>
32+
<Pagination
33+
currentPage={currentPage} totalPages={10} showFromEnd={1} showFromCurrent={1}
34+
Page={({ page, current }) =>
35+
<LinkForPage page={page} current={current} onClick={() => setCurrentPage(page)} href="#" />
36+
}
37+
/>
38+
39+
<h2>less links</h2>
40+
<Pagination
41+
currentPage={currentPage} totalPages={2}
42+
Page={({ page, current }) =>
43+
<LinkForPage page={page} current={current} onClick={() => setCurrentPage(page)} href="#" />
44+
}
45+
/>
46+
47+
<h2>more links and summary</h2>
48+
<Pagination
49+
currentPage={currentPage} totalPages={40} totalItems={395} pageSize={10}
50+
Page={({ page, current }) =>
51+
<LinkForPage page={page} current={current} onClick={() => setCurrentPage(page)} href="#" />
52+
}
53+
/>
54+
55+
<h2>zero links</h2>
56+
<Pagination
57+
currentPage={currentPage} totalPages={0}
58+
Page={({ page, current }) =>
59+
<LinkForPage page={page} current={current} onClick={() => setCurrentPage(page)} href="#" />
60+
}
61+
/>
62+
63+
<h2>one link</h2>
64+
<Pagination
65+
currentPage={currentPage} totalPages={1}
66+
Page={({ page, current }) =>
67+
<LinkForPage page={page} current={current} onClick={() => setCurrentPage(page)} href="#" />
68+
}
69+
/>
70+
</div>
71+
};

src/components/Pagination.tsx

+193
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import React from 'react';
2+
import styled from 'styled-components';
3+
import { palette } from "../theme/palette";
4+
5+
export const LinkForPage = styled(({ page, current, href, onClick, className }: {
6+
page: number;
7+
current?: boolean;
8+
href: string;
9+
className?: string;
10+
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
11+
}) => {
12+
const currentValue = current ? "page" : undefined;
13+
14+
return (
15+
<a
16+
className={className}
17+
aria-label={`Page ${page}`}
18+
aria-current={currentValue}
19+
href={href || '#'}
20+
onClick={onClick}
21+
>
22+
{page}
23+
</a>
24+
);
25+
})`
26+
`;
27+
28+
export const Pagination = styled((props: {
29+
className?: string;
30+
Page: (props: {page: number; current: boolean}) => React.ReactElement;
31+
currentPage: number;
32+
totalPages: number;
33+
totalItems?: number;
34+
pageSize?: number;
35+
showFromEnd?: number;
36+
showFromCurrent?: number;
37+
}) => {
38+
const {
39+
showFromEnd,
40+
showFromCurrent,
41+
pageSize,
42+
totalItems,
43+
className,
44+
currentPage,
45+
totalPages,
46+
Page,
47+
} = {
48+
showFromEnd: 3,
49+
showFromCurrent: 2,
50+
...props
51+
};
52+
53+
// the paginator would be empty, so short-circuit
54+
if (totalPages === 0 || totalPages === 1) {
55+
return null;
56+
}
57+
58+
// prevent nav from changing size as you switch pages
59+
const minEntries = showFromEnd * 2 + showFromCurrent * 2 +
60+
1 + // the current page
61+
2 // for the ellipsis
62+
;
63+
64+
const middleRange: [number, number] = [
65+
Math.max(1, Math.min(currentPage - showFromCurrent, totalPages + 1)),
66+
Math.min(totalPages, currentPage + showFromCurrent) + 1
67+
];
68+
const startRange: [number, number] = [
69+
1,
70+
Math.min(middleRange[0], showFromEnd + 1)
71+
];
72+
const endRange: [number, number] = [
73+
Math.max(1, middleRange[1], totalPages - showFromEnd + 1),
74+
totalPages + 1
75+
];
76+
77+
const numberOfEntries = Math.max(0, startRange[1] - startRange[0]) +
78+
Math.max(0, middleRange[1] - middleRange[0]) +
79+
Math.max(0, endRange[1] - endRange[0]) +
80+
(startRange[1] === middleRange[0] ? 0 : 1) +
81+
(middleRange[1] === endRange[0] ? 0 : 1)
82+
;
83+
84+
if (numberOfEntries < minEntries) {
85+
let remaining = minEntries - numberOfEntries;
86+
const delta = Math.floor(remaining / 2);
87+
88+
const firstGap = middleRange[0] - startRange[1];
89+
const secondGap = endRange[0] - middleRange[1];
90+
91+
const firstMod = Math.min(firstGap, secondGap === 0
92+
// there is no second gap, try use entire diff in the first
93+
? remaining
94+
: secondGap < (remaining - delta)
95+
// there is a gap but its smaller than the delta, so use it all
96+
// in the first and add one for losing the ellipsis
97+
? remaining - secondGap + 1
98+
: delta
99+
);
100+
remaining -= firstMod;
101+
const secondMod = Math.min(secondGap, remaining);
102+
103+
middleRange[0] = Math.max(1, middleRange[0] - firstMod);
104+
middleRange[1] = Math.min(totalPages + 1, middleRange[1] + secondMod);
105+
startRange[1] = Math.min(middleRange[0], showFromEnd + 1);
106+
endRange[0] = Math.max(middleRange[1], totalPages - showFromEnd + 1);
107+
}
108+
109+
return (
110+
<div className={className}>
111+
<nav aria-label="pagination links">
112+
<ul>
113+
{range(...startRange).map((p) =>
114+
<li key={p} className={currentPage === p ? 'active' : undefined}>
115+
<Page page={p} current={currentPage === p} />
116+
</li>
117+
)}
118+
{startRange[1] !== middleRange[0] ?
119+
<li className="disabled">
120+
<span>...</span>
121+
</li>
122+
: null}
123+
{range(...middleRange).map((p) =>
124+
<li key={p} className={currentPage === p ? 'active' : undefined}>
125+
<Page page={p} current={currentPage === p} />
126+
</li>
127+
)}
128+
{middleRange[1] !== endRange[0] ?
129+
<li className="disabled">
130+
<span>...</span>
131+
</li>
132+
: null}
133+
{range(...endRange).map((p) =>
134+
<li key={p} className={currentPage === p ? 'active' : undefined}>
135+
<Page page={p} current={currentPage === p} />
136+
</li>
137+
)}
138+
</ul>
139+
</nav>
140+
{pageSize && totalItems ? <div className="pagination-info">
141+
{(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, totalItems)} of {totalItems}
142+
</div> : null}
143+
</div>
144+
);
145+
})`
146+
text-align: center;
147+
148+
> nav > ul {
149+
list-style: none;
150+
padding: 0;
151+
border: thin solid ${palette.neutralLight};
152+
border-radius: 0.5rem;
153+
display: inline-block;
154+
margin: 0 auto;
155+
156+
> li {
157+
margin: 0;
158+
min-width: 4rem;
159+
text-align: center;
160+
display: inline-block;
161+
162+
&:not(:last-child) {
163+
border-right: thin solid ${palette.neutralLight};
164+
}
165+
166+
&.active,
167+
&:focus-within:not(.disabled),
168+
&:hover:not(.disabled) {
169+
background-color: ${palette.neutralLighter};
170+
}
171+
172+
> ${LinkForPage},span {
173+
padding: 1rem;
174+
display: block;
175+
text-decoration: none;
176+
font-size: 1.6rem;
177+
line-height: 1.3rem;
178+
margin: 0;
179+
color: inherit;
180+
}
181+
}
182+
}
183+
184+
.pagination-info {
185+
margin-top: 0.5rem;
186+
font-size: 1.6rem;
187+
}
188+
`;
189+
190+
function range(lower: number, upper: number) {
191+
if (upper < lower) return [];
192+
return Array.from({length: upper-lower}).map((_, i) => i + lower);
193+
}

0 commit comments

Comments
 (0)