I recently implemented a component for Pagination buttons using blueprint. It implements collapsing the buttons, and can also support loading parameter. I wrote it in jsx, but it's easy to port it to typescript. Would a PR be accepted to add such a component in @blueprintjs/core package? Thanks!

@prashnts wow that's pretty neat! we'd happily review a PR, but it may take 2-3 weeks to ship because the team has some internal commitments. are you comfortable with the typescript conversion?
This is pretty neat indeed, but what's the case for adding it to core? I prefer being conservative regarding what gets in, and so far can't see this fit in. I also think it's fair to say that pagination is a dying pattern in modern applications, and even internally we've never had a need for it.
We really should set up that community contrib package/infra to welcome these @giladgray
@giladgray yes, I am. Plus the blueprint source here is a great reference. :)
@llorca while I don't agree that pagination is dying (a lot of enterprise apps would want it), I do agree that the case of adding components composed of core components back to core isn't justified. A contrib infra would be more justified.
@prashnts any chance you have this somewhere to take a look at or have published as -contrib to npm?
Are there any updates on this? Or is the component shown by @prashnts available somewhere?
that screenshot is just a ButtonGroup with some disabled ... buttons, no big deal.
If pagination is a dying pattern, what is the modern alternative? For example if a filter gives a result of 200 records (database query), I only show like 10 to keep things fast. Are there better ways?
Would be nice to have it :D Where I can find that @prashnts ?
A very simple pagination component for blueprint using useReducer hook based on this article https://jasonwatmore.com/post/2017/03/14/react-pagination-example-with-logic-like-google
import { useReducer } from 'react'
import { Button, ButtonGroup, Intent } from '@blueprintjs/core'
import PropTypes from 'prop-types'
const getState = ({ currentPage, size, total }) => {
const totalPages = Math.ceil(total / size)
// create an array of pages to ng-repeat in the pager control
let startPage, endPage
if (totalPages <= 10) {
// less than 10 total pages so show all
startPage = 1
endPage = totalPages
} else {
// more than 10 total pages so calculate start and end pages
if (currentPage <= 6) {
startPage = 1
endPage = 10
} else if (currentPage + 4 >= totalPages) {
startPage = totalPages - 9
endPage = totalPages
} else {
startPage = currentPage - 5
endPage = currentPage + 4
}
}
const pages = [...Array(endPage + 1 - startPage).keys()].map(
i => startPage + i
)
// Too large or small currentPage
let correctCurrentpage = currentPage
if (currentPage > totalPages) correctCurrentpage = totalPages
if (currentPage <= 0) correctCurrentpage = 1
return {
currentPage: correctCurrentpage,
size,
total,
pages,
totalPages
}
}
const reducer = (state, action) => {
switch (action.type) {
case 'PAGE_CHANGE':
return getState({
currentPage: action.page,
size: state.size,
total: state.total
})
default:
throw new Error()
}
}
const Pagination = ({ initialPage, total, size, onPageChange }) => {
const [state, dispatch] = useReducer(
reducer,
{ currentPage: initialPage, total, size },
getState
)
if (state.totalPages === 1) return null
return (
<div>
<h3>{JSON.stringify(state)}</h3>
<br />
<ButtonGroup>
<Button
disabled={state.currentPage === 1}
onClick={() => {
dispatch({ type: 'PAGE_CHANGE', page: 1 })
onPageChange(1)
}}
>
First
</Button>
{state.pages.map(page => (
<Button
key={page}
intent={state.currentPage === page ? Intent.PRIMARY : Intent.NONE}
disabled={state.currentPage === page}
onClick={() => {
dispatch({ type: 'PAGE_CHANGE', page })
onPageChange(page)
}}
>
{page}
</Button>
))}
<Button
disabled={state.currentPage === state.totalPages}
onClick={() => {
dispatch({ type: 'PAGE_CHANGE', page: state.totalPages })
onPageChange(state.totalPages)
}}
>
Last
</Button>
</ButtonGroup>
</div>
)
}
Pagination.propTypes = {
initialPage: PropTypes.number.isRequired,
size: PropTypes.number.isRequired,
total: PropTypes.number.isRequired,
onPageChange: PropTypes.func
}
Pagination.defaultProps = {
initialPage: 1,
size: 25
}
export default Pagination
A typescript version base on tphan18's script ( with small change )
import React, { useReducer, Reducer } from 'react';
import { Button, ButtonGroup, Intent } from '@blueprintjs/core';
interface Props {
initialPage?: number;
total: number;
size?: number;
onPageChange: (page: number) => void;
}
interface initialState {
currentPage: number;
size: number;
total: number;
}
interface State extends initialState {
pages: number[];
totalPages: number;
}
type Actions = { type: 'PAGE_CHANGE'; page: number };
const getState = ({ currentPage, size, total }: initialState): State => {
const totalPages = Math.ceil(total / size);
// create an array of pages to ng-repeat in the pager control
let startPage = 0,
endPage = 0;
if (totalPages <= 10) {
// less than 10 total pages so show all
startPage = 1;
endPage = totalPages;
} else {
// more than 10 total pages so calculate start and end pages
if (currentPage <= 6) {
startPage = 1;
endPage = 10;
} else if (currentPage + 4 >= totalPages) {
startPage = totalPages - 9;
endPage = totalPages;
} else {
startPage = currentPage - 5;
endPage = currentPage + 4;
}
}
const pages = Array.from(
{ length: endPage + 1 - startPage },
(_, i) => startPage + i
);
// Too large or small currentPage
let correctCurrentpage = currentPage;
if (currentPage > totalPages) correctCurrentpage = totalPages;
if (currentPage <= 0) correctCurrentpage = 1;
return {
currentPage: correctCurrentpage,
size,
total,
pages,
totalPages
};
};
const reducer: Reducer<State, Actions> = (state, action) => {
switch (action.type) {
case 'PAGE_CHANGE':
return getState({
...state,
currentPage: action.page
});
default:
throw new Error();
}
};
export const Pagination = React.memo<Props>(
({ initialPage = 1, total, size = 10, onPageChange }) => {
const [state, dispatch] = useReducer(
reducer,
{ currentPage: initialPage, total, size, totalPages: 0 },
getState
);
const changePage = (page: number) => {
dispatch({ type: 'PAGE_CHANGE', page });
onPageChange(page);
};
if (state.totalPages === 1) return null;
return (
<div>
<ButtonGroup>
<Button
disabled={state.currentPage === 1}
onClick={() => changePage(1)}
>
First
</Button>
<Button
icon="chevron-left"
disabled={state.currentPage === 1}
onClick={() => changePage(Math.max(1, state.currentPage - 1))}
/>
{state.pages.map(page => (
<Button
key={page}
intent={state.currentPage === page ? Intent.PRIMARY : Intent.NONE}
onClick={() => changePage(page)}
>
{page}
</Button>
))}
<Button
icon="chevron-right"
disabled={state.currentPage === state.totalPages}
onClick={() =>
changePage(Math.min(state.currentPage + 1, state.totalPages))
}
/>
<Button
disabled={state.currentPage === state.totalPages}
onClick={() => changePage(state.totalPages)}
>
Last
</Button>
</ButtonGroup>
</div>
);
}
);
For what it's worth, here's another iteration on @Pong420 's TypeScript version. I've added some tooltips, clarified what a lot of the "magic numbers" in the original mean, and added the ellipsis behavior in this proposal. This version always shows an odd number of pages, the current page plus a certain number on either side of it, which is more like what the proposed pull request had.
import {
Button,
ButtonGroup,
Intent,
Position,
Tooltip,
} from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import * as React from "react";
import { Reducer, useReducer } from "react";
interface Props {
initialPage?: number;
total: number;
size?: number;
onPageChange: (page: number) => void;
}
interface InitialState {
currentPage: number;
size: number;
total: number;
}
interface State extends InitialState {
pages: number[];
showEndEllipsis: boolean;
showStartEllipsis: boolean;
totalPages: number;
}
interface Actions {
// tslint:disable-next-line:no-reserved-keywords
type: "PAGE_CHANGE";
page: number;
}
const getState = ({ currentPage, size, total }: InitialState): State => {
const totalPages = Math.ceil(total / size);
const PAGES_TO_SHOW = 5;
const PAGES_ON_EITHER_SIDE = 2;
let showStartEllipsis = false;
let showEndEllipsis = false;
// create an array of pages to repeat in the pager control
let startPage = 0;
let endPage = 0;
if (totalPages <= PAGES_TO_SHOW) {
// less than PAGES_TO_SHOW total pages, so show all
startPage = 1;
endPage = totalPages;
} else {
if (currentPage <= PAGES_TO_SHOW - PAGES_ON_EITHER_SIDE) {
// more than PAGINATION_THRESHOLD total pages so calculate start and end pages
startPage = 1;
endPage = PAGES_TO_SHOW;
showEndEllipsis = true;
} else if (currentPage + PAGES_ON_EITHER_SIDE >= totalPages) {
// current page approaching the total pages
startPage = totalPages - (PAGES_TO_SHOW - 1);
endPage = totalPages;
showStartEllipsis = true;
} else {
// current page is somewhere in the middle
startPage = currentPage - PAGES_ON_EITHER_SIDE;
endPage = currentPage + PAGES_ON_EITHER_SIDE;
showStartEllipsis = true;
showEndEllipsis = true;
}
}
const pages = Array.from(
{ length: endPage + 1 - startPage },
(_, i) => startPage + i
);
// Too large or small currentPage
let correctCurrentPage = currentPage;
if (currentPage > totalPages) {
correctCurrentPage = totalPages;
}
if (currentPage <= 0) {
correctCurrentPage = 1;
}
return {
currentPage: correctCurrentPage,
pages,
showEndEllipsis,
showStartEllipsis,
size,
total,
totalPages,
};
};
const reducer: Reducer<State, Actions> = (state, action) => {
switch (action.type) {
case "PAGE_CHANGE":
return getState({
...state,
currentPage: action.page,
});
default:
throw new Error();
}
};
export const Pagination = React.memo<Props>(
({ initialPage = 1, total, size = 10, onPageChange }) => {
const [state, dispatch] = useReducer(
reducer,
{ currentPage: initialPage, total, size, totalPages: 0 },
getState
);
const changePage = (page: number) => {
dispatch({ type: "PAGE_CHANGE", page });
onPageChange(page);
};
if (state.totalPages === 1) {
return null;
}
return (
<div>
<ButtonGroup>
<Tooltip
content="First Page"
disabled={state.currentPage === 1}
position={Position.TOP}
>
<Button
disabled={state.currentPage === 1}
icon={IconNames.DOUBLE_CHEVRON_LEFT}
onClick={() => changePage(1)}
/>
</Tooltip>
<Tooltip
content="Previous Page"
disabled={state.currentPage === 1}
position={Position.TOP}
>
<Button
icon={IconNames.CHEVRON_LEFT}
disabled={state.currentPage === 1}
onClick={() =>
changePage(Math.max(1, state.currentPage - 1))
}
/>
</Tooltip>
{state.showStartEllipsis && (
<Button disabled={true}>…</Button>
)}
{state.pages.map(page => (
<Button
key={page}
intent={
state.currentPage === page
? Intent.PRIMARY
: Intent.NONE
}
onClick={() => changePage(page)}
>
{page}
</Button>
))}
{state.showEndEllipsis && (
<Button disabled={true}>…</Button>
)}
<Tooltip
content="Next Page"
disabled={state.currentPage === state.totalPages}
position={Position.TOP}
>
<Button
icon={IconNames.CHEVRON_RIGHT}
disabled={state.currentPage === state.totalPages}
onClick={() =>
changePage(
Math.min(
state.currentPage + 1,
state.totalPages
)
)
}
/>
</Tooltip>
<Tooltip
content="Last Page"
disabled={state.currentPage === state.totalPages}
position={Position.TOP}
>
<Button
disabled={state.currentPage === state.totalPages}
icon={IconNames.DOUBLE_CHEVRON_RIGHT}
onClick={() => changePage(state.totalPages)}
/>
</Tooltip>
</ButtonGroup>
</div>
);
}
);
Hey y'all! Pretty glad that my this little PR proposal that I forgot about is still open. :)
I guess you can call me the slightly fatigued with constant churn in JS ecosystem since I still try to keep the code as per standard, but don't get to keep on it. Thanks for keeping blueprintjs stable forever!
This is how I did it back in 2017, but still works. I did make some questionable choices in composition, and might not be super readable but you can copy the ascii arts in comments if you want! It still gets used here: https://github.com/CyberCRI/learn-ext/blob/master/src/components/resources/pagination.js because it "works"!
import React from 'react'
import { Button, ButtonGroup } from '@blueprintjs/core'
const CELL_COUNT = 8
function pagingCells (n, pos, maxCells=CELL_COUNT) {
// Consider an array of pages with length `n`. Let `p` be cursor position.
// [1, 2, 3, ..., n-1, n]
//
// Requirements:
// - In all cases we want to keep `1` and `n` visible.
// - We cant render more than CELL_COUNT items.
// - If the cells exceed CELL_COUNT, insert `...` wherever appropriate.
const offset = n - pos
const pivot = ~~(maxCells / 2)
let cells = []
if (n > CELL_COUNT) {
// Fill in first and last positions
cells[0] = { nr: 1 }
cells[1] = { nr: 2 }
cells[CELL_COUNT - 2] = { nr: n - 1 }
cells[CELL_COUNT - 1] = { nr: n }
if (pos <= pivot) {
// last ellipse is enabled and the rest of the array is filled
cells[CELL_COUNT - 2].ellipsis = true
for (let i = 2; i < CELL_COUNT - 2; i++) {
cells[i] = { nr: i + 1 }
}
} else if (offset < pivot) {
// a ellipsis is enabled and the later part of array is filled
cells[1].ellipsis = true
for (let i = 2; i < CELL_COUNT - 2; i++) {
cells[i] = { nr: n - CELL_COUNT + i + 1 }
}
} else {
// both a and b ellipsis are enabled
cells[1].ellipsis = true
cells[CELL_COUNT - 2].ellipsis = true
// Current selected is put in centre
cells[pivot] = { nr: pos }
// Fill next and prev to mid point
// CELL_COUNT - 5 := n{MID, FIRST, SECOND, LAST, SECONDLAST}
for (let i = 1; i < CELL_COUNT - 5; i++) {
cells[pivot + i] = { nr: pos + i }
cells[pivot - i] = { nr: pos - i }
}
}
} else {
for (let i = 0; i < n; i++) {
cells[i] = { nr: i + 1, ellipsis: false }
}
}
return cells
}
export const Pagination = ({count, cursor, onPaginate, maxCells=CELL_COUNT}) => {
// Renders a Pagination Button Group, inserting ellipsis based on cursor.
// โโโโโฌโโโโฌโโโโฌโโโโฌโโโโ
// โ < โ 1 โ 2 โ 3 โ > โ
// โโโโโดโโโโดโโโโดโโโโดโโโโ
// โโโโโฌโโโโฌโโโโฌโโโโฌโโโโฌโโโโฌโโโโฌโโโโฌโโโโ
// โ < โ 1 โ 2 โ 3 โ 4 โ...โ 9 โ10 โ > โ
// โโโโโดโโโโดโโโโดโโโโดโโโโดโโโโดโโโโดโโโโดโโโโ
// โโโโโฌโโโโฌโโโโฌโโโโฌโโโโฌโโโโฌโโโโฌโโโโฌโโโโ
// โ < โ 1 โ...โ 4 โ 5 โ 6 โ...โ10 โ > โ
// โโโโโดโโโโดโโโโดโโโโดโโโโดโโโโดโโโโดโโโโดโโโโ
const PrevPage = <Button
icon='arrow-left'
disabled={cursor <= 1}
onClick={() => onPaginate(cursor - 1)}
text='Previous'/>
const NextPage = <Button
rightIcon='arrow-right'
disabled={cursor >= count}
onClick={() => onPaginate(cursor + 1)}
text='Next'/>
return (
<ButtonGroup className='pagination'>
{PrevPage}
{pagingCells(count, cursor, maxCells).map(({ nr, ellipsis }) =>
<Button
text={!ellipsis && nr}
icon={ellipsis && 'more'}
disabled={ellipsis}
key={nr}
active={nr === cursor}
onClick={() => onPaginate(nr)}/>
)}
{NextPage}
</ButtonGroup>
)
}
PS: I am still not convinced about typescript so maybe that's why I "forgot" about the PR ;)
I guess I can add, as a very passive user of bp3 for last few years is basically that its a "pretty neat" toolkit. I've ended up using it for several of my school projects; "production" systems; and more academic user. It never gave me the fatigue. So, thanks y'all. Feature Request? Can you fix the iconSvgPath.js taking huge bundle sizes next? :) I can share some "built with blueprintjs by sleep deprived undergrads" kind of links if anyone's interested. Most are also open source. Be warned, though, it's mostly questionable bodge-job.
I believe this version may have some edge cases; The ancient style code along with ancient tests is available in this gist i just created. I made a gist to be less critical and more helpful if you do decide to use this!
Why consider this, over reducers? Because while the samples are clean and great they unnecessarily depend on complex react specific API. What if you want to redo this in Swift or something? I'd just rewrite that function in Swift, and use whatever swift uses to do the UI classes? (well I haven't actually, but if I had to, it's probably because I don't have time to bikeshed).
True, this UI is slightly complicated thing to get right the first time; which is why I bothered writing a test for it. But to give a context, for new users, that gist may look slightly strange from current Blueprintjs patterns. It's the first version of the code when I made this PR proposal, before BP3 was released and you guys broke all my CSS. But I was grateful for some global prefix in scss code, which I set to "pt-" which held the fort together until I had more time to stop overriding your stylesheets! Some API patterns changed, and we got functional components (about which I need to find some good arguments against, still. They look good pattern, and were easy to adapt with). However the "juice" stayed the same because I didn't see any good reasons to do it again.
So, I didn't follow through on this PR, and maybe I'd have impressed you guys with my all CS skillz and ASCII comments. But really, that's what I knew then and i'm glad it still interests some people! And while pagination pattern is still not dead for me (@giladgray, @llorca, thanks for engaging with a noob, but i've been stubborn to keep using coffeescript ;). It's fun to reinvent the wheel, because everyone gets to learn new (https://xkcd.com/1053/) stuff; and the reducer examples actually increase the composition capabilities of "my version", but I think it should be outside the pure component and use some context or whatever we call global variables these days:).
PS: We've always been fighting about what's good pattern and what's not. It's fun! Check out this rant from Knuth https://www.cs.sjsu.edu/~mak/CS185C/KnuthStructuredProgrammingGoTo.pdf
Most helpful comment
If pagination is a dying pattern, what is the modern alternative? For example if a filter gives a result of 200 records (database query), I only show like 10 to keep things fast. Are there better ways?