diff --git a/cypress/integration/Pagination.spec.ts b/cypress/integration/Pagination.spec.ts index afbf16a277..79cbb35648 100644 --- a/cypress/integration/Pagination.spec.ts +++ b/cypress/integration/Pagination.spec.ts @@ -1,293 +1,406 @@ import * as h from '../helpers'; -describe('Button', () => { +describe('Pagination', () => { before(() => { h.stories.visit(); }); - context('given Default pagination story is rendered', () => { + context('given the Jump Controls story is rendered', () => { beforeEach(() => { - h.stories.load('Labs/Pagination/React', 'Default'); + h.stories.load('Labs/Pagination/React', 'Jump Controls'); }); - context('when screen width is larger than 500', () => { - it('should not have any axe errors', () => { - cy.checkA11y(); + it('should not have any axe errors', () => { + cy.checkA11y(); + }); + + context('given the nav', () => { + it('should have an aria-label', () => { + cy.findByLabelText('Pagination').should('have.ariaLabel', 'Pagination'); }); + }); - context('when the first page is selected', () => { - it('should have first page button with an aria label of "Selected, Page 1"', () => { - cy.contains('button', '1').should('have.attr', 'aria-label', 'Selected, Page 1'); - }); + context('given the page list', () => { + it('should be an ol element with a role of list', () => { + cy.get('ol').should('have.attr', 'role', 'list'); + }); - it('should disable previous page button', () => { - cy.findByLabelText('Previous Page').should('be.disabled'); - }); + it('should have five list items', () => { + cy.get('ol') + .find('li') + .should('have.length', 5); + }); - it('should not disable next page button', () => { - cy.findByLabelText('Next Page').should('not.be.disabled'); - }); + it('should have page buttons with correct text and aria-labels', () => { + cy.get('ol') + .find('button') + .eq(0) + .should('have.ariaLabel', 'Page 1') + .and('contain.text', '1'); + + cy.get('ol') + .find('button') + .eq(1) + .should('have.ariaLabel', 'Page 2') + .and('contain.text', '2'); + + cy.get('ol') + .find('button') + .eq(2) + .should('have.ariaLabel', 'Page 3') + .and('contain.text', '3'); + + cy.get('ol') + .find('button') + .eq(3) + .should('have.ariaLabel', 'Page 4') + .and('contain.text', '4'); + + cy.get('ol') + .find('button') + .eq(4) + .should('have.ariaLabel', 'Page 5') + .and('contain.text', '5'); + }); - it('should show 5 page buttons', () => { - cy.findAllByLabelText(/Page \d$/).should('have.length', 5); - }); + it('should add correctly apply aria-current to the current page', () => { + cy.get('ol') + .find('button') + .first() + .should('have.attr', 'aria-current', 'page'); + }); - it('should label all page buttons', () => { - cy.contains('button', '2').should('have.attr', 'aria-label', 'Page 2'); - cy.contains('button', '3').should('have.attr', 'aria-label', 'Page 3'); - cy.contains('button', '4').should('have.attr', 'aria-label', 'Page 4'); - cy.contains('button', '5').should('have.attr', 'aria-label', 'Page 5'); + context('when a page button is clicked', () => { + beforeEach(() => { + // click the 'page 4' button + cy.get('ol') + .find('button') + .eq(3) + .click(); }); - it('should reflect the current page number', () => { - cy.findByTestId('pageNumber').should('have.text', '1'); + it('should properly re-render the page range', () => { + // get the first page button in the range + cy.get('ol') + .find('button') + .first() + .should('contain.text', '2'); + // get the current page button + cy.get('ol') + .find('button') + .get('[aria-current="page"]') + .should('contain.text', '4'); + // get the last page button in the range + cy.get('ol') + .find('button') + .last() + .should('contain.text', '6'); }); }); + }); - context('when the second page is clicked', () => { - beforeEach(() => { - cy.findByLabelText('Page 2').click(); - }); + context('given the step control buttons', () => { + it('should have a jump-to-first button with an aria-label', () => { + cy.findByLabelText('First').should('have.attr', 'aria-label'); + }); - it('should active page 2', () => { - cy.contains('button', '2').should('have.attr', 'aria-label', 'Selected, Page 2'); - }); + it('should have a step-to-previous button with an aria-label', () => { + cy.findByLabelText('Previous').should('have.attr', 'aria-label'); + }); - it('should keep focus on page 2', () => { - cy.contains('button', '2').should('have.focus'); - }); + it('should have a step-to-next button with an aria-label', () => { + cy.findByLabelText('Next').should('have.attr', 'aria-label'); + }); + + it('should have a jump-to-last button with an aria-label', () => { + cy.findByLabelText('Last').should('have.attr', 'aria-label'); + }); - it('should not disable previous page button', () => { - cy.findByLabelText('Previous Page').should('not.be.disabled'); + context('when the first page is the current page', () => { + it('should disable the jump-to-first button', () => { + cy.findByLabelText('First').should('have.attr', 'aria-disabled', 'true'); }); - it('should not disable next page button', () => { - cy.findByLabelText('Next Page').should('not.be.disabled'); + it('should disable the step-to-previous button', () => { + cy.findByLabelText('Previous').should('have.attr', 'aria-disabled', 'true'); }); - it('should reflect the current page number', () => { - cy.findByTestId('pageNumber').should('have.text', '2'); + it('should not update the current page when the step-to-previous button is clicked', () => { + cy.findByLabelText('Previous').click(); + // get the current page button + cy.get('ol') + .find('button') + .get('[aria-current="page"]') + .should('contain.text', 1); }); }); - context('when there are 10 pages (for page number distribution)', () => { + context('when the last page is the current page', () => { beforeEach(() => { - cy.changeKnob('total', 100); + cy.findByLabelText('Last').click(); }); - it('should show 6 numbers', () => { - cy.findAllByLabelText(/Page \d+$/).should('have.length', 6); + it('should disable the jump-to-last button', () => { + cy.findByLabelText('Next').should('have.attr', 'aria-disabled', 'true'); }); - context('when page 1 is selected', () => { - it('should show pages 1 through 5 and page 10', () => { - cy.findByLabelText(/Page 1$/).should('exist'); - cy.findByLabelText(/Page 2$/).should('exist'); - cy.findByLabelText(/Page 3$/).should('exist'); - cy.findByLabelText(/Page 4$/).should('exist'); - cy.findByLabelText(/Page 5$/).should('exist'); - cy.findByLabelText(/Page 10$/).should('exist'); - }); + it('should disable the step-to-next button', () => { + cy.findByLabelText('Last').should('have.attr', 'aria-disabled', 'true'); }); - context('when page 2 is selected', () => { - beforeEach(() => { - cy.findByLabelText('Page 2').click(); - }); - - it('should show pages 1 through 5 and page 10', () => { - cy.findByLabelText(/Page 1$/).should('exist'); - cy.findByLabelText(/Page 2$/).should('exist'); - cy.findByLabelText(/Page 3$/).should('exist'); - cy.findByLabelText(/Page 4$/).should('exist'); - cy.findByLabelText(/Page 5$/).should('exist'); - cy.findByLabelText(/Page 10$/).should('exist'); - }); + it('should not update the current page when the step-to-previous button is clicked', () => { + cy.findByLabelText('Next').click(); + // get the current page button + cy.get('ol') + .find('button') + .get('[aria-current="page"]') + .should('contain.text', 10); }); + }); - context('when page 3 is selected', () => { - beforeEach(() => { - cy.findByLabelText('Page 3').click(); - }); - - it('should show pages 1 through 5 and page 10', () => { - cy.findByLabelText(/Page 1$/).should('exist'); - cy.findByLabelText(/Page 2$/).should('exist'); - cy.findByLabelText(/Page 3$/).should('exist'); - cy.findByLabelText(/Page 4$/).should('exist'); - cy.findByLabelText(/Page 5$/).should('exist'); - cy.findByLabelText(/Page 10$/).should('exist'); - }); + context('when the jump-to-first button is clicked', () => { + beforeEach(() => { + // click the 'page 4' button to enable the jump-to-first button + cy.get('ol') + .find('button') + .eq(3) + .click(); }); - context('when page 4 is selected', () => { - beforeEach(() => { - cy.findByLabelText('Page 4').click(); - }); - - it('should show pages 2 through 6 and page 10', () => { - cy.findByLabelText(/Page 2$/).should('exist'); - cy.findByLabelText(/Page 3$/).should('exist'); - cy.findByLabelText(/Page 4$/).should('exist'); - cy.findByLabelText(/Page 5$/).should('exist'); - cy.findByLabelText(/Page 6$/).should('exist'); - cy.findByLabelText(/Page 10$/).should('exist'); - }); + it('should properly re-render the page range (1,2,3,4,5)', () => { + cy.findByLabelText('First').click(); + // get the first page button in the range + cy.get('ol') + .find('button') + .first() + .should('contain.text', '1'); + // get the current page button + cy.get('ol') + .find('button') + .get('[aria-current="page"]') + .should('contain.text', '1'); + // get the last page button in the range + cy.get('ol') + .find('button') + .last() + .should('contain.text', '5'); }); + }); - context('when page 6 is selected', () => { - beforeEach(() => { - cy.findByLabelText('Page 10').click(); - cy.findByLabelText('Page 6').click(); - }); - - it('should show pages 4 through 8 and page 10', () => { - cy.findByLabelText(/Page 4$/).should('exist'); - cy.findByLabelText(/Page 5$/).should('exist'); - cy.findByLabelText(/Page 6$/).should('exist'); - cy.findByLabelText(/Page 7$/).should('exist'); - cy.findByLabelText(/Page 8$/).should('exist'); - cy.findByLabelText(/Page 10$/).should('exist'); - }); + context('when the step-to-previous button is clicked', () => { + beforeEach(() => { + // click the 'page 5' button to enable the step-to-previous button + // get the last page button in the range + cy.get('ol') + .find('button') + .last() + .click(); }); - context('when page 7 is selected', () => { - beforeEach(() => { - cy.findByLabelText('Page 10').click(); - cy.findByLabelText('Page 7').click(); - }); - - it('should show page 1 and pages 6 through 10', () => { - cy.findByLabelText(/Page 1$/).should('exist'); - cy.findByLabelText(/Page 6$/).should('exist'); - cy.findByLabelText(/Page 7$/).should('exist'); - cy.findByLabelText(/Page 8$/).should('exist'); - cy.findByLabelText(/Page 9$/).should('exist'); - cy.findByLabelText(/Page 10$/).should('exist'); - }); + it('should properly re-render the page range', () => { + cy.findByLabelText('Previous').click(); + // get the current page button + cy.get('ol') + .find('button') + .get('[aria-current="page"]') + .should('contain.text', '4'); + + cy.findByLabelText('Previous').click(); + // get the current page button + cy.get('ol') + .find('button') + .get('[aria-current="page"]') + .should('contain.text', '3'); }); + }); - context('when page 8 is selected', () => { - beforeEach(() => { - cy.findByLabelText('Page 10').click(); - cy.findByLabelText('Page 8').click(); - }); - - it('should show page 1 and pages 6 through 10', () => { - cy.findByLabelText(/Page 1$/).should('exist'); - cy.findByLabelText(/Page 6$/).should('exist'); - cy.findByLabelText(/Page 7$/).should('exist'); - cy.findByLabelText(/Page 8$/).should('exist'); - cy.findByLabelText(/Page 9$/).should('exist'); - cy.findByLabelText(/Page 10$/).should('exist'); - }); + context('when the step-to-next button is clicked', () => { + beforeEach(() => { + // click the 'page 5' button + // get the last page button in the range + cy.get('ol') + .find('button') + .last() + .click(); }); - context('when page 9 is selected', () => { - beforeEach(() => { - cy.findByLabelText('Page 10').click(); - cy.findByLabelText('Page 9').click(); - }); - - it('should show page 1 and pages 6 through 10', () => { - cy.findByLabelText(/Page 1$/).should('exist'); - cy.findByLabelText(/Page 6$/).should('exist'); - cy.findByLabelText(/Page 7$/).should('exist'); - cy.findByLabelText(/Page 8$/).should('exist'); - cy.findByLabelText(/Page 9$/).should('exist'); - cy.findByLabelText(/Page 10$/).should('exist'); - }); + it('should properly re-render the page range', () => { + cy.findByLabelText('Next').click(); + // get the current page button + cy.get('ol') + .find('button') + .get('[aria-current="page"]') + .should('contain.text', '6'); + + cy.findByLabelText('Next').click(); + // get the current page button + cy.get('ol') + .find('button') + .get('[aria-current="page"]') + .should('contain.text', '7'); }); + }); - context('when page 10 is selected', () => { - beforeEach(() => { - cy.findByLabelText('Page 10').click(); - }); - - it('should show page 1 and pages 6 through 10', () => { - cy.findByLabelText(/Page 1$/).should('exist'); - cy.findByLabelText(/Page 6$/).should('exist'); - cy.findByLabelText(/Page 7$/).should('exist'); - cy.findByLabelText(/Page 8$/).should('exist'); - cy.findByLabelText(/Page 9$/).should('exist'); - cy.findByLabelText(/Page 10$/).should('exist'); - }); - - it('should disable the next page button', () => { - cy.findByLabelText('Next Page').should('be.disabled'); - }); + context('when the jump-to-last button is clicked', () => { + it('should properly re-render the page range (96,97,98,99,100)', () => { + cy.findByLabelText('Last').click(); + // get the first page button in the range + cy.get('ol') + .find('button') + .first() + .should('contain.text', '6'); + // get the current page button + cy.get('ol') + .find('button') + .get('[aria-current="page"]') + .should('contain.text', '10'); + // get the last page button in the range + cy.get('ol') + .find('button') + .last() + .should('contain.text', '10'); }); }); }); - }); - - context('given Default pagination story is rendered', () => { - beforeEach(() => { - h.stories.load('Labs/Pagination/React', 'With Go To'); - }); - context('when screen width is larger than 500', () => { - it('should not have any axe errors', () => { - cy.checkA11y(); + context('given the additional details region', () => { + it('should have a role of status', () => { + cy.findByRole('status').should('exist'); }); - context('when 50 is entered into the Go To input', () => { - beforeEach(() => { - cy.findByLabelText('Go To') - .type('50') - .type('{enter}'); - }); + it('should set aria-live to polite', () => { + cy.findByRole('status').should('have.attr', 'aria-live', 'polite'); + }); - it('should set page 50 as the selected page', () => { - cy.findByText('50').should('have.attr', 'aria-label', 'Selected, Page 50'); - }); + it('should set aria-atomic to true', () => { + cy.findByRole('status').should('have.attr', 'aria-atomic', 'true'); }); - context('when -1 is entered into the Go To input', () => { - beforeEach(() => { - cy.findByLabelText('Go To') - .type('-1') - .type('{enter}'); - }); + it('should set aria-relevant to true', () => { + cy.findByRole('status').should('have.attr', 'aria-relevant', 'true'); + }); - it('should not change the selected page', () => { - cy.findByText('1').should('have.attr', 'aria-label', 'Selected, Page 1'); - }); + it('should describe the current page range and the total page count', () => { + cy.findByRole('status').should('contain.text', '1-10 of 100 results'); }); }); }); - context('when screen width is smaller than 500', () => { + context('given the Custom Range story is rendered', () => { beforeEach(() => { - cy.viewport(414, 736); // iPhone 6/7/8 Plus - h.stories.load('Labs/Pagination/React', 'Default'); + h.stories.load('Labs/Pagination/React', 'Custom Range'); }); - it('should show 2 page buttons', () => { - cy.findAllByLabelText(/Page \d+$/).should('have.length', 2); + it('should not have any axe errors', () => { + cy.checkA11y(); }); - context('when the last page is clicked', () => { - beforeEach(() => { - cy.findByLabelText('Page 5').click(); + context('given the page list', () => { + it('should have three list items', () => { + cy.get('ol') + .find('li') + .should('have.length', 3); }); + }); + }); - it('should show 3 numbers', () => { - cy.findAllByLabelText(/Page \d+$/).should('have.length', 3); - }); + context('given the GoTo Form story is rendered', () => { + beforeEach(() => { + h.stories.load('Labs/Pagination/React', 'Go To Form'); + }); - it('should disable the next page button', () => { - cy.findByLabelText('Next Page').should('be.disabled'); - }); + it('should not have any axe errors', () => { + cy.checkA11y(); }); - context('when there are 3 pages', () => { - beforeEach(() => { - cy.changeKnob('total', 30); + context('given the Go To Form', () => { + it('should be a form element', () => { + cy.get('form').should('exist'); }); - it('should show 3 page buttons', () => { - cy.findAllByLabelText(/Page \d+$/).should('have.length', 3); + context('given the input field', () => { + it('should be a text field', () => { + cy.get('form') + .find('input') + .should('have.attr', 'type', 'text'); + }); + + it('should have an aria-label', () => { + cy.get('form') + .find('input') + .should('have.ariaLabel', 'Go to page number'); + }); + + it('should set size to 1', () => { + cy.get('form') + .find('input') + .should('have.attr', 'size', '1'); + }); + + it('should go to the specified page if the value is within the range', () => { + cy.get('form') + .find('input') + .type('8'); + cy.get('form') + .find('input') + .type('{enter}'); + + // get the current page button + cy.get('ol') + .find('button') + .get('[aria-current="page"]') + .should('contain.text', '8'); + }); + + it('should go to the last page if a value is above the range size is submitted', () => { + cy.get('form') + .find('input') + .type('11'); + cy.get('form') + .find('input') + .type('{enter}'); + + // get the current page button + cy.get('ol') + .find('button') + .get('[aria-current="page"]') + .should('contain.text', '10'); + // get the first page button in the range + cy.get('ol') + .find('button') + .first() + .should('contain.text', '6'); + // get the last page button in the range + cy.get('ol') + .find('button') + .last() + .should('contain.text', '10'); + }); + + it('should go to the first page if a number below the range size is submitted', () => { + cy.get('form') + .find('input') + .type('0'); + cy.get('form') + .find('input') + .type('{enter}'); + + // get the current page button + cy.get('ol') + .find('button') + .get('[aria-current="page"]') + .should('contain.text', '1'); + // get the first page button in the range + cy.get('ol') + .find('button') + .first() + .should('contain.text', '1'); + // get the last page button in the range + cy.get('ol') + .find('button') + .last() + .should('contain.text', '5'); + }); }); }); }); diff --git a/modules/_labs/pagination/react/MIGRATION_GUIDE.md b/modules/_labs/pagination/react/MIGRATION_GUIDE.md new file mode 100644 index 0000000000..98a56596ee --- /dev/null +++ b/modules/_labs/pagination/react/MIGRATION_GUIDE.md @@ -0,0 +1,107 @@ +# Migration Guide + +## Deprecated API + +Below is a table of props in < v4.5 and notes about how they relate to the current API. + +| Name | Required in < v4.5? | New Name | Notes | +| ------------------------------ | ------------------- | -------------- | --------------------------------------------------------------------------------------------- | +| `pageSize` | βœ… `true` | n/a | This is not needed in the new Pagination API | +| `total` | βœ… `true` | n/a | This is not needed in the new Pagination API | +| `currentPage` | βœ… `true` | n/a | This is not needed in the new Pagination API (though you can provide an `initialCurrentPage`) | +| `onPageChange` | βœ… `true` | `onPageChange` | This is now an optional prop in the new Pagination API | +| `showLabel` | 🚫 `false` | n/a | You can now access the label directly with the `AdditionalDetails` component | +| `customLabel` | 🚫 `false` | n/a | You can now access the label directly with the `AdditionalDetails` component | +| `showGoTo` | 🚫 `false` | n/a | You can now access the `GoToForm` component directly | +| `goToLabel` | 🚫 `false` | n/a | You can now access the label directly with the `GoToLabel` component | +| `paginationContainerAriaLabel` | 🚫 `false` | n/a | You can now access the Pagination container directly | +| `previousPageAriaLabel` | 🚫 `false` | n/a | You can now access the `StepToPrevious` button directly | +| `nextPageAriaLabel` | 🚫 `false` | n/a | You can now access the `StepToNext` button directly | +| `pageButtonAriaLabel` | 🚫 `false` | n/a | You can now access the `PageButton`s directly | + +## Example + +If you were previously writing your `Pagination component like this: + +```tsx +import * as React from 'react'; +import Pagination from '@workday/canvas-kit-labs-react-pagination'; + +const [currentPage, setCurrentPage] = React.useState(1); + +const MyPaginationComponent = () => { + return ( + setCurrentPage(pageNumber)} + showLabel + showGoTo + dataLabel="candidate" + /> + ); +}; +``` + +You would now write something like this: + +```tsx +import * as React from 'react'; +import { + Pagination, + getLastPage, + getVisibleResultsMin, + getVisibleResultsMax, +} from '@workday/canvas-kit-labs-react-pagination'; + +const MyPaginationComponent = () => { + // In this example, Pagination state is handled internally, + // but this is added here to simplify the example + const [currentPage, setCurrentPage] = React.useState(1); + const resultsPerPage = 10; + const totalCount = 108; + const lastPage = getLastPage(resultCount, totalCount); + + return ( + setCurrentPage(pageNumber)} + > + + + + + {({state}) => + state.range.map(pageNumber => ( + + + + )) + } + + + + + + {`of ${totalCount} candidates`} + + + + {({state}) => + `${getVisibleResultsMin(state.currentPage, resultCount)}-${getVisibleResultsMax( + state.currentPage, + resultCount, + totalCount + )} of ${totalCount} candidates` + } + + + ); +}; +``` + +For more detailed information on this component, please refer to the +[storybook documentation](https://workday.github.io/canvas-kit/?path=/docs/labs-pagination-react--step-controls) diff --git a/modules/_labs/pagination/react/README.md b/modules/_labs/pagination/react/README.md index 96b771e330..4df340a3ba 100644 --- a/modules/_labs/pagination/react/README.md +++ b/modules/_labs/pagination/react/README.md @@ -1,95 +1,12 @@ # Canvas Kit React Pagination +If you're upgrading from < v4.5, please refer to the [migration guide](MIGRATION_GUIDE.md). + LABS: Beta This component is work in progress and currently in pre-release. -Contains a component for a pagination bar and dispatches for page changes - -## Installation - -```sh -yarn add @workday/canvas-kit-labs-react-pagination -``` - -## Usage - -```tsx -import * as React from 'react'; -import Pagination from '@workday/canvas-kit-labs-react-pagination'; - -const [currentPage, setCurrentPage] = React.useState(1); - -return ( - setCurrentPage(p)} - showLabel - showGoTo - dataLabel="candidate" - /> -); -``` - -## Static Properties - -> None - -## Component Props - -### Required - -#### total: number - -> The total number of items. - -#### pageSize: number - -> The number of items to display per page. - -#### currentPage: number - -> The current page being displayed. - -#### onPageChange: (page: number) => void - -> Dispatch which is invoked when the page is changed. - -### Optional - -#### showLabel?: boolean - -> Shows a label below the pagination bar describing the items currently being viewed. - -#### showGoTo?: boolean - -> Shows a box adjacent to the pagination bar where a page can be entered and is submitted when -> 'Enter' key is pressed. - -#### goToLabel?: string - -> Determines the label next to the Go To box. Defaults to 'Go To'. Only usable while showGoTo is set -> to true. - -#### customLabel?: (from: number, to: number, items: number, itemLabel: string) => string - -> A function to build a custom label below the pagination bar. - -#### paginationContainerAriaLabel?: string; - -> Customizes the aria label for the Pagination container div. Default is 'Pagination'. - -#### previousPageAriaLabel?: string; - -> Customizes the aria label for the Previous Page Arrow. Default is 'Previous Page'. - -#### nextPageAriaLabel?: string; - -> Customizes the aria label for the Next Page Arrow. Default is 'Next Page'. - -#### pageButtonAriaLabel?: (page: number, selected: boolean) => string; +`Pagination` is a compound component for handling navigation between pages in a range. -> Customizes each page button. Default is (page: number, selected: boolean) => -> `${selected ? 'Selected, ' : ''}Page ${page}` +For more detailed information on this component, please refer to the +[storybook documentation](https://workday.github.io/canvas-kit/?path=/docs/labs-pagination-react--step-controls) diff --git a/modules/_labs/pagination/react/index.ts b/modules/_labs/pagination/react/index.ts index c416c8c92a..c54573299f 100644 --- a/modules/_labs/pagination/react/index.ts +++ b/modules/_labs/pagination/react/index.ts @@ -1,5 +1 @@ -import Pagination from './lib/Pagination'; - -export default Pagination; -export {Pagination}; export * from './lib/Pagination'; diff --git a/modules/_labs/pagination/react/lib/GoTo.tsx b/modules/_labs/pagination/react/lib/GoTo.tsx deleted file mode 100644 index 433395a4fd..0000000000 --- a/modules/_labs/pagination/react/lib/GoTo.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import styled from '@emotion/styled'; -import {type} from '@workday/canvas-kit-react-core'; -import TextInput from '@workday/canvas-kit-react-text-input'; -import React from 'react'; -import uuid from 'uuid/v4'; - -interface GoToProps { - /** Will be called when the user submits the form. In this case, it is when the enter key is pressed */ - onSubmit: (page: number) => void; - /** Max number of pages we could go to */ - max: number; - /** Label for the "Go To" input */ - label?: string; -} -const StyledLabel = styled('label')({ - margin: 'auto 0', - paddingRight: '8px', - ...type.body, - ...type.variant.label, -}); - -const GoToWrapper = styled('div')({ - display: 'flex', - paddingLeft: '12px', -}); - -const StyledForm = styled('form')({ - minWidth: '10px', -}); - -const GoTo = ({onSubmit, max, label = 'Go To'}: GoToProps) => { - const [value, setValue] = React.useState(''); - const [goToId] = React.useState(() => uuid()); // https://codesandbox.io/s/p2ndq - - const validatePage = (text: string) => { - const textAsInteger = parseInt(text, 10); - if (textAsInteger < 1) { - return 0; - } - if (textAsInteger > max) { - return 0; - } - return textAsInteger; - }; - - const formSubmit = (e: any) => { - e.preventDefault(); - const page = validatePage(value); - if (page) { - onSubmit(page); - } - }; - return ( - - {label} - - { - setValue(e.target.value); - }} - /> - - - ); -}; - -export default GoTo; diff --git a/modules/_labs/pagination/react/lib/Pages.tsx b/modules/_labs/pagination/react/lib/Pages.tsx deleted file mode 100644 index e7ac877444..0000000000 --- a/modules/_labs/pagination/react/lib/Pages.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/** @jsx jsx */ -import {css, jsx} from '@emotion/core'; -import styled from '@emotion/styled'; -import range from 'lodash/range'; -import React from 'react'; - -import type from '@workday/canvas-kit-labs-react-core'; -import {IconButton} from '@workday/canvas-kit-react-button'; -import canvas from '@workday/canvas-kit-react-core'; - -interface PagesProps { - total: number; - current: number; - onPageClick: (page: number) => void; - isMobile: boolean; - pageButtonAriaLabel: (page: number, selected: boolean) => string; -} - -const ellipsisStyle = css({ - pointerEvents: 'none', - width: canvas.spacing.l, - textAlign: 'center', - display: 'inline-block', -}); - -const PageButton = styled(IconButton)<{current: boolean}>( - { - width: 'auto', - ...type.small, - }, - ({current}) => ({ - color: current ? canvas.colors.frenchVanilla100 : undefined, - pointerEvents: current ? 'none' : undefined, - '&:not(:hover)': {transition: current ? 'none !important' : undefined}, - }) -); - -/** - * Given some information about the page, return a tuple of left and right number - * arrays. The left array will be numbers before a split and the right array will - * be numbers after the split. An empty right array means there is no split. - * @param total Total pages - * @param current current page - * @param isMobile mobile mode - */ -export function getPages(total: number, current: number, isMobile: boolean): [number[], number[]] { - const max = isMobile ? 3 : 7; // max pages to be shown at once - const maxWithSplit = isMobile ? 2 : 6; // max amount of pages shown if pages are split - const padNumber = isMobile ? 0 : 2; // padding pages around active page - const showEndThreshold = isMobile ? 1 : 4; // how many pages to last page where first page is show again and last pages are visible - - // show all pages on left side - if (total <= max) { - return [range(1, total + 1), []]; - } - - // Mobile shows last pages without first page, unlike desktop - if (isMobile && current >= total - showEndThreshold) { - return [range(total - max + 1, total + 1), []]; - } - - // show padding pages around current page on left and last page on right - if (current <= total - showEndThreshold) { - const minPage = Math.max(1, current - padNumber); - const maxPage = Math.max(maxWithSplit, current + padNumber + 1); - return [range(minPage, maxPage), [total]]; - } - - // show first page on left and last pages on the right - return [[1], range(total - maxWithSplit + padNumber, total + 1)]; -} - -const Pages = ({total, current, onPageClick, isMobile, pageButtonAriaLabel}: PagesProps) => { - const pageToButton = (page: number) => ( - onPageClick(page)} - toggled={page === current} - current={page === current} - > - {page} - - ); - - const [left, right] = getPages(total, current, isMobile); - - const ellipsis = - right.length === 0 - ? [] - : [ - - ... - , - ]; - - const buttons = [...left.map(pageToButton), ...ellipsis, ...right.map(pageToButton)]; - - return {buttons}; -}; - -export default Pages; diff --git a/modules/_labs/pagination/react/lib/Pagination.tsx b/modules/_labs/pagination/react/lib/Pagination.tsx deleted file mode 100644 index 93ef1d6e71..0000000000 --- a/modules/_labs/pagination/react/lib/Pagination.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import styled from '@emotion/styled'; -import React from 'react'; - -import {type} from '@workday/canvas-kit-labs-react-core'; -import {IconButton} from '@workday/canvas-kit-react-button'; -import canvas from '@workday/canvas-kit-react-core'; -import {chevronLeftSmallIcon, chevronRightSmallIcon} from '@workday/canvas-system-icons-web'; - -import GoTo from './GoTo'; -import Pages from './Pages'; - -export interface PaginationProps extends React.HTMLAttributes { - /** The total number of items. */ - total: number; - /** The number of items to display per page. */ - pageSize: number; - /** The current page being displayed. */ - currentPage: number; - /** Dispatch which is invoked when the page is changed. */ - onPageChange: (page: number) => void; - /** Shows a box adjacent to the pagination bar where a page can be entered and is submitted when 'Enter' key is pressed. */ - showGoTo?: boolean; - /** Shows a label below the pagination bar describing the items currently being viewed. */ - showLabel?: boolean; - /** A function to build a custom label below the pagination bar. */ - customLabel?: (from: number, to: number, total: number) => string; - /** Determines the label next to the Go To box. Only usable while showGoTo is set to true. */ - goToLabel?: string; - /** Customizes the aria label for the Pagination container div. */ - paginationContainerAriaLabel?: string; - /** Customizes the aria label for the Previous Page Arrow. */ - previousPageAriaLabel?: string; - /** Customizes the aria label for the Next Page Arrow. */ - nextPageAriaLabel?: string; - /** Customizes the aria-label on each page button. */ - pageButtonAriaLabel?: (page: number, selected: boolean) => string; - /** Optional width to pass to component. This is the width the container deems is available. You can use a measure component to get this. */ - width?: number; - /** - * Announces page changes to screen readers using aria-live - * Note: Your application may already announce page changes to screen readers through - * other means like focus changes or other aria-live regions. Set this to `false` to remove - * redundant announcement to screen reader users. - * @default true - */ - announceLabelToScreenReaders?: boolean; -} - -const StyledLabel = styled('div')({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - ...type.small, - color: canvas.typeColors.hint, - width: '100%', - paddingTop: '12px', -}); - -const StyledContainer = styled('nav')({ - display: 'flex', - alignItems: 'center', - flexFlow: 'row wrap', - justifyContent: 'center', -}); - -const ButtonsContainer = styled('div')({ - '& > * ': { - margin: `0 ${canvas.spacing.xxxs}`, - }, -}); - -const defaultCustomLabel: PaginationProps['customLabel'] = (from, to, total) => { - const item = `item${total > 1 ? 's' : ''}`; - - return `${from.toLocaleString()}\u2013${to.toLocaleString()} of ${total.toLocaleString()} ${item}`; -}; - -const defaultPageButtonAriaLabel: PaginationProps['pageButtonAriaLabel'] = (page, selected) => - `${selected ? 'Selected, ' : ''}Page ${page}`; - -const Pagination = (props: PaginationProps) => { - const { - showGoTo = false, - showLabel = false, - goToLabel = 'Go To', - paginationContainerAriaLabel = 'Pagination', - previousPageAriaLabel = 'Previous Page', - nextPageAriaLabel = 'Next Page', - pageButtonAriaLabel = defaultPageButtonAriaLabel, - customLabel = defaultCustomLabel, - announceLabelToScreenReaders = true, - total, - pageSize, - currentPage, - onPageChange, - width, - ...elemProps - } = props; - - const numPages = Math.ceil(total / pageSize); - const isMobile = width ? width < 500 : false; - - const labelFrom = (currentPage - 1) * pageSize + 1; - const labelTo = currentPage * pageSize >= total ? total : currentPage * pageSize; - - return ( - <> - - - onPageChange(currentPage - 1)} - /> - - numPages} - aria-label={nextPageAriaLabel} - variant={IconButton.Variant.Square} - size={IconButton.Size.Small} - icon={chevronRightSmallIcon} - onClick={e => onPageChange(currentPage + 1)} - /> - - {showGoTo && } - {showLabel && ( - - {customLabel(labelFrom, labelTo, total)} - - )} - - - ); -}; - -export default Pagination; diff --git a/modules/_labs/pagination/react/lib/Pagination/AdditionalDetails.tsx b/modules/_labs/pagination/react/lib/Pagination/AdditionalDetails.tsx new file mode 100644 index 0000000000..2b4b52f633 --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/AdditionalDetails.tsx @@ -0,0 +1,44 @@ +/** @jsx jsx */ +import * as React from 'react'; +import {jsx, css} from '@emotion/core'; +import {type, typeColors} from '@workday/canvas-kit-react-core'; +import {accessibleHide} from '@workday/canvas-kit-react-common'; + +import {PaginationModel} from './types'; +import {Flex, FlexProps} from './common/Flex'; +import {useLiveRegion} from './common/useLiveRegion'; + +export interface AdditionalDetailsProps extends FlexProps { + children: (model: PaginationModel) => React.ReactNode | React.ReactNode; + model: PaginationModel; + shouldAnnounceToScreenReader?: boolean; + shouldHideDetails?: boolean; +} + +// Ideally, these styles would be applied directly to a Text component +const textStyles = css({ + ...type.body, + color: typeColors.hint, +}); + +export const AdditionalDetails = ({ + model, + children, + shouldAnnounceToScreenReader, + shouldHideDetails = false, + ...elemProps +}: AdditionalDetailsProps) => { + const liveRegionProps = useLiveRegion({shouldAnnounceToScreenReader}); + const detailStyles = css([textStyles, shouldHideDetails ? accessibleHide : null]); + + return ( + + {typeof children === 'function' ? children(model) : children} + + ); +}; diff --git a/modules/_labs/pagination/react/lib/Pagination/Controls.tsx b/modules/_labs/pagination/react/lib/Pagination/Controls.tsx new file mode 100644 index 0000000000..87048e39aa --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/Controls.tsx @@ -0,0 +1,118 @@ +import * as React from 'react'; +import {IconButton, IconButtonProps} from '@workday/canvas-kit-react-button'; +import { + chevronLeftSmallIcon, + chevron2xLeftSmallIcon, + chevronRightSmallIcon, + chevron2xRightSmallIcon, +} from '@workday/canvas-system-icons-web'; + +import {PaginationModel} from './types'; +import {HStack} from './common/Stack'; +import {useRTL} from './common/utils/useRTL'; + +export type ControlButtonProps = IconButtonProps & { + model: PaginationModel; +}; + +export type ControlsProps = React.HTMLAttributes; + +export const Controls = ({children, ...elemProps}: ControlsProps) => { + return ( + + {children} + + ); +}; + +export const JumpToFirstButton = ({model, onClick, ...restProps}: ControlButtonProps) => { + const isDisabled = model.state.currentPage <= model.state.firstPage; + const handleClick = (e: React.MouseEvent) => { + if (isDisabled) { + return; + } + onClick?.(e); + model.events.setCurrentPage(model.state.firstPage); + }; + const {shouldUseRTL} = useRTL(); + const icon = shouldUseRTL ? chevron2xRightSmallIcon : chevron2xLeftSmallIcon; + return ( + + ); +}; + +export const StepToPreviousButton = ({onClick, model, ...restProps}: ControlButtonProps) => { + const isDisabled = model.state.currentPage <= model.state.firstPage; + const handleClick = (e: React.MouseEvent) => { + if (isDisabled) { + return; + } + onClick?.(e); + model.events.setCurrentPage(model.state.currentPage - 1); + }; + const {shouldUseRTL} = useRTL(); + const icon = shouldUseRTL ? chevronRightSmallIcon : chevronLeftSmallIcon; + return ( + + ); +}; + +export const StepToNextButton = ({model, onClick, ...restProps}: ControlButtonProps) => { + const isDisabled = model.state.currentPage >= model.state.lastPage; + const handleClick = (e: React.MouseEvent) => { + if (isDisabled) { + return; + } + onClick?.(e); + model.events.setCurrentPage(model.state.currentPage + 1); + }; + const {shouldUseRTL} = useRTL(); + const icon = shouldUseRTL ? chevronLeftSmallIcon : chevronRightSmallIcon; + return ( + + ); +}; + +export const JumpToLastButton = ({model, onClick, ...restProps}: ControlButtonProps) => { + const isDisabled = model.state.currentPage >= model.state.lastPage; + const handleClick = (e: React.MouseEvent) => { + if (isDisabled) { + return; + } + onClick?.(e); + model.events.setCurrentPage(model.state.lastPage); + }; + const {shouldUseRTL} = useRTL(); + const icon = shouldUseRTL ? chevron2xLeftSmallIcon : chevron2xRightSmallIcon; + return ( + + ); +}; diff --git a/modules/_labs/pagination/react/lib/Pagination/GoTo/Form.tsx b/modules/_labs/pagination/react/lib/Pagination/GoTo/Form.tsx new file mode 100644 index 0000000000..c8300ed61f --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/GoTo/Form.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import {HStack, HStackProps} from '../common/Stack'; +import {Spacing} from '../common/utils/space'; +import {useRTL} from '../common/utils/useRTL'; + +export type GoToFormProps = React.FormHTMLAttributes & + Omit & { + spacing?: Spacing; + }; + +export const GoToForm = ({children, onSubmit, spacing = 'xxs', ...elemProps}: GoToFormProps) => { + const {shouldUseRTL} = useRTL(); + return ( + + {children} + + ); +}; diff --git a/modules/_labs/pagination/react/lib/Pagination/GoTo/GoTo.tsx b/modules/_labs/pagination/react/lib/Pagination/GoTo/GoTo.tsx new file mode 100644 index 0000000000..b895ab22d8 --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/GoTo/GoTo.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import {GoToForm, GoToFormProps} from './Form'; +import {GoToLabel, GoToLabelProps} from './Label'; +import {GoToTextInput, GoToTextInputProps} from './TextInput'; +import {useGoToForm, UseGoToFormConfig} from './useGoToForm'; +import {PaginationModel} from '../types'; + +export interface GoToProps extends UseGoToFormConfig { + model: PaginationModel; + children?: React.ReactNode; +} + +const GoToContext = React.createContext({} as ReturnType); + +export const GoTo = (props: GoToProps) => { + const {children, model} = props; + + const goToContext = useGoToForm({model}); + return {children}; +}; + +GoTo.Form = (props: GoToFormProps) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const {formProps} = React.useContext(GoToContext); + return ; +}; + +GoTo.TextInput = (props: GoToTextInputProps) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const {inputProps} = React.useContext(GoToContext); + return ; +}; + +GoTo.Label = (props: GoToLabelProps) => { + return ; +}; diff --git a/modules/_labs/pagination/react/lib/Pagination/GoTo/Label.tsx b/modules/_labs/pagination/react/lib/Pagination/GoTo/Label.tsx new file mode 100644 index 0000000000..1cf8d1ac9f --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/GoTo/Label.tsx @@ -0,0 +1,33 @@ +/** @jsx jsx */ +import * as React from 'react'; +import {jsx, css, Interpolation} from '@emotion/core'; +import {type, typeColors} from '@workday/canvas-kit-react-core'; + +import {PaginationModel} from '../types'; + +export interface GoToLabelProps extends React.LabelHTMLAttributes { + model: PaginationModel; + children?: (model: PaginationModel) => React.ReactNode | React.ReactNode; +} + +const labelStyles = css({ + ...type.body, + color: typeColors.hint, + whiteSpace: 'nowrap', +}); + +export const GoToLabel = ({ + css: cssProp, + model, + children, + ...elemProps +}: GoToLabelProps) => { + return ( + + ); +}; diff --git a/modules/_labs/pagination/react/lib/Pagination/GoTo/TextInput.tsx b/modules/_labs/pagination/react/lib/Pagination/GoTo/TextInput.tsx new file mode 100644 index 0000000000..05fd8e52e6 --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/GoTo/TextInput.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; + +import TextInput, {TextInputProps} from '@workday/canvas-kit-react-text-input'; + +export type GoToTextInputProps = TextInputProps & { + 'aria-label': string; +}; + +export const GoToTextInput = ({ + 'aria-label': ariaLabel, + value = '', + ...elemProps +}: GoToTextInputProps) => { + return ; +}; diff --git a/modules/_labs/pagination/react/lib/Pagination/GoTo/index.ts b/modules/_labs/pagination/react/lib/Pagination/GoTo/index.ts new file mode 100644 index 0000000000..80c58857a2 --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/GoTo/index.ts @@ -0,0 +1,4 @@ +import {GoTo, GoToProps} from './GoTo'; + +export {GoTo, GoToProps}; +export * from './GoTo'; diff --git a/modules/_labs/pagination/react/lib/Pagination/GoTo/useGoToForm.tsx b/modules/_labs/pagination/react/lib/Pagination/GoTo/useGoToForm.tsx new file mode 100644 index 0000000000..d9e42c309f --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/GoTo/useGoToForm.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; + +import {PaginationModel} from '../types'; + +export interface UseGoToFormConfig { + model: PaginationModel; + onSubmit?: (event: React.FormEvent) => void; +} + +export const useGoToForm = ( + {onSubmit, model}: UseGoToFormConfig = {} as UseGoToFormConfig +) => { + const [value, setValue] = React.useState(); + + React.useEffect(() => { + if (value !== undefined && model.state.currentPage !== value) { + setValue(model.state.currentPage); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [model.state.currentPage]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + onSubmit?.(event); + model.events.goTo(value || 0); + }; + + const handleChange = (event: React.ChangeEvent) => { + const formattedValue = parseInt(event.target.value, 10) || 0; + setValue(formattedValue); + }; + + const formProps = { + onSubmit: handleSubmit, + }; + + const inputProps = { + value, + onChange: handleChange, + }; + + return { + formProps, + inputProps, + }; +}; diff --git a/modules/_labs/pagination/react/lib/Pagination/Nav.tsx b/modules/_labs/pagination/react/lib/Pagination/Nav.tsx new file mode 100644 index 0000000000..b36fb55dad --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/Nav.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import {Flex, FlexProps} from './common/Flex'; + +export interface PaginationNavProps extends Omit { + 'aria-label': string; +} + +export const PaginationNav = ({'aria-label': ariaLabel, ...elemProps}: PaginationNavProps) => { + return ( + + ); +}; diff --git a/modules/_labs/pagination/react/lib/Pagination/PageButton.tsx b/modules/_labs/pagination/react/lib/Pagination/PageButton.tsx new file mode 100644 index 0000000000..1e05d446f8 --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/PageButton.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import {styled} from '@workday/canvas-kit-react-common'; +import type from '@workday/canvas-kit-labs-react-core'; +import canvas from '@workday/canvas-kit-react-core'; +import {IconButton, IconButtonProps} from '@workday/canvas-kit-react-button'; + +import {PaginationModel} from './types'; + +const toggledStlyes = { + color: canvas.colors.frenchVanilla100, + fontWeight: 700, + pointerEvents: 'none', +}; + +const StyledPageButton = styled(IconButton)<{toggled?: boolean}>( + { + ...type.small, + }, + ({toggled}) => { + return toggled ? toggledStlyes : {}; + } +); + +export interface PageButtonProps extends IconButtonProps { + model: PaginationModel; + pageNumber: number; +} + +export const PageButton = ({ + model, + onClick, + pageNumber, + children, + ...elemProps +}: PageButtonProps) => { + const isCurrentPage = pageNumber === model.state.currentPage; + + const handleClick = (e: React.MouseEvent) => { + onClick?.(e); + model.events.goTo(pageNumber); + }; + + return ( + + {children || pageNumber} + + ); +}; diff --git a/modules/_labs/pagination/react/lib/Pagination/PageList.tsx b/modules/_labs/pagination/react/lib/Pagination/PageList.tsx new file mode 100644 index 0000000000..f35dc0aeb0 --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/PageList.tsx @@ -0,0 +1,30 @@ +/** @jsx jsx */ +import * as React from 'react'; +import {jsx, css} from '@emotion/core'; + +import {PaginationModel} from './types'; +import {List, ListItem, ListItemProps, ListProps} from './common/List'; +import {useStack} from './common/Stack'; +import {useRTL} from './common/utils/useRTL'; + +export interface PageListProps extends Omit { + model: PaginationModel; + children?: (model: PaginationModel) => React.ReactNode[] | React.ReactNode; +} + +export const PageList = ({model, children, ...elemProps}: PageListProps) => { + const {shouldUseRTL} = useRTL(); + const stackStyles = useStack({direction: 'row', spacing: 'xxxs', shouldUseRTL}); + + return ( + + {typeof children === 'function' ? children(model) : children} + + ); +}; + +export type PageListItemProps = ListItemProps; + +export const PageListItem = ({children, ...elemProps}: PageListItemProps) => { + return {children}; +}; diff --git a/modules/_labs/pagination/react/lib/Pagination/Pagination.tsx b/modules/_labs/pagination/react/lib/Pagination/Pagination.tsx new file mode 100644 index 0000000000..ea466a6d78 --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/Pagination.tsx @@ -0,0 +1,157 @@ +import * as React from 'react'; +import {IconButtonProps} from '@workday/canvas-kit-react-button'; + +import {PaginationModel} from './types'; +import {usePaginationModel, UsePaginationModelConfig} from './usePaginationModel'; + +import {PaginationNav, PaginationNavProps} from './Nav'; +import { + JumpToFirstButton, + StepToPreviousButton, + StepToNextButton, + JumpToLastButton, + Controls, + ControlsProps, +} from './Controls'; + +import {PageList, PageListProps, PageListItem, PageListItemProps} from './PageList'; +import {PageButton, PageButtonProps} from './PageButton'; + +import {GoTo} from './GoTo'; +import {GoToFormProps} from './GoTo/Form'; +import {GoToLabelProps} from './GoTo/Label'; +import {GoToTextInputProps} from './GoTo/TextInput'; + +import {AdditionalDetails, AdditionalDetailsProps} from './AdditionalDetails'; + +export const PaginationContext = React.createContext({} as PaginationModel); + +type PaginationConfigurationProps = PaginationNavProps & { + lastPage: number; + firstPage?: number; + initialCurrentPage?: number; + rangeSize?: number; + model?: never; + onPageChange?: (pageNumber: number) => void; +}; + +type PaginationModelProps = PaginationNavProps & { + lastPage?: never; + firstPage?: never; + initialCurrentPage?: never; + rangeSize?: never; + model: PaginationModel; + onPageChange?: never; +}; + +function useDefaultModel(model: T | undefined, config: C, fn: (config: C) => T) { + return model || fn(config); +} + +export type PaginationProps = PaginationConfigurationProps | PaginationModelProps; + +export const Pagination = (props: PaginationProps) => { + const model = useDefaultModel(props.model, props as UsePaginationModelConfig, usePaginationModel); + + const { + 'aria-label': ariaLabel, + children, + lastPage, + firstPage, + initialCurrentPage, + rangeSize, + onPageChange, + ...restProps + } = props; + + return ( + + + {children} + + + ); +}; + +Pagination.Controls = (props: ControlsProps) => { + return ; +}; + +Pagination.JumpToFirstButton = (props: IconButtonProps) => { + // The linter doesn't recognize the dot syntax, so we're disabling the rule + // eslint-disable-next-line react-hooks/rules-of-hooks + const model = React.useContext(PaginationContext); + return ; +}; + +Pagination.StepToPreviousButton = (props: IconButtonProps) => { + // The linter doesn't recognize the dot syntax, so we're disabling the rule + // eslint-disable-next-line react-hooks/rules-of-hooks + const model = React.useContext(PaginationContext); + return ; +}; + +Pagination.StepToNextButton = (props: IconButtonProps) => { + // The linter doesn't recognize the dot syntax, so we're disabling the rule + // eslint-disable-next-line react-hooks/rules-of-hooks + const model = React.useContext(PaginationContext); + return ; +}; + +Pagination.JumpToLastButton = (props: IconButtonProps) => { + // The linter doesn't recognize the dot syntax, so we're disabling the rule + // eslint-disable-next-line react-hooks/rules-of-hooks + const model = React.useContext(PaginationContext); + return ; +}; + +Pagination.PageList = (props: Omit) => { + // The linter doesn't recognize the dot syntax, so we're disabling the rule + // eslint-disable-next-line react-hooks/rules-of-hooks + const model = React.useContext(PaginationContext); + return ; +}; + +Pagination.PageListItem = (props: PageListItemProps) => { + return ; +}; + +Pagination.PageButton = ({ + 'aria-label': ariaLabel, + ...elemProps +}: Omit) => { + // The linter doesn't recognize the dot syntax, so we're disabling the rule + // eslint-disable-next-line react-hooks/rules-of-hooks + const model = React.useContext(PaginationContext); + return ; +}; + +Pagination.AdditionalDetails = (props: Omit) => { + // The linter doesn't recognize the dot syntax, so we're disabling the rule + // eslint-disable-next-line react-hooks/rules-of-hooks + const model = React.useContext(PaginationContext); + return ; +}; + +Pagination.GoToForm = ({children}: GoToFormProps) => { + // The linter doesn't recognize the dot syntax, so we're disabling the rule + // eslint-disable-next-line react-hooks/rules-of-hooks + const model = React.useContext(PaginationContext); + return ( + + {children} + + ); +}; + +Pagination.GoToTextInput = (props: GoToTextInputProps) => { + return ; +}; + +Pagination.GoToLabel = (props: Omit) => { + // The linter doesn't recognize the dot syntax, so we're disabling the rule + // eslint-disable-next-line react-hooks/rules-of-hooks + const model = React.useContext(PaginationContext); + + return ; +}; diff --git a/modules/_labs/pagination/react/lib/Pagination/buildPageRange.ts b/modules/_labs/pagination/react/lib/Pagination/buildPageRange.ts new file mode 100644 index 0000000000..4ddbed8c4b --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/buildPageRange.ts @@ -0,0 +1,27 @@ +import {PaginationState} from './types'; + +const buildRange = (max: number, min: number): number[] => { + // `max` determines the size of the range, and `min + index` determines its values + return [...Array(max)].map((_, index) => min + index); +}; + +type BuildPageRangeConfig = Pick; + +export const buildPageRange = ({currentPage, lastPage, rangeSize}: BuildPageRangeConfig) => { + // prevent the range size exceeding the number of pages + const adjustedRangeSize = lastPage < rangeSize ? lastPage : rangeSize; + + // Prevent the range from going below 1 + if (currentPage <= Math.floor(rangeSize / 2)) { + const rangeMin = 1; + return buildRange(adjustedRangeSize, rangeMin); + } + // Prevent the range from going above the lastPage + if (currentPage + Math.floor(adjustedRangeSize / 2) > lastPage) { + const rangeMin = lastPage - adjustedRangeSize + 1; + return buildRange(adjustedRangeSize, rangeMin); + } + + const rangeMin = currentPage - Math.floor(adjustedRangeSize / 2); + return buildRange(adjustedRangeSize, rangeMin); +}; diff --git a/modules/_labs/pagination/react/lib/Pagination/common/Box.tsx b/modules/_labs/pagination/react/lib/Pagination/common/Box.tsx new file mode 100644 index 0000000000..3164df48a6 --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/common/Box.tsx @@ -0,0 +1,44 @@ +/** @jsx jsx */ +import * as React from 'react'; +import {jsx, css} from '@emotion/core'; +import styled from '@emotion/styled'; + +import {space, SpaceProps} from './utils/space'; +import {layout, LayoutProps} from './utils/layout'; +import {safelySpreadProps} from './utils/safelySpreadProps'; + +const getBoxStyles = (props: BoxProps) => { + let styleProps = {}; + for (const key in props) { + if (key in props) { + if (key in space) { + const value = props[key as keyof BoxProps]; + const styleFn = space[key as keyof SpaceProps]; + const styles = styleFn(value); + styleProps = {...styleProps, ...styles}; + } + if (key in layout) { + const attr = layout[key as keyof LayoutProps]; + styleProps = {...styleProps, [attr]: props[key as keyof BoxProps]}; + } + } + } + return styleProps; +}; + +type As = keyof JSX.IntrinsicElements; + +export interface BoxProps extends SpaceProps, LayoutProps, React.HTMLAttributes { + as?: As; +} + +export const StyledBox = styled('div')({ + boxSizing: 'border-box', +}); + +export const Box = React.forwardRef(({as = 'div', ...props}, ref) => { + // TODO: Memoize style props with React.useMemo + const boxStyles = getBoxStyles(props); + const safeProps = safelySpreadProps(props); + return ; +}); diff --git a/modules/_labs/pagination/react/lib/Pagination/common/Flex.tsx b/modules/_labs/pagination/react/lib/Pagination/common/Flex.tsx new file mode 100644 index 0000000000..f7811c1e18 --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/common/Flex.tsx @@ -0,0 +1,23 @@ +import styled from '@emotion/styled'; + +import {flex, FlexProps as BaseFlexProps} from './utils/flex'; + +import {Box, BoxProps} from './Box'; + +export type FlexProps = BaseFlexProps & BoxProps; + +const getFlexStyles = (props: FlexProps) => { + let flexProps = {}; + + for (const key in props) { + if (key in flex) { + const attr = flex[key as keyof BaseFlexProps]; + flexProps = {...flexProps, [attr]: props[key as keyof FlexProps]}; + } + } + return flexProps; +}; + +export const Flex = styled(Box)({display: 'flex'}, getFlexStyles); + +Flex.displayName = 'Flex'; diff --git a/modules/_labs/pagination/react/lib/Pagination/common/List.tsx b/modules/_labs/pagination/react/lib/Pagination/common/List.tsx new file mode 100644 index 0000000000..854a08f2c3 --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/common/List.tsx @@ -0,0 +1,39 @@ +/** @jsx jsx */ +import * as React from 'react'; +import {jsx, css} from '@emotion/core'; + +import {Flex, FlexProps} from './Flex'; + +export interface ListProps extends FlexProps, React.OlHTMLAttributes { + as: 'ol' | 'ul'; +} + +const listStyles = css({ + listStyle: 'none', +}); + +export const List = React.forwardRef( + ({as, children, ...elemProps}, ref) => { + return ( + + {children} + + ); + } +); + +List.displayName = 'List'; + +export type ListItemProps = Omit; + +export const ListItem = React.forwardRef( + ({children, ...elemProps}, ref) => { + return ( + + {children} + + ); + } +); + +ListItem.displayName = 'ListItem'; diff --git a/modules/_labs/pagination/react/lib/Pagination/common/Stack.tsx b/modules/_labs/pagination/react/lib/Pagination/common/Stack.tsx new file mode 100644 index 0000000000..fdcd06090a --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/common/Stack.tsx @@ -0,0 +1,98 @@ +/** @jsx jsx */ +import * as React from 'react'; +import {jsx, css} from '@emotion/core'; +import {spacing as sp} from '@workday/canvas-kit-react-core'; + +import {Flex, FlexProps} from './Flex'; +import {Spacing} from './utils/space'; +import {getValidChildren} from './utils/getValidChildren'; +import {useRTL} from './utils/useRTL'; + +type StackDirection = 'row' | 'column' | 'row-reverse' | 'column-reverse'; + +export interface StackProps extends FlexProps { + children?: React.ReactNode; + direction: StackDirection; + flexDirection?: StackDirection; + spacing: Spacing; +} + +const selector = '& > *:not(style) ~ *:not(style)'; + +type Options = { + spacing: Spacing; + direction: StackDirection; + shouldUseRTL: boolean; +}; + +function getDirectionStyles(options: Options) { + const directionStyles = { + column: {marginTop: sp[options.spacing]}, + 'column-reverse': {marginBottom: sp[options.spacing]}, + 'row-reverse': options.shouldUseRTL + ? {marginLeft: sp[options.spacing]} + : {marginRight: sp[options.spacing]}, + row: options.shouldUseRTL + ? {marginRight: sp[options.spacing]} + : {marginLeft: sp[options.spacing]}, + }; + + return directionStyles[options.direction]; +} + +function getStackStyles(options: Options) { + const {direction} = options; + + return { + flexDirection: direction, + [selector]: getDirectionStyles(options), + }; +} + +export const useStack = ({direction, shouldUseRTL, spacing}: Options) => { + return React.useMemo(() => getStackStyles({direction, shouldUseRTL, spacing}), [ + direction, + spacing, + shouldUseRTL, + ]); +}; + +export const Stack = React.forwardRef( + ({children, direction, spacing, ...elemProps}: StackProps, ref) => { + const validChildren = getValidChildren(children); + const {shouldUseRTL} = useRTL(); + const stackStyles = useStack({direction, spacing, shouldUseRTL}); + + return ( + + {validChildren} + + ); + } +); + +Stack.displayName = 'Stack'; + +export interface HStackProps extends Omit { + direction?: 'row' | 'row-reverse'; +} + +export const HStack = React.forwardRef( + ({direction = 'row', spacing, ...elemProps}: HStackProps, ref) => { + return ; + } +); + +HStack.displayName = 'HStack'; + +export interface VStackProps extends Omit { + direction?: 'column' | 'column-reverse'; +} + +export const VStack = React.forwardRef( + ({direction = 'column', spacing, ...elemProps}: VStackProps, ref) => { + return ; + } +); + +VStack.displayName = 'VStack'; diff --git a/modules/_labs/pagination/react/lib/Pagination/common/useLiveRegion.tsx b/modules/_labs/pagination/react/lib/Pagination/common/useLiveRegion.tsx new file mode 100644 index 0000000000..649c58b594 --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/common/useLiveRegion.tsx @@ -0,0 +1,21 @@ +type UseLiveRegionConfig = { + 'aria-atomic'?: React.AriaAttributes['aria-atomic']; + 'aria-live'?: 'polite' | 'assertive'; + 'aria-relevant'?: React.AriaAttributes['aria-relevant']; + role?: 'status' | 'alert' | 'log'; + shouldAnnounceToScreenReader?: boolean; +}; + +export function useLiveRegion(config = {} as UseLiveRegionConfig) { + const {shouldAnnounceToScreenReader = true, ...restConfig} = config; + if (shouldAnnounceToScreenReader) { + return { + 'aria-atomic': true, + 'aria-live': 'polite', + 'aria-relevant': true, + role: 'status', + ...restConfig, + } as UseLiveRegionConfig; + } + return {}; +} diff --git a/modules/_labs/pagination/react/lib/Pagination/common/utils/flex.ts b/modules/_labs/pagination/react/lib/Pagination/common/utils/flex.ts new file mode 100644 index 0000000000..6b6cc6a1f0 --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/common/utils/flex.ts @@ -0,0 +1,110 @@ +// TODO: Consider using CSSType instead +type Globals = '-moz-initial' | 'inherit' | 'initial' | 'revert' | 'unset'; +type GlobalsNumber = number | '-moz-initial' | 'inherit' | 'initial' | 'revert' | 'unset'; +type SelfPosition = + | 'center' + | 'end' + | 'flex-end' + | 'flex-start' + | 'self-end' + | 'self-start' + | 'start'; +type ContentDistribution = 'space-around' | 'space-between' | 'space-evenly' | 'stretch'; +type ContentPosition = 'center' | 'end' | 'flex-end' | 'flex-start' | 'start'; +type AlignItemsProperty = Globals | SelfPosition | 'baseline' | 'normal' | 'stretch'; +type AlignContentProperty = Globals | ContentDistribution | ContentPosition | 'baseline' | 'normal'; +type JustifyItemsProperty = + | Globals + | SelfPosition + | 'baseline' + | 'left' + | 'legacy' + | 'normal' + | 'right' + | 'stretch'; +type JustifyContentProperty = + | Globals + | ContentDistribution + | ContentPosition + | 'left' + | 'normal' + | 'right'; +type FlexWrapProperty = + | '-moz-initial' + | 'inherit' + | 'initial' + | 'revert' + | 'unset' + | 'nowrap' + | 'wrap' + | 'wrap-reverse'; + +type FlexDirectionProperty = + | '-moz-initial' + | 'inherit' + | 'initial' + | 'revert' + | 'unset' + | 'column' + | 'column-reverse' + | 'row' + | 'row-reverse'; +type JustifySelfProperty = + | Globals + | SelfPosition + | 'auto' + | 'baseline' + | 'left' + | 'normal' + | 'right' + | 'stretch' + | 'auto' + | 'baseline' + | 'left' + | 'normal' + | 'right' + | 'stretch'; +type AlignSelfProperty = Globals | SelfPosition | 'auto' | 'baseline' | 'normal' | 'stretch'; +export interface FlexProps { + alignItems?: AlignItemsProperty; + alignContent?: AlignContentProperty; + display?: 'flex' | 'inline-flex'; + justifyItems?: JustifyItemsProperty; + justifyContent?: JustifyContentProperty; + flexWrap?: FlexWrapProperty; + wrap?: FlexWrapProperty; // a helpful alias for flexWrap + flexDirection?: FlexDirectionProperty; + direction?: FlexDirectionProperty; // a helpful alias for flexDirection + flex?: number | string; + flexGrow?: number | string; + grow?: number | string; // a helpful alias for flexGrow + flexShrink?: number | string; + shrink?: number | string; // a helpful alias for flexShrink + flexBasis?: number | string; + basis?: number | string; // a helpful alias for flexBasis + justifySelf?: JustifySelfProperty; + alignSelf?: AlignSelfProperty; + order?: GlobalsNumber; +} + +export const flex = { + alignItems: 'alignItems', + alignContent: 'alignContent', + display: 'display', + justifyItems: 'justifyItems', + justifyContent: 'justifyContent', + flexWrap: 'flexWrap', + wrap: 'flexWrap', + flexDirection: 'flexDirection', + direction: 'flexDirection', + flex: 'flex', + flexGrow: 'flexGrow', + grow: 'flexGrow', + flexShrink: 'flexShrink', + shrink: 'flexShrink', + flexBasis: 'flexBasis', + basis: 'basis', + justifySelf: 'justifySelf', + alignSelf: 'alignSelf', + order: 'order', +}; diff --git a/modules/_labs/pagination/react/lib/Pagination/common/utils/getValidChildren.ts b/modules/_labs/pagination/react/lib/Pagination/common/utils/getValidChildren.ts new file mode 100644 index 0000000000..701a5a2d81 --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/common/utils/getValidChildren.ts @@ -0,0 +1,7 @@ +import * as React from 'react'; + +export function getValidChildren(children: React.ReactNode) { + return React.Children.toArray(children).filter(child => + React.isValidElement(child) + ) as React.ReactElement[]; +} diff --git a/modules/_labs/pagination/react/lib/Pagination/common/utils/helpers.ts b/modules/_labs/pagination/react/lib/Pagination/common/utils/helpers.ts new file mode 100644 index 0000000000..b3714b954a --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/common/utils/helpers.ts @@ -0,0 +1,60 @@ +/** + * Returns the last page for the total results + * @param resultCount number of results per page + * @param totalCount total number of results + * @example + * const lastPage = getLastPage(10, 120); //=> 12 + */ +export function getLastPage(resultCount: number, totalCount: number): number { + return Math.ceil(totalCount / resultCount); +} + +/** + * Returns the first number in the pagination range + * @param range the range of numbers for the pagination component + * @example + * const rangeMin = getRangeMin([1,2,3,4,5]) //=> 1 + */ +export function getRangeMin(range: number[]): number { + return range[0]; +} + +/** + * Returns the last number in the pagination range + * @param range the range of numbers for the pagination component + * @example + * const rangeMax = getRangeMax([1,2,3,4,5]) //=> 5 + */ +export function getRangeMax(range: number[]): number { + return range[range.length - 1]; +} + +/** + * Returns the first page number for the visible results + * @param currentPage current page for the range + * @param resultCount number of results per page + * @example + * const pageMin = getVisibleResultsMin(5, 10); //=> 41 + */ +export function getVisibleResultsMin(currentPage: number, resultCount: number): number { + return currentPage * resultCount - resultCount + 1; +} + +/** + * Returns the last page number for the visible results + * @param currentPage current page for the range + * @param resultCount number of results per page + * @param totalCount total number of results + * @example + * const pageMax = getVisiblePageMax(5,10,100); //=> 50 + */ +export function getVisibleResultsMax( + currentPage: number, + resultCount: number, + totalCount: number +): number { + if (totalCount < currentPage * resultCount) { + return totalCount; + } + return currentPage * resultCount; +} diff --git a/modules/_labs/pagination/react/lib/Pagination/common/utils/layout.ts b/modules/_labs/pagination/react/lib/Pagination/common/utils/layout.ts new file mode 100644 index 0000000000..7a2a1e2db8 --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/common/utils/layout.ts @@ -0,0 +1,40 @@ +type Globals = '-moz-initial' | 'inherit' | 'initial' | 'revert' | 'unset'; +type OverflowProperty = Globals | 'auto' | 'hidden' | 'scroll' | 'visible' | string; +type OverflowXProperty = + | '-moz-initial' + | 'inherit' + | 'initial' + | 'revert' + | 'unset' + | 'auto' + | 'hidden' + | 'scroll' + | 'visible'; +type OverflowYProperty = OverflowXProperty; +export interface LayoutProps { + display?: string; + height?: number | string; + maxHeight?: number | string; + maxWidth?: number | string; + minHeight?: number | string; + minWidth?: number | string; + overflow?: OverflowProperty; + overflowX?: OverflowXProperty; + overflowY?: OverflowYProperty; + verticalAlign?: string; + width?: number | string; +} + +export const layout = { + display: 'display', + height: 'height', + maxHeight: 'maxHeight', + maxWidth: 'maxWidth', + minHeight: 'minHeight', + minWidth: 'minWidth', + overflow: 'overflow', + overflowX: 'overflowX', + overflowY: 'overflowY', + verticalAlign: 'verticalAlign', + width: 'width', +}; diff --git a/modules/_labs/pagination/react/lib/Pagination/common/utils/safelySpreadProps.ts b/modules/_labs/pagination/react/lib/Pagination/common/utils/safelySpreadProps.ts new file mode 100644 index 0000000000..d2a79ee6a2 --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/common/utils/safelySpreadProps.ts @@ -0,0 +1,29 @@ +import {flex} from './flex'; +import {layout} from './layout'; +import {space} from './space'; + +const styleProps = { + ...flex, + ...layout, + ...space, +}; + +/** + * Removes style props from props to prevent them from being applied to the HTML element. + * Returns an object of props that is stripped of all style props. + * @example + * const Box = (props) => { + * const safeProps = safelySpreadProps(props); + * return
+ * } + * + */ +export function safelySpreadProps(props: T) { + const safeProps = {} as T; + for (const key in props) { + if (!(key in styleProps)) { + safeProps[key as keyof T] = props[key as keyof T]; + } + } + return safeProps; +} diff --git a/modules/_labs/pagination/react/lib/Pagination/common/utils/space.ts b/modules/_labs/pagination/react/lib/Pagination/common/utils/space.ts new file mode 100644 index 0000000000..d08a6d55cf --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/common/utils/space.ts @@ -0,0 +1,109 @@ +import {spacing} from '@workday/canvas-kit-react-core'; + +export type Spacing = keyof typeof spacing; + +export interface SpaceProps { + margin?: Spacing; + m?: Spacing; + marginX?: Spacing; + mx?: Spacing; + marginY?: Spacing; + my?: Spacing; + marginT?: Spacing; + mt?: Spacing; + marginR?: Spacing; + mr?: Spacing; + marginB?: Spacing; + mb?: Spacing; + marginL?: Spacing; + ml?: Spacing; + padding?: Spacing; + p?: Spacing; + paddingX?: Spacing; + px?: Spacing; + paddingY?: Spacing; + py?: Spacing; + paddingT?: Spacing; + pt?: Spacing; + paddingR?: Spacing; + pr?: Spacing; + paddingB?: Spacing; + pb?: Spacing; + paddingL?: Spacing; + pl?: Spacing; +} + +const margin = (value: Spacing) => ({margin: spacing[value] || value}); + +const marginX = (value: Spacing) => ({ + marginLeft: spacing[value], // || value, + marginRight: spacing[value], // || value +}); +const marginY = (value: Spacing) => ({ + marginBottom: spacing[value], // || value, + marginTop: spacing[value], // || value +}); +const marginT = (value: Spacing) => ({ + marginTop: spacing[value], // || value +}); +const marginR = (value: Spacing) => ({ + marginRight: spacing[value], // || value +}); +const marginB = (value: Spacing) => ({ + marginBottom: spacing[value], // || value +}); +const marginL = (value: Spacing) => ({ + marginLeft: spacing[value], // || value +}); +const padding = (value: Spacing) => ({padding: spacing[value]}); // || value }); +const paddingX = (value: Spacing) => ({ + paddingLeft: spacing[value], // || value, + paddingRight: spacing[value], // || value +}); +const paddingY = (value: Spacing) => ({ + paddingBottom: spacing[value], // || value, + paddingTop: spacing[value], // || value +}); +const paddingT = (value: Spacing) => ({ + paddingTop: spacing[value], // || value +}); +const paddingR = (value: Spacing) => ({ + paddingRight: spacing[value], // || value +}); +const paddingB = (value: Spacing) => ({ + paddingBottom: spacing[value], // || value +}); +const paddingL = (value: Spacing) => ({ + paddingLeft: spacing[value], // || value +}); + +export const space = { + margin, + m: margin, // helpful alias for margin + marginX, + mx: marginX, // helpful alias for marginX + marginY, + my: marginY, // helpful alias for marginY + marginT, + mt: marginT, // helpful alias for marginT + marginR, + mr: marginR, // helpful alias for marginR + marginB, + mb: marginB, // helpful alias for marginB + marginL, + ml: marginL, // helpful alias for marginL + padding, + p: padding, // helpful alias for padding + paddingX, + px: paddingX, // helpful alias for paddingX + paddingY, + py: paddingY, // helpful alias for paddingY + paddingT, + pt: paddingT, // helpful alias for paddingT + paddingR, + pr: paddingR, // helpful alias for paddingR + paddingB, + pb: paddingB, // helpful alias for paddingB + paddingL, + pl: paddingL, // helpful alias for paddingL +}; diff --git a/modules/_labs/pagination/react/lib/Pagination/common/utils/useRTL.ts b/modules/_labs/pagination/react/lib/Pagination/common/utils/useRTL.ts new file mode 100644 index 0000000000..b073ca4b5d --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/common/utils/useRTL.ts @@ -0,0 +1,13 @@ +import { + useTheme, + PartialEmotionCanvasTheme, + ContentDirection, +} from '@workday/canvas-kit-react-common'; + +export const useRTL = (partialTheme?: PartialEmotionCanvasTheme) => { + const theme = useTheme(partialTheme); + const shouldUseRTL = theme.canvas.direction === ContentDirection.RTL; + return { + shouldUseRTL, + }; +}; diff --git a/modules/_labs/pagination/react/lib/Pagination/index.tsx b/modules/_labs/pagination/react/lib/Pagination/index.tsx new file mode 100644 index 0000000000..2cb7f9b9f7 --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/index.tsx @@ -0,0 +1,4 @@ +export * from './Pagination'; +export * from './usePaginationModel'; +export * from './types'; +export * from './common/utils/helpers'; diff --git a/modules/_labs/pagination/react/lib/Pagination/types.ts b/modules/_labs/pagination/react/lib/Pagination/types.ts new file mode 100644 index 0000000000..07b9b26c10 --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/types.ts @@ -0,0 +1,23 @@ +import * as React from 'react'; + +export interface PaginationState { + currentPage: number; // current page + firstPage: number; // first page + lastPage: number; // last page + range: number[]; // array of page numbers + rangeSize: number; // size of the range +} + +export interface PaginationEvents { + setCurrentPage: React.Dispatch>; + first: () => void; + last: () => void; + next: () => void; + previous: () => void; + goTo: (page: number) => void; +} + +export interface PaginationModel { + state: PaginationState; + events: PaginationEvents; +} diff --git a/modules/_labs/pagination/react/lib/Pagination/usePaginationModel.ts b/modules/_labs/pagination/react/lib/Pagination/usePaginationModel.ts new file mode 100644 index 0000000000..b586c66e6e --- /dev/null +++ b/modules/_labs/pagination/react/lib/Pagination/usePaginationModel.ts @@ -0,0 +1,85 @@ +import * as React from 'react'; + +import {buildPageRange} from './buildPageRange'; +import {getRangeMax, getRangeMin} from './common/utils/helpers'; +import {PaginationModel} from './types'; + +export type UsePaginationModelConfig = { + lastPage: number; + firstPage?: number; + initialCurrentPage?: number; + onPageChange?: (pageNumber: number) => void; + rangeSize?: number; +}; + +export const usePaginationModel = ({ + firstPage = 1, + initialCurrentPage = 1, + lastPage, + rangeSize = 5, + onPageChange, +}: UsePaginationModelConfig): PaginationModel => { + const [currentPage, setCurrentPage] = React.useState(initialCurrentPage); + + const changePage = (page: number) => { + onPageChange?.(page); + + setCurrentPage(page); + }; + + const first = () => { + changePage(firstPage); + }; + + const last = () => { + changePage(lastPage); + }; + + const next = () => { + changePage(currentPage + 1); + }; + + const previous = () => { + changePage(currentPage - 1); + }; + + const goTo = (pageNumber: number) => { + if (pageNumber < firstPage) { + // a safeguard to prevent for going to a page below the range + changePage(firstPage); + } else if (pageNumber > lastPage) { + // a safeguard to prevent going to a page above the range + changePage(lastPage); + } else { + changePage(pageNumber); + } + }; + + const range = buildPageRange({currentPage, lastPage, rangeSize}); + const rangeMin = getRangeMin(range); + const rangeMax = getRangeMax(range); + + const state = { + firstPage, + currentPage, + lastPage, + range, + rangeSize, + rangeMin, + rangeMax, + }; + + const events = { + setCurrentPage: changePage, + first, + last, + next, + previous, + goTo, + }; + + return { + state, + events, + }; +}; diff --git a/modules/_labs/pagination/react/package.json b/modules/_labs/pagination/react/package.json index 3baf4194d0..b11819d608 100644 --- a/modules/_labs/pagination/react/package.json +++ b/modules/_labs/pagination/react/package.json @@ -51,10 +51,9 @@ "@emotion/styled": "^10.0.27", "@workday/canvas-kit-labs-react-core": "^4.4.2", "@workday/canvas-kit-react-button": "^4.4.2", + "@workday/canvas-kit-react-common": "^4.4.2", "@workday/canvas-kit-react-core": "^4.4.2", "@workday/canvas-kit-react-text-input": "^4.4.2", - "@workday/canvas-system-icons-web": "1.0.41", - "lodash": "^4.17.14", - "uuid": "^3.3.3" + "@workday/canvas-system-icons-web": "1.0.41" } } diff --git a/modules/_labs/pagination/react/spec/GoTo.spec.tsx b/modules/_labs/pagination/react/spec/GoTo.spec.tsx deleted file mode 100644 index 2ff50c5c2b..0000000000 --- a/modules/_labs/pagination/react/spec/GoTo.spec.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import {fireEvent, render} from '@testing-library/react'; -import * as React from 'react'; - -import Pagination from '..'; - -describe('Pagination Go To', () => { - const setPage = (input: HTMLElement, page: number | string) => { - fireEvent.change(input, {target: {value: page}}); - fireEvent.submit(input); - }; - - test('Setting page in Go To should change page when valid', async () => { - let currentPage = 1; - const setCurrentPage = (page: number) => (currentPage = page); - const goToLabel = 'goToPage'; - - const {getByLabelText} = render( - setCurrentPage(p)} - showLabel - showGoTo - goToLabel={goToLabel} - /> - ); - - const goToBox = getByLabelText(goToLabel); - - setPage(goToBox, 3); - await expect(currentPage).toBe(3); - - setPage(goToBox, 5); - await expect(currentPage).toBe(5); - - setPage(goToBox, 10); - await expect(currentPage).toBe(10); - }); - - test('Setting page in Go To to invalid numbers should not change page', () => { - let currentPage = 1; - const setCurrentPage = (page: number) => (currentPage = page); - const goToLabel = 'goToPage'; - - const {getByLabelText} = render( - setCurrentPage(p)} - showLabel - showGoTo - goToLabel={goToLabel} - /> - ); - - const goToBox = getByLabelText(goToLabel); - - setPage(goToBox, -1); - expect(currentPage).toBe(1); - - setPage(goToBox, 11); - expect(currentPage).toBe(1); - - setPage(goToBox, 100); - expect(currentPage).toBe(1); - - setPage(goToBox, 'abcd'); - expect(currentPage).toBe(1); - }); -}); diff --git a/modules/_labs/pagination/react/spec/Pages.spec.tsx b/modules/_labs/pagination/react/spec/Pages.spec.tsx deleted file mode 100644 index f90e4d1e08..0000000000 --- a/modules/_labs/pagination/react/spec/Pages.spec.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import {getPages} from '../lib/Pages'; - -describe('Pages', () => { - describe('Desktop', () => { - describe('when current page is 1', () => { - it('should handle one page', () => { - expect(getPages(1, 1, false)).toEqual([[1], []]); - }); - - it('should handle 2 pages', () => { - expect(getPages(2, 1, false)).toEqual([[1, 2], []]); - }); - - it('should handle 3 pages', () => { - expect(getPages(3, 1, false)).toEqual([[1, 2, 3], []]); - }); - - it('should handle 4 pages', () => { - expect(getPages(4, 1, false)).toEqual([[1, 2, 3, 4], []]); - }); - - it('should handle 5 pages', () => { - expect(getPages(5, 1, false)).toEqual([[1, 2, 3, 4, 5], []]); - }); - - it('should handle 6 pages', () => { - expect(getPages(6, 1, false)).toEqual([[1, 2, 3, 4, 5, 6], []]); - }); - - it('should handle 7 pages', () => { - expect(getPages(7, 1, false)).toEqual([[1, 2, 3, 4, 5, 6, 7], []]); - }); - - it('should split after 8 pages', () => { - expect(getPages(8, 1, false)).toEqual([[1, 2, 3, 4, 5], [8]]); - }); - }); - - describe('when page count is 10', () => { - it('should show first 5 pages when current page is 1', () => { - expect(getPages(10, 1, false)).toEqual([[1, 2, 3, 4, 5], [10]]); - }); - - it('should show first 5 pages when current page is 2', () => { - expect(getPages(10, 2, false)).toEqual([[1, 2, 3, 4, 5], [10]]); - }); - - it('should show first 5 pages when current page is 3', () => { - expect(getPages(10, 3, false)).toEqual([[1, 2, 3, 4, 5], [10]]); - }); - - it('should keep 4th page centered in available pages on left side', () => { - expect(getPages(10, 4, false)).toEqual([[2, 3, 4, 5, 6], [10]]); - }); - - it('should keep 5th page centered in available pages on left side', () => { - expect(getPages(10, 5, false)).toEqual([[3, 4, 5, 6, 7], [10]]); - }); - - it('should keep 6th page centered in available pages on left side', () => { - expect(getPages(10, 6, false)).toEqual([[4, 5, 6, 7, 8], [10]]); - }); - - it('should show first page and the last 5 numbers when the current page is 7 (within 4 pages of the last page)', () => { - expect(getPages(10, 7, false)).toEqual([[1], [6, 7, 8, 9, 10]]); - }); - - it('should show first page and the last 5 numbers when the current page is 8 (within 4 pages of the last page)', () => { - expect(getPages(10, 8, false)).toEqual([[1], [6, 7, 8, 9, 10]]); - }); - - it('should show first page and the last 5 numbers when the current page is 8 (within 4 pages of the last page)', () => { - expect(getPages(10, 8, false)).toEqual([[1], [6, 7, 8, 9, 10]]); - }); - - it('should show first page and the last 5 numbers when the current page is 9 (within 4 pages of the last page)', () => { - expect(getPages(10, 9, false)).toEqual([[1], [6, 7, 8, 9, 10]]); - }); - - it('should show first page and the last 5 numbers when the current page is 10 (within 4 pages of the last page)', () => { - expect(getPages(10, 10, false)).toEqual([[1], [6, 7, 8, 9, 10]]); - }); - }); - }); - - describe('mobile', () => { - describe('when current page is 1', () => { - it('should show all pages when page count is 1', () => { - expect(getPages(1, 1, true)).toEqual([[1], []]); - }); - - it('should show all pages when page count is 2', () => { - expect(getPages(2, 1, true)).toEqual([[1, 2], []]); - }); - - it('should show all pages when page count is 3', () => { - expect(getPages(3, 1, true)).toEqual([[1, 2, 3], []]); - }); - }); - - describe('when page count is 5', () => { - it('should show first 5 pages when current page is 1', () => { - expect(getPages(5, 1, true)).toEqual([[1], [5]]); - }); - - it('should show first 5 pages when current page is 2', () => { - expect(getPages(5, 2, true)).toEqual([[2], [5]]); - }); - - it('should show first 5 pages when current page is 3', () => { - expect(getPages(5, 3, true)).toEqual([[3], [5]]); - }); - - it('should keep 4th page centered in available pages on left side', () => { - expect(getPages(5, 4, true)).toEqual([[3, 4, 5], []]); - }); - - it('should keep 5th page centered in available pages on left side', () => { - expect(getPages(5, 5, true)).toEqual([[3, 4, 5], []]); - }); - }); - }); -}); diff --git a/modules/_labs/pagination/react/spec/Pagination.spec.tsx b/modules/_labs/pagination/react/spec/Pagination.spec.tsx deleted file mode 100644 index 56731eac41..0000000000 --- a/modules/_labs/pagination/react/spec/Pagination.spec.tsx +++ /dev/null @@ -1,276 +0,0 @@ -import {render} from '@testing-library/react'; -import _ from 'lodash'; -import React from 'react'; - -import Pagination from '..'; - -const defaultProps = { - total: 10, - pageSize: 1, - currentPage: 1, - onPageChange: () => {}, // eslint-disable-line no-empty-function - pageButtonAriaLabel: (page: number, active: boolean) => - `paginationButton${active ? 'Active' : page}`, -}; - -describe('Pagination Pages', () => { - it('should show the page total', () => { - const {getByText} = render(); - - expect(getByText('10')); - }); - - it('should show the correct number of pages', () => { - const {getByText} = render(); - - expect(getByText('10')); - }); - - it('should pass through the page button aria label', () => { - const {getByText} = render( - { - return `${active ? 'Active, ' : ''}MyPage ${page}`; - }} - /> - ); - - expect(getByText('1')).toHaveAttribute('aria-label', 'Active, MyPage 1'); - expect(getByText('2')).toHaveAttribute('aria-label', 'MyPage 2'); - }); - - it('should pass through the current page', () => { - const {getByText} = render(); - - expect(getByText('2')).toHaveAttribute('aria-label', 'paginationButtonActive'); - }); - - it('should show the go to input if showGoTo is true', () => { - const {getByText} = render(); - - expect(getByText('Go To')); - }); - - it('should pass through label if showLabel is true', () => { - const {getByText} = render( - - ); - - expect(getByText(/items/)).toHaveTextContent('1\u20131 of 10 items'); - }); - - it('should pass through customLabel if showLabel is true', () => { - const {getByText} = render( - `${from} - ${to} of ${total}`} - /> - ); - - expect(getByText(/100/)).toHaveTextContent('1 - 10 of 100'); - }); - - it('should pass through goToLabel', () => { - const {getByText} = render( - - ); - - expect(getByText('My Goto')); - }); - - it('should pass through paginationContainerLabel', () => { - const {container} = render( - - ); - - expect(container.querySelector('nav')).toHaveAttribute('aria-label', 'My Pagination'); - }); - - it('should pass through previousPageAriaLabel', () => { - const {getByLabelText} = render( - - ); - - expect(getByLabelText('My Previous Page')); - }); - - it('should pass through nextPageAriaLabel', () => { - const {getByLabelText} = render( - - ); - - expect(getByLabelText('My Next Page')); - }); - - it('should pass through pageButtonAriaLabel', () => { - const {getByText} = render( - (selected ? 'Active' : `Page ${page}`)} - /> - ); - - expect(getByText('1')).toHaveAttribute('aria-label', 'Active'); - expect(getByText('2')).toHaveAttribute('aria-label', 'Page 2'); - }); - - it('should pass through additional attributes to the container element', () => { - const {getByTestId} = render( - - ); - - expect(getByTestId('test')).toHaveAttribute('data-test', 'pass'); - }); - - _.range(1, 10).forEach(page => { - it(`Clicking page ${page + 1} brings you to page ${page}`, () => { - let currentPage = page; - - const setCurrentPage = (page: number) => (currentPage = page); - const {getByText} = render( - setCurrentPage(p)} - /> - ); - - getByText(`${page + 1}`).click(); - expect(currentPage).toEqual(page + 1); - }); - }); - - it('Clicking the last page on the first page brings you to last page', () => { - let currentPage = 1; - - const setCurrentPage = (page: number) => (currentPage = page); - const {getByText} = render( - setCurrentPage(p)} - /> - ); - - getByText('10').click(); - expect(currentPage).toEqual(10); - }); - - _.range(2, 6).forEach(page => { - it(`Test pagination with ${page} pages`, () => { - const currentPage = page; - const pageSize = 10; - - const {queryByLabelText, getByLabelText} = render( - - ); - - expect(getByLabelText('paginationButtonActive')); - for (let i = 1; i < page; i++) { - expect(getByLabelText(`paginationButton${i}`)); - } - expect(queryByLabelText(`paginationButton${page}`)).toBeNull(); - }); - }); - - it('Test pagination with 1 page', () => { - const {getByText} = render( - - ); - - expect(getByText('1')); - }); - - it('Last page should be visible on first page', () => { - const {getByText} = render(); - - expect(getByText('10')); - }); - - it('Last page should be visible on middle page', () => { - const {getByText} = render(); - - expect(getByText('100')); - }); - - it('First page should be visible on last page', () => { - const {getByText} = render(); - - expect(getByText('1')); - }); - - it('First page should be visible 97/100 page', () => { - const {getByText} = render(); - - expect(getByText('1')); - }); - - it('First page should not be visible 96/100 page', () => { - const {queryByText, getByText} = render( - - ); - - expect(getByText('100')); - expect(queryByText('1')).toBeNull(); - }); - - it('First page should not be visible 4/100 page', () => { - const {queryByText, getByText} = render( - - ); - - expect(getByText('100')); - expect(queryByText('1')).toBeNull(); - }); - - it('First page should be visible 3/100 page', () => { - const {getByText} = render(); - - expect(getByText('1')); - expect(getByText('100')); - }); - - it('Numbers should not shift when three selected', () => { - const {getByText} = render(); - - expect(getByText('1')); - expect(getByText('2')); - expect(getByText('3')); - expect(getByText('4')); - expect(getByText('5')); - }); - - it('Numbers should shift when middle number clicked', () => { - const {queryByText, getByText} = render(); - - expect(queryByText('1')).toBeNull(); - expect(getByText('2')); - expect(getByText('3')); - expect(getByText('4')); - expect(getByText('5')); - expect(getByText('6')); - }); - - _.range(9, 11).forEach(page => { - it(`Three numbers visible on last page - currentPage of ${page} - and first page not visible on mobile`, () => { - const {queryByText, getByText} = render( - - ); - - expect(getByText('8')); - expect(getByText('9')); - expect(queryByText('1')).toBeNull(); - }); - }); -}); diff --git a/modules/_labs/pagination/react/spec/buildPageRange.spec.ts b/modules/_labs/pagination/react/spec/buildPageRange.spec.ts new file mode 100644 index 0000000000..1afe3e2da9 --- /dev/null +++ b/modules/_labs/pagination/react/spec/buildPageRange.spec.ts @@ -0,0 +1,94 @@ +import {buildPageRange} from '../lib/Pagination/buildPageRange'; +const context = describe; + +describe('buildPageRange', () => { + context('given the range size is less than the number of pages', () => { + it('should return a range with the correct size', () => { + /* + e.g Given there are 10 pages, + and the current page is set to 5 with a range size of 5, + the range should be [2,3,4,5,6,7,8] + */ + const currentPage = 5; + const lastPage = 10; + const rangeSize = 7; + const pageRange = buildPageRange({currentPage, lastPage, rangeSize}); + + expect(pageRange).toEqual([2, 3, 4, 5, 6, 7, 8]); + }); + + it('should prevent the range from going below one', () => { + /* + e.g Given there are 10 pages, + and the current page is set to 2 with a range size of 5, + the range should start at 1 (not go to -1) + */ + const currentPage = 2; + const lastPage = 10; + const rangeSize = 5; + const pageRange = buildPageRange({currentPage, lastPage, rangeSize}); + + expect(pageRange).toEqual([1, 2, 3, 4, 5]); + }); + + it('should prevent the range from going above the number of pages', () => { + /* + e.g Given there are 10 pages, + and the current page is set to 9 with a range size of 5, + the range should end at 10 (not go to 11) + */ + const currentPage = 9; + const lastPage = 10; + const rangeSize = 5; + const pageRange = buildPageRange({currentPage, lastPage, rangeSize}); + + expect(pageRange).toEqual([6, 7, 8, 9, 10]); + }); + }); + + context('given the range size is greater than the number of pages', () => { + it('should constrain the range size to the number of pages', () => { + /* + e.g Given there are 3 pages, + and the current page is set to 2 with a range size of 7, + the range size should be [2,3,4,5,6,7,8] + */ + const currentPage = 2; + const lastPage = 3; + const rangeSize = 7; + const pageRange = buildPageRange({currentPage, lastPage, rangeSize}); + + expect(pageRange).toEqual([1, 2, 3]); + }); + + it('should prevent the range from going below one', () => { + /* + e.g Given there are 3 pages, + and the range is set to 5, + and the current page is set to 1, + the range size should be adjusted to 3 + */ + const currentPage = 1; + const lastPage = 3; + const rangeSize = 5; + const pageRange = buildPageRange({currentPage, lastPage, rangeSize}); + + expect(pageRange).toEqual([1, 2, 3]); + }); + + it('should prevent the range from going above the number of pages', () => { + /* + e.g Given there are 3 pages, + and the range is set to 5, + and the current page is set to 3, + the range size should be adjusted to 3 + */ + const currentPage = 3; + const lastPage = 3; + const rangeSize = 5; + const pageRange = buildPageRange({currentPage, lastPage, rangeSize}); + + expect(pageRange).toEqual([1, 2, 3]); + }); + }); +}); diff --git a/modules/_labs/pagination/react/spec/helpers.spec.ts b/modules/_labs/pagination/react/spec/helpers.spec.ts new file mode 100644 index 0000000000..7111004ca6 --- /dev/null +++ b/modules/_labs/pagination/react/spec/helpers.spec.ts @@ -0,0 +1,70 @@ +import { + getLastPage, + getRangeMin, + getRangeMax, + getVisibleResultsMin, + getVisibleResultsMax, +} from '../lib/Pagination/common/utils/helpers'; +const context = describe; + +describe('Pagination helpers', () => { + context('getLastPage', () => { + it('should return the last page for the total results', () => { + const resultCount = 10; + const totalCount = 128; + const lastPage = getLastPage(resultCount, totalCount); + + expect(lastPage).toBe(13); + }); + }); + + context('getRangeMin', () => { + it('should return the first number in the pagination range', () => { + const range = [1, 2, 3, 4, 5]; + const rangeMin = getRangeMin(range); + + expect(rangeMin).toBe(1); + }); + }); + + context('getRangeMax', () => { + it('should return the last number in the pagination range', () => { + const range = [1, 2, 3, 4, 5]; + const rangeMax = getRangeMax(range); + + expect(rangeMax).toBe(5); + }); + }); + + context('getVisibleResultsMin', () => { + it('should return the first page number for the visible results', () => { + const resultCount = 5; + const totalCount = 10; + const pageMin = getVisibleResultsMin(resultCount, totalCount); + + expect(pageMin).toBe(41); + }); + }); + + context('getVisibleResultsMax', () => { + context('given the total count is greater than the current page * the result count', () => { + it('should return the last page number for the visible results', () => { + const currentPage = 5; + const resultCount = 10; + const totalCount = 55; + const pageMax = getVisibleResultsMax(currentPage, resultCount, totalCount); + expect(pageMax).toBe(50); + }); + }); + + context('given the total count is less than the current page * the result count', () => { + it('should return the total count as the last page', () => { + const currentPage = 5; + const resultCount = 10; + const totalCount = 42; + const pageMax = getVisibleResultsMax(currentPage, resultCount, totalCount); + expect(pageMax).toBe(totalCount); + }); + }); + }); +}); diff --git a/modules/_labs/pagination/react/spec/safelySpreadProps.spec.ts b/modules/_labs/pagination/react/spec/safelySpreadProps.spec.ts new file mode 100644 index 0000000000..3dbb98d1e2 --- /dev/null +++ b/modules/_labs/pagination/react/spec/safelySpreadProps.spec.ts @@ -0,0 +1,38 @@ +import {safelySpreadProps} from '../lib/Pagination/common/utils/safelySpreadProps'; +const context = describe; + +describe('safelySpreadProps', () => { + const styleProps = { + display: 'inline-flex', + margin: 'xs', + maxHeight: 240, + width: 55, + }; + + const elementProps = { + type: 'text', + id: 'a380d50-e49d-4ebd-bb01-d9fcfeda7a6c', + class: 'css-byi76b', + size: '1', + value: '', + }; + context('given style and element props', () => { + it('should remove all style props', () => { + const safeProps = safelySpreadProps({ + ...styleProps, + ...elementProps, + }); + + expect(safeProps).not.toEqual(styleProps); + }); + + it('should retain all element props', () => { + const safeProps = safelySpreadProps({ + ...styleProps, + ...elementProps, + }); + + expect(safeProps).toEqual(elementProps); + }); + }); +}); diff --git a/modules/_labs/pagination/react/spec/useGoToForm.spec.ts b/modules/_labs/pagination/react/spec/useGoToForm.spec.ts new file mode 100644 index 0000000000..cd8f6378aa --- /dev/null +++ b/modules/_labs/pagination/react/spec/useGoToForm.spec.ts @@ -0,0 +1,27 @@ +import {useGoToForm, UseGoToFormConfig} from '../lib/Pagination/GoTo/useGoToForm'; +import {usePaginationModel} from '../lib/Pagination/usePaginationModel'; +import {renderHook} from '@testing-library/react-hooks'; +const context = describe; + +describe('useGoToForm', () => { + context('given the config state', () => { + it('should provide onSubmit in formProps', () => { + const {result} = renderHook>(() => { + const paginationModel = usePaginationModel({lastPage: 10}); + return useGoToForm({model: paginationModel}); + }); + + expect(result.current.formProps).toHaveProperty('onSubmit'); + }); + + it('should provide the correct input props', () => { + const {result} = renderHook>(() => { + const paginationModel = usePaginationModel({lastPage: 10}); + return useGoToForm({model: paginationModel}); + }); + + expect(result.current.inputProps).toHaveProperty('value'); + expect(result.current.inputProps).toHaveProperty('onChange'); + }); + }); +}); diff --git a/modules/_labs/pagination/react/spec/usePaginationModel.spec.ts b/modules/_labs/pagination/react/spec/usePaginationModel.spec.ts new file mode 100644 index 0000000000..d9e583d3a7 --- /dev/null +++ b/modules/_labs/pagination/react/spec/usePaginationModel.spec.ts @@ -0,0 +1,123 @@ +import {renderHook, act} from '@testing-library/react-hooks'; +import {usePaginationModel, UsePaginationModelConfig} from '../lib/Pagination/usePaginationModel'; +const context = describe; + +describe('usePaginationModel hook', () => { + context('given the model state', () => { + context('given a default state', () => { + const {result} = renderHook>( + () => + usePaginationModel({ + lastPage: 10, + }) + ); + + it('should set currentPage to 1', () => { + expect(result.current.state.currentPage).toBe(1); + }); + + it('should set rangeSize to 5', () => { + expect(result.current.state.rangeSize).toBe(5); + }); + }); + + context('given a configured state', () => { + const {result} = renderHook>( + () => + usePaginationModel({ + initialCurrentPage: 4, + lastPage: 10, + rangeSize: 3, + }) + ); + + it('should set currentPage correctly', () => { + expect(result.current.state.currentPage).toBe(4); + }); + }); + }); + + context('given the model events', () => { + const buildModel = ( + config = {} as Partial + ): ReturnType => { + const defaultConfig = { + initialCurrentPage: 5, + lastPage: 10, + }; + // eslint-disable-next-line react-hooks/rules-of-hooks + return usePaginationModel({ + ...defaultConfig, + ...config, + }); + }; + + it('should set currentPage to the first page on first', () => { + const {result} = renderHook(() => buildModel()); + act(() => { + result.current.events.first(); + }); + + expect(result.current.state.currentPage).toBe(1); + }); + + it('should set currentPage to the last page on last', () => { + const {result} = renderHook(() => buildModel()); + act(() => { + result.current.events.last(); + }); + expect(result.current.state.currentPage).toBe(10); + }); + + it('should set currentPage to the next page on next', () => { + const {result} = renderHook(() => buildModel()); + act(() => { + result.current.events.next(); + }); + expect(result.current.state.currentPage).toBe(6); + }); + + it('should set currentPage to the previous page on previous', () => { + const {result} = renderHook(() => buildModel()); + act(() => { + result.current.events.previous(); + }); + expect(result.current.state.currentPage).toBe(4); + }); + + it('should call onPageChange with the currentPage when on updates', () => { + const onPageChange = jest.fn(); + const {result} = renderHook(() => buildModel({onPageChange})); + act(() => { + result.current.events.previous(); + }); + expect(onPageChange).toHaveBeenCalledWith(4); + }); + + context('given the goTo event', () => { + it('should set currentPage to the given page if it is within the page count', () => { + const {result} = renderHook(() => buildModel()); + act(() => { + result.current.events.goTo(8); + }); + expect(result.current.state.currentPage).toBe(8); + }); + + it('should set currentPage to the first page if the given page is below the page count', () => { + const {result} = renderHook(() => buildModel()); + act(() => { + result.current.events.goTo(0); + }); + expect(result.current.state.currentPage).toBe(1); + }); + + it('should set currentPage to the last page if the given page is above the page count', () => { + const {result} = renderHook(() => buildModel()); + act(() => { + result.current.events.goTo(12); + }); + expect(result.current.state.currentPage).toBe(10); + }); + }); + }); +}); diff --git a/modules/_labs/pagination/react/stories/examples/CustomRange.tsx b/modules/_labs/pagination/react/stories/examples/CustomRange.tsx new file mode 100644 index 0000000000..0afc2b38b3 --- /dev/null +++ b/modules/_labs/pagination/react/stories/examples/CustomRange.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { + Pagination, + getLastPage, + getVisibleResultsMax, + getVisibleResultsMin, +} from '../../lib/Pagination'; + +export const CustomRange = () => { + const resultCount = 10; + const totalCount = 100; + const lastPage = getLastPage(resultCount, totalCount); + + return ( + console.log(pageNumber)} + rangeSize={3} + > + + + + {({state}) => + state.range.map(pageNumber => ( + + + + )) + } + + + + + {({state}) => + `${getVisibleResultsMin(state.currentPage, resultCount)}-${getVisibleResultsMax( + state.currentPage, + resultCount, + totalCount + )} of ${totalCount} results` + } + + + ); +}; diff --git a/modules/_labs/pagination/react/stories/examples/GoToForm.tsx b/modules/_labs/pagination/react/stories/examples/GoToForm.tsx new file mode 100644 index 0000000000..6a83312511 --- /dev/null +++ b/modules/_labs/pagination/react/stories/examples/GoToForm.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { + Pagination, + getLastPage, + getVisibleResultsMax, + getVisibleResultsMin, +} from '../../lib/Pagination'; + +export const GoToForm = () => { + const resultCount = 10; + const totalCount = 100; + const lastPage = getLastPage(resultCount, totalCount); + + return ( + console.log(pageNumber)} + aria-label="Pagination" + lastPage={lastPage} + > + + + + + {({state}) => + state.range.map(pageNumber => ( + + + + )) + } + + + + + + {({state}) => `of ${totalCount} results`} + + + + {({state}) => + `${getVisibleResultsMin(state.currentPage, resultCount)}-${getVisibleResultsMax( + state.currentPage, + resultCount, + totalCount + )} of ${totalCount} results` + } + + + ); +}; diff --git a/modules/_labs/pagination/react/stories/examples/HoistedModel.tsx b/modules/_labs/pagination/react/stories/examples/HoistedModel.tsx new file mode 100644 index 0000000000..86f94d7757 --- /dev/null +++ b/modules/_labs/pagination/react/stories/examples/HoistedModel.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { + Pagination, + getLastPage, + getVisibleResultsMax, + getVisibleResultsMin, + usePaginationModel, +} from '../../lib/Pagination'; + +export const HoistedModel = () => { + const resultCount = 10; + const totalCount = 100; + const lastPage = getLastPage(resultCount, totalCount); + + const model = usePaginationModel({ + lastPage, + onPageChange: number => console.log(number), + }); + return ( + + + + + {({state}) => + state.range.map(pageNumber => ( + + + + )) + } + + + + + {({state}) => + `${getVisibleResultsMin(state.currentPage, resultCount)}-${getVisibleResultsMax( + state.currentPage, + resultCount, + totalCount + )} of ${totalCount} results` + } + + + ); +}; diff --git a/modules/_labs/pagination/react/stories/examples/JumpControls.tsx b/modules/_labs/pagination/react/stories/examples/JumpControls.tsx new file mode 100644 index 0000000000..6be708e512 --- /dev/null +++ b/modules/_labs/pagination/react/stories/examples/JumpControls.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { + Pagination, + getLastPage, + getVisibleResultsMax, + getVisibleResultsMin, +} from '../../lib/Pagination'; + +export const JumpControls = () => { + const resultCount = 10; + const totalCount = 100; + const lastPage = getLastPage(resultCount, totalCount); + + return ( + console.log(pageNumber)} + aria-label="Pagination" + lastPage={lastPage} + > + + + + + {({state}) => + state.range.map(pageNumber => ( + + + + )) + } + + + + + + {({state}) => + `${getVisibleResultsMin(state.currentPage, resultCount)}-${getVisibleResultsMax( + state.currentPage, + resultCount, + totalCount + )} of ${totalCount} results` + } + + + ); +}; diff --git a/modules/_labs/pagination/react/stories/examples/RTL.tsx b/modules/_labs/pagination/react/stories/examples/RTL.tsx new file mode 100644 index 0000000000..11ebf0c78d --- /dev/null +++ b/modules/_labs/pagination/react/stories/examples/RTL.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { + Pagination, + getLastPage, + getVisibleResultsMax, + getVisibleResultsMin, + usePaginationModel, +} from '../../lib/Pagination'; + +import {CanvasProvider, ContentDirection} from '@workday/canvas-kit-react-common'; + +export const RTL = () => { + const resultCount = 10; + const totalCount = 100; + const lastPage = getLastPage(resultCount, totalCount); + + return ( + + + + + + + {({state}) => + state.range.map(pageNumber => ( + + + + )) + } + + + + + + {() => `Ω…Ω† 100 ءفحاΨͺ`} + + + + {({state}) => + `${getVisibleResultsMax( + state.currentPage, + resultCount, + totalCount + )}-${getVisibleResultsMin(state.currentPage, resultCount)} Ω…Ω† 100 ءفحاΨͺ` + } + + + + ); +}; diff --git a/modules/_labs/pagination/react/stories/examples/ShowAdditionalDetails.tsx b/modules/_labs/pagination/react/stories/examples/ShowAdditionalDetails.tsx new file mode 100644 index 0000000000..1e260af050 --- /dev/null +++ b/modules/_labs/pagination/react/stories/examples/ShowAdditionalDetails.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { + Pagination, + getLastPage, + getVisibleResultsMax, + getVisibleResultsMin, +} from '../../lib/Pagination'; + +export const ShowAdditionalDetails = () => { + const resultCount = 10; + const totalCount = 100; + const lastPage = getLastPage(resultCount, totalCount); + + return ( + console.log(pageNumber)} + aria-label="Pagination" + lastPage={lastPage} + rangeSize={3} + initialCurrentPage={3} + > + + + + + {({state}) => + state.range.map(pageNumber => ( + + + + )) + } + + + + + + {() => `of ${totalCount} results`} + + + + {({state}) => + `${getVisibleResultsMin(state.currentPage, resultCount)}-${getVisibleResultsMax( + state.currentPage, + resultCount, + totalCount + )} of ${totalCount} results` + } + + + ); +}; diff --git a/modules/_labs/pagination/react/stories/examples/StepControls.tsx b/modules/_labs/pagination/react/stories/examples/StepControls.tsx new file mode 100644 index 0000000000..e5523c9d12 --- /dev/null +++ b/modules/_labs/pagination/react/stories/examples/StepControls.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { + Pagination, + getLastPage, + getVisibleResultsMax, + getVisibleResultsMin, +} from '../../lib/Pagination'; + +export const StepControls = () => { + const resultCount = 10; + const totalCount = 100; + const lastPage = getLastPage(resultCount, totalCount); + + return ( + console.log(pageNumber)} + aria-label="Pagination" + lastPage={lastPage} + > + + + + {({state}) => + state.range.map(pageNumber => ( + + + + )) + } + + + + + {({state}) => + `${getVisibleResultsMin(state.currentPage, resultCount)}-${getVisibleResultsMax( + state.currentPage, + resultCount, + totalCount + )} of ${totalCount} results` + } + + + ); +}; diff --git a/modules/_labs/pagination/react/stories/pagination.stories.mdx b/modules/_labs/pagination/react/stories/pagination.stories.mdx new file mode 100644 index 0000000000..76a08551aa --- /dev/null +++ b/modules/_labs/pagination/react/stories/pagination.stories.mdx @@ -0,0 +1,804 @@ +import {Meta} from '@storybook/addon-docs/blocks'; + +import {Pagination} from '@workday/canvas-kit-labs-react-pagination'; +import {StepControls} from './examples/StepControls'; +import {CustomRange} from './examples/CustomRange'; +import {JumpControls} from './examples/JumpControls'; +import {GoToForm} from './examples/GoToForm'; +import {ShowAdditionalDetails} from './examples/ShowAdditionalDetails'; +import {HoistedModel} from './examples/HoistedModel'; +import {RTL} from './examples/RTL'; + + + +# Canvas Kit Labs React Pagination + +_This component is a work-in-progress and currently in pre-release._ + + + LABS: Beta + + +`Pagination` is a compound component for handling navigation between pages in a range. + +> πŸ“ If you're upgrading from < v4.5, please refer to the [migration guide](../MIGRATION_GUIDE.md) + +## Table of Contents + +- [Canvas Kit React Pagination](#canvas-kit-react-pagination) + - [Table of Contents](#table-of-contents) + - [Installation](#installation) + - [Usage](#usage) + - [Basic Usage](#basic-usage) + - [Pagination with Step Controls](#pagination-with-step-controls) + - [Pagination with Jump Controls](#pagination-with-jump-controls) + - [Pagination with a GoTo Form](#pagination-with-a-goto-form) + - [Advanced Usage](#advanced-usage) + - [Hoisted Model Pattern](#hoisted-model-pattern) + - [Components](#components) + - [Pagination](#pagination) + - [Usage](#usage-1) + - [Component Props](#component-props) + - [Pagination.Controls](#paginationcontrols) + - [Usage](#usage-2) + - [Component Props](#component-props-1) + - [Pagination.JumpToFirstButton](#paginationjumptofirstbutton) + - [Usage](#usage-3) + - [Component Props](#component-props-2) + - [Pagination.StepToPreviousButton](#paginationsteptopreviousbutton) + - [Usage](#usage-4) + - [Component Props](#component-props-3) + - [Pagination.StepToNextButton](#paginationsteptonextbutton) + - [Usage](#usage-5) + - [Component Props](#component-props-4) + - [Pagination.JumpToLastButton](#paginationjumptolastbutton) + - [Usage](#usage-6) + - [Component Props](#component-props-5) + - [Pagination.PageList](#paginationpagelist) + - [Usage](#usage-7) + - [Component Props](#component-props-6) + - [Pagination.PageListItem](#paginationpagelistitem) + - [Usage](#usage-8) + - [Component Props](#component-props-7) + - [Pagination.PageButton](#paginationpagebutton) + - [Usage](#usage-9) + - [Component Props](#component-props-8) + - [Pagination.GoToForm](#paginationgotoform) + - [Usage](#usage-10) + - [Component Props](#component-props-9) + - [Pagination.GoToTextInput](#paginationgototextinput) + - [Usage](#usage-11) + - [Component Props](#component-props-10) + - [Pagination.GoToLabel](#paginationgotolabel) + - [Usage](#usage-12) + - [Component Props](#component-props-11) + - [Pagination.AdditionalDetails](#paginationadditionaldetails) + - [Usage](#usage-13) + - [Component Props](#component-props-12) + - [Models, Hooks, & Utils](#models-hooks--utils) + - [PaginationModel](#paginationmodel) + - [State](#state) + - [Events](#events) + - [usePaginationModel Hook](#usepaginationmodel-hook) + - [Configuration](#configuration) + - [Usage](#usage-14) + - [getLastPage](#getlastpage) + - [getRangeMin](#getrangemin) + - [getRangeMax](#getrangemax) + - [getVisibleResultsMin](#getvisibleresultsmin) + - [getVisibleResultsMax](#getvisibleresultsmax) + - [PaginationContext](#paginationcontext) + +## Installation + +```sh +yarn add @workday/canvas-kit-labs-react-pagination +``` + +## Usage + +`Pagination` is a compound component, meaning it can be put together and used in a variety of +different ways. We'll first cover [basic usage](#basic-usage) examples followed by +[advanced usage](#advanced-usage). Each of the examples is intended to be standalone, so you can +skip to the section that best fits your use case. + +These examples are designed to provide copy-and-paste snippets to help you get up and running +quickly without much explanation. Further detail on individual component props, behavior, and +accessibility can be found in the [Components](#components) section. + +--- + +### Basic Usage + +Below are examples we expect will work for most use cases. Please refer to the +[Advanced Usage](#advanced-usage) section for details on supporting custom implementations. + +#### Step Controls + +In this example, the component uses step controls (`Pagination.StepToPreviousButton` and +`Pagination.StepToNextButton`) that allow you to move to the next page and previous pages. + + + +--- + +#### Jump Controls + +This example adds jump controls (`Pagination.JumpToFirstButton` and `Pagination.JumpToLastButton`) +that allow you to skip to the first and last items in the range. + + + +--- + +#### GoTo Form + +This example adds a form (`Pagination.GoToForm`) that allows you to skip to a specific page within +the range. + + + +--- + +#### Additional Details + +This example adds a visible section (`Pagination.AdditionalDetails`). It is an `aria-live` region +that announces the current page update to screen readers. Because of that, it's important to +**always†** include it in your `Pagination` component. + +In the case where you would also have multiple `Pagination` components sharing the same state and +you'd like to keep the `AdditionalDetails` component on multiple, you will need to set +`shouldAnnounceToScreenReader` to `false` on all but one component to prevent announcement. + +> **†** _The only exception to this rule is when you have multiple `Pagination` components that are +> sharing the same state and rendered on the page. You can then safely remove all but one of the +> `AdditionalDetails` sections. This will prevent a screenreader from announcing updates multiple +> times to a user._ + + + +--- + +#### RTL (Right-to-Left) + +This example shows how the component supports right-to-left languages. + + + +--- + +#### Custom Range + +This example uses a custom range that allows you to control the width of the component. + + + +--- + +### Advanced Usage + +Below are examples for more advanced / custom usage of these components. We expect most people won't +need to read this section, but if you're needing to go beyond our basic examples or just curious, +feel free to explore this section. While this section is not exhaustive, it provides additional +insight into what's possible. + +#### Hoisted Model Pattern + +If you're unfamiliar with compound components, the pagination model, or the `usePaginationModel` +hook, we would recommend reviewing the [PaginationModel](#paginationmodel) section first. + +If you want the `Pagination` component to handle its state and events internally and hook into page +change events, the basic usage examples above should be sufficient. However, if you need external +access to the model, you can use the hoisted model pattern. You can create a model outside of the +component with the `usePaginationModel` hook. + +For this example, we'll create a `Pagination` component that handles 128 total items with 10 items +per page. + + + +## Components + +### Pagination + +The `Pagination` component is a wrapper for a React context provider and `nav` element. Child +components such as `Pagination.StepToPreviousButton`, `Pagination.PageList`, etc. subscribe to that +context. + +#### Usage + +`Pagination` can be used with or without providing a model. For basic usage, you only need to +provide a configuration as in the example below. + +```tsx + console.log(pageNumber)} +> + {/* child components go here */} + +``` + +However, if you'd like to provide a model, you can follow the example below. This is called the +hoisted model pattern. Note that in this pattern, the only props needed are `model` and +`aria-label`. + +```tsx +const modelConfig = { + lastPage: 100, + initialCurrentPage: 6, + rangeSize: 3, + onPageChange: pageNumber => console.log(pageNumber), +}; +const model = usePaginationModel(modelConfig); + +return ( + + {/* child components go here */} + +); +``` + +#### Component Props + +Given that there are two ways to configure `Pagination`, there are also two props tables. For the +configuration pattern, follow this table: + +| name | type | required? | default | recommended | +| ------------------ | ------------------------------ | ---------- | ------- | ---------------------------------------- | +| aria-label | `string` | βœ… `true` | n/a | "Pagination" (and translated equivalent) | +| lastPage | `number` | βœ… `true` | n/a | n/a | +| initialCurrentPage | `number` | 🚫 `false` | 1 | n/a | +| rangeSize | `number` | 🚫 `false` | 5 | n/a | +| firstPage | `number` | 🚫 `false` | 1 | n/a | +| onPageChange | `(pageNumber: number) => void` | 🚫 `false` | n/a | n/a | + +And for the hoisted model pattern, follow this table: + +| name | type | required? | default | recommended | +| ---------- | ----------------- | --------- | ------- | ---------------------------------------- | +| aria-label | `string` | βœ… `true` | n/a | "Pagination" (and translated equivalent) | +| model | `PaginationModel` | βœ… `true` | n/a | n/a | + +This component also supports +[all native HTMLElement props](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes). +The `aria-label` prop is required for accessibility. We recommend using `"Pagination"` as seen in +the example. + +--- + +### Pagination.Controls + +`Pagination.Controls` is a styled `div` wrapper that provides proper alignment and spacing between +elements inside `Pagination`. It does not handle any internal business logic or state. + +#### Usage + +```tsx +{/* child components go here */} +``` + +#### Component Props + +This component supports +[all native HTMLDivElement props](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/div#Attributes). + +--- + +### Pagination.JumpToFirstButton + +`Pagination.JumpToFirstButton` is an `IconButton` that subscribes to `Pagination`'s context. This +allows it to know when to disable and set `currentPage` to the first page. + +#### Usage + +```tsx + +``` + +#### Component Props + +| name | type | required? | default | recommended | +| ---------- | -------- | --------- | ------- | ----------------------------------- | +| aria-label | `string` | βœ… `true` | n/a | "First" (and translated equivalent) | + +This component also supports all `IconButton` props. + +--- + +### Pagination.StepToPreviousButton + +`Pagination.StepToPreviousButton` is an `IconButton` that subscribes to `Pagination`'s context. This +allows it to know when to disable and decrement the `currentPage`. + +#### Usage + +```tsx + +``` + +#### Component Props + +| name | type | required? | default | recommended | +| ---------- | -------- | --------- | ------- | -------------------------------------- | +| aria-label | `string` | βœ… `true` | n/a | "Previous" (and translated equivalent) | + +This component also supports all `IconButton` props. + +--- + +### Pagination.StepToNextButton + +`Pagination.StepToNextButton` is an `IconButton` that subscribes to `Pagination`'s context. This +allows it to know when to disable and increment the `currentPage`. + +#### Usage + +```tsx + +``` + +#### Component Props + +| name | type | required? | default | recommended | +| ---------- | -------- | --------- | ------- | ---------------------------------- | +| aria-label | `string` | βœ… `true` | n/a | "Next" (and translated equivalent) | + +This component also supports all `IconButton` props. + +--- + +### Pagination.JumpToLastButton + +`Pagination.JumpToLastButton` is an `IconButton` that subscribes to `Pagination`'s context. This +allows it to know when to disable and set `currentPage` to the last page. + +#### Usage + +```tsx + +``` + +#### Component Props + +| name | type | required? | default | recommended | +| ---------- | -------- | --------- | ------- | ---------------------------------- | +| aria-label | `string` | βœ… `true` | n/a | "Last" (and translated equivalent) | + +This component also supports all `IconButton` props. + +--- + +### Pagination.PageList + +`Pagination.PageList` is an ordered list (`ol`) that subscribes to `Pagination`'s context. This +allows it generate the `range` of page numbers. It also handles spacing between the child elements. +This component will accept either child elements or functional children (sometimes called the render +prop pattern). It's likely you'll want to use the functional children, but in the off-chance you +need a static list of items, this component will support it. The usage section below will provide +examples of both. + +#### Usage + +The functional children snippet below will likely be the most common use case. + +```tsx + + {({state}) => + state.range.map(pageNumber => ( + + + + )) + } + +``` + +#### Component Props + +| name | type | required? | default | recommended | +| -------- | ---------------------------------------------------------------- | ---------- | ------- | ----------- | +| children | (model: PaginationModel) => React.ReactNode[] \| React.ReactNode | 🚫 `false` | n/a | n/a | + +This component also supports +[all native HTMLOLElement props](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/ol#Attributes). + +--- + +### Pagination.PageListItem + +`Pagination.PageListItem` is a styled `li` element. It provides a child semantic element for the +`PageList` component and is important for accessibility. It does not handle any internal business +logic or state. + +#### Usage + +```tsx +{/* child element goes here */} +``` + +#### Component Props + +This component also supports +[all native HTMLLIElement props](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/li#Attributes). + +--- + +### Pagination.PageButton + +`Pagination.PageButton` is an `IconButton` that subscribes to `Pagination`'s context. This allows it +to know set the `toggled` styling when it is the current item and update the `currentPage`. + +#### Usage + +`PageButton` will render the `pageNumber` as its children. + +```tsx + +``` + +#### Component Props + +| name | type | required? | default | recommended | +| ---------- | -------- | --------- | ------- | ------------------------------------------------ | +| aria-label | `string` | βœ… `true` | n/a | `Page ${pageNumber}` (and translated equivalent) | +| pageNumber | `number` | βœ… `true` | n/a | n/a | + +This component also supports all `IconButton` props. + +--- + +### Pagination.GoToForm + +`Pagination.GoToForm` is a wrapper for a React context provider and `form` element. Child components +such as `Pagination.GoToTextInput` and `Pagination.GoToLabel` subscribe to that context to manage +the form state and behavior as well as update the `currentPage` in the `Pagination` component. + +#### Usage + +```tsx +{/* child elements go here */} +``` + +#### Component Props + +This component supports +[all native HTMLFormElement props](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#Attributes). + +--- + +### Pagination.GoToTextInput + +#### Usage + +```tsx + +``` + +#### Component Props + +| name | type | required? | default | recommended | +| ---------- | -------- | --------- | ------- | ----------------------------------------------- | +| aria-label | `string` | βœ… `true` | n/a | `Go to page number` (and translated equivalent) | + +This component also supports all `TextInput` props. + +--- + +### Pagination.GoToLabel + +`Pagination.GoToLabel` is a styled `label` element that subscribes to the `Pagination` context. This +allows it to pass the `Pagination` context to child elements. + +#### Usage + +This component will accept either child elements or functional children (sometimes called the render +prop pattern). It's likely you'll want to use the functional children, but in the off-chance you +need static child elements, this component will support it. Examples of both patterns are provided +below. + +**Functional Children** + +Use this pattern when you need access to the state in the `Pagination` context for your text. + +```tsx +{({state}) => `of ${state.lastPage} results`} +``` + +#### Component Props + +| name | type | required? | default | recommended | +| -------- | -------------------------------------------------------------- | ---------- | ------- | ----------- | +| htmlFor | `string` | 🚫 `false` | n/a | n/a | +| children | (model: PaginationModel) => React.ReactNode \| React.ReactNode | 🚫 `false` | n/a | n/a | + +This component also supports +[all native HTMLLabelElement props](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/label#Attributes). + +--- + +### Pagination.AdditionalDetails + +`Pagination.AdditionalDetails` is a styled `div` container that subscribes to the `Pagination` +context. This allows it to pass the `Pagination` context to child elements. It is also an +`aria-live` region that announces the current page update to screen readers. Because of that, it's +important to always\* include it in your `Pagination` component. + +_\* The only exception to this rule is when you have multiple `Pagination` components that are +sharing the same state and rendered on the page. You can then safely remove all but one of the +`AdditionalDetails` sections. This will prevent a screenreader from announcing updates multiple +times to a user._ + +In the case where you would also have multiple `Pagination` components sharing the same state and +you'd like to keep the `AdditionalDetails` component on multiple, you will need to set +`shouldAnnounceToScreenReader` to `false` on all but one component to prevent announcement. + +#### Usage + +This component will accept either child elements or functional children (sometimes called the render +prop pattern). It's likely you'll want to use the functional children, but in the off-chance you +need static child elements, this component will support it. + +```tsx + + {({state}) => + `${getVisibleResultsMin(state.currentPage, resultCount)}-${getVisibleResultsMax( + state.currentPage, + resultCount, + totalCount + )} of ${totalCount} results` + } + +``` + +#### Component Props + +| name | type | required? | default | recommended | +| ---------------------------- | -------------------------------------------------------------- | ---------- | ------- | ----------- | +| shouldHideDetails | `boolean` | 🚫 `false` | false | n/a | +| shouldAnnounceToScreenReader | `boolean` | 🚫 `false` | true | n/a | +| children | (model: PaginationModel) => React.ReactNode \| React.ReactNode | βœ… `true` | n/a | n/a | + +## Models, Hooks, & Utils + +### PaginationModel + +> **Note:** The model and compound component pattern presented here will be updated and solidified +> in v5 of canvas-kit. While the general concepts are the same, there will be some breaking changes +> in v5. If you'd like to learn about models and compound components in v5, please refer to +> [this doc](https://github.com/Workday/canvas-kit/blob/master/COMPOUND_COMPONENTS.md). + +The `PaginationModel` is the core of the `Pagination` component. That said, if you're using the +higher-level context API components and following the basic usage guidelines, you shouldn't need to +know how the model operates to successfully implement the component. But if you have an advanced use +case or you're feeling curious, this section is for you. + +If `Pagination` was stripped of all its markup, attributes, and styling, what would remain is the +model. The `PaginationModel` is how we describe the state and behavior of the component. You could +completely swap out the underlying elements, attributes, and styles, and the model would remain the +same. `PaginationModel` is an object that is composed of two parts: `state` and `events`. The +model's `state` describes the current state of the component, and `events` are functions that act on +that state (also called behaviors). Below is an example `Pagination` model: + +```tsx +const paginationModel = { + state: { + firstPage, + currentPage, + lastPage, + range, + rangeSize, + }, + events: { + first, + last, + next, + previous, + setCurrentPage, + goTo, + }; +}; + +``` + +#### State + +The `state` describes the current state of the `Pagination` component. As events are fired, the +state is updated, and the component will re-render. Below are the `state` keys and descriptions of +their values: + +- `firstPage` - the page number for the first page +- `currentPage` - the page number for the current page +- `lastPage` - the page number for the last page (it can also be used as a total page count) +- `range` - an array of page numbers included in the pagination range +- `rangeSize` - the size of the pagination range + +#### Events + +The model's `events` describe behaviors that act on `state`. Below are the `events` keys and +descriptions of their values: + +- `first` - sets the current page to the first page +- `last` - sets the current page to the last page +- `next` - increments the current page by 1 +- `previous` - decrements the current page by 1 +- `goTo` - sets the current page to a given page number\* (with safeguards) +- `setCurrentPage` - sets the current page to a given page number (no safeguards) + +\* _`goTo` is very similar to `setCurrentPage`, but it has some built-in safeguards. If the page +number provided is below the first page (e.g: `0`), `currentPage` will be set to the `firstPage`. +Similarly, if the number provided is larger than the `lastPage`, it will set `currentPage` to the +`lastPage`._ + +--- + +### usePaginationModel Hook + +The `Pagination` model can be created with the `usePaginationModel` hook. Normally `Pagination` will +create it for you under the hood with the configuration props you provide the component, but you can +also hoist the model outside of the component depending on your use case. + +#### Configuration + +The hook configuration is identical to the `Pagination` component configuration. The only value you +need to provide is `lastPage`, but you can also provide others. The full list is below: + +| name | type | required | default | +| ------------------- | ------------------------------ | ---------- | ------- | +| lastPage | `number` | βœ… `true` | n/a | +| firstPage? | `number` | 🚫 `false` | 1 | +| initialCurrentPage? | `number` | 🚫 `false` | n/a | +| onPageChange? | `(pageNumber: number) => void` | 🚫 `false` | n/a | +| rangeSize? | `number` | 🚫 `false` | 5 | + +#### Usage + +Using this hook is fairly straightforward. Create a configuration, pass it to the hook, and it +returns a model. + +```ts +const paginationConfig = { + lastPage: 100, + rangeSize: 3, + onPageChange: pageNumber => console.log(pageNumber), +}; + +const model = usePaginationModel(paginationConfig); +``` + +VoilΓ ! Now you can pass the model to `Pagination` like so: + +```tsx +const paginationConfig = { + lastPage: 100, + rangeSize: 3, + onPageChange: pageNumber => console.log(pageNumber), +}; + +const model = usePaginationModel(paginationConfig); + +return ( + + {/* child components go here */} + +); +``` + +If you're hoisting the model, it's presumably because you need more access to the model's `state` +and `events` outside of the component. You could have an external button trigger events to update +the state, or have another component use the model's state. See the +[Advanced Usage](#advanced-usage) section for more detailed information. + +--- + +### getLastPage + +This function takes the number of results per page and the total count of results and returns the +last page number. Here's an example: + +Given there are 10 results per page, and there are 128 total results, the function will return 13. + +```ts +const resultCount = 10; +const totalCount = 128; +const lastPage = getLastPage(resultCount, totalCount); //=> 13 +``` + +--- + +### getRangeMin + +This function takes the pagination range and returns its minimum value. Here's an example: + +Given the pagination range is 1-5, the function will return 1. + +```ts +const range = [1, 2, 3, 4, 5]; +const rangeMin = getRangeMin(range); //=> 1 +``` + +--- + +### getRangeMax + +This function takes the pagination range and returns its maximum value. Here's an example: + +Given the pagination range is 1-5, the function will return 5. + +```ts +const range = [1, 2, 3, 4, 5]; +const rangeMin = getRangeMax(range); //=> 5 +``` + +--- + +### getVisibleResultsMin + +This function takes the current page, and number of results per page, and returns the minimum value +for that page. Here's an example: + +Given there are 10 results per page, and the current page is 5, the function will return 41. + +```ts +const currentPage = 5; +const resultCount = 10; +const pageMin = getVisibleResultsMin(currentPage, resultCount); //=> 41 +``` + +--- + +### getVisibleResultsMax + +This function takes the current page, number of results per page, and the total number of results, +and returns the maximum value for that page. Here's an example: + +Given there are 10 results per page, the current page is 5, and there are 42 results total, the +function will return 42. + +```ts +const currentPage = 5; +const resultCount = 10; +const totalCount = 42; +const pageMax = getVisibleResultsMax(currentPage, resultCount, totalCount); //=> 42 +``` + +--- + +### PaginationContext + +You can also subscribe directly to `PaginationContext`. This is useful if you want to create a new, +custom subcomponent for your implementation and want it to subscribe to `Pagination`'s context. +Here's a simple example: + +```tsx +import * as React from 'react'; +import {Pagination, PaginationContext} from '@workday/canvas-kit-labs-react-pagination'; + +const CustomButton = (props: React.HTMLAttributes) => { + const model = React.useContext(PaginationContext); + + const handleClick = (e: React.MouseEvent) => { + // if onClick is provided, pass the event along + props.onClick?.(e); + model.events.goTo(10); + }; + + return ( + + ); +}; + +export const CustomPagination = () => { + return ( + + + {/* other child components go here */} + + ); +}; +``` diff --git a/modules/_labs/pagination/react/stories/stories.tsx b/modules/_labs/pagination/react/stories/stories.tsx deleted file mode 100644 index c354706e23..0000000000 --- a/modules/_labs/pagination/react/stories/stories.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/// -import styled from '@emotion/styled'; -import {boolean, number, text} from '@storybook/addon-knobs'; -import {storiesOf} from '@storybook/react'; -import React, {useEffect, useState} from 'react'; -import withReadme from 'storybook-readme/with-readme'; - -import {Pagination} from '@workday/canvas-kit-labs-react-pagination'; -import README from '../README.md'; - -const useWindowWidth = () => { - // lock to these widths to avoid re-rendering component on every width change - const [width, setWidth] = useState(window.innerWidth > 500 ? 800 : 450); - - useEffect(() => { - const handleWindowSizeChange = () => { - setWidth(window.innerWidth > 500 ? 800 : 450); - }; - window.addEventListener('resize', handleWindowSizeChange); - return () => window.removeEventListener('resize', handleWindowSizeChange); - }, []); - - return width; -}; - -const Wrapper = styled('div')({ - display: 'block', - textAlign: 'center', -}); - -const getAriaLabels = () => ({ - paginationContainerAriaLabel: text('paginationContainerAriaLabel', 'Pagination'), - previousPageAriaLabel: text('previousPageAriaLabel', 'Previous Page'), - nextPageAriaLabel: text('nextPageAriaLabel', 'Next Page'), -}); - -storiesOf('Labs/Pagination/React', module) - .addParameters({ - component: Pagination, - }) - .addDecorator(withReadme(README)) - .add('Default', () => { - const [currentPage, setCurrentPage] = React.useState(1); - const width = useWindowWidth(); - - return ( - -

- Current Page: {currentPage} -

- setCurrentPage(p)} - width={width} - {...getAriaLabels()} - /> -
- ); - }) - .add('With Go To', () => { - const [currentPage, setCurrentPage] = React.useState(1); - const width = useWindowWidth(); - - return ( - -

Current Page: {currentPage}

- setCurrentPage(p)} - width={width} - {...getAriaLabels()} - /> -
- ); - }) - .add('With Custom Label', () => { - const [currentPage, setCurrentPage] = React.useState(1); - const width = useWindowWidth(); - - return ( - -

Current Page: {currentPage}

- setCurrentPage(p)} - showLabel={boolean('showLabel', true)} - showGoTo={boolean('showGoTo', false)} - goToLabel={text('goToLabel', 'Go To')} - customLabel={(from: number, to: number, items: number) => - `${from.toLocaleString()}\u2013${to.toLocaleString()} of ${items.toLocaleString()} ${ - items > 1 ? 'candidates' : 'candidate' - }` - } - width={width} - {...getAriaLabels()} - /> -
- ); - }); diff --git a/modules/_labs/pagination/react/stories/stories_visualTesting.tsx b/modules/_labs/pagination/react/stories/stories_visualTesting.tsx index 4f3dffeb8b..1652a23865 100644 --- a/modules/_labs/pagination/react/stories/stories_visualTesting.tsx +++ b/modules/_labs/pagination/react/stories/stories_visualTesting.tsx @@ -1,45 +1,146 @@ /// import React from 'react'; +import withReadme from 'storybook-readme/with-readme'; +import {CanvasProvider, ContentDirection} from '@workday/canvas-kit-react-common'; +import {StaticStates} from '@workday/canvas-kit-labs-react-core'; + import {ComponentStatesTable, withSnapshotsEnabled} from '../../../../../utils/storybook'; -import {Pagination} from '@workday/canvas-kit-labs-react-pagination'; + +import { + Pagination, + getLastPage, + getVisibleResultsMax, + getVisibleResultsMin, +} from '../lib/Pagination'; + +import README from '../README.md'; export default withSnapshotsEnabled({ title: 'Testing/React/Labs/Pagination', component: Pagination, + decorators: [withReadme(README)], }); -export const PaginationStates = () => { - const defaults = { - total: 1000, - pageSize: 10, - showLabel: true, - showGoTo: true, - currentPage: 1, - onPageChange: (_: any) => { - /* don't do anything */ - }, - }; +const TableRenderer = ({direction = ContentDirection.LTR}) => { + const resultCount = 10; + const totalCount = 100; + const lastPage = getLastPage(resultCount, totalCount); + + return ( + + + + {props => ( + + + {props.shouldShowJumpControls && ( + + )} + + + {({state}) => + state.range.map(pageNumber => ( + + + + )) + } + + + {props.shouldShowJumpControls && } + {props.shouldShowGoToForm && ( + + + + {() => + direction === ContentDirection.RTL + ? `Ω…Ω† 100 ءفحاΨͺ` + : `of ${totalCount} pages` + } + + + )} + + + {({state}) => + direction === ContentDirection.RTL + ? `${getVisibleResultsMax( + state.currentPage, + resultCount, + totalCount + )}-${getVisibleResultsMin(state.currentPage, resultCount)} Ω…Ω† 100 ءفحاΨͺ` + : `${getVisibleResultsMin( + state.currentPage, + resultCount + )}-${getVisibleResultsMax( + state.currentPage, + resultCount, + totalCount + )} of ${totalCount} pages` + } + + + )} + + + + ); +}; + +export const VisualStatesLeftToRight = () => { + return ( + <> +

Left-To-Right Pagination

+ + + ); +}; + +export const VisualStatesRightToLeft = () => { return ( - - {props => } - + <> +

Right-To-Left Pagination

+ + ); };