From e2cd9f2369f46a69fe371fdb77ccd001bfecd0bf Mon Sep 17 00:00:00 2001 From: Viktor Rusakov Date: Fri, 13 Jan 2023 09:12:03 +0200 Subject: [PATCH] feat: refactor Pagination --- src/Button/Button.scss | 12 + src/DataTable/TablePagination.jsx | 9 +- src/DataTable/TablePaginationMinimal.jsx | 7 +- src/Pagination/DefaultPagination.jsx | 41 ++ src/Pagination/MinimalPagination.jsx | 12 + src/Pagination/Pagination.scss | 333 +++++-------- src/Pagination/PaginationContext.jsx | 199 ++++++++ src/Pagination/README.md | 108 +++- src/Pagination/ReducedPagination.jsx | 14 + src/Pagination/_variables.scss | 35 +- src/Pagination/constants.js | 16 +- src/Pagination/getPaginationRange.js | 4 + src/Pagination/index.jsx | 461 ++---------------- src/Pagination/subcomponents/Ellipsis.jsx | 13 + .../subcomponents/NextPageButton.jsx | 64 +++ src/Pagination/subcomponents/PageButton.jsx | 33 ++ .../subcomponents/PageOfCountButton.jsx | 26 + .../subcomponents/PaginationDropdown.jsx | 31 ++ .../subcomponents/PreviousPageButton.jsx | 63 +++ .../subcomponents/ScreenReaderComponent.jsx | 17 + src/index.js | 4 +- 21 files changed, 806 insertions(+), 696 deletions(-) create mode 100644 src/Pagination/DefaultPagination.jsx create mode 100644 src/Pagination/MinimalPagination.jsx create mode 100644 src/Pagination/PaginationContext.jsx create mode 100644 src/Pagination/ReducedPagination.jsx create mode 100644 src/Pagination/subcomponents/Ellipsis.jsx create mode 100644 src/Pagination/subcomponents/NextPageButton.jsx create mode 100644 src/Pagination/subcomponents/PageButton.jsx create mode 100644 src/Pagination/subcomponents/PageOfCountButton.jsx create mode 100644 src/Pagination/subcomponents/PaginationDropdown.jsx create mode 100644 src/Pagination/subcomponents/PreviousPageButton.jsx create mode 100644 src/Pagination/subcomponents/ScreenReaderComponent.jsx diff --git a/src/Button/Button.scss b/src/Button/Button.scss index cf5f7e3cd67..7ae1ee5fd9e 100644 --- a/src/Button/Button.scss +++ b/src/Button/Button.scss @@ -358,6 +358,12 @@ fieldset:disabled a.btn { $btn-tertiary-color, $btn-tertiary-color ); + + &.disabled, + &:disabled { + color: $yiq-text-dark; + } + @include button-focus(theme-color("primary", "focus")); } @@ -380,6 +386,12 @@ fieldset:disabled a.btn { $btn-inverse-tertiary-color, $btn-inverse-tertiary-color ); + + &.disabled, + &:disabled { + color: $yiq-text-light; + } + @include button-focus($white); } diff --git a/src/DataTable/TablePagination.jsx b/src/DataTable/TablePagination.jsx index 1e40e5d8e74..21a13a1ff75 100644 --- a/src/DataTable/TablePagination.jsx +++ b/src/DataTable/TablePagination.jsx @@ -14,10 +14,15 @@ function TablePagination() { const pageIndex = state?.pageIndex; return ( - gotoPage(pageNum - 1)} + onPageSelect={(pageNum) => gotoPage(pageNum - 1)} pageCount={pageCount} + icons={{ + leftIcon: null, + rightIcon: null, + }} /> ); } diff --git a/src/DataTable/TablePaginationMinimal.jsx b/src/DataTable/TablePaginationMinimal.jsx index f68b70049fa..cdbd713135b 100644 --- a/src/DataTable/TablePaginationMinimal.jsx +++ b/src/DataTable/TablePaginationMinimal.jsx @@ -1,6 +1,7 @@ import React, { useContext } from 'react'; -import { Pagination } from '..'; import DataTableContext from './DataTableContext'; +import { Pagination } from '..'; +import { ArrowBackIos, ArrowForwardIos } from '../../icons'; function TablePaginationMinimal() { const { @@ -21,6 +22,10 @@ function TablePaginationMinimal() { pageCount={pageCount} paginationLabel="table pagination" onPageSelect={(pageNum) => gotoPage(pageNum - 1)} + icons={{ + leftIcon: ArrowBackIos, + rightIcon: ArrowForwardIos, + }} /> ); } diff --git a/src/Pagination/DefaultPagination.jsx b/src/Pagination/DefaultPagination.jsx new file mode 100644 index 00000000000..21643c9e640 --- /dev/null +++ b/src/Pagination/DefaultPagination.jsx @@ -0,0 +1,41 @@ +import React, { useContext } from 'react'; +import { useMediaQuery } from 'react-responsive'; +import PaginationContext from './PaginationContext'; +import PreviousPageButton from './subcomponents/PreviousPageButton'; +import NextPageButton from './subcomponents/NextPageButton'; +import PageOfCountButton from './subcomponents/PageOfCountButton'; +import breakpoints from '../utils/breakpoints'; +import { ELLIPSIS } from './constants'; +import Ellipsis from './subcomponents/Ellipsis'; +import newId from '../utils/newId'; +import PageButton from './subcomponents/PageButton'; + +export default function DefaultPagination() { + return ( + + ); +} + +function PaginationPages() { + const { displayPages } = useContext(PaginationContext); + const isMobile = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth }); + + if (isMobile) { + return ; + } + + return ( + <> + {displayPages.map((pageIndex) => { + if (pageIndex === ELLIPSIS) { + return ; + } + return ; + })} + + ); +} \ No newline at end of file diff --git a/src/Pagination/MinimalPagination.jsx b/src/Pagination/MinimalPagination.jsx new file mode 100644 index 00000000000..012eeb8a997 --- /dev/null +++ b/src/Pagination/MinimalPagination.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import PreviousPageButton from './subcomponents/PreviousPageButton'; +import NextPageButton from './subcomponents/NextPageButton'; + +export default function MinimalPagination() { + return ( +
    + + +
+ ); +} diff --git a/src/Pagination/Pagination.scss b/src/Pagination/Pagination.scss index d1f634d917c..8747629995b 100644 --- a/src/Pagination/Pagination.scss +++ b/src/Pagination/Pagination.scss @@ -1,13 +1,4 @@ @import "variables"; -@import "~bootstrap/scss/pagination"; - -.pagination { - align-items: center; - - .dropdown { - z-index: 4; - } -} %pagination-icon-button-right { border-top-right-radius: 50%; @@ -19,108 +10,64 @@ border-bottom-left-radius: 50%; } -.pagination-icon-button-right { - @extend %pagination-icon-button-right; -} - -.pagination-icon-button-left { - @extend %pagination-icon-button-left; -} - -.pagination-default { - .page-link { - &.previous .pgn__icon { - margin-inline-start: 0; - margin-inline-end: $pagination-margin-x; - } +.pagination { + display: flex; - &.next .pgn__icon { - margin-inline-start: $pagination-margin-x; - margin-inline-end: 0; - } + .dropdown { + z-index: 4; } .page-item { &:first-child .page-link { - [dir="rtl"] & { - border-radius: 0 $pagination-border-radius-lg $pagination-border-radius-lg 0; - } + margin-left: 0; + + @include border-left-radius($border-radius); } &:last-child .page-link { - [dir="rtl"] & { - border-radius: $pagination-border-radius-lg 0 0 $pagination-border-radius-lg; - } + @include border-right-radius($border-radius); } - } -} - -.page-link { - border: none; - - &.btn-primary:not(:disabled):not(.disabled):focus { - background-color: $pagination-bg; - color: $pagination-focus-color-text; - } - - &:focus { - box-shadow: none; - } - &.btn-primary:focus::before { - border: $pagination-focus-border-width solid $pagination-focus-color; - - @include button-size($btn-padding-y, $btn-padding-x, $btn-font-size, $btn-line-height, $btn-border-radius); - } - - div { - display: flex; - } + &:first-child .btn-icon.page-link { + @extend %pagination-icon-button-left; + } - [dir="rtl"] & { - svg { - transform: scale(-1); + &:last-child .btn-icon.page-link { + @extend %pagination-icon-button-right; } - } - &:focus::before, - &.focus::before { - border-radius: 0; - } -} + &.active .page-link { + z-index: 3; + } -.page-item { - > .btn { - transition: none; - line-height: $pagination-line-height; + > .btn { + transition: none; + line-height: $pagination-line-height; + } } - &.active .page-link.btn-primary:not(:disabled):not(.disabled):focus { - background-color: $pagination-focus-color; - color: $pagination-bg; - } -} + @include list-unstyled(); + @include border-radius(); -.pagination-small { - .page-link { - font-size: $pagination-font-size-sm; - line-height: $pagination-line-height; - padding: .375rem .78rem; + &-small { + .page-link { + font-size: $pagination-font-size-sm; + line-height: $pagination-line-height; + padding: .375rem .78rem; - &.previous, - &.next { - padding: 0 $pagination-padding-y; - line-height: $pagination-secondary-height-sm; + &.previous, + &.next { + padding: 0 $pagination-padding-y; + line-height: $pagination-secondary-height-sm; - div { - display: flex; - align-items: center; + div { + display: flex; + align-items: center; + } } } - } - &:not(.pagination-default) { - .page-link { + &:not(.pagination-default) .page-link { &.previous, &.next { padding: 0; @@ -128,176 +75,122 @@ } } } -} - -.pagination-secondary { - button.next, - button.previous { - height: $pagination-secondary-height; - padding: 0 $pagination-padding-y; - } - &.pagination-small { + &-secondary { button.next, button.previous { - height: $pagination-secondary-height-sm; - line-height: $pagination-line-height; + height: $pagination-secondary-height; + padding: 0 $pagination-padding-y; } - } - - .page-item:first-child .page-link { - @extend %pagination-icon-button-left; - } - - .page-item:last-child .page-link { - @extend %pagination-icon-button-right; - } -} -.pagination-inverse { - %dark-styles { - background-color: transparent; - color: $white; + &.pagination-small { + button.next, + button.previous { + height: $pagination-secondary-height-sm; + line-height: $pagination-line-height; + } + } } - .pgn__dark-styles { - @extend %dark-styles; + .ellipsis { + border: 0; + margin-left: 0; } - .page-item { - &.disabled .page-link { - @extend %dark-styles; + &-inverse { + .ellipsis { + color: $white; } - &.active button.page-link { - background-color: $pagination-bg; - color: $pagination-color; + .dropdown .dropdown-toggle::after { + border-top: $pagination-toggle-border solid $pagination-dropdown-color-inverse; } + } - button.page-link { - @extend %dark-styles; + &-reduced { + &-dropdown-menu { + overflow-y: auto; + max-height: $pagination-reduced-dropdown-max-height; + min-width: $pagination-reduced-dropdown-min-width; - &:focus { - box-shadow: none; + a { + text-align: center; } } - &:not(.active):focus { - box-shadow: $level-1-box-shadow; + .dropdown-toggle::after { + width: 0; + height: 0; + border-left: $pagination-toggle-border solid transparent; + border-right: $pagination-toggle-border solid transparent; + border-top: $pagination-toggle-border solid $gray-700; + transform: rotate(0); + inset-inline-start: .5rem; + top: 0; + margin-inline-end: 1rem; } - } - .page-link { - &:focus::before, - &.focus::before { - display: none; + button.next, + button.previous { + height: $pagination-secondary-height; + padding: 0 $pagination-padding-y; } - } - .dropdown { - .btn-tertiary { - color: $pagination-color-inverse; + &.pagination-small { + .btn.dropdown-toggle { + font-size: $pagination-font-size-sm; - &::after { - border-top: $pagination-toggle-border solid $pagination-color-inverse; + &::after { + border-left-width: $pagination-toggle-border-sm; + border-right-width: $pagination-toggle-border-sm; + border-top-width: $pagination-toggle-border-sm; + } } - &:active, - &:hover { - background-color: transparent; - } - - &:not(:disabled):not(.disabled):active { - color: $pagination-color-inverse; + button.previous, + button.next { + line-height: $pagination-icon-height; + height: $pagination-icon-height; } } } - .show > .dropdown-toggle { - background-color: transparent; - } -} - -.pgn__reduced-pagination-dropdown { - overflow-y: auto; - max-height: $pagination-reduced-dropdown-max-height; - min-width: $pagination-reduced-dropdown-min-width; - - a { - text-align: center; - } -} - -.pagination-reduced { - .dropdown-toggle::after { - width: 0; - height: 0; - border-left: $pagination-toggle-border solid transparent; - border-right: $pagination-toggle-border solid transparent; - border-top: $pagination-toggle-border solid $gray-700; - transform: rotate(0); - inset-inline-start: .5rem; - top: 0; - margin-inline-end: 1rem; - } - - button.next, - button.previous { - height: $pagination-secondary-height; - padding: 0 $pagination-padding-y; - } - - &.pagination-small { - .btn.dropdown-toggle { - font-size: $pagination-font-size-sm; - - &::after { - border-left-width: $pagination-toggle-border-sm; - border-right-width: $pagination-toggle-border-sm; - border-top-width: $pagination-toggle-border-sm; - } + &-minimal { + .page-item:first-child { + margin-inline-end: .3rem; } - button.previous, - button.next { - line-height: $pagination-icon-height; - height: $pagination-icon-height; + button.next, + button.previous { + padding: $pagination-padding-y; + height: $pagination-secondary-height; } - } - - .page-item:first-child .page-link { - @extend %pagination-icon-button-left; - } - .page-item:last-child .page-link { - @extend %pagination-icon-button-right; + &.pagination-small { + button.next, + button.previous { + padding: 0 $pagination-padding-y; + height: $pagination-secondary-height-sm; + } + } } } -.pagination-minimal { - .page-item:first-child { - margin-inline-end: .3rem; - } - - button.next, - button.previous { - padding: $pagination-padding-y; - height: $pagination-secondary-height; - } +.page-link { + border: none; + margin-left: -$pagination-border-width; - &.pagination-small { - button.next, - button.previous { - padding: 0 $pagination-padding-y; - height: $pagination-secondary-height-sm; - } + &:focus { + z-index: 3; } - .page-item:first-child .page-link { - @extend %pagination-icon-button-left; + div { + display: flex; } - .page-item:last-child .page-link { - @extend %pagination-icon-button-right; + [dir="rtl"] & { + svg { + transform: scale(-1); + } } } diff --git a/src/Pagination/PaginationContext.jsx b/src/Pagination/PaginationContext.jsx new file mode 100644 index 00000000000..ed34a1e3ec0 --- /dev/null +++ b/src/Pagination/PaginationContext.jsx @@ -0,0 +1,199 @@ +import React, { + createContext, + useEffect, + useRef, + useState, +} from 'react'; +import PropTypes from 'prop-types'; +import { PAGINATION_VARIANTS } from './constants'; +import getPaginationRange from './getPaginationRange'; + +const PaginationContext = createContext({}); + +function PaginationContextProvider({ + children, onPageSelect, invertColors, maxPagesDisplayed, + buttonLabels, icons, variant, + pageCount, currentPage: controlledCurrentPage, initialPage, +}) { + const [currentPage, setCurrentPage] = useState(controlledCurrentPage || initialPage); + const [pageButtonSelected, setPageButtonSelected] = useState(false); + const previousButtonRef = useRef(null); + const nextButtonRef = useRef(null); + const pageButtonRef = useRef([]); + + useEffect(() => { + const currentPageRef = pageButtonRef[currentPage]; + + if (currentPageRef && pageButtonSelected) { + currentPageRef.focus(); + setPageButtonSelected(false); + } + }, [currentPage, pageButtonSelected]); + + const isUncontrolled = () => controlledCurrentPage === undefined; + const isPageButtonActive = (page) => page === currentPage; + const isOnFirstPage = () => (currentPage === 1 || pageCount === 0); + const isOnLastPage = () => currentPage === pageCount || pageCount === 0; + const isDefaultVariant = () => variant === PAGINATION_VARIANTS.default; + + if (!isUncontrolled() && controlledCurrentPage !== currentPage) { + setCurrentPage(controlledCurrentPage); + } + + const getPageButtonRefHandler = (pageNum) => (element) => { pageButtonRef.current[pageNum] = element; }; + + const handlePageSelect = (page) => { + if (page !== currentPage) { + if (isUncontrolled()) { + setCurrentPage(page); + } + setPageButtonSelected(true); + onPageSelect(page); + } + }; + + const handlePreviousButtonClick = () => { + if (isOnFirstPage()) { + return; + } + onPageSelect(currentPage - 1); + if (currentPage === 2) { + nextButtonRef.current.focus(); + } + if (isUncontrolled()) { + setCurrentPage((prevState) => prevState - 1); + } + }; + + const handleNextButtonClick = () => { + if (isOnLastPage()) { + return; + } + onPageSelect(currentPage + 1); + if (currentPage === pageCount - 1) { + previousButtonRef.current.focus(); + } + if (isUncontrolled()) { + setCurrentPage((prevState) => prevState + 1); + } + }; + + const getAriaLabelForPreviousButton = () => { + let ariaLabel = `${buttonLabels.previous}`; + + if (!isOnFirstPage()) { + ariaLabel += `, ${buttonLabels.page} ${currentPage - 1}`; + } + + return ariaLabel; + }; + + const getAriaLabelForNextButton = () => { + let ariaLabel = `${buttonLabels.next}`; + + if (!isOnLastPage()) { + ariaLabel += `, ${buttonLabels.page} ${currentPage + 1}`; + } + + return ariaLabel; + }; + + const getAriaLabelForPageButton = (page) => { + let ariaLabel = `${buttonLabels.page} ${page}`; + + if (isPageButtonActive(page)) { + ariaLabel += `, ${buttonLabels.currentPage}`; + } + + return ariaLabel; + }; + + const getAriaLabelForPageOfCountButton = () => `${buttonLabels.page} ${currentPage}, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} ${pageCount}`; + const getLabelForPageOfCountButton = () => currentPage; + + const getScreenReaderText = () => `${buttonLabels.page} ${currentPage}, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} ${pageCount}`; + const getPageOfText = () => `${currentPage} ${buttonLabels.pageOfCount} ${pageCount}`; + + const getPageButtonVariant = (page) => { + let buttonVariant = isPageButtonActive(page) ? 'primary' : 'tertiary'; + + if (invertColors) { + buttonVariant = `inverse-${buttonVariant}`; + } + + return buttonVariant; + }; + + const getNextButtonIcon = () => icons.rightIcon; + const getPrevButtonIcon = () => icons.leftIcon; + + const displayPages = getPaginationRange({ + currentIndex: currentPage, + count: pageCount, + length: maxPagesDisplayed, + requireFirstAndLastPages: true, + }); + + const value = { + invertColors, + displayPages, + pageCount, + buttonLabels, + previousButtonRef, + nextButtonRef, + pageButtonRef, + getPrevButtonIcon, + getNextButtonIcon, + getAriaLabelForNextButton, + getAriaLabelForPageButton, + getAriaLabelForPreviousButton, + getAriaLabelForPageOfCountButton, + getLabelForPageOfCountButton, + getPageButtonVariant, + handlePreviousButtonClick, + handleNextButtonClick, + handlePageSelect, + isOnFirstPage, + isOnLastPage, + isPageButtonActive, + isDefaultVariant, + getScreenReaderText, + getPageOfText, + getPageButtonRefHandler, + }; + + return ( + + {children} + + ); +} + +PaginationContextProvider.propTypes = { + children: PropTypes.node.isRequired, + onPageSelect: PropTypes.func.isRequired, + pageCount: PropTypes.number.isRequired, + buttonLabels: PropTypes.shape({ + previous: PropTypes.string, + next: PropTypes.string, + page: PropTypes.string, + currentPage: PropTypes.string, + pageOfCount: PropTypes.string, + }).isRequired, + currentPage: PropTypes.number, + maxPagesDisplayed: PropTypes.number.isRequired, + icons: PropTypes.shape({ + leftIcon: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + rightIcon: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + }).isRequired, + variant: PropTypes.oneOf(['default', 'secondary', 'reduced', 'minimal']).isRequired, + invertColors: PropTypes.bool.isRequired, + initialPage: PropTypes.number.isRequired, +}; + +PaginationContextProvider.defaultProps = { + currentPage: undefined, +}; + +export { PaginationContextProvider }; +export default PaginationContext; diff --git a/src/Pagination/README.md b/src/Pagination/README.md index 2742842b4bf..ce94b58ccb3 100644 --- a/src/Pagination/README.md +++ b/src/Pagination/README.md @@ -17,61 +17,102 @@ notes: | Navigation between multiple pages of some set of results. Controls are provided to navigate through multiple pages of related data. -## Basic usage (Default Size) +## Default Size + +### Uncontrolled Usage + +```jsx live + console.log(`page ${page} selected`)} +/> +``` + +### Controlled Usage + +```jsx live +() => { + const [currentPage, setCurrentPage] = useState(1); + + const handlePageSelect = (page) => setTimeout(() => setCurrentPage(page), 1000); + + return ( + handlePageSelect(page)} + /> + ); +} +``` + +### Uncontrolled usage with initial page ```jsx live console.log('page selected')} + initialPage={5} + onPageSelect={(page) => console.log(`page ${page} selected`)} /> ``` -## Secondary +### Secondary ```jsx live console.log('page selected')} + onPageSelect={(page) => console.log(`page ${page} selected`)} + icons={{ + leftIcon: ArrowBackIos, + rightIcon: ArrowForwardIos, + }} /> ``` -## Reduced +### Reduced ```jsx live console.log('page selected')} + onPageSelect={(page) => console.log(`page ${page} selected`)} /> ``` -## Minimal +### Minimal ```jsx live console.log('page selected')} + onPageSelect={(page) => console.log(`page ${page} selected`)} + icons={{ + leftIcon: ArrowBackIos, + rightIcon: ArrowForwardIos, + }} /> ``` -## Basic usage (Small Size) +## Small Size +### Default variant ```jsx live console.log('page selected')} + onPageSelect={(page) => console.log(`page ${page} selected`)} /> ``` -## Secondary +### Secondary ```jsx live console.log('page selected')} + onPageSelect={(page) => console.log(`page ${page} selected`)} /> ``` -## Reduced +### Reduced ```jsx live console.log('page selected')} + onPageSelect={(page) => console.log(`page ${page} selected`)} /> ``` -## Minimal +### Minimal ```jsx live console.log('page selected')} + onPageSelect={(page) => console.log(`page ${page} selected`)} /> ``` @@ -115,21 +156,36 @@ Navigation between multiple pages of some set of results. Controls are provided paginationLabel="pagination navigation" pageCount={20} invertColors - onPageSelect={() => console.log('page selected')} + onPageSelect={(page) => console.log(`page ${page} selected`)} + /> + console.log(`page ${page} selected`)} + icons={{ + leftIcon: ArrowBackIos, + rightIcon: ArrowForwardIos, + }} /> console.log('page selected')} + onPageSelect={(page) => console.log(`page ${page} selected`)} /> console.log('page selected')} + onPageSelect={(page) => console.log(`page ${page} selected`)} + icons={{ + leftIcon: ArrowBackIos, + rightIcon: ArrowForwardIos, + }} /> ``` @@ -143,7 +199,15 @@ Navigation between multiple pages of some set of results. Controls are provided pageCount={20} invertColors size="small" - onPageSelect={() => console.log('page selected')} + onPageSelect={(page) => console.log(`page ${page} selected`)} + /> + console.log(`page ${page} selected`)} /> console.log('page selected')} + onPageSelect={(page) => console.log(`page ${page} selected`)} /> console.log('page selected')} + onPageSelect={(page) => console.log(`page ${page} selected`)} /> ``` diff --git a/src/Pagination/ReducedPagination.jsx b/src/Pagination/ReducedPagination.jsx new file mode 100644 index 00000000000..090e35db6d7 --- /dev/null +++ b/src/Pagination/ReducedPagination.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import PreviousPageButton from './subcomponents/PreviousPageButton'; +import NextPageButton from './subcomponents/NextPageButton'; +import PaginationDropdown from './subcomponents/PaginationDropdown'; + +export default function ReducedPagination() { + return ( +
    + + + +
+ ); +} diff --git a/src/Pagination/_variables.scss b/src/Pagination/_variables.scss index 1fc4c09c413..e03ddfd3926 100644 --- a/src/Pagination/_variables.scss +++ b/src/Pagination/_variables.scss @@ -1,52 +1,19 @@ // Pagination $pagination-padding-y: .625rem !default; -$pagination-padding-x: 1rem !default; -$pagination-padding-y-sm: .8rem !default; -$pagination-padding-x-sm: .6rem !default; -$pagination-padding-y-lg: .75rem !default; -$pagination-padding-x-lg: 1.5rem !default; $pagination-margin-x: .5rem !default; -$pagination-margin-y: .5rem !default; $pagination-line-height: 1.5rem !default; $pagination-font-size-sm: .875rem !default; -$pagination-icon-size: 1.375rem !default; -$pagination-icon-size-sm: 1rem !default; $pagination-icon-width: 2.25rem !default; $pagination-icon-height: 2.25rem !default; -$pagination-padding-icon: .5rem !default; $pagination-toggle-border: .3125rem !default; $pagination-toggle-border-sm: .25rem !default; $pagination-secondary-height: 2.75rem !default; $pagination-secondary-height-sm: 2.25rem !default; -$pagination-color: $link-color !default; -$pagination-color-inverse: $white !default; -$pagination-bg: $white !default; +$pagination-dropdown-color-inverse: $white !default; $pagination-border-width: $border-width !default; -$pagination-border-color: theme-color("gray", "border") !default; - -$pagination-focus-box-shadow: $input-btn-focus-box-shadow !default; -$pagination-focus-outline: 0 !default; -$pagination-focus-border-width: .125rem !default; -$pagination-focus-color: $primary-500 !default; -$pagination-focus-color-text: $black !default; - -$pagination-hover-color: $link-hover-color !default; -$pagination-hover-bg: theme-color("gray", "background") !default; -$pagination-hover-border-color: theme-color("gray", "border") !default; - -$pagination-active-color: $component-active-color !default; -$pagination-active-bg: $component-active-bg !default; -$pagination-active-border-color: $pagination-active-bg !default; - -$pagination-disabled-color: theme-color("gray", "light-text") !default; -$pagination-disabled-bg: $white !default; -$pagination-disabled-border-color: theme-color("gray", "disabled-border") !default; - -$pagination-border-radius-sm: $border-radius-sm !default; -$pagination-border-radius-lg: $border-radius-lg !default; $pagination-reduced-dropdown-max-height: 60vh !default; $pagination-reduced-dropdown-min-width: 6rem !default; diff --git a/src/Pagination/constants.js b/src/Pagination/constants.js index e4b063b11e2..472b68b5272 100644 --- a/src/Pagination/constants.js +++ b/src/Pagination/constants.js @@ -1,2 +1,16 @@ -/* eslint-disable import/prefer-default-export */ export const ELLIPSIS = '...'; + +export const PAGINATION_VARIANTS = { + default: 'default', + secondary: 'secondary', + reduced: 'reduced', + minimal: 'minimal', +}; + +export const PAGINATION_BUTTON_LABEL_PREV = 'Previous'; +export const PAGINATION_BUTTON_LABEL_NEXT = 'Next'; +export const PAGINATION_BUTTON_LABEL_PAGE = 'Page'; +export const PAGINATION_BUTTON_LABEL_CURRENT_PAGE = 'Current Page'; +export const PAGINATION_BUTTON_LABEL_PAGE_OF_COUNT = 'of'; +export const PAGINATION_BUTTON_ICON_BUTTON_NEXT_ALT = 'Go to next page'; +export const PAGINATION_BUTTON_ICON_BUTTON_PREV_ALT = 'Go to previous page'; diff --git a/src/Pagination/getPaginationRange.js b/src/Pagination/getPaginationRange.js index 69c35cce5d8..5a8910c1b59 100644 --- a/src/Pagination/getPaginationRange.js +++ b/src/Pagination/getPaginationRange.js @@ -6,6 +6,10 @@ const getPaginationRange = ({ length, requireFirstAndLastPages = true, }) => { + if (count === 0) { + return []; + } + const boundedLength = Math.min(count, length); const unboundedStartIndex = currentIndex - Math.ceil(boundedLength / 2); const zeroBoundedStartIndex = Math.max(0, unboundedStartIndex); diff --git a/src/Pagination/index.jsx b/src/Pagination/index.jsx index 792d7559b05..e23e3cf8ca1 100644 --- a/src/Pagination/index.jsx +++ b/src/Pagination/index.jsx @@ -1,414 +1,53 @@ -/* eslint-disable max-len */ +import React from 'react'; import classNames from 'classnames'; import PropTypes from 'prop-types'; -import React from 'react'; -import MediaQuery from 'react-responsive'; - -import { - ChevronLeft, ChevronRight, ArrowBackIos, ArrowForwardIos, -} from '../../icons'; -import { greaterThan } from '../utils/propTypes'; -import { Button, Dropdown, IconButton } from '..'; -import Icon from '../Icon'; -import breakpoints from '../utils/breakpoints'; -import newId from '../utils/newId'; -import { ELLIPSIS } from './constants'; -import getPaginationRange from './getPaginationRange'; - -export const PAGINATION_BUTTON_LABEL_PREV = 'Previous'; -export const PAGINATION_BUTTON_LABEL_NEXT = 'Next'; -export const PAGINATION_BUTTON_LABEL_PAGE = 'Page'; -export const PAGINATION_BUTTON_LABEL_CURRENT_PAGE = 'Current Page'; -export const PAGINATION_BUTTON_LABEL_PAGE_OF_COUNT = 'of'; -export const PAGINATION_BUTTON_ICON_BUTTON_NEXT_ALT = 'Go to next page'; -export const PAGINATION_BUTTON_ICON_BUTTON_PREV_ALT = 'Go to previous page'; - -const VARIANTS = { - default: 'default', - secondary: 'secondary', - reduced: 'reduced', - minimal: 'minimal', -}; - -function ReducedPagination({ currentPage, pageCount, handlePageSelect }) { - if (pageCount <= 1) { return null; } - return ( - - - {currentPage} of {pageCount} - - - {[...Array(pageCount).keys()].map(pageNum => ( - handlePageSelect(pageNum + 1)} key={pageNum}> - {pageNum + 1} - - ))} - - - ); -} - -class Pagination extends React.Component { - constructor(props) { - super(props); - - this.previousButtonRef = null; - this.nextButtonRef = null; - - this.pageRefs = {}; - - this.state = { - currentPage: this.props.currentPage, - pageButtonSelected: false, - }; - - this.handlePageSelect = this.handlePageSelect.bind(this); - } - shouldComponentUpdate(nextProps, nextState) { - // Update only when the props and currentPage state changes to avoid re-render - // if only the pageButtonSelected state is changed. - return nextProps !== this.props || nextState.currentPage !== this.state.currentPage; - } - - componentDidUpdate(prevProps, prevState) { - const { currentPage, pageButtonSelected } = this.state; - const currentPageRef = this.pageRefs[currentPage]; - - if (currentPageRef && pageButtonSelected) { - currentPageRef.focus(); - this.setPageButtonSelectedState(false); - } - /* eslint-disable react/no-did-update-set-state */ - if ( - this.state.currentPage === prevState.currentPage - && (this.props.currentPage !== prevProps.currentPage - || this.props.currentPage !== this.state.currentPage) - ) { - this.setState({ - currentPage: this.props.currentPage, - }); - } - } - - handlePageSelect(page) { - if (page !== this.state.currentPage) { - this.setState({ - currentPage: page, - pageButtonSelected: true, - }); - this.props.onPageSelect(page); - } - } - - handlePreviousNextButtonClick(page) { - const { pageCount } = this.props; - - if (page === 1) { - this.nextButtonRef.focus(); - } else if (page === pageCount) { - this.previousButtonRef.focus(); - } - this.setState({ currentPage: page }); - this.props.onPageSelect(page); - } - - setPageButtonSelectedState(value) { - this.setState({ pageButtonSelected: value }); - } - - renderEllipsisButton() { - return ( -
  • - - ... - -
  • - ); - } - - renderPageButton(page) { - const { buttonLabels } = this.props; - const active = page === this.state.currentPage || null; - - let ariaLabel = `${buttonLabels.page} ${page}`; - if (active) { - ariaLabel += `, ${buttonLabels.currentPage}`; - } +import ReducedPagination from './ReducedPagination'; +import MinimalPagination from './MinimalPagination'; +import DefaultPagination from './DefaultPagination'; +import { PaginationContextProvider } from './PaginationContext'; +import { PAGINATION_VARIANTS } from './constants'; +import PaginationScreenReaderText from './subcomponents/ScreenReaderComponent'; - return ( -
  • - -
  • - ); - } - - renderPageOfCountButton() { - const { currentPage } = this.state; - const { pageCount, buttonLabels } = this.props; - - const ariaLabel = `${buttonLabels.page} ${currentPage}, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} ${pageCount}`; - - const label = ( - - {`${currentPage} `} - {buttonLabels.pageOfCount} - {` ${pageCount}`} - - ); - - return ( -
  • - - {label} - -
  • - ); - } - - renderPreviousButton() { - const { - buttonLabels, icons, variant, size, pageCount, - } = this.props; - const { currentPage } = this.state; - const isFirstPage = currentPage === 1; - const isDisabled = isFirstPage || pageCount === 0; - const previousPage = isFirstPage ? null : currentPage - 1; - const iconSize = (variant !== VARIANTS.reduced && size !== 'small') || variant === VARIANTS.minimal; - - let ariaLabel = `${buttonLabels.previous}`; - if (previousPage) { - ariaLabel += `, ${buttonLabels.page} ${previousPage}`; - } - - return ( -
  • - { - variant === VARIANTS.default - ? ( - - ) - : ( - { this.handlePreviousNextButtonClick(previousPage); }} - ref={(element) => { this.previousButtonRef = element; }} - disabled={isDisabled} - alt={PAGINATION_BUTTON_ICON_BUTTON_PREV_ALT} - /> - ) - } -
  • - ); - } - - renderNextButton() { - const { - buttonLabels, pageCount, icons, variant, size, - } = this.props; - const { currentPage } = this.state; - const isLastPage = (currentPage === pageCount); - const isDisabled = isLastPage || (pageCount <= 1); - const nextPage = isLastPage ? null : currentPage + 1; - const iconSize = (variant !== VARIANTS.reduced && size !== 'small') || variant === VARIANTS.minimal; - - let ariaLabel = `${buttonLabels.next}`; - if (nextPage) { - ariaLabel += `, ${buttonLabels.page} ${nextPage}`; +import { greaterThan } from '../utils/propTypes'; +import { ChevronLeft, ChevronRight } from '../../icons'; + +function Pagination(props) { + const { + invertColors, + variant, + size, + paginationLabel, + className, + } = props; + + const renderPaginationComponent = () => { + if (variant === PAGINATION_VARIANTS.reduced) { + return ; } - return ( -
  • - {variant === VARIANTS.default ? ( - - ) : ( - { this.handlePreviousNextButtonClick(nextPage); }} - ref={(element) => { this.nextButtonRef = element; }} - disabled={isDisabled} - alt={PAGINATION_BUTTON_ICON_BUTTON_NEXT_ALT} - /> - )} -
  • - ); - } - - renderScreenReaderSection() { - const { currentPage } = this.state; - const { buttonLabels, pageCount } = this.props; - - const description = `${buttonLabels.page} ${currentPage}, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} ${pageCount}`; - - return ( -
    - {description} -
    - ); - } - - renderPageButtons() { - const { currentPage } = this.state; - const { pageCount, maxPagesDisplayed } = this.props; - - const pages = getPaginationRange({ - currentIndex: currentPage, - count: pageCount, - length: maxPagesDisplayed, - requireFirstAndLastPages: true, - }); - - if (pageCount <= 1) { - return null; + if (variant === PAGINATION_VARIANTS.minimal) { + return ; } - return pages.map((pageIndex) => { - if (pageIndex === ELLIPSIS) { - return this.renderEllipsisButton(); - } - return this.renderPageButton(pageIndex + 1); - }); - } - - renderReducedPagination() { - const { currentPage } = this.state; - const { pageCount } = this.props; - return ( -
      - {this.renderPreviousButton()} - - {this.renderNextButton()} -
    - ); - } + return ; + }; - renderMinimalPaginations() { - return ( -
      - {this.renderPreviousButton()} - {this.renderNextButton()} -
    - ); - } - - render() { - const { variant, invertColors, size } = this.props; - return ( + return ( + - ); - } + + ); } Pagination.propTypes = { @@ -477,40 +116,34 @@ Pagination.propTypes = { * string, symbol, etc. Default is chevrons rendered using fa-css. */ icons: PropTypes.shape({ - leftIcon: PropTypes.node, - rightIcon: PropTypes.node, + leftIcon: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + rightIcon: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), }), variant: PropTypes.oneOf(['default', 'secondary', 'reduced', 'minimal']), invertColors: PropTypes.bool, size: PropTypes.oneOf(['default', 'small']), + initialPage: PropTypes.number, }; Pagination.defaultProps = { icons: { - leftIcon: , - rightIcon: , + leftIcon: ChevronLeft, + rightIcon: ChevronRight, }, buttonLabels: { - previous: PAGINATION_BUTTON_LABEL_PREV, - next: PAGINATION_BUTTON_LABEL_NEXT, - page: PAGINATION_BUTTON_LABEL_PAGE, - currentPage: PAGINATION_BUTTON_LABEL_CURRENT_PAGE, - pageOfCount: PAGINATION_BUTTON_LABEL_PAGE_OF_COUNT, + previous: 'Previous', + next: 'Next', + page: 'Page', + currentPage: 'Current Page', + pageOfCount: 'of', }, className: undefined, - currentPage: 1, + initialPage: 1, + currentPage: undefined, maxPagesDisplayed: 7, variant: 'default', invertColors: false, size: 'default', }; -ReducedPagination.propTypes = { - currentPage: PropTypes.number.isRequired, - pageCount: PropTypes.number.isRequired, - handlePageSelect: PropTypes.func.isRequired, -}; - -Pagination.Reduced = ReducedPagination; - export default Pagination; diff --git a/src/Pagination/subcomponents/Ellipsis.jsx b/src/Pagination/subcomponents/Ellipsis.jsx new file mode 100644 index 00000000000..9c6e26ce983 --- /dev/null +++ b/src/Pagination/subcomponents/Ellipsis.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import classNames from 'classnames'; +import { ELLIPSIS } from '../constants'; + +export default function Ellipsis() { + return ( +
  • + + {ELLIPSIS} + +
  • + ); +} diff --git a/src/Pagination/subcomponents/NextPageButton.jsx b/src/Pagination/subcomponents/NextPageButton.jsx new file mode 100644 index 00000000000..12a04fcd6c9 --- /dev/null +++ b/src/Pagination/subcomponents/NextPageButton.jsx @@ -0,0 +1,64 @@ +import React, { useContext } from 'react'; +import classNames from 'classnames'; +import { PAGINATION_BUTTON_ICON_BUTTON_NEXT_ALT } from '../constants'; +import PaginationContext from '../PaginationContext'; +import Button from '../../Button'; +import IconButton from '../../IconButton'; +import Icon from '../../Icon'; + +export default function NextPageButton() { + const { + invertColors, + getPageButtonVariant, + isDefaultVariant, + isOnLastPage, + getAriaLabelForNextButton, + handleNextButtonClick, + getNextButtonIcon, + buttonLabels, + nextButtonRef, + } = useContext(PaginationContext); + + const isDisabled = isOnLastPage(); + const icon = getNextButtonIcon(); + + if (isDefaultVariant()) { + return ( +
  • + +
  • + ); + } + + if (!icon) { + return null; + } + + return ( +
  • + +
  • + ); +} diff --git a/src/Pagination/subcomponents/PageButton.jsx b/src/Pagination/subcomponents/PageButton.jsx new file mode 100644 index 00000000000..3f2c4ad866b --- /dev/null +++ b/src/Pagination/subcomponents/PageButton.jsx @@ -0,0 +1,33 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Button from '../../Button'; +import PaginationContext from '../PaginationContext'; + +export default function PageButton({ pageNum }) { + const { + isPageButtonActive, + getAriaLabelForPageButton, + getPageButtonVariant, + handlePageSelect, + getPageButtonRefHandler, + } = useContext(PaginationContext); + + return ( +
  • + +
  • + ); +} + +PageButton.propTypes = { + pageNum: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, +}; diff --git a/src/Pagination/subcomponents/PageOfCountButton.jsx b/src/Pagination/subcomponents/PageOfCountButton.jsx new file mode 100644 index 00000000000..0b96f074a81 --- /dev/null +++ b/src/Pagination/subcomponents/PageOfCountButton.jsx @@ -0,0 +1,26 @@ +import React, { useContext } from 'react'; +import classNames from 'classnames'; +import PaginationContext from '../PaginationContext'; + +export default function PageOfCountButton() { + const { getAriaLabelForPageOfCountButton, getLabelForPageOfCountButton } = useContext(PaginationContext); + + const ariaLabel = getAriaLabelForPageOfCountButton(); + const label = getLabelForPageOfCountButton(); + + return ( +
  • + + {label} + +
  • + ); +} diff --git a/src/Pagination/subcomponents/PaginationDropdown.jsx b/src/Pagination/subcomponents/PaginationDropdown.jsx new file mode 100644 index 00000000000..dcd39e43bad --- /dev/null +++ b/src/Pagination/subcomponents/PaginationDropdown.jsx @@ -0,0 +1,31 @@ +import React, { useContext } from 'react'; +import PaginationContext from '../PaginationContext'; +import Dropdown from '../../Dropdown'; + +export default function PaginationDropdown() { + const { + getPageOfText, + pageCount, + handlePageSelect, + getPageButtonVariant, + } = useContext(PaginationContext); + + if (pageCount <= 1) { + return null; + } + + return ( + + + {getPageOfText()} + + + {[...Array(pageCount).keys()].map(pageNum => ( + handlePageSelect(pageNum + 1)} key={pageNum}> + {pageNum + 1} + + ))} + + + ); +} diff --git a/src/Pagination/subcomponents/PreviousPageButton.jsx b/src/Pagination/subcomponents/PreviousPageButton.jsx new file mode 100644 index 00000000000..c2a34e0ff39 --- /dev/null +++ b/src/Pagination/subcomponents/PreviousPageButton.jsx @@ -0,0 +1,63 @@ +import React, { useContext } from 'react'; +import classNames from 'classnames'; +import { PAGINATION_BUTTON_ICON_BUTTON_PREV_ALT } from '../constants'; +import Button from '../../Button'; +import IconButton from '../../IconButton'; +import Icon from '../../Icon'; +import PaginationContext from '../PaginationContext'; + +export default function PreviousPageButton() { + const { + invertColors, + isDefaultVariant, + isOnFirstPage, + getAriaLabelForPreviousButton, + handlePreviousButtonClick, + getPrevButtonIcon, + buttonLabels, + previousButtonRef, + } = useContext(PaginationContext); + + const isDisabled = isOnFirstPage(); + const icon = getPrevButtonIcon(); + + if (isDefaultVariant()) { + return ( +
  • + +
  • + ); + } + + if (!icon) { + return null; + } + + return ( +
  • + +
  • + ); +} diff --git a/src/Pagination/subcomponents/ScreenReaderComponent.jsx b/src/Pagination/subcomponents/ScreenReaderComponent.jsx new file mode 100644 index 00000000000..d67b34cbc57 --- /dev/null +++ b/src/Pagination/subcomponents/ScreenReaderComponent.jsx @@ -0,0 +1,17 @@ +import React, { useContext } from 'react'; +import PaginationContext from '../PaginationContext'; + +export default function PaginationScreenReaderText() { + const { getScreenReaderText } = useContext(PaginationContext); + + return ( +
    + {getScreenReaderText()} +
    + ); +} diff --git a/src/index.js b/src/index.js index 6ecd477ddd9..926161827ef 100644 --- a/src/index.js +++ b/src/index.js @@ -96,8 +96,8 @@ export { export { default as Navbar, NavbarBrand, NAVBAR_LABEL } from './Navbar'; export { default as Overlay, OverlayTrigger } from './Overlay'; export { default as PageBanner, PAGE_BANNER_DISMISS_ALT_TEXT } from './PageBanner'; +export { default as Pagination } from './Pagination'; export { - default as Pagination, PAGINATION_BUTTON_LABEL_PREV, PAGINATION_BUTTON_ICON_BUTTON_NEXT_ALT, PAGINATION_BUTTON_ICON_BUTTON_PREV_ALT, @@ -105,7 +105,7 @@ export { PAGINATION_BUTTON_LABEL_CURRENT_PAGE, PAGINATION_BUTTON_LABEL_NEXT, PAGINATION_BUTTON_LABEL_PAGE, -} from './Pagination'; +} from './Pagination/constants'; export { default as Popover, PopoverTitle, PopoverContent } from './Popover'; export { default as ProgressBar } from './ProgressBar'; export { default as ProductTour } from './ProductTour';