diff --git a/src/Button/index.scss b/src/Button/index.scss index 52c3eae069b..e5d1fa8fb94 100644 --- a/src/Button/index.scss +++ b/src/Button/index.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 42bb00acc5f..0497c4cf76d 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 615a74b3f4a..ce5a6f87a0d 100644 --- a/src/DataTable/TablePaginationMinimal.jsx +++ b/src/DataTable/TablePaginationMinimal.jsx @@ -1,6 +1,7 @@ import React, { useContext } from 'react'; import DataTableContext from './DataTableContext'; import Pagination from '../Pagination'; +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/DataTable/tests/TablePagination.test.jsx b/src/DataTable/tests/TablePagination.test.jsx index da039952813..b2835878074 100644 --- a/src/DataTable/tests/TablePagination.test.jsx +++ b/src/DataTable/tests/TablePagination.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, act } from '@testing-library/react'; +import { render, act, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import TablePagination from '../TablePagination'; @@ -29,21 +29,21 @@ describe('', () => { it( 'Shows dropdown button with the page count as label and performs actions when dropdown items are clicked', async () => { - const { getAllByTestId, getByRole } = render(); - const dropdownButton = getByRole('button', { name: /2 of 3/i }); + render(); + const dropdownButton = screen.getByRole('button', { name: /2 of 3/i }); expect(dropdownButton).toBeInTheDocument(); await act(async () => { await userEvent.click(dropdownButton); }); - const dropdownChoices = getAllByTestId('pagination-dropdown-item'); + const dropdownChoices = screen.getAllByTestId('pagination-dropdown-item'); expect(dropdownChoices.length).toEqual(instance.pageCount); await act(async () => { - await userEvent.click(dropdownChoices[1], undefined, { skipPointerEventsCheck: true }); + await userEvent.click(dropdownChoices[2], undefined, { skipPointerEventsCheck: true }); }); expect(instance.gotoPage).toHaveBeenCalledTimes(1); - expect(instance.gotoPage).toHaveBeenCalledWith(1); + expect(instance.gotoPage).toHaveBeenCalledWith(2); }, ); }); diff --git a/src/Pagination/DefaultPagination.jsx b/src/Pagination/DefaultPagination.jsx new file mode 100644 index 00000000000..2ca7c1048b6 --- /dev/null +++ b/src/Pagination/DefaultPagination.jsx @@ -0,0 +1,43 @@ +import React, { useContext } from 'react'; +import { useMediaQuery } from 'react-responsive'; +import PaginationContext from './PaginationContext'; +import { ELLIPSIS } from './constants'; +import { + PreviousPageButton, + NextPageButton, + PageOfCountButton, + PageButton, + Ellipsis, +} from './subcomponents'; +import breakpoints from '../utils/breakpoints'; +import newId from '../utils/newId'; + +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 ; + })} + + ); +} + +export default function DefaultPagination() { + return ( +
    + + + +
+ ); +} diff --git a/src/Pagination/MinimalPagination.jsx b/src/Pagination/MinimalPagination.jsx new file mode 100644 index 00000000000..4b89247509e --- /dev/null +++ b/src/Pagination/MinimalPagination.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { PreviousPageButton, NextPageButton } from './subcomponents'; + +export default function MinimalPagination() { + return ( +
    + + +
+ ); +} diff --git a/src/Pagination/Pagination.test.jsx b/src/Pagination/Pagination.test.jsx index 98f13d30ab4..cfaf6019fc9 100644 --- a/src/Pagination/Pagination.test.jsx +++ b/src/Pagination/Pagination.test.jsx @@ -1,26 +1,40 @@ import React from 'react'; -import { render, act, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - import { Context as ResponsiveContext } from 'react-responsive'; - +import renderer from 'react-test-renderer'; +import { + render, + act, + screen, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; import breakpoints from '../utils/breakpoints'; import Pagination from '.'; +import { + PAGINATION_VARIANTS, + ELLIPSIS, + PAGINATION_BUTTON_LABEL_CURRENT_PAGE, + PAGINATION_BUTTON_LABEL_NEXT, + PAGINATION_BUTTON_LABEL_PREV, + PAGINATION_BUTTON_LABEL_PAGE, +} from './constants'; const baseProps = { - state: { pageIndex: 1 }, + currentPage: 1, paginationLabel: 'pagination navigation', pageCount: 5, onPageSelect: () => {}, }; describe('', () => { - it('renders', () => { - const props = { - ...baseProps, - }; - const { container } = render(); - expect(container).toBeInTheDocument(); + it('renders default variant', () => { + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders with inverse colors', () => { + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); }); it('renders screen reader section', () => { @@ -31,65 +45,94 @@ describe('', () => { currentPage: 'Página actual', pageOfCount: 'de', }; + const expectedSrText = `${buttonLabels.page} 1, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} ${baseProps.pageCount}`; const props = { ...baseProps, buttonLabels, }; render(); - const srText = screen.getByText(`${buttonLabels.page} 1, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} ${baseProps.pageCount}`); - expect(srText).toBeInTheDocument(); + const srText = screen.getByText(expectedSrText); + expect(srText).toHaveClass('sr-only'); }); - describe('handles currentPage props properly', () => { - it('overrides state currentPage when props currentPage changes', () => { - const initialPage = 1; - const newPage = 2; - const props = { - ...baseProps, - currentPage: initialPage, - }; - const { rerender } = render(); - expect(screen.getByText('Page 1, Current Page, of 5')).toBeInTheDocument(); - rerender(); - expect(screen.getByText('Page 2, Current Page, of 5')).toBeInTheDocument(); + it('correctly handles initial page prop', () => { + render(); + expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('3'); + }); + + it('renders ellipsis if there are too many pages', () => { + render(); + expect(screen.getByText(ELLIPSIS)).toBeInTheDocument(); + }); + + describe('handles controlled and uncontrolled behaviour properly', () => { + it('does not internally change page on page click if currentPage is provided', () => { + render(); + expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('1'); + + userEvent.click(screen.getByText(PAGINATION_BUTTON_LABEL_NEXT)); + expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('1'); + + userEvent.click(screen.getByRole('button', { name: `${PAGINATION_BUTTON_LABEL_PAGE} 3` })); + expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('1'); }); - it('does not override state currentPage when props currentPage changes with existing value', () => { - const currentPage = 2; - const props = { - ...baseProps, - currentPage, - }; - const { rerender } = render(); - expect(screen.getByText(`Page ${currentPage}, Current Page, of 5`)).toBeInTheDocument(); - rerender(); - expect(screen.getByText(`Page ${currentPage}, Current Page, of 5`)).toBeInTheDocument(); + it('controls page selection internally if currentPage is not provided', () => { + render(); + expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('1'); + + userEvent.click(screen.getByText(PAGINATION_BUTTON_LABEL_NEXT)); + expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('2'); + + userEvent.click(screen.getByRole('button', { name: `${PAGINATION_BUTTON_LABEL_PAGE} 3` })); + expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('3'); + + userEvent.click(screen.getByText(PAGINATION_BUTTON_LABEL_PREV)); + expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('2'); + }); + + it('does not chang page if you click "next" button while on last page', () => { + render(); + expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('5'); + userEvent.click(screen.getByText(PAGINATION_BUTTON_LABEL_NEXT)); + expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('5'); + }); + + it('does not chang page if you click "previous" button while on first page', () => { + render(); + expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('1'); + userEvent.click(screen.getByText(PAGINATION_BUTTON_LABEL_PREV)); + expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('1'); }); }); describe('handles focus properly', () => { - it('should change focus to next button if previous page is first page', async () => { + it('should change focus to next button if previous page is first page', () => { const props = { ...baseProps, currentPage: 2, + buttonLabel: { + previous: 'Previous', + next: 'Next', + }, }; render(); - const previousButton = screen.getByLabelText(/Previous/); - const nextButton = screen.getByLabelText(/Next/); - await userEvent.click(previousButton); - expect(document.activeElement).toEqual(nextButton); + userEvent.click(screen.getByText(PAGINATION_BUTTON_LABEL_PREV)); + expect(screen.getByText(PAGINATION_BUTTON_LABEL_NEXT)).toHaveFocus(); }); - it('should change focus to previous button if next page is last page', async () => { + it('should change focus to previous button if next page is last page', () => { const props = { ...baseProps, currentPage: baseProps.pageCount - 1, + buttonLabel: { + previous: 'Previous', + next: 'Next', + }, }; render(); - const previousButton = screen.getByLabelText(/Previous/); - const nextButton = screen.getByLabelText(/Next/); - await userEvent.click(nextButton); - expect(document.activeElement).toEqual(previousButton); + userEvent.click(screen.getByText(props.buttonLabel.next)); + expect(screen.getByText(props.buttonLabel.previous)).toHaveFocus(); }); }); @@ -101,94 +144,113 @@ describe('', () => { paginationLabel, }; render(); - expect(screen.getByLabelText(paginationLabel)).toBeInTheDocument(); + expect(screen.getByRole('navigation')).toHaveAttribute('aria-label', paginationLabel); }); describe('should use correct number of pages', () => { it('should show 5 buttons on desktop', () => { - render( + render(( - , - ); + + )); - const pageButtons = screen.getAllByLabelText(/^Page/); - expect(pageButtons.length).toBe(5); + const buttonsAriaLabel = new RegExp(`^${PAGINATION_BUTTON_LABEL_PAGE}`); + expect(screen.queryAllByRole('button', { name: buttonsAriaLabel })).toHaveLength(5); }); - it('should show 1 button on mobile', () => { - // Use extra small window size to display the mobile version of Pagination. - render( + it('should show page of count text instead of pag buttons on mobile', () => { + const buttonLabels = { + previous: 'Anterior', + next: 'Siguiente', + page: 'Página', + currentPage: 'Página actual', + pageOfCount: 'de', + }; + const pageCount = 5; + const currentPage = 1; + const props = { + ...baseProps, + buttonLabels, + pageCount, + currentPage, + }; + + // Use extra small window size to display the mobile version of `Pagination`. + render(( - - , - ); - const pageButtons = screen.getAllByLabelText(/^Page/); - expect(pageButtons.length).toBe(1); + + + )); + + const pageOfCountLabel = `${buttonLabels.page} ${currentPage}, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} ${pageCount}`; + const buttonsAriaLabel = new RegExp(`^${PAGINATION_BUTTON_LABEL_PAGE}`); + expect(screen.queryAllByRole('button', { name: buttonsAriaLabel })).toHaveLength(0); + expect(screen.queryByLabelText(pageOfCountLabel)).toBeInTheDocument(); }); }); describe('should fire callbacks properly', () => { - it('should not fire onPageSelect when selecting current page', async () => { + it('should not fire onPageSelect when selecting current page', () => { const spy = jest.fn(); const props = { ...baseProps, onPageSelect: spy, }; - render( + render(( - , - ); + + )); - const previousButton = screen.getByLabelText(/Previous/); - await userEvent.click(previousButton); + userEvent.click(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })); expect(spy).toHaveBeenCalledTimes(0); }); - it('should fire onPageSelect callback when selecting new page', async () => { + it('should fire onPageSelect callback when selecting new page', () => { const spy = jest.fn(); const props = { ...baseProps, onPageSelect: spy, }; - render( + render(( - , - ); + + )); - const pageButtons = screen.getAllByLabelText(/^Page/); - await userEvent.click(pageButtons[1]); + userEvent.click(screen.getByLabelText(`${PAGINATION_BUTTON_LABEL_PAGE} 2`)); expect(spy).toHaveBeenCalledTimes(1); - await userEvent.click(pageButtons[2]); + userEvent.click(screen.getByLabelText(`${PAGINATION_BUTTON_LABEL_PAGE} 3`)); expect(spy).toHaveBeenCalledTimes(2); }); }); }); describe('fires previous and next button click handlers', () => { - it('previous button onClick', async () => { + it('previous button onClick', () => { const spy = jest.fn(); const props = { ...baseProps, - currentPage: 2, onPageSelect: spy, + currentPage: 3, }; render(); - await userEvent.click(screen.getByLabelText(/Previous/)); + const expectedPrevButtonAriaLabel = `${PAGINATION_BUTTON_LABEL_PREV}, ${PAGINATION_BUTTON_LABEL_PAGE} 2`; + userEvent.click(screen.getByRole('button', { name: expectedPrevButtonAriaLabel })); expect(spy).toHaveBeenCalledTimes(1); }); - it('next button onClick', async () => { + it('next button onClick', () => { const spy = jest.fn(); const props = { ...baseProps, onPageSelect: spy, }; render(); - await userEvent.click(screen.getByLabelText(/Next/)); + const expectedNextButtonAriaLabel = `${PAGINATION_BUTTON_LABEL_NEXT}, ${PAGINATION_BUTTON_LABEL_PAGE} 2`; + userEvent.click(screen.getByRole('button', { name: expectedNextButtonAriaLabel })); expect(spy).toHaveBeenCalledTimes(1); }); }); @@ -201,112 +263,95 @@ describe('', () => { currentPage: 'Página actual', pageOfCount: 'de', }; - - let props = { + const props = { ...baseProps, buttonLabels, }; - /** - * made a proxy component because setProps can only be used with root component and - * Responsive Context Provider is needed to mock screen - */ - // eslint-disable-next-line react/prop-types - function Proxy({ currentPage, width }) { - return ( - - - - ); - } - - it('uses passed in previous button label', async () => { - render( - , - ); - expect(screen.getByText(buttonLabels.previous)).toBeInTheDocument(); + it('uses passed in previous button label', () => { + const { rerender } = render(); + // default label is used if we're on the first page + expect(screen.getByRole('button', { name: buttonLabels.previous })).toBeInTheDocument(); - await userEvent.click(screen.getByText(buttonLabels.next)); - expect(screen.getByLabelText(`${buttonLabels.previous}, ${buttonLabels.page} 4`)).toBeInTheDocument(); + rerender(); + // label should change if we're not on the first page + const expectedPrevButtonAriaLabel = `${buttonLabels.previous}, ${buttonLabels.page} 4`; + expect(screen.getByRole('button', { name: expectedPrevButtonAriaLabel })).toBeInTheDocument(); }); it('uses passed in next button label', () => { - const { rerender } = render( - , - ); - expect(screen.getByLabelText(`${buttonLabels.next}, ${buttonLabels.page} 2`)).toBeInTheDocument(); - - rerender( - , - ); - expect(screen.getByLabelText(buttonLabels.next)).toBeInTheDocument(); + const { rerender } = render(); + // label should change if we're not on the last page + const expectedNextButtonAriaLabel = `${buttonLabels.next}, ${buttonLabels.page} 2`; + expect(screen.getByRole('button', { name: expectedNextButtonAriaLabel })).toBeInTheDocument(); + + rerender(); + // default label is used if we're on the last page + expect(screen.getByRole('button', { name: buttonLabels.next })).toBeInTheDocument(); }); it('uses passed in page button label', () => { - const { rerender } = render( + const currentPageLabel = `${buttonLabels.page} 1, ${buttonLabels.currentPage}`; + const pageLabel = `${buttonLabels.page} 1`; + + const { rerender } = render(( - , - ); - expect(screen.getByText(`${buttonLabels.page} 1, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} 5`)).toBeInTheDocument(); - expect(screen.getByLabelText(`${buttonLabels.page} 1, ${buttonLabels.currentPage}`)).toBeInTheDocument(); - - rerender( + + )); + expect(screen.getByText('1')).toHaveAttribute('aria-label', currentPageLabel); + rerender(( - , - ); - expect(screen.getByText(`${buttonLabels.page} 2, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} 5`)).toBeInTheDocument(); - expect(screen.getByLabelText(`${buttonLabels.page} 1`)).toBeInTheDocument(); - - rerender( - , - ); - expect(screen.getByText(`${buttonLabels.page} 1, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} 5`)).toBeInTheDocument(); + + )); + expect(screen.getByText('1')).toHaveAttribute('aria-label', pageLabel); + + rerender(( + + + + )); + + const pageOfCountLabel = `${buttonLabels.page} 1, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} 5`; + expect(screen.queryByLabelText(pageOfCountLabel)).toBeInTheDocument(); }); it('for the reduced variant shows dropdown button with the page count as label', async () => { render(); - const dropdownButton = screen.getByRole('button', { name: /1 of 5/i, attributes: { 'aria-haspopup': 'true' } }); - expect(dropdownButton.textContent).toContain(`${baseProps.state.pageIndex} of ${baseProps.pageCount}`); - - await userEvent.click(dropdownButton); + const dropdownLabel = `${baseProps.currentPage} de ${baseProps.pageCount}`; await act(async () => { - const dropdownChoices = screen.getAllByTestId('pagination-dropdown-item'); - expect(dropdownChoices.length).toBe(baseProps.pageCount); + userEvent.click(screen.getByRole('button', { name: dropdownLabel })); }); + expect(screen.queryAllByRole('button', { name: /^\d+$/ }).length).toEqual(baseProps.pageCount); }); it('renders only previous and next buttons in minimal variant', () => { - render( - pageNumber} - pageCount={12} - paginationLabel="Label" - />, - ); - const items = screen.getAllByRole('listitem'); - expect(items.length).toBe(2); + render(); + expect(screen.queryAllByRole('button').length).toEqual(2); }); - it('renders chevrons and buttons disabled when pageCount is 1 or 0 for all variants', () => { - const variantTypes = ['default', 'secondary', 'reduced', 'minimal']; - variantTypes.forEach((variantType) => { - for (let i = 0; i < 3; i++) { - props = { - ...baseProps, - variant: variantType, - pageCount: i, - }; - const { container } = render(); - const disabledButtons = container.querySelectorAll('button[disabled]'); - expect(props.pageCount).toEqual(i); - expect(disabledButtons.length).toEqual(i === 2 ? 1 : 2); - } - }); - }); + test.each(Object.values(PAGINATION_VARIANTS))( + 'renders chevrons and buttons disabled when pageCount is 1 || 0 for %s variant', + (variant) => { + const { rerender } = render(); + + const nextButtonLabel = new RegExp(PAGINATION_BUTTON_LABEL_NEXT, 'i'); + const prevButtonLabel = new RegExp(PAGINATION_BUTTON_LABEL_PREV, 'i'); + + expect(screen.getByRole('button', { name: nextButtonLabel })).toBeDisabled(); + expect(screen.getByRole('button', { name: prevButtonLabel })).toBeDisabled(); + + rerender(); + expect(screen.getByRole('button', { name: nextButtonLabel })).toBeDisabled(); + expect(screen.getByRole('button', { name: prevButtonLabel })).toBeDisabled(); + + rerender(); + expect(screen.getByRole('button', { name: nextButtonLabel })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: prevButtonLabel })).toBeDisabled(); + }, + ); }); }); diff --git a/src/Pagination/PaginationContext.jsx b/src/Pagination/PaginationContext.jsx new file mode 100644 index 00000000000..c6dbcffc029 --- /dev/null +++ b/src/Pagination/PaginationContext.jsx @@ -0,0 +1,191 @@ +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 = () => { + onPageSelect(currentPage - 1); + if (currentPage === 2) { + nextButtonRef.current.focus(); + } + if (isUncontrolled()) { + setCurrentPage((prevState) => prevState - 1); + } + }; + + const handleNextButtonClick = () => { + 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 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, + 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(Object.values(PAGINATION_VARIANTS)).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 3db95e620fd..13577ea9484 100644 --- a/src/Pagination/README.md +++ b/src/Pagination/README.md @@ -18,61 +18,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 (Small Size) +### Secondary (Small Size) ```jsx live console.log('page selected')} + onPageSelect={(page) => console.log(`page ${page} selected`)} /> ``` -## Reduced (Small Size) +### Reduced (Small Size) ```jsx live console.log('page selected')} + onPageSelect={(page) => console.log(`page ${page} selected`)} /> ``` -## Minimal (Small Size) +### Minimal (Small Size) ```jsx live console.log('page selected')} + onPageSelect={(page) => console.log(`page ${page} selected`)} /> ``` @@ -116,21 +157,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, + }} /> ``` @@ -144,7 +200,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..453c4195b1c --- /dev/null +++ b/src/Pagination/ReducedPagination.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { PreviousPageButton, NextPageButton, PaginationDropdown } from './subcomponents'; + +export default function ReducedPagination() { + return ( +
    + + + +
+ ); +} diff --git a/src/Pagination/__snapshots__/Pagination.test.jsx.snap b/src/Pagination/__snapshots__/Pagination.test.jsx.snap new file mode 100644 index 00000000000..cf3993c5a34 --- /dev/null +++ b/src/Pagination/__snapshots__/Pagination.test.jsx.snap @@ -0,0 +1,301 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders default variant 1`] = ` + +`; + +exports[` renders with inverse colors 1`] = ` + +`; 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 000f1318cbb..085f6247b5f 100644 --- a/src/Pagination/index.jsx +++ b/src/Pagination/index.jsx @@ -1,420 +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 from '../Button'; -import Dropdown from '../Dropdown'; -import IconButton from '../IconButton'; -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} - data-testid="pagination-dropdown-item" - > - {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 { ScreenReaderText } from './subcomponents'; - 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 = { @@ -483,40 +116,35 @@ Pagination.propTypes = { * string, symbol, etc. Default is chevrons rendered using fa-css. */ icons: PropTypes.shape({ - leftIcon: PropTypes.node, - rightIcon: PropTypes.node, + leftIcon: PropTypes.elementType, + rightIcon: PropTypes.elementType, }), 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; +export * from './constants'; diff --git a/src/Pagination/index.scss b/src/Pagination/index.scss index d1f634d917c..cdaf2303ecb 100644 --- a/src/Pagination/index.scss +++ b/src/Pagination/index.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,69 @@ border-bottom-left-radius: 50%; } -.pagination-icon-button-right { - @extend %pagination-icon-button-right; -} - -.pagination-icon-button-left { - @extend %pagination-icon-button-left; -} +.pagination { + display: flex; -.pagination-default { - .page-link { - &.previous .pgn__icon { - margin-inline-start: 0; - margin-inline-end: $pagination-margin-x; - } + .dropdown { + z-index: 4; + } - &.next .pgn__icon { - margin-inline-start: $pagination-margin-x; - margin-inline-end: 0; - } + .page-of-count { + margin: 0 .5rem; + border: 0; } .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 +80,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/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..78c4f7313a5 --- /dev/null +++ b/src/Pagination/subcomponents/PageOfCountButton.jsx @@ -0,0 +1,25 @@ +import React, { useContext } from 'react'; +import classNames from 'classnames'; +import PaginationContext from '../PaginationContext'; + +export default function PageOfCountButton() { + const { getAriaLabelForPageOfCountButton, getPageOfText } = useContext(PaginationContext); + + const ariaLabel = getAriaLabelForPageOfCountButton(); + const label = getPageOfText(); + + return ( +
  • + + {label} + +
  • + ); +} diff --git a/src/Pagination/subcomponents/PaginationDropdown.jsx b/src/Pagination/subcomponents/PaginationDropdown.jsx new file mode 100644 index 00000000000..02ad81981d9 --- /dev/null +++ b/src/Pagination/subcomponents/PaginationDropdown.jsx @@ -0,0 +1,35 @@ +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} + data-testid="pagination-dropdown-item" + > + {pageNum + 1} + + ))} + + + ); +} diff --git a/src/Pagination/subcomponents/PreviousPageButton.jsx b/src/Pagination/subcomponents/PreviousPageButton.jsx new file mode 100644 index 00000000000..d0ab0f11bd9 --- /dev/null +++ b/src/Pagination/subcomponents/PreviousPageButton.jsx @@ -0,0 +1,64 @@ +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, + getPageButtonVariant, + 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/ScreenReaderText.jsx b/src/Pagination/subcomponents/ScreenReaderText.jsx new file mode 100644 index 00000000000..d67b34cbc57 --- /dev/null +++ b/src/Pagination/subcomponents/ScreenReaderText.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/Pagination/subcomponents/index.js b/src/Pagination/subcomponents/index.js new file mode 100644 index 00000000000..707481db8ac --- /dev/null +++ b/src/Pagination/subcomponents/index.js @@ -0,0 +1,7 @@ +export { default as Ellipsis } from './Ellipsis'; +export { default as NextPageButton } from './NextPageButton'; +export { default as PageButton } from './PageButton'; +export { default as PageOfCountButton } from './PageOfCountButton'; +export { default as PaginationDropdown } from './PaginationDropdown'; +export { default as PreviousPageButton } from './PreviousPageButton'; +export { default as ScreenReaderText } from './ScreenReaderText';