diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index af3855f0ea..24bdc1438d 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -5,9 +5,13 @@ name: build
on:
push:
- branches: [ master ]
+ branches:
+ - master
+ - development
pull_request:
- branches: [ master ]
+ branches:
+ - master
+ - development
jobs:
build:
diff --git a/examples/create-react-app-typescript/src/components/App.tsx b/examples/create-react-app-typescript/src/components/App.tsx
index c3db5c0774..27a02332b8 100644
--- a/examples/create-react-app-typescript/src/components/App.tsx
+++ b/examples/create-react-app-typescript/src/components/App.tsx
@@ -14,6 +14,7 @@ import HelpDrawerExample from './Examples/HelpDrawerExample';
import MaskedFieldExample from './Examples/MaskedFieldExample';
import ModalDialogExample from './Examples/ModalDialogExample';
import MonthPickerExample from './Examples/MonthPickerExample';
+import PaginationExample from './Examples/PaginationExample';
import SpinnerExample from './Examples/SpinnerExample';
import TableExample from './Examples/TableExample';
import TabsExample from './Examples/TabsExample';
@@ -110,6 +111,7 @@ function App() {
+
diff --git a/examples/create-react-app-typescript/src/components/Examples/PaginationExample.tsx b/examples/create-react-app-typescript/src/components/Examples/PaginationExample.tsx
new file mode 100644
index 0000000000..da868d70c4
--- /dev/null
+++ b/examples/create-react-app-typescript/src/components/Examples/PaginationExample.tsx
@@ -0,0 +1,13 @@
+import { Pagination } from '@cmsgov/design-system';
+import React from 'react';
+
+function PaginationExample(): React.ReactElement {
+ return (
+
+ );
+}
+
+export default PaginationExample;
diff --git a/examples/create-react-app/src/components/App.js b/examples/create-react-app/src/components/App.js
index 9ee2297e6b..9184580962 100644
--- a/examples/create-react-app/src/components/App.js
+++ b/examples/create-react-app/src/components/App.js
@@ -14,6 +14,7 @@ import HelpDrawerExample from './Examples/HelpDrawerExample';
import MaskedFieldExample from './Examples/MaskedFieldExample';
import ModalDialogExample from './Examples/ModalDialogExample';
import MonthPickerExample from './Examples/MonthPickerExample';
+import PaginationExample from './Examples/PaginationExample';
import SpinnerExample from './Examples/SpinnerExample';
import TableExample from './Examples/TableExample';
import TabsExample from './Examples/TabsExample';
@@ -110,6 +111,7 @@ function App() {
+
diff --git a/examples/create-react-app/src/components/Examples/PaginationExample.js b/examples/create-react-app/src/components/Examples/PaginationExample.js
new file mode 100644
index 0000000000..66fea20027
--- /dev/null
+++ b/examples/create-react-app/src/components/Examples/PaginationExample.js
@@ -0,0 +1,13 @@
+import { Pagination } from '@cmsgov/design-system';
+import React from 'react';
+
+function PaginationExample() {
+ return (
+
+ );
+}
+
+export default PaginationExample;
diff --git a/packages/design-system-docs/src/pages/components/Pagination/Pagination.example.jsx b/packages/design-system-docs/src/pages/components/Pagination/Pagination.example.jsx
new file mode 100644
index 0000000000..3ab3b20ea7
--- /dev/null
+++ b/packages/design-system-docs/src/pages/components/Pagination/Pagination.example.jsx
@@ -0,0 +1,86 @@
+import { Pagination } from '@design-system';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import State from '../State/State';
+
+ReactDOM.render(
+
+
Default pagination
+
+ {([page, setPage]) => {
+ const totalPages = 15;
+ const onPageChange = (evt, page) => {
+ evt.preventDefault();
+ setPage(page);
+ };
+ return (
+ <>
+
+ Current page: {page} / {totalPages}
+
+ `#/results/${page}`}
+ />
+ >
+ );
+ }}
+
+
+
Pagination with hidden navigation
+
+ {([page, setPage]) => {
+ const totalPages = 15;
+ const onPageChange = (evt, page) => {
+ evt.preventDefault();
+ setPage(page);
+ };
+ return (
+ <>
+
+ Current page: {page} / {totalPages}
+
+ `#${page}`}
+ isNavigationHidden
+ />
+ >
+ );
+ }}
+
+
+
Compact pagination
+
+ {([page, setPage]) => {
+ const totalPages = 15;
+ const onPageChange = (evt, page) => {
+ evt.preventDefault();
+ setPage(page);
+ };
+ return (
+ <>
+
+ Current page: {page} / {totalPages}
+
+ `#/results?page=${page}`}
+ compact
+ />
+ >
+ );
+ }}
+
+
,
+ document.getElementById('js-example')
+);
diff --git a/packages/design-system-docs/src/pages/components/Pagination/_Pagination.docs.scss b/packages/design-system-docs/src/pages/components/Pagination/_Pagination.docs.scss
new file mode 100644
index 0000000000..6dfa233f69
--- /dev/null
+++ b/packages/design-system-docs/src/pages/components/Pagination/_Pagination.docs.scss
@@ -0,0 +1,83 @@
+/*
+Pagination
+
+@uswds https://designsystem.digital.gov/components/pagination/
+
+Pagination is navigation for paginated content.
+
+## About the pagination component
+
+Paginated content is any content split into multiple pages determined by a specific amount of content per page, not split by any meaningful attribute, like feature or subject or step. Search results and article collections are often paginated. Readers use the pagination component to move from page to page in paginated content, or directly to the first or last page of the paginated set.
+
+Markup: pagination.example.html
+
+@status Draft
+
+Style guide: components.pagination
+*/
+
+/*
+``
+
+Pagination requires two properties: `totalPages`, which is the total number of pages, and `onPageChange`, which is a function used to handle state changes when the user clicks a page.
+
+Pagination also comes in two styles: default, which is compact on mobile viewports and expands to reveal individual pages on larger viewports, and `compact`, which maintains the compact layout regardless of viewport size.
+
+@react-example Pagination.example.jsx
+
+@react-props Pagination.tsx
+
+Style guide: components.pagination.react
+*/
+
+/*
+---
+
+### When to use
+
+- Typically used for paginated search results.
+- Can be used for multi-page collections of related items, including articles related to a category or tag, content archives, and history or activity. Splitting a large collection of related items into individual pages can improve browsability and scannability.
+
+### When to consider alternatives
+
+- If you need to indicate progress in a series of steps that must be completed in succession, like an onboarding or checkout flow, this isn't the component for you.
+- If the length of the entire collection is less than 3-4 screen lengths long, consider showing all the items at once instead of paginating.
+
+### Usage
+
+- Users want to know the length of a paginated section, so show the size of the paginated set.
+- Highlight the current page the user is on in relation to the entire collection of pages.
+- Don't split the navigation items over multiple lines as this can make individual pages more difficult to understand and select.
+- Don't include out-of-sequence items directly adjacent to one another. Wherever there are missing pages, an ellipses should be used.
+- Avoid adding complexity by focusing on the essentials and avoid adding more items to Pagination just to fill the space.
+- Use touch targets that are big enough to select with any finger and have enough separation to avoid mistakes.
+- Do not use buttons for URL-based pagination. Because the URL is updated, the browser history stack updates and a link is the correct tag to use.
+- Consider page load, performance, and the user's scrolling preferences when determining how many items are displayed on each page.
+
+### Style customization
+
+The following Sass variables can be overridden to theme alerts:
+
+- `$pagination-nav-link-color` - Used to set color of next/previous and page number links.
+- `$pagination-nav-link-color-hover` - Used to set color of next/previous and page number links on hover.
+- `$pagination-nav-link-color-active` - Used to set color of next/previous and page number links when active.
+- `$pagination-nav-link-color-focus` - Used to set color of next/previous and page number links on focus.
+
+- `$pagination-current-page-color` - Used to set the current page element text color.
+- `$pagination-overflow-color` - Used to set the overflow element text color.
+- `$pagination-page-count-color` - Used to set the page count element text color.
+
+### Accessibility
+
+- Use a wrapping `` element to identify Pagination as a navigation section.
+- If more than one `` element is present on a page, Pagination **must** have an ARIA label attribute defined on the `` element that describes its purpose.
+- Use an unordered list for the navigation items as this allows screen readers to voice the number of elements in the Pagination component.
+- Use `aria-current="page"` on the current page's element to properly voice the current page for screen readers.
+- Voice the word "page" before the page numbers.
+- If *Previous* navigation link has keyboard focus, pressing TAB key **must** move focus to the first pagination page link.
+- If pagination page has keyboard focus, pressing TAB key **must** move focus to the next pagination page link.
+- If last pagination page has keyboard focus, pressing TAB key **must** move focus to the *Next* pagination navigation link.
+- Once a pagination page has been clicked, focus should shift to the top-level piece of content on that page, usually an ``.
+
+Style guide: components.pagination.guidance
+*/
diff --git a/packages/design-system-docs/src/pages/components/Pagination/pagination.example.html b/packages/design-system-docs/src/pages/components/Pagination/pagination.example.html
new file mode 100644
index 0000000000..3eca33e41b
--- /dev/null
+++ b/packages/design-system-docs/src/pages/components/Pagination/pagination.example.html
@@ -0,0 +1,154 @@
+Default pagination
+
+
+
+Compact pagination
+
+
diff --git a/packages/design-system-docs/src/pages/components/State/State.jsx b/packages/design-system-docs/src/pages/components/State/State.jsx
new file mode 100644
index 0000000000..5d0a67a94a
--- /dev/null
+++ b/packages/design-system-docs/src/pages/components/State/State.jsx
@@ -0,0 +1,9 @@
+import React from 'react';
+
+const State = (props) => {
+ const result = React.useState(props.default);
+
+ return props.children(result);
+};
+
+export default State;
diff --git a/packages/design-system/src/components/Pagination/Ellipses.tsx b/packages/design-system/src/components/Pagination/Ellipses.tsx
new file mode 100644
index 0000000000..ec052f083e
--- /dev/null
+++ b/packages/design-system/src/components/Pagination/Ellipses.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+
+export default function Ellipses(): React.ReactElement {
+ return (
+
+ …
+
+ );
+}
diff --git a/packages/design-system/src/components/Pagination/Page.test.tsx b/packages/design-system/src/components/Pagination/Page.test.tsx
new file mode 100644
index 0000000000..169583e1bb
--- /dev/null
+++ b/packages/design-system/src/components/Pagination/Page.test.tsx
@@ -0,0 +1,19 @@
+import Page from './Page';
+import React from 'react';
+import { shallow } from 'enzyme';
+
+describe('Page', () => {
+ const onPageChange = jest.fn();
+
+ it('should render interactive el if not current', () => {
+ const wrapper = shallow(
+
+ );
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('should render static el if current', () => {
+ const wrapper = shallow( );
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/packages/design-system/src/components/Pagination/Page.tsx b/packages/design-system/src/components/Pagination/Page.tsx
new file mode 100644
index 0000000000..1df547cde7
--- /dev/null
+++ b/packages/design-system/src/components/Pagination/Page.tsx
@@ -0,0 +1,50 @@
+import Button from '../Button/Button';
+import React from 'react';
+
+export interface PageProps {
+ /**
+ * Defines the page number.
+ */
+ index: number;
+ /**
+ * Renders current page if true, other links if false.
+ */
+ isActive: boolean;
+ /**
+ * A callback function used to handle state changes.
+ */
+ onPageChange?: (evt: React.MouseEvent) => void;
+ /**
+ * Defines application-specific routing in url for links.
+ */
+ href: string;
+}
+
+export default function Page({
+ href,
+ index,
+ isActive,
+ onPageChange,
+}: PageProps): React.ReactElement {
+ return (
+
+ {isActive ? (
+
+ {index}
+
+ ) : (
+
+ {index}
+
+ )}
+
+ );
+}
diff --git a/packages/design-system/src/components/Pagination/Pagination.e2e.test.js b/packages/design-system/src/components/Pagination/Pagination.e2e.test.js
new file mode 100644
index 0000000000..99ae3f17dd
--- /dev/null
+++ b/packages/design-system/src/components/Pagination/Pagination.e2e.test.js
@@ -0,0 +1,32 @@
+/* global driver */
+import { ROOT_URL } from '@cmsgov/design-system-scripts/helpers/e2e/constants';
+
+import assertNoAxeViolations from '@cmsgov/design-system-scripts/helpers/e2e/assertNoAxeViolations';
+import { getElementById } from '@cmsgov/design-system-scripts/helpers/e2e';
+
+const rootURL = `${ROOT_URL}/example/components.pagination.react/`;
+
+describe('Pagination component', () => {
+ it('should render default component', async () => {
+ await driver.get(rootURL);
+
+ const el = await getElementById('test-default');
+ expect(el).toBeTruthy();
+ });
+ it('should render pagination with hidden navigation', async () => {
+ await driver.get(rootURL);
+
+ const el = await getElementById('test-hidden-nav');
+ expect(el).toBeTruthy();
+ });
+ it('should render compact component', async () => {
+ await driver.get(rootURL);
+
+ const el = await getElementById('test-compact');
+ expect(el).toBeTruthy();
+ });
+ it('should have no accessibility violations', async () => {
+ await driver.get(rootURL);
+ await assertNoAxeViolations(null, 'color-contrast');
+ });
+});
diff --git a/packages/design-system/src/components/Pagination/Pagination.test.tsx b/packages/design-system/src/components/Pagination/Pagination.test.tsx
new file mode 100644
index 0000000000..a6b2f33327
--- /dev/null
+++ b/packages/design-system/src/components/Pagination/Pagination.test.tsx
@@ -0,0 +1,264 @@
+import './matchMedia.mock';
+import { mount, shallow } from 'enzyme';
+import Pagination from './Pagination';
+import React from 'react';
+
+describe('Pagination', () => {
+ const pageButtonSelector = '.ds-c-button';
+ const onPageChange = jest.fn();
+
+ const render = (overrideProps = {}, shouldDeepRender = false) => {
+ const props = {
+ totalPages: 3,
+ onPageChange: onPageChange,
+ renderHref: () => '/test',
+ ...overrideProps,
+ };
+
+ return shouldDeepRender ? mount( ) : shallow( );
+ };
+
+ it('should render component', () => {
+ const wrapper = render({ currentPage: 2 });
+ expect(wrapper.is('nav')).toBe(true);
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ describe('accessibility attributes', () => {
+ it('should have navigation label', () => {
+ const wrapper = render({ totalPages: 8 });
+ expect(wrapper.prop('aria-label')).toEqual('Pagination');
+ });
+ it('should set a custom navigation label', () => {
+ const wrapper = render({ totalPages: 8, ariaLabel: 'Pagey page page' });
+ expect(wrapper.prop('aria-label')).toEqual('Pagey page page');
+ });
+ });
+
+ it('should add custom className if specified', () => {
+ const customClassName = 'custom-class';
+ const wrapper = render({ totalPages: 8, className: customClassName });
+ expect(wrapper.hasClass(customClassName)).toBeTruthy();
+ });
+
+ describe('interactivity', () => {
+ describe('onPageChange', () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('should call onPageChange when "previous" is pressed', () => {
+ const wrapper = render({ currentPage: 2 });
+ wrapper.childAt(0).simulate('click', {});
+
+ expect(onPageChange).toHaveBeenCalledTimes(1);
+ expect(onPageChange).toHaveBeenCalledWith(expect.anything(), 1);
+ });
+
+ it('should call onPageChange when "next" is pressed', () => {
+ const wrapper = render({ currentPage: 2 });
+ wrapper.childAt(2).simulate('click', {});
+
+ expect(onPageChange).toHaveBeenCalledTimes(1);
+ expect(onPageChange).toHaveBeenCalledWith(expect.anything(), 3);
+ });
+
+ it('should call onPageChange when a page is pressed', () => {
+ const wrapper = render({ currentPage: 2 }, true);
+ wrapper.find('ul').childAt(0).find(pageButtonSelector).simulate('click', {});
+
+ expect(onPageChange).toHaveBeenCalledTimes(1);
+ expect(onPageChange).toHaveBeenCalledWith(expect.anything(), 1);
+ });
+ });
+
+ describe('href', () => {
+ it('should have appropriate href for "previous"', () => {
+ const wrapper = render({ currentPage: 2, renderHref: (currentPage) => `#${currentPage}` });
+ const prevEl = wrapper.childAt(0);
+
+ expect(prevEl.prop('href')).toBe('#1');
+ });
+
+ it('should have appropriate href for next page in the page set', () => {
+ const wrapper = render({
+ currentPage: 4,
+ totalPages: 8,
+ renderHref: (currentPage) => `#${currentPage}`,
+ });
+ const prevEl = wrapper.childAt(2);
+
+ expect(prevEl.prop('href')).toBe('#5');
+ });
+
+ it('should have appropriate href for "next"', () => {
+ const wrapper = render({ currentPage: 2, renderHref: (currentPage) => `#${currentPage}` });
+ const nextEl = wrapper.childAt(2);
+
+ expect(nextEl.prop('href')).toBe('#3');
+ });
+ });
+ });
+
+ describe('navigation slot behavior', () => {
+ it('should show "previous" navigation slot if current page is not first page of set', () => {
+ const wrapper = render({ currentPage: 2 });
+ const firstChild = wrapper.childAt(0);
+ expect(firstChild.dive().type()).toEqual('a');
+ expect(firstChild.dive().text()).toEqual('Previous');
+ });
+
+ it('should hide "previous" navigation slot if current page is first page of set', () => {
+ const wrapper = render({ currentPage: 1 });
+ const prevButton = wrapper.childAt(0);
+ expect(prevButton.type()).toEqual('span');
+ });
+
+ it('should show "next" navigation slot if current page is not last page of set', () => {
+ const wrapper = render({ currentPage: 2 });
+ const lastChild = wrapper.children().last();
+ expect(lastChild.dive().type()).toEqual('a');
+ expect(lastChild.dive().text()).toEqual('Next');
+ });
+
+ it('should hide "next" navigation slot if current page is last page of set', () => {
+ const wrapper = render({ currentPage: 3 });
+ const lastChild = wrapper.children().last();
+ expect(lastChild.type()).toEqual('span');
+ });
+ });
+
+ describe('pagination slot behavior', () => {
+ it('should begin page count with 1', () => {
+ const wrapper = render({ currentPage: 1 });
+ const firstPage = wrapper.find('ul').childAt(0).dive().find(pageButtonSelector);
+
+ expect(firstPage).toBeDefined();
+ expect(firstPage.text()).toBe('1');
+ });
+
+ it('should end page count with page total', () => {
+ const lastPageNum = 3;
+ const wrapper = render({ currentPage: 1, totalPages: lastPageNum }, true);
+ const lastPage = wrapper.find('ul').childAt(2).find(pageButtonSelector);
+
+ expect(lastPage).toBeDefined();
+ expect(lastPage.text()).toBe(`${lastPageNum}`);
+ });
+
+ it('should highlight current page with correct styles', () => {
+ const wrapper = render({ currentPage: 3, totalPages: 5 });
+ const currentPageEl = wrapper.find('ul').childAt(2).dive().find(pageButtonSelector);
+
+ expect(currentPageEl.type()).toEqual('span');
+ expect(currentPageEl.prop('aria-current')).toEqual('true');
+ expect(currentPageEl.hasClass('ds-c-pagination__current-page')).toBeTruthy();
+ });
+
+ describe('less than 7 pages', () => {
+ it('should show all pages', () => {
+ const totalPageNum = 5;
+ const wrapper = render({ currentPage: 1, totalPages: totalPageNum });
+ const listEl = wrapper.find('ul');
+ const pageItems = listEl.children();
+
+ expect(pageItems.length).toEqual(totalPageNum);
+ expect(listEl).toMatchSnapshot();
+ });
+
+ it('should never show ellipses', () => {
+ const wrapper = render({ totalPages: 6 });
+
+ expect(wrapper.find('Ellipses').length).toBe(0);
+ });
+ });
+
+ describe('more than 7 pages', () => {
+ it('should not show beginning ellipses for pages 1 - 3', () => {
+ const wrapper1 = render({ currentPage: 1, totalPages: 35 }, true);
+ const wrapper2 = render({ currentPage: 2, totalPages: 35 }, true);
+ const wrapper3 = render({ currentPage: 3, totalPages: 35 }, true);
+
+ expect(wrapper1.find('Ellipses').length).toBe(1);
+ let listEl = wrapper1.find('ul');
+ let secondSlot = listEl.childAt(1).find(pageButtonSelector);
+ expect(listEl.children().length).toBe(7);
+ expect(secondSlot).toBeDefined();
+ expect(secondSlot.text()).toBe('2');
+
+ expect(wrapper2.find('Ellipses').length).toBe(1);
+ listEl = wrapper2.find('ul');
+ secondSlot = listEl.childAt(1).find(pageButtonSelector);
+ expect(listEl.children().length).toBe(7);
+ expect(secondSlot).toBeDefined();
+ expect(secondSlot.text()).toBe('2');
+
+ expect(wrapper3.find('Ellipses').length).toBe(1);
+ listEl = wrapper3.find('ul');
+ secondSlot = listEl.childAt(1).find(pageButtonSelector);
+ expect(listEl.children().length).toBe(7);
+ expect(secondSlot).toBeDefined();
+ expect(secondSlot.text()).toBe('2');
+ });
+
+ it('should not show end ellipses for last 3 pages', () => {
+ const wrapperLast = render({ currentPage: 35, totalPages: 35 }, true);
+ const wrapperSecondLast = render({ currentPage: 34, totalPages: 35 }, true);
+ const wrapperThirdLast = render({ currentPage: 33, totalPages: 35 }, true);
+
+ expect(wrapperLast.find('Ellipses').length).toBe(1);
+ let listEl = wrapperLast.find('ul');
+ let secondLastSlot = listEl.childAt(5).find(pageButtonSelector);
+ expect(listEl.children().length).toBe(7);
+ expect(secondLastSlot).toBeDefined();
+ expect(secondLastSlot.text()).toBe('34');
+
+ expect(wrapperSecondLast.find('Ellipses').length).toBe(1);
+ listEl = wrapperSecondLast.find('ul');
+ secondLastSlot = listEl.childAt(5).find(pageButtonSelector);
+ expect(listEl.children().length).toBe(7);
+ expect(secondLastSlot).toBeDefined();
+ expect(secondLastSlot.text()).toBe('34');
+
+ expect(wrapperThirdLast.find('Ellipses').length).toBe(1);
+ listEl = wrapperThirdLast.find('ul');
+ secondLastSlot = listEl.childAt(5).find(pageButtonSelector);
+ expect(listEl.children().length).toBe(7);
+ expect(secondLastSlot).toBeDefined();
+ expect(secondLastSlot.text()).toBe('34');
+ });
+
+ it('should show both ellipses for number in middle', () => {
+ const wrapperEndMiddle = render({ currentPage: 10, totalPages: 35 });
+ const wrapperBeginningMiddle = render({ currentPage: 30, totalPages: 35 });
+
+ let listEl = wrapperEndMiddle.find('ul');
+ expect(listEl.children().length).toBe(7);
+ expect(wrapperEndMiddle.find('Ellipses').length).toBe(2);
+
+ listEl = wrapperBeginningMiddle.find('ul');
+ expect(wrapperBeginningMiddle.find('Ellipses').length).toBe(2);
+ expect(listEl.children().length).toBe(7);
+ });
+ });
+ });
+
+ describe('with compact prop enabled', () => {
+ it('should render compact variant', () => {
+ const wrapper = render({ currentPage: 2, compact: true });
+ const compactClassName = 'ds-c-pagination__page-count';
+
+ expect(wrapper.find(compactClassName)).toBeTruthy();
+ expect(wrapper.contains('ul')).toBe(false);
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it('should render non-interactive text nodes in place of pagination slot links', () => {
+ const wrapper = render({ currentPage: 2, compact: true });
+ const pages = wrapper.childAt(1);
+
+ expect(pages.type()).toEqual('span');
+ expect(pages.contains('a')).toBe(false);
+ });
+ });
+});
diff --git a/packages/design-system/src/components/Pagination/Pagination.tsx b/packages/design-system/src/components/Pagination/Pagination.tsx
new file mode 100644
index 0000000000..cd079432a9
--- /dev/null
+++ b/packages/design-system/src/components/Pagination/Pagination.tsx
@@ -0,0 +1,354 @@
+import Button from '../Button/Button';
+import Ellipses from './Ellipses';
+import Page from './Page';
+import React from 'react';
+import classNames from 'classnames';
+
+export interface PaginationProps {
+ /**
+ * Defines `aria-label` on wrapping Pagination element. Since this exists on a `` element, the word "navigation" should be omitted from this label. Optional.
+ */
+ ariaLabel?: string;
+ /**
+ * Class to be applied to parent `` element of Pagination component. Optional.
+ */
+ className?: string;
+ /**
+ * Renders compact layout. Optional.
+ */
+ compact?: boolean;
+ /**
+ * Defines active page in Pagination. Optional.
+ */
+ currentPage?: number;
+ /**
+ * Determines if navigation is hidden when current page is the first or last of Pagination page set. Optional.
+ */
+ isNavigationHidden?: boolean;
+ /**
+ * A callback function used to handle state changes.
+ */
+ onPageChange: (evt: React.MouseEvent, page: number) => void;
+ /**
+ * Defines application-specific routing in url for links.
+ */
+ renderHref: (page: number) => string;
+ /**
+ * Sets custom label on start navigation. Added for language support. Optional.
+ */
+ startLabelText?: string;
+ /**
+ * Sets custom ARIA label on start navigation. Added for language support. Label structure should be the equivalent of: Previous Page. Optional.
+ */
+ startAriaLabel?: string;
+ /**
+ * Sets custom label on end navigation. Added for language support. Optional.
+ */
+ endLabelText?: string;
+ /**
+ * Sets custom ARIA label on end navigation. Added for language support. Label structure should be the equivalent of: Next Page. Optional.
+ */
+ endAriaLabel?: string;
+ /**
+ * Sets total number of pages in Pagination component.
+ */
+ totalPages: number;
+}
+
+// Determines number of pages visible to either side of active page.
+const overflow = 1;
+
+// Determines total number of visible pages without Ellipses.
+const maxVisiblePages = 7;
+
+function paginationBuilder(page: number, pages: number): number[] {
+ const paginationRange = [];
+
+ let start = page - overflow;
+ let end = page + overflow;
+
+ const availableSlots = maxVisiblePages - 2;
+
+ /**
+ * If the current page is < `maxVisiblePages`,
+ * add 1 - 5 pages.
+ */
+ if (page < availableSlots) {
+ start = 1;
+ end = availableSlots;
+ }
+
+ /**
+ * If the current page equals `pages` - 1,
+ * make sure `start` begins one page earlier.
+ */
+ if (page === pages - 2) {
+ start -= 1;
+ end += 1;
+ }
+
+ /**
+ * If `end` page is two from the end,
+ * make sure the last page shows instead of ellipsis.
+ */
+ if (end === pages - 2) {
+ end += 1;
+ }
+
+ /**
+ * If `end` > `pages`,
+ * add last pages to `paginationRange[]`.
+ */
+ if (end >= pages) {
+ start = pages - (availableSlots - 1);
+ end = pages;
+ }
+
+ /**
+ * If `pages` is 5 or fewer,
+ * all pages added to `paginationRange[]`
+ */
+ if (pages <= maxVisiblePages) {
+ start = 1;
+ end = pages;
+ }
+
+ for (let i = start; i <= end; i++) {
+ paginationRange.push(i);
+ }
+
+ return paginationRange;
+}
+
+function Pagination({
+ ariaLabel,
+ className,
+ compact,
+ currentPage,
+ renderHref,
+ onPageChange,
+ isNavigationHidden,
+ startLabelText,
+ startAriaLabel,
+ endLabelText,
+ endAriaLabel,
+ totalPages,
+ ...rest
+}: PaginationProps): React.ReactElement {
+ const classes = classNames('ds-c-pagination', className);
+
+ /**
+ * `useState` and `useEffect` determine if
+ * mobile layout of component is rendered.
+ */
+
+ const [isMobile, setIsMobile] = React.useState(false);
+ React.useEffect(() => {
+ if (window) {
+ // Mobile media query derived from: https://design.cms.gov/guidelines/responsive/
+ const media = window.matchMedia('(max-width: 543px)');
+
+ if (media.matches !== isMobile) {
+ setIsMobile(media.matches);
+ }
+
+ const listener = () => {
+ setIsMobile(media.matches);
+ };
+
+ media.addEventListener('change', listener);
+ return () => media.removeEventListener('change', listener);
+ } else {
+ setIsMobile(true);
+ }
+ }, [isMobile]);
+
+ const pageChange = React.useCallback(
+ (page) => (evt: React.MouseEvent) => onPageChange(evt, page),
+ [onPageChange]
+ );
+
+ const pages = [];
+
+ /**
+ * If `compact` or `isMobile` is true,
+ * don't run code to populate `pages[]`.
+ */
+ if (!compact || !isMobile) {
+ const pageRange = paginationBuilder(currentPage, totalPages);
+
+ if (pageRange[0] >= 2) {
+ /**
+ * If `pageRange` begins with a page of 2 or greater,
+ * begin Pagination with Page 1
+ */
+ pages.push(
+
+ );
+
+ /**
+ * If `pageRange` doesn't equal 2, second Pagination element is Ellipses,
+ * otherwise page count continues.
+ */
+ if (pageRange[0] !== 2) {
+ pages.push( );
+ }
+ }
+
+ /**
+ * Renders all Page components in range (3 pages) to Pagination component.
+ */
+
+ pageRange.map((page) => {
+ pages.push(
+
+ );
+ });
+
+ /**
+ * Defines if/when the Ellipses component appears
+ * at the end of the Pagination component -
+ * as long as there are fewer than 7 pages.
+ */
+ if (currentPage <= totalPages - 3 && totalPages > maxVisiblePages) {
+ if (currentPage < totalPages - 3) {
+ pages.push( );
+ }
+
+ pages.push(
+
+ );
+ }
+ }
+
+ const startIcon = (
+
+
+
+ );
+
+ const endIcon = (
+
+
+
+ );
+
+ return (
+
+ {currentPage === 1 ? (
+
+
+ {startIcon}
+
+ {startLabelText}
+
+ ) : (
+
+
+ {startIcon}
+
+ {startLabelText}
+
+ )}
+
+ {isMobile || compact ? (
+
+ Page {currentPage} of {totalPages}
+
+ ) : (
+
+ )}
+
+ {currentPage === totalPages ? (
+
+ {endLabelText}
+
+ {endIcon}
+
+
+ ) : (
+
+ {endLabelText}
+
+ {endIcon}
+
+
+ )}
+
+ );
+}
+
+Pagination.defaultProps = {
+ ariaLabel: 'Pagination',
+ compact: false,
+ currentPage: 1,
+ isNavigationHidden: false,
+ startLabelText: 'Previous',
+ startAriaLabel: 'Previous Page',
+ endLabelText: 'Next',
+ endAriaLabel: 'Next Page',
+};
+
+export default Pagination;
diff --git a/packages/design-system/src/components/Pagination/__snapshots__/Page.test.tsx.snap b/packages/design-system/src/components/Pagination/__snapshots__/Page.test.tsx.snap
new file mode 100644
index 0000000000..832e3635f2
--- /dev/null
+++ b/packages/design-system/src/components/Pagination/__snapshots__/Page.test.tsx.snap
@@ -0,0 +1,27 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Page should render interactive el if not current 1`] = `
+
+
+ 1
+
+
+`;
+
+exports[`Page should render static el if current 1`] = `
+
+
+ 1
+
+
+`;
diff --git a/packages/design-system/src/components/Pagination/__snapshots__/Pagination.test.tsx.snap b/packages/design-system/src/components/Pagination/__snapshots__/Pagination.test.tsx.snap
new file mode 100644
index 0000000000..c97872fbed
--- /dev/null
+++ b/packages/design-system/src/components/Pagination/__snapshots__/Pagination.test.tsx.snap
@@ -0,0 +1,212 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Pagination pagination slot behavior less than 7 pages should show all pages 1`] = `
+
+`;
+
+exports[`Pagination should render component 1`] = `
+
+
+
+
+
+
+
+ Previous
+
+
+
+ Next
+
+
+
+
+
+
+
+`;
+
+exports[`Pagination with compact prop enabled should render compact variant 1`] = `
+
+
+
+
+
+
+
+ Previous
+
+
+ Page
+
+ 2
+
+ of
+
+ 3
+
+
+
+ Next
+
+
+
+
+
+
+
+`;
diff --git a/packages/design-system/src/components/Pagination/index.ts b/packages/design-system/src/components/Pagination/index.ts
new file mode 100644
index 0000000000..afd8de2b91
--- /dev/null
+++ b/packages/design-system/src/components/Pagination/index.ts
@@ -0,0 +1 @@
+export { default as Pagination } from './Pagination';
diff --git a/packages/design-system/src/components/Pagination/matchMedia.mock.js b/packages/design-system/src/components/Pagination/matchMedia.mock.js
new file mode 100644
index 0000000000..af17a152ec
--- /dev/null
+++ b/packages/design-system/src/components/Pagination/matchMedia.mock.js
@@ -0,0 +1,11 @@
+// following Jest's recommendation: https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
+window.matchMedia = (query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(), // Deprecated
+ removeListener: jest.fn(), // Deprecated
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+});
diff --git a/packages/design-system/src/components/index.js b/packages/design-system/src/components/index.js
index 634a08a1f7..f48133d5de 100644
--- a/packages/design-system/src/components/index.js
+++ b/packages/design-system/src/components/index.js
@@ -17,6 +17,7 @@ export * from './FormLabel';
export * from './HelpDrawer';
export * from './InlineError';
export * from './MonthPicker';
+export * from './Pagination';
export * from './Review';
export * from './SkipNav';
export * from './Spinner';
diff --git a/packages/design-system/src/images/chevron-left-solid.svg b/packages/design-system/src/images/chevron-left-solid.svg
new file mode 100644
index 0000000000..41061c287f
--- /dev/null
+++ b/packages/design-system/src/images/chevron-left-solid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/design-system/src/images/chevron-right-solid.svg b/packages/design-system/src/images/chevron-right-solid.svg
new file mode 100644
index 0000000000..6f3ecc4dc0
--- /dev/null
+++ b/packages/design-system/src/images/chevron-right-solid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/design-system/src/styles/components/_Pagination.scss b/packages/design-system/src/styles/components/_Pagination.scss
new file mode 100644
index 0000000000..f1815fbd82
--- /dev/null
+++ b/packages/design-system/src/styles/components/_Pagination.scss
@@ -0,0 +1,85 @@
+$pagination-nav-link-color: $color-primary;
+$pagination-nav-link-color-hover: $color-primary-darker;
+$pagination-nav-link-color-active: $color-primary-darkest;
+$pagination-nav-link-color-focus: $color-primary-darker;
+$pagination-nav-disabled-color: $color-gray-lighter;
+
+$pagination-current-page-color: $color-base;
+$pagination-overflow-color: $color-gray;
+$pagination-page-count-color: $color-gray;
+
+.ds-c-pagination {
+ align-items: end;
+ display: flex;
+ justify-content: space-between;
+ ul {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ }
+ li {
+ display: inline-block;
+ list-style: none;
+ }
+ .ds-c-button {
+ color: $pagination-nav-link-color;
+ font-weight: normal;
+ min-width: 44px;
+ padding: 8px 0;
+ }
+ a:focus {
+ color: $pagination-nav-link-color-focus;
+ }
+ a:hover {
+ color: $pagination-nav-link-color-hover;
+ text-decoration: underline;
+ }
+ a:active {
+ color: $pagination-nav-link-color-active;
+ }
+ .ds-c-pagination__current-page {
+ color: $pagination-current-page-color;
+ font-size: $font-size-xl;
+ font-weight: bold;
+ text-decoration: none;
+ &:hover {
+ cursor: auto;
+ text-decoration: none;
+ }
+ }
+ .ds-c-pagination__overflow {
+ border: 2px solid transparent;
+ color: $pagination-overflow-color;
+ display: inline-block;
+ padding: 8px;
+ }
+ .ds-c-pagination__page-count {
+ border: 2px solid transparent;
+ color: $pagination-page-count-color;
+ display: inline-block;
+ padding: 8px;
+ }
+ .ds-c-pagination__nav {
+ flex: 0 0 auto;
+ text-decoration: none;
+ &:hover {
+ text-decoration-thickness: 3px;
+ text-underline-offset: 5px;
+ }
+ }
+ .ds-c-pagination__nav--disabled {
+ border: 1px solid transparent;
+ color: $pagination-nav-disabled-color;
+ padding: 8px 0;
+ }
+ .ds-c-pagination__nav--img-container {
+ display: inline-block;
+ height: 24px;
+ margin-left: 4px;
+ vertical-align: middle;
+ }
+ .ds-c-pagination__nav--image {
+ height: 20px;
+ width: 20px;
+ }
+}
diff --git a/packages/design-system/src/styles/components/index.scss b/packages/design-system/src/styles/components/index.scss
index b1a895f26a..4ff57dd59f 100644
--- a/packages/design-system/src/styles/components/index.scss
+++ b/packages/design-system/src/styles/components/index.scss
@@ -12,6 +12,7 @@
@import 'HelpDrawer.scss';
@import 'List.scss';
@import 'MonthPicker.scss';
+@import 'Pagination.scss';
@import 'Review.scss';
@import 'SkipNav.scss';
@import 'Spinner.scss';
diff --git a/packages/design-system/src/types/index.d.ts b/packages/design-system/src/types/index.d.ts
index 9baa1f60c4..8781e460df 100644
--- a/packages/design-system/src/types/index.d.ts
+++ b/packages/design-system/src/types/index.d.ts
@@ -28,6 +28,7 @@ export * from './FilterChip';
export * from './FormControl';
export * from './FormLabel';
export * from './InlineError';
+export * from './Pagination';
export * from './Spinner';
export * from './Table';
export * from './TextField';