optimizely-oui
Version:
Optimizely's Component Library.
281 lines (253 loc) • 10.7 kB
JavaScript
/* eslint-disable no-script-url */
import React, { Component } from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import { checkPropIsGreaterThanOrEqualTo } from "../../utils/custom-prop-types";
import Spinner from "../Spinner";
import Link from "../Link";
import _ from "lodash";
import flatten from "lodash/flatten";
import Button from "../Button";
const ELLIPSES_PLACEHOLDER = null;
const PAGE_NUMBER_PLACEHOLDER = "[pageNumber]";
class PaginationControls extends Component {
handlePageChange = newPage => {
return () => {
const { goToPage, isLoading } = this.props;
if (!isLoading) {
goToPage(newPage);
}
};
};
getCoercedInputs() {
const { currentPage, totalSlots, totalPages } = this.props;
// Ensure total pages is at least 2
let sanitizedTotalPages = Math.max(totalPages, 2);
// Ensure at least 7 slots
let sanitizedTotalSlots = Math.min(Math.max(totalSlots, 7), sanitizedTotalPages);
// Calculate number of numbers on either side of currentPage number
// 5 represents the 5 guaranteed slots
// (firstPage, beginning dots, currentPage, endingDots, lastPage)
let buffer = Math.floor((sanitizedTotalSlots - 5) / 2);
// Calculate max consecutive numbers (besides 1, lastPage, and ellipses)
// 3 represents the 3 guaranteed slots when not in the middle state
// (firstPage, endingDots, lastPage)
let remainingSlotsToFill = sanitizedTotalSlots - 3;
// Adjust current page to stay in range
let sanitizedCurrentPage = Math.max(Math.min(currentPage, sanitizedTotalPages), 1);
return {
totalPages: sanitizedTotalPages,
totalSlots: sanitizedTotalSlots,
buffer,
remainingSlotsToFill,
currentPage: sanitizedCurrentPage,
};
}
fillPageSlots() {
const { totalPages, totalSlots, buffer, remainingSlotsToFill, currentPage } = this.getCoercedInputs();
const distanceFromCurrentPageToFront = currentPage - 1;
const distanceFromCurrentPageToBack = totalPages - currentPage;
const currentPageIsCloseToBack = distanceFromCurrentPageToBack + buffer <= remainingSlotsToFill;
const currentPageIsCloseToFront = distanceFromCurrentPageToFront + buffer <= remainingSlotsToFill;
const ellipsesRequired = totalPages > totalSlots;
const pageNumbers = _
// Generate entire array of page numbers
.times(totalPages, i => i + 1)
// Filter down numbers to only the ones we want to show
.filter(activePage => {
let distanceBetweenCurrentAndActivePages = Math.abs(currentPage - activePage);
let distanceBetweenActivePageAndFront = activePage - 1;
let distanceBetweenActivePageAndBack = totalPages - activePage;
if (activePage === 1 || activePage === currentPage || activePage === totalPages || !ellipsesRequired) {
return true;
}
if (distanceBetweenCurrentAndActivePages <= buffer) {
return true;
}
if (currentPageIsCloseToFront && distanceBetweenActivePageAndFront <= remainingSlotsToFill) {
return true;
}
if (currentPageIsCloseToBack && distanceBetweenActivePageAndBack <= remainingSlotsToFill) {
return true;
}
})
// Map over the number array and find spots to insert ellipses
.map((val, idx, arr) => {
const frontEllipsesEligible = !currentPageIsCloseToFront && idx === 0;
const backEllipsesEligible = !currentPageIsCloseToBack && idx === arr.length - 2;
if (ellipsesRequired && (frontEllipsesEligible || backEllipsesEligible)) {
return [val, ELLIPSES_PLACEHOLDER];
}
return val;
});
return flatten(pageNumbers);
}
getHrefUrl(pageNumber) {
const { hrefBaseUrl } = this.props;
return hrefBaseUrl ? hrefBaseUrl.replace(PAGE_NUMBER_PLACEHOLDER, pageNumber) : undefined;
}
renderPageNumbers() {
const { goToPage, hrefBaseUrl, isLoading } = this.props;
const { totalPages, currentPage } = this.getCoercedInputs();
const controlSlots = this.fillPageSlots();
return controlSlots.map((val, idx) => {
const ariaLabelString = "Page " + val;
let ariaLabelSuffix = "";
let isCurrent = val === currentPage;
if (isCurrent) {
ariaLabelSuffix = ", current page";
} else if (val === totalPages) {
ariaLabelSuffix = ", last page";
}
let content;
if (val === ELLIPSES_PLACEHOLDER) {
content = <span>...</span>;
} else if (hrefBaseUrl) {
content = (
<Link
ariaLabel={ariaLabelString + ariaLabelSuffix}
style={isCurrent ? "dark" : "default"}
href={this.getHrefUrl(val)}
onClick={!isCurrent && goToPage ? this.handlePageChange(val) : undefined}
isDisabled={isLoading}
>
{isCurrent ? <strong>{val}</strong> : val}
</Link>
);
} else {
content = (
<Button
ariaLabel={ariaLabelString + ariaLabelSuffix}
isDisabled={isLoading}
style="unstyled"
onClick={!isCurrent && goToPage ? this.handlePageChange(val) : undefined}
>
<Link style={isCurrent ? "dark" : "default"} isDisabled={isLoading}>
{isCurrent ? <strong>{val}</strong> : val}
</Link>
</Button>
);
}
return (
<li className="push--sides" key={`link-${idx}`}>
{content}
</li>
);
});
}
render() {
const {
className,
currentPage,
goToPage,
hrefBaseUrl,
isLoading,
testSection,
totalPages,
totalSlots,
...props
} = this.props;
let sanitizedTotalPages = Math.max(totalPages, 2);
let previousButton;
let nextButton;
if (hrefBaseUrl) {
previousButton = (
<Link
href={this.getHrefUrl(currentPage - 1)}
onClick={this.handlePageChange(Math.max(1, currentPage - 1))}
isDisabled={currentPage <= 1 || isLoading}
ariaLabel="Previous Page"
>
Previous
</Link>
);
nextButton = (
<Link
ariaLabel="Next Page"
href={this.getHrefUrl(currentPage + 1)}
onClick={this.handlePageChange(Math.min(sanitizedTotalPages, currentPage + 1))}
isDisabled={currentPage >= sanitizedTotalPages || isLoading}
>
Next
</Link>
);
} else {
previousButton = (
<Button
ariaLabel="Previous Page"
isDisabled={currentPage <= 1 || isLoading}
style="unstyled"
onClick={this.handlePageChange(Math.max(1, currentPage - 1))}
>
<Link isDisabled={currentPage <= 1 || isLoading}>Previous</Link>
</Button>
);
nextButton = (
<Button
ariaLabel="Next Page"
isDisabled={currentPage >= sanitizedTotalPages || isLoading}
style="unstyled"
onClick={this.handlePageChange(Math.min(sanitizedTotalPages, currentPage + 1))}
>
<Link isDisabled={currentPage >= sanitizedTotalPages || isLoading}>Next</Link>
</Button>
);
}
return (
<nav className={classNames("oui-pagination-controls", className)} data-test-section={testSection} {...props}>
{isLoading && <Spinner size="tiny" />}
<ul className="flex flex-justified--center flex-align--center">
<li className="push--sides">{previousButton}</li>
{this.renderPageNumbers()}
<li className="push--sides">{nextButton}</li>
</ul>
</nav>
);
}
}
PaginationControls.propTypes = {
/** CSS class names. */
className: PropTypes.string,
/** Sets the current page number */
currentPage: PropTypes.number.isRequired,
/** Function that is called when a pagination link is clicked */
goToPage: PropTypes.func,
// eslint-disable-next-line valid-jsdoc
/**
* Base URL to use for the Link's href.
* Must include the string "[pageNumber]"
*/
hrefBaseUrl: function (props, propName, componentName) {
if (props[propName] && typeof props[propName] !== "string") {
return new Error(
"Invalid prop `" + propName + "` supplied to" + " `" + componentName + "`. Must be of type string."
);
} else if (props[propName] && !props[propName].includes(PAGE_NUMBER_PLACEHOLDER)) {
return new Error(
"Invalid prop '" +
propName +
"' supplied to" +
" '" +
componentName +
`'. Must contain the string \'${PAGE_NUMBER_PLACEHOLDER}\'.`
);
}
},
/** Sets the total number of pages */
isLoading: PropTypes.bool,
/** Hook for automated JavaScript tests */
testSection: PropTypes.string,
/** Sets the total number of pages. Must be >=2 */
totalPages: checkPropIsGreaterThanOrEqualTo(2, true),
/**
* Sets the total number of spots allowed
* between the Previous and Next buttons.
* Must be >= 7, default is 9
*/
totalSlots: checkPropIsGreaterThanOrEqualTo(7, false),
};
PaginationControls.defaultProps = {
testSection: "",
totalSlots: 9,
};
export default PaginationControls;