diff --git a/packages/react/src/components/pagination/buttons/nav-button/pagination-nav-button.tsx b/packages/react/src/components/pagination/buttons/nav-button/pagination-nav-button.tsx new file mode 100644 index 0000000000..fd80a4e826 --- /dev/null +++ b/packages/react/src/components/pagination/buttons/nav-button/pagination-nav-button.tsx @@ -0,0 +1,47 @@ +import { VoidFunctionComponent } from 'react'; +import { StyledIconButton } from './styled'; +import { usePaginationContext } from '../../context'; +import { useDeviceContext } from '../../../device-context-provider/device-context-provider'; + +export type NavigationAction = 'previous' | 'next'; + +export interface PaginationNavButtonProps { + action: NavigationAction; +} + +export const PaginationNavButton: VoidFunctionComponent = ( + { action }, +) => { + const { isMobile } = useDeviceContext(); + const { + totalPages, + pagesDisplayed, + currentPage, + changePage, + } = usePaginationContext(); + const showNavButtons = totalPages > 3 || pagesDisplayed < totalPages; + + if (!showNavButtons) return null; + + const isPrevious = action === 'previous'; + const isDisabled = isPrevious ? currentPage <= 1 : currentPage >= totalPages; + const onClick = (): void => changePage(currentPage + (isPrevious ? -1 : 1)); + const iconName = isPrevious ? 'chevronLeft' : 'chevronRight'; + const label = isPrevious ? 'previous page' : 'next page'; + const dataTestId = isPrevious ? 'previousPageButton' : 'nextPageButton'; + + return ( + + ); +}; diff --git a/packages/react/src/components/pagination/buttons/nav-button/styled.ts b/packages/react/src/components/pagination/buttons/nav-button/styled.ts new file mode 100644 index 0000000000..5eb9b6320b --- /dev/null +++ b/packages/react/src/components/pagination/buttons/nav-button/styled.ts @@ -0,0 +1,12 @@ +import styled from 'styled-components'; +import { IconButton } from '../../../buttons'; + +export const StyledIconButton = styled(IconButton)<{ $isVisible: boolean, $isMobile: boolean }>` + align-items: center; + display: flex; + gap: 0.5rem; + min-height: ${({ $isMobile }) => ($isMobile ? 'var(--size-2x)' : 'var(--size-1halfx)')}; + min-width: ${({ $isMobile }) => ($isMobile ? 'var(--size-2x)' : 'var(--size-1halfx)')}; + visibility: ${({ $isVisible }) => ($isVisible ? 'visible' : 'hidden')}; + width: auto; +`; diff --git a/packages/react/src/components/pagination/buttons/page-button/pagination-page-button.tsx b/packages/react/src/components/pagination/buttons/page-button/pagination-page-button.tsx new file mode 100644 index 0000000000..ae4a2387aa --- /dev/null +++ b/packages/react/src/components/pagination/buttons/page-button/pagination-page-button.tsx @@ -0,0 +1,47 @@ +import { FC, useCallback } from 'react'; +import { useDeviceContext } from '../../../device-context-provider/device-context-provider'; +import { PageButtonWrapper, StyledButton, StyledText } from './styled'; +import { usePaginationContext } from '../../context'; + +interface PageButtonProps { + index: number; +} + +export const PaginationPageButton: FC = ({ + index, +}) => { + const { isMobile } = useDeviceContext(); + const { currentPage, changePage } = usePaginationContext(); + const isSelected = index === currentPage; + const id = `page-${index}`; + + const handlePageKeyDown = useCallback((key: string, page: number): void => { + if (key === 'Enter') { + changePage(page); + } + }, [changePage]); + + return ( + + changePage(index)} + onKeyPress={(event) => handlePageKeyDown(event.key, index)} + type="button" + $isMobile={isMobile} + > + + {index} + + + + + ); +}; diff --git a/packages/react/src/components/pagination/buttons/page-button/styled.ts b/packages/react/src/components/pagination/buttons/page-button/styled.ts new file mode 100644 index 0000000000..fbc5b14e25 --- /dev/null +++ b/packages/react/src/components/pagination/buttons/page-button/styled.ts @@ -0,0 +1,45 @@ +import styled from 'styled-components'; +import { focus } from '../../../../utils/css-state'; + +type SelectionSuffix = '-selected' | ''; + +function getSelectionSuffix(isSelected: boolean): SelectionSuffix { + return isSelected ? '-selected' : ''; +} + +export const PageButtonWrapper = styled.li` + list-style: none; +`; + +export const StyledButton = styled.button<{ $isSelected: boolean, $isMobile: boolean }>` + align-items: center; + background-color: ${({ $isSelected, theme }) => theme.component[`pagination-page${getSelectionSuffix($isSelected)}-background-color`]}; + border: ${({ $isSelected, theme }) => ($isSelected ? `1px solid ${theme.component['pagination-page-selected-border-color']}` : 'none')}; + border-radius: var(--border-radius-4x); + display: flex; + justify-content: center; + max-height: ${({ $isMobile }) => ($isMobile ? 'var(--size-2x)' : 'var(--size-1halfx)')}; + min-width: ${({ $isMobile }) => ($isMobile ? 'var(--size-2x)' : 'var(--size-1halfx)')}; + padding: ${({ $isMobile }) => ($isMobile ? '0.25rem 0.5rem' : '0.125rem 0.25rem')}; + user-select: none; + + ${focus}; + + &:hover { + background-color: ${({ $isSelected, theme }) => theme.component[`pagination-page${getSelectionSuffix($isSelected)}-hover-background-color`]}; + } +`; + +export const StyledText = styled.span<{ $isSelected: boolean, $isMobile: boolean }>` + color: ${({ $isSelected, theme }) => theme.component[`pagination-page${getSelectionSuffix($isSelected)}-text-color`]}; + font-size: ${({ $isMobile }) => ($isMobile ? 1 : 0.875)}rem; + font-weight: ${({ $isSelected }) => ($isSelected ? 'var(--font-bold)' : 'var(--font-normal)')}; + letter-spacing: 0.0125rem; + line-height: ${({ $isMobile }) => ($isMobile ? 1.5 : 1.25)}rem; + + &:hover { + ${({ $isSelected, theme }) => !$isSelected && ` + color: ${theme.component['pagination-page-hover-text-color']}; + `} + } +`; diff --git a/packages/react/src/components/pagination/buttons/pagination-buttons.tsx b/packages/react/src/components/pagination/buttons/pagination-buttons.tsx new file mode 100644 index 0000000000..9bd1316338 --- /dev/null +++ b/packages/react/src/components/pagination/buttons/pagination-buttons.tsx @@ -0,0 +1,37 @@ +import { FC } from 'react'; +import { useDeviceContext } from '../../device-context-provider/device-context-provider'; +import { usePaginationContext } from '../context'; +import { range } from '../../../utils/range'; +import { calculateShownPageRange } from '../util/pagination-util'; +import { PaginationPageButton } from './page-button/pagination-page-button'; +import { PaginationNavButton } from './nav-button/pagination-nav-button'; +import { PaginationButtonsWrapper, PaginationPageButtonsWrapper } from './styled'; + +export const PaginationButtons: FC = () => { + const { isMobile } = useDeviceContext(); + const { currentPage, totalPages, pagesDisplayed } = usePaginationContext(); + const { begin, end } = calculateShownPageRange(totalPages, pagesDisplayed, currentPage); + + if (totalPages <= 1) { + return null; + } + + return ( + + + + {range(begin, end).map((i) => ( + + ))} + + + + + ); +}; diff --git a/packages/react/src/components/pagination/buttons/styled.ts b/packages/react/src/components/pagination/buttons/styled.ts new file mode 100644 index 0000000000..d4c334bf3b --- /dev/null +++ b/packages/react/src/components/pagination/buttons/styled.ts @@ -0,0 +1,16 @@ +import styled from 'styled-components'; + +export const PaginationButtonsWrapper = styled.div<{ $isMobile: boolean }>` + align-items: center; + display: flex; + gap: ${({ $isMobile }) => ($isMobile ? '0.75rem' : '0.5rem')}; + padding: 0; +`; + +export const PaginationPageButtonsWrapper = styled.ol<{ $isMobile: boolean }>` + align-items: center; + display: flex; + gap: ${({ $isMobile }) => ($isMobile ? '0.75rem' : '0.5rem')}; + margin: 0; + padding: 0; +`; diff --git a/packages/react/src/components/pagination/context.ts b/packages/react/src/components/pagination/context.ts new file mode 100644 index 0000000000..d2cd2c930a --- /dev/null +++ b/packages/react/src/components/pagination/context.ts @@ -0,0 +1,24 @@ +import { createContext, useContext } from 'react'; + +export interface PaginationContextProps { + resultsPerPage: number; + numberOfResults: number; + totalPages: number; + pagesDisplayed: number; + currentPage: number; + + /** + * Function callback to change page + */ + changePage: (pageNumber: number) => void; +} + +export const PaginationContext = createContext(undefined); + +export function usePaginationContext(): PaginationContextProps { + const context = useContext(PaginationContext); + if (!context) { + throw new Error('usePaginationContext must be used within a PaginationProvider'); + } + return context; +} diff --git a/packages/react/src/components/pagination/index.ts b/packages/react/src/components/pagination/index.ts new file mode 100644 index 0000000000..5df5a0f546 --- /dev/null +++ b/packages/react/src/components/pagination/index.ts @@ -0,0 +1,2 @@ +export { Pagination } from './pagination'; +export { PaginationProps } from './pagination'; diff --git a/packages/react/src/components/pagination/label/pagination-label.tsx b/packages/react/src/components/pagination/label/pagination-label.tsx new file mode 100644 index 0000000000..f8960c446f --- /dev/null +++ b/packages/react/src/components/pagination/label/pagination-label.tsx @@ -0,0 +1,42 @@ +import { FC } from 'react'; +import { ScreenReaderOnlyText } from '../../screen-reader-only-text/ScreenReaderOnlyText'; +import { useTranslation } from '../../../i18n/use-translation'; +import { useDeviceContext } from '../../device-context-provider/device-context-provider'; +import { ResultsLabelHeading } from './styled'; +import { usePaginationContext } from '../context'; + +interface PageLabelProps { + id: string; +} + +export const PaginationLabel: FC = ({ + id, +}) => { + const { t } = useTranslation('pagination'); + const { isMobile } = useDeviceContext(); + const { resultsPerPage, currentPage, numberOfResults } = usePaginationContext(); + + const pageStartIndex = resultsPerPage && numberOfResults + ? (resultsPerPage * (currentPage - 1)) + 1 : 0; + const pageEndIndex = resultsPerPage && numberOfResults + ? Math.min(numberOfResults, resultsPerPage * currentPage) : 0; + + return ( +
+ + + {t('results', { + pageStartIndex, + pageEndIndex, + numberOfResults, + })} + +
+ ); +}; diff --git a/packages/react/src/components/pagination/label/styled.ts b/packages/react/src/components/pagination/label/styled.ts new file mode 100644 index 0000000000..06e754df83 --- /dev/null +++ b/packages/react/src/components/pagination/label/styled.ts @@ -0,0 +1,10 @@ +import styled from 'styled-components'; +import { Heading } from '../../heading/heading'; + +export const ResultsLabelHeading = styled(Heading)<{ $isMobile: boolean }>` + font-size: ${({ $isMobile }) => ($isMobile ? 1 : 0.875)}rem; + font-weight: var(--font-normal); + letter-spacing: 0.0125rem; + line-height: ${({ $isMobile }) => ($isMobile ? 1.5 : 1.25)}rem; + margin: 0; +`; diff --git a/packages/react/src/components/pagination/pagination.test.tsx b/packages/react/src/components/pagination/pagination.test.tsx index e1bec8c6c9..f26d43863a 100644 --- a/packages/react/src/components/pagination/pagination.test.tsx +++ b/packages/react/src/components/pagination/pagination.test.tsx @@ -1,7 +1,7 @@ -import { shallow } from 'enzyme'; import { findByTestId } from '../../test-utils/enzyme-selectors'; -import { renderWithProviders } from '../../test-utils/renderer'; +import { mountWithProviders, renderWithProviders } from '../../test-utils/renderer'; import { Pagination } from './pagination'; +import { PaginationPageButton } from './buttons/page-button/pagination-page-button'; describe('Pagination', () => { test('Matches the mobile snapshot', () => { @@ -42,99 +42,139 @@ describe('Pagination', () => { describe('pages list', () => { test('should display pages', () => { - const wrapper = shallow(); - const pages = findByTestId(wrapper, 'page-', { modifier: '^' }); + const wrapper = mountWithProviders( + , + ); + + const pageButtons = wrapper.find(PaginationPageButton); - expect(pages).toHaveLength(5); + expect(pageButtons).toHaveLength(5); + wrapper.unmount(); }); test('should go to page 2 when clicking on page 2', () => { const callback = jest.fn(); - const wrapper = shallow( - , + const wrapper = mountWithProviders( + , ); - const pageButton = findByTestId(wrapper, 'page-2'); + const pageButton = findByTestId(wrapper, 'page-2').at(0); pageButton.simulate('click'); expect(callback).toHaveBeenCalledWith(2); + wrapper.unmount(); }); test('should highlight selected page', () => { - const wrapper = shallow(); - const pageButton = findByTestId(wrapper, 'page-3'); + const wrapper = mountWithProviders( + , + ); + const pageButton = findByTestId(wrapper, 'page-3').at(0); - expect(pageButton.prop('isSelected')).toBe(true); + expect(pageButton.prop('$isSelected')).toBe(true); + wrapper.unmount(); + }); + + test('should not display pages when displayed page less than 2', () => { + const wrapper = mountWithProviders( + , + ); + + const pageButtons = wrapper.find(PaginationPageButton); + + expect(pageButtons).toHaveLength(0); + wrapper.unmount(); }); }); describe('results label', () => { test('should display zero results when number of results is undefined', () => { - const wrapper = shallow(); - const label = findByTestId(wrapper, 'resultsLabel'); + const wrapper = mountWithProviders( + , + ); + const label = findByTestId(wrapper, 'resultsLabel').at(0); - expect(label.text()).toEqual('0–0 of 0 results'); + expect(label.text()).toEqual('Pagination - 0–0 of 0 results'); + wrapper.unmount(); }); test('should display the number of results when provided', () => { - const wrapper = shallow(); - const label = findByTestId(wrapper, 'resultsLabel'); + const wrapper = mountWithProviders( + , + ); + const label = findByTestId(wrapper, 'resultsLabel').at(0); - expect(label.text()).toEqual('1–3 of 12345 results'); + expect(label.text()).toEqual('Pagination - 1–3 of 12345 results'); + wrapper.unmount(); }); test('should display first page results label when number of results is even', () => { - const wrapper = shallow( + const wrapper = mountWithProviders( , ); - const label = findByTestId(wrapper, 'resultsLabel'); + const label = findByTestId(wrapper, 'resultsLabel').at(0); - expect(label.text()).toEqual('1–6 of 30 results'); + expect(label.text()).toEqual('Pagination - 1–6 of 30 results'); + wrapper.unmount(); }); test('should display second page results label when number of results is uneven', () => { - const wrapper = shallow( + const wrapper = mountWithProviders( , ); - const label = findByTestId(wrapper, 'resultsLabel'); + const label = findByTestId(wrapper, 'resultsLabel').at(0); - expect(label.text()).toEqual('7–12 of 30 results'); + expect(label.text()).toEqual('Pagination - 7–12 of 30 results'); + wrapper.unmount(); }); test('should display last page results label when number of results is uneven', () => { - const wrapper = shallow( + const wrapper = mountWithProviders( , ); - const label = findByTestId(wrapper, 'resultsLabel'); + const label = findByTestId(wrapper, 'resultsLabel').at(0); - expect(label.text()).toEqual('25–30 of 30 results'); + expect(label.text()).toEqual('Pagination - 25–30 of 30 results'); + wrapper.unmount(); }); test('should display first page results label when number of results is uneven', () => { - const wrapper = shallow( + const wrapper = mountWithProviders( , ); - const label = findByTestId(wrapper, 'resultsLabel'); + const label = findByTestId(wrapper, 'resultsLabel').at(0); - expect(label.text()).toEqual('1–50 of 1530 results'); + expect(label.text()).toEqual('Pagination - 1–50 of 1530 results'); + wrapper.unmount(); }); test('should display second page results label when number of results is uneven', () => { - const wrapper = shallow( + const wrapper = mountWithProviders( , ); - const label = findByTestId(wrapper, 'resultsLabel'); + const label = findByTestId(wrapper, 'resultsLabel').at(0); - expect(label.text()).toEqual('51–100 of 1530 results'); + expect(label.text()).toEqual('Pagination - 51–100 of 1530 results'); + wrapper.unmount(); }); test('should display last page results label when number of results is uneven', () => { - const wrapper = shallow( + const wrapper = mountWithProviders( , ); - const label = findByTestId(wrapper, 'resultsLabel'); + const label = findByTestId(wrapper, 'resultsLabel').at(0); - expect(label.text()).toEqual('1501–1530 of 1530 results'); + expect(label.text()).toEqual('Pagination - 1501–1530 of 1530 results'); + wrapper.unmount(); }); }); @@ -152,7 +192,7 @@ describe('Pagination', () => { describe(testCase.id, () => { test(`should go to page ${testCase.goesToPage} when clicked`, () => { const callback = jest.fn(); - const wrapper = shallow( + const wrapper = mountWithProviders( { onPageChange={callback} />, ); - const button = findByTestId(wrapper, testCase.id); + const button = findByTestId(wrapper, testCase.id).at(0); button.simulate('click'); expect(callback).toHaveBeenCalledWith(testCase.goesToPage); + wrapper.unmount(); }); test(`should be disabled when on page ${testCase.disabledWhenOnPage}`, () => { - const wrapper = shallow( + const wrapper = mountWithProviders( , ); - const button = findByTestId(wrapper, testCase.id); + const button = findByTestId(wrapper, testCase.id).at(0); - expect(button.prop('enabled')).toBe(false); + expect(button.prop('disabled')).toBe(true); + wrapper.unmount(); }); test(`should be enabled when on page ${testCase.enabledWhenOnPage}`, () => { - const wrapper = shallow( + const wrapper = mountWithProviders( , ); - const button = findByTestId(wrapper, testCase.id); + const button = findByTestId(wrapper, testCase.id).at(0); - expect(button.prop('enabled')).toBe(true); + expect(button.prop('disabled')).toBe(false); + wrapper.unmount(); }); test(`should not be rendered when there's less than ${testCase.stopRenderAt} page`, () => { - const wrapper = shallow(); - const button = findByTestId(wrapper, testCase.id); + const wrapper = mountWithProviders( + , + ); + const button = findByTestId(wrapper, testCase.id).at(0); expect(button.exists()).toBe(false); + wrapper.unmount(); }); }); }); diff --git a/packages/react/src/components/pagination/pagination.test.tsx.snap b/packages/react/src/components/pagination/pagination.test.tsx.snap index e95856794c..f064d748f1 100644 --- a/packages/react/src/components/pagination/pagination.test.tsx.snap +++ b/packages/react/src/components/pagination/pagination.test.tsx.snap @@ -1,27 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Pagination Matches the desktop snapshot 1`] = ` -.c4 { - border: 0; - -webkit-clip: rect(0,0,0,0); - clip: rect(0,0,0,0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - width: 1px; -} - -.c5 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - margin: 0 var(--spacing-half); - padding: 0; -} - .c0 { -webkit-align-items: center; -webkit-box-align: center; @@ -31,26 +10,74 @@ exports[`Pagination Matches the desktop snapshot 1`] = ` display: -webkit-flex; display: -ms-flexbox; display: flex; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + gap: 1.5rem; + -webkit-box-pack: end; + -webkit-justify-content: flex-end; + -ms-flex-pack: end; + justify-content: flex-end; + min-height: 1.75rem; } .c3 { - font-size: 0.9rem; + border: 0; + -webkit-clip: rect(0,0,0,0); + clip: rect(0,0,0,0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +.c1 { + color: #1B1C1E; + font-size: 1rem; font-weight: var(--font-normal); line-height: 1.5rem; - margin-bottom: 0; - margin-right: var(--spacing-3x); - white-space: nowrap; + margin: var(--spacing-3x) 0; } .c2 { font-size: 0.875rem; - line-height: 1.5rem; + font-weight: var(--font-normal); + -webkit-letter-spacing: 0.0125rem; + -moz-letter-spacing: 0.0125rem; + -ms-letter-spacing: 0.0125rem; + letter-spacing: 0.0125rem; + line-height: 1.25rem; + margin: 0; } -.c1 { + +`; + +exports[`Pagination Matches the desktop snapshot with multiples digits page numbers 1`] = ` +.c0 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -59,46 +86,18 @@ exports[`Pagination Matches the desktop snapshot 1`] = ` display: -webkit-flex; display: -ms-flexbox; display: flex; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + gap: 1.5rem; + -webkit-box-pack: end; + -webkit-justify-content: flex-end; + -ms-flex-pack: end; + justify-content: flex-end; + min-height: 1.75rem; } -
- -
-`; - -exports[`Pagination Matches the desktop snapshot with multiples digits page numbers 1`] = ` -.c4 { +.c3 { border: 0; -webkit-clip: rect(0,0,0,0); clip: rect(0,0,0,0); @@ -110,44 +109,51 @@ exports[`Pagination Matches the desktop snapshot with multiples digits page numb width: 1px; } -.c5 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - margin: 0 var(--spacing-half); - padding: 0; -} - -.c0 { - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; -} - -.c3 { - font-size: 0.9rem; +.c1 { + color: #1B1C1E; + font-size: 1rem; font-weight: var(--font-normal); line-height: 1.5rem; - margin-bottom: 0; - margin-right: var(--spacing-3x); - white-space: nowrap; + margin: var(--spacing-3x) 0; } .c2 { font-size: 0.875rem; - line-height: 1.5rem; + font-weight: var(--font-normal); + -webkit-letter-spacing: 0.0125rem; + -moz-letter-spacing: 0.0125rem; + -ms-letter-spacing: 0.0125rem; + letter-spacing: 0.0125rem; + line-height: 1.25rem; + margin: 0; } -.c1 { + +`; + +exports[`Pagination Matches the mobile snapshot 1`] = ` +.c0 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -156,46 +162,18 @@ exports[`Pagination Matches the desktop snapshot with multiples digits page numb display: -webkit-flex; display: -ms-flexbox; display: flex; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + gap: 2.25rem; + -webkit-box-pack: end; + -webkit-justify-content: flex-end; + -ms-flex-pack: end; + justify-content: flex-end; + min-height: 1.75rem; } -
- -
-`; - -exports[`Pagination Matches the mobile snapshot 1`] = ` -.c4 { +.c3 { border: 0; -webkit-clip: rect(0,0,0,0); clip: rect(0,0,0,0); @@ -207,44 +185,51 @@ exports[`Pagination Matches the mobile snapshot 1`] = ` width: 1px; } -.c5 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - margin: 0 var(--spacing-half); - padding: 0; -} - -.c0 { - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; -} - -.c3 { +.c1 { + color: #1B1C1E; font-size: 1rem; font-weight: var(--font-normal); - line-height: 2rem; - margin-bottom: var(--spacing-1halfx); - margin-right: 0; - white-space: nowrap; + line-height: 1.5rem; + margin: var(--spacing-3x) 0; } .c2 { - font-size: 0.875rem; + font-size: 1rem; + font-weight: var(--font-normal); + -webkit-letter-spacing: 0.0125rem; + -moz-letter-spacing: 0.0125rem; + -ms-letter-spacing: 0.0125rem; + letter-spacing: 0.0125rem; line-height: 1.5rem; + margin: 0; } -.c1 { + +`; + +exports[`Pagination Matches the mobile snapshot with multiples digits page numbers 1`] = ` +.c0 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -253,46 +238,18 @@ exports[`Pagination Matches the mobile snapshot 1`] = ` display: -webkit-flex; display: -ms-flexbox; display: flex; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + gap: 2.25rem; + -webkit-box-pack: end; + -webkit-justify-content: flex-end; + -ms-flex-pack: end; + justify-content: flex-end; + min-height: 1.75rem; } -
- -
-`; - -exports[`Pagination Matches the mobile snapshot with multiples digits page numbers 1`] = ` -.c4 { +.c3 { border: 0; -webkit-clip: rect(0,0,0,0); clip: rect(0,0,0,0); @@ -304,86 +261,45 @@ exports[`Pagination Matches the mobile snapshot with multiples digits page numbe width: 1px; } -.c5 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - margin: 0 var(--spacing-half); - padding: 0; -} - -.c0 { - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; -} - -.c3 { +.c1 { + color: #1B1C1E; font-size: 1rem; font-weight: var(--font-normal); - line-height: 2rem; - margin-bottom: var(--spacing-1halfx); - margin-right: 0; - white-space: nowrap; + line-height: 1.5rem; + margin: var(--spacing-3x) 0; } .c2 { - font-size: 0.875rem; + font-size: 1rem; + font-weight: var(--font-normal); + -webkit-letter-spacing: 0.0125rem; + -moz-letter-spacing: 0.0125rem; + -ms-letter-spacing: 0.0125rem; + letter-spacing: 0.0125rem; line-height: 1.5rem; + margin: 0; } -.c1 { - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; -} - -
- -
+ Pagination - + + 0–0 of 0 results + + + `; diff --git a/packages/react/src/components/pagination/pagination.tsx b/packages/react/src/components/pagination/pagination.tsx index bf89927ab8..c6da63ee1a 100644 --- a/packages/react/src/components/pagination/pagination.tsx +++ b/packages/react/src/components/pagination/pagination.tsx @@ -1,106 +1,13 @@ -import { useEffect, useState, VoidFunctionComponent } from 'react'; -import styled from 'styled-components'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { useId } from '../../hooks/use-id'; -import { useTranslation } from '../../i18n/use-translation'; -import { focus } from '../../utils/css-state'; import { clamp } from '../../utils/math'; -import { range } from '../../utils/range'; -import { IconButton } from '../buttons/icon-button'; import { useDeviceContext } from '../device-context-provider/device-context-provider'; -import { IconName } from '../icon/icon'; -import { ScreenReaderOnlyText } from '../screen-reader-only-text/ScreenReaderOnlyText'; -import { calculateShownPageRange } from './util/pagination-util'; +import { Navigation } from './styled'; +import { PaginationContext } from './context'; +import { PaginationLabel } from './label/pagination-label'; +import { PaginationButtons } from './buttons/pagination-buttons'; -type SelectionSuffix = '-selected' | ''; - -function getSelectionSuffix(isSelected: boolean): SelectionSuffix { - return isSelected ? '-selected' : ''; -} - -const Pages = styled.ol` - display: flex; - margin: 0 var(--spacing-half); - padding: 0; -`; - -const Page = styled.li<{ isSelected: boolean, isMobile: boolean }>` - align-items: center; - background-color: ${({ isSelected, theme }) => theme.component[`pagination-page${getSelectionSuffix(isSelected)}-background-color`]}; - border-radius: var(--border-radius-4x); - box-sizing: border-box; - color: ${({ isSelected, theme }) => theme.component[`pagination-page${getSelectionSuffix(isSelected)}-text-color`]}; - display: inline-flex; - font-size: ${({ isMobile }) => (isMobile ? 1 : 0.9)}rem; - font-weight: var(--font-normal); - height: ${({ isMobile }) => (isMobile ? 'var(--size-2x)' : 'var(--size-1halfx)')}; - justify-content: center; - line-height: ${({ isMobile }) => (isMobile ? 2 : 1.5)}rem; - margin: 0 var(--spacing-half); - min-width: ${({ isMobile }) => (isMobile ? 'var(--size-2x)' : 'var(--size-1halfx)')}; - outline: ${({ isSelected, theme }) => (isSelected ? `1px solid ${theme.component['pagination-page-selected-border-color']}` : 'none')}; - padding: 0 var(--spacing-1x); - text-align: center; - - ${focus}; - - &:hover { - background-color: ${({ isSelected, theme }) => theme.component[`pagination-page${getSelectionSuffix(isSelected)}-hover-background-color`]}; - cursor: ${({ isSelected }) => (isSelected ? 'default' : 'pointer')}; - } -`; - -interface NavButtonProps { - iconName: IconName; - label: string; - enabled: boolean; - - onClick(): void; -} - -const NavButton: VoidFunctionComponent = ({ - enabled, - iconName, - label, - ...props -}) => ( - -); - -const Container = styled.div<{ isMobile: boolean }>` - align-items: center; - display: flex; - flex-direction: ${(props) => (props.isMobile ? 'column' : 'row')}; -`; - -const ResultsLabel = styled.span<{ isMobile: boolean }>` - font-size: ${(props) => (props.isMobile ? 1 : 0.9)}rem; - font-weight: var(--font-normal); - line-height: ${(props) => (props.isMobile ? 2 : 1.5)}rem; - margin-bottom: ${(props) => (props.isMobile ? 'var(--spacing-1halfx)' : 0)}; - margin-right: ${(props) => (props.isMobile ? 0 : 'var(--spacing-3x)')}; - white-space: nowrap; -`; - -const CurrentPageLabelHeading = styled.h3` - font-size: 0.875rem; - line-height: 1.5rem; -`; - -const Navigation = styled.nav` - align-items: center; - display: flex; -`; - -interface PaginationProps { +export interface PaginationProps { className?: string; /** * Number of results to display per page @@ -120,122 +27,66 @@ interface PaginationProps { * @default 3 */ pagesShown?: number; - /** - * Function callback when page is changed + * The current active page of the pagination */ - onPageChange?(pageNumber: number): void; + activePage?: number; /** - * The current active page of the pagination + * Function callback when page is changed */ - activePage?: number; + onPageChange?(pageNumber: number): void; } -export const Pagination: VoidFunctionComponent = ({ +export const Pagination: FC = ({ className, resultsPerPage, numberOfResults, defaultActivePage = 1, pagesShown = 3, - onPageChange = () => undefined, activePage, + onPageChange = () => undefined, }) => { - const { t } = useTranslation('pagination'); const { isMobile } = useDeviceContext(); const headingId = useId(); const currentNumberOfResults = numberOfResults === undefined ? 0 : numberOfResults; const totalPages = currentNumberOfResults === 0 ? 0 : Math.ceil(currentNumberOfResults / resultsPerPage); - const pagesDisplayed = Math.min(pagesShown, totalPages); + const pagesDisplayed = Math.min(Math.max(pagesShown, 1), totalPages); const [currentPage, setCurrentPage] = useState(clamp(activePage || defaultActivePage, 1, totalPages)); - const canNavigatePrevious = currentPage > 1; - const canNavigateNext = currentPage < totalPages; - const forwardBackwardNavActive = totalPages > 3 || pagesDisplayed < totalPages; useEffect(() => { - if (activePage && currentPage !== activePage) { - setCurrentPage(activePage); + if (activePage) { + const clampedActivePage = clamp(activePage, 1, totalPages); + if (currentPage !== clampedActivePage) { + setCurrentPage(clampedActivePage); + } } - }, [activePage, currentPage]); + }, [activePage, currentPage, totalPages]); - function changePage(page: number): void { + const changePage = useCallback((page: number): void => { setCurrentPage(page); onPageChange(page); - } - - function handlePageKeyDown(key: string, page: number): void { - if (key === 'Enter') { - changePage(page); - } - } - - const { begin, end } = calculateShownPageRange(totalPages, pagesDisplayed, currentPage); - const pages = range(begin, end).map((i) => { - const id = `page-${i}`; - const isCurrentPage = i === currentPage; + }, [onPageChange]); - return ( - changePage(i)} - onKeyPress={(event) => handlePageKeyDown(event.key, i)} - isMobile={isMobile} - tabIndex={0} - > - {i} - - ); - }); - - const ariaLabelledby = headingId; - let pageStartIndex; - let pageEndIndex; - - if (resultsPerPage !== 0 && currentNumberOfResults !== 0) { - pageStartIndex = (resultsPerPage * (currentPage - 1)) + 1; - pageEndIndex = Math.min(currentNumberOfResults, (resultsPerPage * currentPage)); - } else { - pageStartIndex = 0; - pageEndIndex = 0; - } + const value = useMemo(() => ({ + resultsPerPage, + numberOfResults: currentNumberOfResults, + totalPages, + pagesDisplayed, + currentPage, + changePage, + }), [resultsPerPage, currentNumberOfResults, totalPages, pagesDisplayed, currentPage, changePage]); return ( - - -
- - - - {t('results', { - pageStartIndex, - pageEndIndex, - numberOfResults: currentNumberOfResults, - })} - - -
- {forwardBackwardNavActive && ( - changePage(currentPage - 1)} - /> - )} - {pages} - {forwardBackwardNavActive && ( - changePage(currentPage + 1)} - /> - )} + + + + -
+ ); }; diff --git a/packages/react/src/components/pagination/styled.ts b/packages/react/src/components/pagination/styled.ts new file mode 100644 index 0000000000..c08acb5ce2 --- /dev/null +++ b/packages/react/src/components/pagination/styled.ts @@ -0,0 +1,10 @@ +import styled from 'styled-components'; + +export const Navigation = styled.nav<{ $isMobile: boolean }>` + align-items: center; + display: flex; + flex-shrink: 0; + gap: ${({ $isMobile }) => ($isMobile ? '2.25rem' : '1.5rem')}; + justify-content: flex-end; + min-height: 1.75rem; +`; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index dde5511eae..4bd70c4435 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -102,7 +102,7 @@ export * from './components/legend/legend'; export { Fieldset } from './components/fieldset'; export { ProgressIndicator } from './components/progress-indicator/progress-indicator'; export { ProgressCircle } from './components/progress-circle/progress-circle'; -export { Pagination } from './components/pagination/pagination'; +export * from './components/pagination'; // Themes export { equisoftTheme, buildTheme } from './themes'; diff --git a/packages/react/src/themes/tokens/component/pagination-tokens.ts b/packages/react/src/themes/tokens/component/pagination-tokens.ts index 603b32e031..c71f63ca00 100644 --- a/packages/react/src/themes/tokens/component/pagination-tokens.ts +++ b/packages/react/src/themes/tokens/component/pagination-tokens.ts @@ -4,6 +4,7 @@ export type PaginationToken = | 'pagination-page-background-color' | 'pagination-page-text-color' | 'pagination-page-hover-background-color' + | 'pagination-page-hover-text-color' | 'pagination-page-selected-hover-background-color' | 'pagination-page-selected-background-color' | 'pagination-page-selected-border-color' @@ -13,6 +14,7 @@ export const defaultPaginationTokens: ComponentTokenMap = { 'pagination-page-background-color': 'transparent-100', 'pagination-page-text-color': 'color-content-subtle', 'pagination-page-hover-background-color': 'color-background-hover', + 'pagination-page-hover-text-color': 'color-content', 'pagination-page-selected-hover-background-color': 'color-background-selected', 'pagination-page-selected-background-color': 'color-background-selected', 'pagination-page-selected-border-color': 'color-border-selected', diff --git a/packages/storybook/stories/pagination.stories.tsx b/packages/storybook/stories/pagination.stories.tsx index cd9ea12f09..f414712ce1 100644 --- a/packages/storybook/stories/pagination.stories.tsx +++ b/packages/storybook/stories/pagination.stories.tsx @@ -48,3 +48,12 @@ export const ControlledPagination: Story = { }, }; ControlledPagination.parameters = rawCodeParameters; + +export const OnePage: Story = { + ...PaginationMeta, + args: { + resultsPerPage: 10, + numberOfResults: 8, + pagesShown: 2, + }, +};