Repository: uber-web/baseui
Pull request #212 (2018-09-10 08:32:39)

feat(pagination): initial component implementation (part 1)
### Pagination Component (part 1)

Part 1 of series of Pagination PR, each built on top of the previous. This one includes:

* Styled Components
* Types
* Icons
* Utils


Patch:
diff --git a/package.json b/package.json
index f217f7212..df3170677 100644
--- a/package.json
+++ b/package.json
@@ -99,6 +99,7 @@
     "webpack-cli": "^3.1.0"
   },
   "dependencies": {
+    "memoize-one": "^4.0.2",
     "popper.js": "^1.14.3",
     "smoothscroll-polyfill": "^0.4.3"
   },
diff --git a/rfcs/pagination-component.md b/rfcs/pagination-component.md
index e4576a80e..0dd91df21 100644
--- a/rfcs/pagination-component.md
+++ b/rfcs/pagination-component.md
@@ -17,9 +17,10 @@ export default () => <StatefulPagination numPages={6} />;
 * `StatefulPagination`
 * `StatefulContainer`
 * `StyledRoot`
-* `StyledPrevButton`
-* `StyledNextButton`
 * `StyledMaxLabel`
+* `StyledDropdownContainer`
+* `StyledDropdownMenu`
+* `StyledDropdownButton`
 * `STATE_CHANGE_TYPE`
 
 ## Pagination API
@@ -30,9 +31,9 @@ export default () => <StatefulPagination numPages={6} />;
   The current page
 * `prepositionLabel: string` - Optional
   Defaults to "of"
-* `overrides: {PrevButton, NextButton, Select}` - Optional
+* `overrides: {PrevButton, NextButton, DropdownContainer, DropdownMenu, DropdownButton}` - Optional
   Presentational override props, all are `React.ComponentType<*> | {component, props, style}`
-* `onBackClick: (event) => void` - Optional
+* `onPrevClick: (event) => void` - Optional
   Callback for prev button click
 * `onNextClick: (event) => void` - Optional
   Callback for next button click
@@ -54,7 +55,7 @@ export default () => <StatefulPagination numPages={6} />;
 
 * `prepositionLabel: string` - Optional
   Defaults to "of"
-* `overrides: {PrevButton, NextButton, Select}` - Optional
+* `overrides: {PrevButton, NextButton, DropdownContainer, DropdownMenu, DropdownButton}` - Optional
   Presentational override props, all are `React.ComponentType<*> | {component, props, style}`
 * `onPrevClick: (event) => void` - Optional
   Callback for prev button click
diff --git a/src/menu/types.js b/src/menu/types.js
index 53c50634f..17dead152 100644
--- a/src/menu/types.js
+++ b/src/menu/types.js
@@ -34,7 +34,7 @@ export type SetRootRefFnT = (ref: React$ElementRef<*>) => void;
 export type RootRefT = React$ElementRef<*>;
 
 export type OnItemSelectFnT = (
-  item: ?ItemT,
+  item: ItemT,
   event: SyntheticEvent<> | KeyboardEvent,
 ) => mixed;
 
diff --git a/src/pagination/constants.js b/src/pagination/constants.js
new file mode 100644
index 000000000..73e25a059
--- /dev/null
+++ b/src/pagination/constants.js
@@ -0,0 +1,5 @@
+// @flow
+
+export const STATE_CHANGE_TYPE = {
+  changePage: 'changePage',
+};
diff --git a/src/pagination/examples.js b/src/pagination/examples.js
new file mode 100644
index 000000000..2e164e220
--- /dev/null
+++ b/src/pagination/examples.js
@@ -0,0 +1,47 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+import * as React from 'react';
+import StatefulPagination from './stateful-pagination';
+import Pagination from './pagination';
+import {KIND} from '../button';
+
+export const suite = 'Pagination Test Suite';
+export const examples = {
+  STATEFUL_PAGINATION: 'Stateful Pagination',
+  STATELESS_PAGINATION: 'Stateless Pagination',
+  PAGINATION_OVERRIDE: 'With Overrides',
+};
+
+export default {
+  [examples.STATEFUL_PAGINATION]: function Story1() {
+    return <StatefulPagination numPages={10} />;
+  },
+  [examples.STATELESS_PAGINATION]: function Story2() {
+    return <Pagination numPages={18} currentPage={4} />;
+  },
+  [examples.PAGINATION_OVERRIDE]: function Story2() {
+    return (
+      <StatefulPagination
+        numPages={10}
+        prepositionLabel="out of"
+        overrides={{
+          NextButton: {
+            props: {
+              kind: KIND.secondary,
+            },
+          },
+          PrevButton: {
+            props: {
+              kind: KIND.secondary,
+            },
+          },
+        }}
+      />
+    );
+  },
+};
diff --git a/src/pagination/icons.js b/src/pagination/icons.js
new file mode 100644
index 000000000..408a4340e
--- /dev/null
+++ b/src/pagination/icons.js
@@ -0,0 +1,57 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+import React from 'react';
+import {LightTheme} from '../themes';
+
+/**
+ * Ideally the content of this file should be replaced with official icons when they are available
+ */
+
+type IconPropsT = {
+  color: string,
+};
+
+export function ArrowLeft({
+  color = LightTheme.colors.primary,
+}: IconPropsT = {}) {
+  return (
+    <svg viewBox="0 0 6 10" fill="none" style={{width: '6px', height: '10px'}}>
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M0 5.00001C3.57629e-07 5.26523 0.105358 5.51958 0.292894 5.70712L4.29289 9.70711C4.68342 10.0976 5.31658 10.0976 5.70711 9.70711C6.09763 9.31658 6.09763 8.68342 5.70711 8.29289L2.41421 5.00001L5.70711 1.70711C6.09763 1.31658 6.09763 0.683416 5.70711 0.292892C5.31658 -0.0976315 4.68342 -0.0976306 4.29289 0.292894L0.292892 4.29291C0.105356 4.48044 -3.57627e-07 4.7348 0 5.00001Z"
+        fill={color}
+      />
+    </svg>
+  );
+}
+
+export function ArrowRight({
+  color = LightTheme.colors.primary,
+}: IconPropsT = {}) {
+  return (
+    <svg viewBox="0 0 6 10" fill="none" style={{width: '6px', height: '10px'}}>
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M0.292894 0.292892C-0.0976306 0.683416 -0.0976315 1.31658 0.292892 1.70711L3.58579 5.00001L0.292894 8.29289C-0.0976306 8.68342 -0.0976315 9.31658 0.292892 9.70711C0.683416 10.0976 1.31658 10.0976 1.70711 9.70711L5.70711 5.70712C5.89464 5.51958 6 5.26523 6 5.00001C6 4.7348 5.89464 4.48044 5.70711 4.29291L1.70711 0.292894C1.31658 -0.0976306 0.683419 -0.0976315 0.292894 0.292892Z"
+        fill={color}
+      />
+    </svg>
+  );
+}
+
+export function ArrowDown() {
+  return (
+    <img
+      src={
+        'data:image/svg+xml;utf8,<svg width="12" height="6" viewBox="0 0 12 6" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5.29289 5.29289L0.853553 0.853553C0.538571 0.538571 0.761654 0 1.20711 0H10.7929C11.2383 0 11.4614 0.538571 11.1464 0.853554L6.70711 5.29289C6.31658 5.68342 5.68342 5.68342 5.29289 5.29289Z" transform="translate(12) scale(-1 1)" fill="#666666"/></svg>'
+      }
+    />
+  );
+}
diff --git a/src/pagination/index.js b/src/pagination/index.js
new file mode 100644
index 000000000..2de7ef606
--- /dev/null
+++ b/src/pagination/index.js
@@ -0,0 +1,20 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+
+export {default as Pagination} from './pagination';
+export {default as StatefulPagination} from './stateful-pagination';
+export {default as StatefulContainer} from './stateful-container';
+export {
+  Root as StyledRoot,
+  MaxLabel as StyledMaxLabel,
+  DropdownContainer as StyledDropdownContainer,
+  DropdownMenu as StyledDropdownMenu,
+  DropdownButton as StyledDropdownButton,
+} from './styled-components';
+// Constants
+export {STATE_CHANGE_TYPE} from './constants';
diff --git a/src/pagination/pagination.js b/src/pagination/pagination.js
new file mode 100644
index 000000000..432504702
--- /dev/null
+++ b/src/pagination/pagination.js
@@ -0,0 +1,195 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+/* global document */
+import * as React from 'react';
+import memoize from 'memoize-one';
+// Files
+import {Button, StyledBaseButton, KIND, SIZE} from '../button';
+import {StatefulMenu as Menu} from '../menu';
+import {
+  Root as StyledRoot,
+  MaxLabel as StyledMaxLabel,
+  DropdownContainer as StyledDropdownContainer,
+  DropdownMenu as StyledDropdownMenu,
+  DropdownButton as StyledDropdownButton,
+} from './styled-components';
+import {ArrowLeft, ArrowRight, ArrowDown} from './icons';
+import {getOverrideObject} from '../helpers/overrides';
+import type {PaginationPropsT, PaginationStateT} from './types';
+
+type MenuItemT = {
+  label: number,
+};
+
+export default class Pagination extends React.PureComponent<
+  PaginationPropsT,
+  PaginationStateT,
+> {
+  static defaultProps = {
+    prepositionLabel: 'of',
+    overrides: {},
+  };
+
+  state = {isMenuOpen: false};
+
+  dropdownContainerRef = React.createRef();
+
+  onPageClick = (event: MouseEvent) => {
+    const el: ?HTMLDivElement = this.dropdownContainerRef.current;
+    /* eslint-disable-next-line flowtype/no-weak-types */
+    if (el && !el.contains((event.target: any))) {
+      this.setState({isMenuOpen: false});
+    }
+  };
+
+  getMenuOptions = memoize(numPages => {
+    const menuOptions = [];
+    for (let i = 1; i <= numPages; i++) {
+      menuOptions.push(({label: i}: MenuItemT));
+    }
+    return menuOptions;
+  });
+
+  onMenuItemSelect = (item: MenuItemT) => {
+    const {onPageChange, currentPage} = this.props;
+    const page = item.label;
+    if (page !== currentPage) {
+      onPageChange && onPageChange(page, currentPage);
+      this.onDropdownButtonClick();
+    }
+  };
+
+  onPrevClick = (...args: *) => {
+    const {currentPage, onPageChange, onPrevClick} = this.props;
+    onPageChange && onPageChange(currentPage - 1, currentPage);
+    onPrevClick && onPrevClick(args);
+  };
+
+  onNextClick = (...args: *) => {
+    const {currentPage, onPageChange, onNextClick} = this.props;
+    onPageChange && onPageChange(currentPage + 1, currentPage);
+    onNextClick && onNextClick(args);
+  };
+
+  onDropdownButtonClick = () => {
+    const isMenuOpen = !this.state.isMenuOpen;
+    // no __BROWSER__ check because click only happens client-side
+    if (isMenuOpen) {
+      document.addEventListener('click', this.onPageClick, {
+        capture: true,
+      });
+    } else {
+      document.removeEventListener('click', this.onPageClick, {
+        capture: true,
+      });
+    }
+    this.setState({isMenuOpen});
+  };
+
+  render() {
+    const {
+      overrides = {},
+      currentPage,
+      prepositionLabel = '',
+      numPages,
+    } = this.props;
+    const {isMenuOpen} = this.state;
+
+    const {component: Root, props: rootProps} = getOverrideObject(
+      overrides.Root,
+      StyledRoot,
+    );
+    const {component: PrevButton, props: prevButtonProps} = getOverrideObject(
+      overrides.PrevButton,
+      StyledBaseButton,
+    );
+    const {component: NextButton, props: nextButtonProps} = getOverrideObject(
+      overrides.NextButton,
+      StyledBaseButton,
+    );
+    const {component: MaxLabel, props: maxLabelProps} = getOverrideObject(
+      overrides.MaxLabel,
+      StyledMaxLabel,
+    );
+    const {
+      component: DropdownContainer,
+      props: dropdownContainerProps,
+    } = getOverrideObject(overrides.DropdownContainer, StyledDropdownContainer);
+    const {
+      component: DropdownButton,
+      props: dropdownButtonProps,
+    } = getOverrideObject(overrides.DropdownButton, StyledDropdownButton);
+    const {
+      component: DropdownMenu,
+      props: dropdownMenuProps,
+    } = getOverrideObject(overrides.DropdownMenu, Menu);
+
+    const options = this.getMenuOptions(numPages);
+
+    return (
+      <Root {...rootProps}>
+        <Button
+          onClick={this.onPrevClick}
+          startEnhancer={ArrowLeft}
+          kind={KIND.tertiary}
+          size={SIZE.condensed}
+          overrides={{
+            BaseButton: PrevButton,
+          }}
+          {...prevButtonProps}
+        >
+          Prev
+        </Button>
+        <DropdownContainer
+          $ref={this.dropdownContainerRef}
+          {...dropdownContainerProps}
+        >
+          <Button
+            onClick={this.onDropdownButtonClick}
+            endEnhancer={ArrowDown}
+            kind={KIND.tertiary}
+            size={SIZE.condensed}
+            overrides={{
+              BaseButton: DropdownButton,
+            }}
+            {...dropdownButtonProps}
+          >
+            {currentPage}
+          </Button>
+          {isMenuOpen && (
+            // $FlowFixMe
+            <DropdownMenu
+              items={options}
+              onItemSelect={this.onMenuItemSelect}
+              initialState={{
+                highlightedIndex: Math.max(currentPage - 1, 0),
+              }}
+              overrides={{List: StyledDropdownMenu}}
+              {...dropdownMenuProps}
+            />
+          )}
+        </DropdownContainer>
+        <MaxLabel
+          {...maxLabelProps}
+        >{`${prepositionLabel} ${numPages}`}</MaxLabel>
+        <Button
+          onClick={this.onNextClick}
+          endEnhancer={ArrowRight}
+          kind={KIND.tertiary}
+          size={SIZE.condensed}
+          overrides={{
+            BaseButton: NextButton,
+          }}
+          {...nextButtonProps}
+        >
+          Next
+        </Button>
+      </Root>
+    );
+  }
+}
diff --git a/src/pagination/stateful-container.js b/src/pagination/stateful-container.js
new file mode 100644
index 000000000..0b94c807b
--- /dev/null
+++ b/src/pagination/stateful-container.js
@@ -0,0 +1,61 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+import * as React from 'react';
+// Files
+import {STATE_CHANGE_TYPE} from './constants';
+import {clamp} from './utils';
+// Types
+import type {StatefulContainerPropsT, StatefulContainerStateT} from './types';
+
+export default class PaginationStatefulContainer extends React.Component<
+  StatefulContainerPropsT,
+  StatefulContainerStateT,
+> {
+  static defaultProps = {
+    initialState: {
+      currentPage: 1,
+    },
+    stateReducer: (changeType: *, changes: *) => changes,
+  };
+
+  state = {...this.props.initialState};
+
+  // Internal set state function that will also invoke stateReducer
+  internalSetState(
+    changeType: $Keys<typeof STATE_CHANGE_TYPE>,
+    changes: StatefulContainerStateT,
+  ) {
+    const {stateReducer} = this.props;
+    if (stateReducer) {
+      this.setState(stateReducer(changeType, changes, this.state));
+    } else {
+      this.setState(changes);
+    }
+  }
+
+  onPageChange = (newPage: number) => {
+    const {numPages, onPageChange} = this.props;
+    const {currentPage} = this.state;
+    const clamped = clamp(newPage, 1, numPages);
+    if (clamped !== currentPage) {
+      onPageChange && onPageChange(clamped, currentPage);
+      this.internalSetState(STATE_CHANGE_TYPE.changePage, {
+        currentPage: clamped,
+      });
+    }
+  };
+
+  render() {
+    const {currentPage} = this.state;
+    const {children} = this.props;
+    return children({
+      currentPage,
+      onPageChange: this.onPageChange,
+    });
+  }
+}
diff --git a/src/pagination/stateful-pagination.js b/src/pagination/stateful-pagination.js
new file mode 100644
index 000000000..d54301e25
--- /dev/null
+++ b/src/pagination/stateful-pagination.js
@@ -0,0 +1,33 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+import * as React from 'react';
+
+import Pagination from './pagination';
+import StatefulContainer from './stateful-container';
+import type {StatefulPaginationPropsT} from './types';
+
+export default function StatefulPagination({
+  numPages,
+  initialState,
+  stateReducer,
+  onPageChange,
+  ...props
+}: StatefulPaginationPropsT) {
+  return (
+    <StatefulContainer
+      numPages={numPages}
+      initialState={initialState}
+      stateReducer={stateReducer}
+      onPageChange={onPageChange}
+    >
+      {renderProps => (
+        <Pagination numPages={numPages} {...renderProps} {...props} />
+      )}
+    </StatefulContainer>
+  );
+}
diff --git a/src/pagination/stories.js b/src/pagination/stories.js
new file mode 100644
index 000000000..b88744ff9
--- /dev/null
+++ b/src/pagination/stories.js
@@ -0,0 +1,21 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+/* global module */
+import {storiesOf} from '@storybook/react';
+import {withReadme} from 'storybook-readme';
+import examples from './examples';
+
+// $FlowFixMe
+import PaginationReadme from '../../rfcs/pagination-component.md';
+
+Object.entries(examples).forEach(([description, example]) =>
+  storiesOf('Pagination', module)
+    .addDecorator(withReadme(PaginationReadme))
+    // $FlowFixMe
+    .add(description, example),
+);
diff --git a/src/pagination/styled-components.js b/src/pagination/styled-components.js
new file mode 100644
index 000000000..e82acf2f6
--- /dev/null
+++ b/src/pagination/styled-components.js
@@ -0,0 +1,42 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+import {styled} from '../styles';
+import {StyledList} from '../menu';
+import {StyledBaseButton} from '../button';
+
+export const Root = styled('div', {
+  display: 'flex',
+  alignItems: 'center',
+});
+
+export const MaxLabel = styled('span', ({$theme}) => ({
+  ...$theme.typography.font300,
+  marginLeft: $theme.sizing.scale300,
+  marginRight: $theme.sizing.scale600,
+}));
+
+export const DropdownContainer = styled('div', ({$theme}) => ({
+  position: 'relative',
+  marginLeft: $theme.sizing.scale600,
+  marginRight: $theme.sizing.scale300,
+}));
+
+export const DropdownMenu = styled(StyledList, ({$theme}) => ({
+  position: 'absolute',
+  overflow: 'auto',
+  maxHeight: '200px',
+  top: 'auto',
+  marginTop: $theme.sizing.scale300,
+  left: 0,
+  right: 0,
+}));
+
+export const DropdownButton = styled(StyledBaseButton, ({$theme}) => ({
+  color: $theme.colors.black,
+  minWidth: `calc(${$theme.sizing.scale1600} + ${$theme.sizing.scale400})`,
+}));
diff --git a/src/pagination/types.js b/src/pagination/types.js
new file mode 100644
index 000000000..350bc2789
--- /dev/null
+++ b/src/pagination/types.js
@@ -0,0 +1,62 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+import type {OverrideT} from '../helpers/overrides';
+import {STATE_CHANGE_TYPE} from './constants';
+
+export type StateReducerFnT = (
+  changeType: $Keys<typeof STATE_CHANGE_TYPE>,
+  changes: StatefulContainerStateT,
+  currentState: StatefulContainerStateT,
+) => StatefulContainerStateT;
+
+export type OverridesT = {
+  Root?: OverrideT<*>,
+  PrevButton?: OverrideT<*>,
+  NextButton?: OverrideT<*>,
+  MaxLabel?: OverrideT<*>,
+  DropdownContainer?: OverrideT<*>,
+  DropdownButton?: OverrideT<*>,
+  DropdownMenu?: OverrideT<*>,
+};
+
+export type PaginationPropsT = {
+  numPages: number,
+  currentPage: number,
+  prepositionLabel?: string,
+  overrides?: OverridesT,
+  onPrevClick?: (event: *) => *,
+  onNextClick?: (event: *) => *,
+  onPageChange?: (nextPage: number, prevPage: number) => *,
+};
+
+export type PaginationStateT = {
+  isMenuOpen: boolean,
+};
+
+export type StatefulPaginationPropsT = {
+  numPages: number,
+  stateReducer?: StateReducerFnT,
+  initialState?: StatefulContainerStateT,
+  onPageChange?: (nextPage: number, prevPage: number) => *,
+  overrides?: OverridesT,
+  prepositionLabel?: string,
+  onPrevClick?: (event: *) => *,
+  onNextClick?: (event: *) => *,
+};
+
+export type StatefulContainerPropsT = {
+  children: *,
+  numPages: number,
+  stateReducer?: StateReducerFnT,
+  initialState?: StatefulContainerStateT,
+  onPageChange?: (nextPage: number, prevPage: number) => *,
+};
+
+export type StatefulContainerStateT = {
+  currentPage: number,
+};
diff --git a/src/pagination/utils.js b/src/pagination/utils.js
new file mode 100644
index 000000000..34195f115
--- /dev/null
+++ b/src/pagination/utils.js
@@ -0,0 +1,12 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+
+// Clamp a number between a min and max
+export function clamp(num: number, minNum: number, maxNum: number) {
+  return Math.min(Math.max(num, minNum), maxNum);
+}
diff --git a/yarn.lock b/yarn.lock
index a997b6511..6c87a22da 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6377,6 +6377,10 @@ mem@^1.1.0:
   dependencies:
     mimic-fn "^1.0.0"
 
+memoize-one@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.0.2.tgz#3fb8db695aa14ab9c0f1644e1585a8806adc1aee"
+
 memory-fs@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.3.0.tgz#7bcc6b629e3a43e871d7e29aca6ae8a7f15cbb20"


Test patch:
diff --git a/src/pagination/__tests__/__snapshots__/styled-components.test.js.snap b/src/pagination/__tests__/__snapshots__/styled-components.test.js.snap
new file mode 100644
index 000000000..21a86182a
--- /dev/null
+++ b/src/pagination/__tests__/__snapshots__/styled-components.test.js.snap
@@ -0,0 +1,46 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Pagination Styled Components DropdownButton - basic renders: correct styles 1`] = `
+Object {
+  "color": "$theme.colors.black",
+  "minWidth": "calc($theme.sizing.scale1600 + $theme.sizing.scale400)",
+}
+`;
+
+exports[`Pagination Styled Components DropdownContainer - basic renders: correct styles 1`] = `
+Object {
+  "marginLeft": "$theme.sizing.scale600",
+  "marginRight": "$theme.sizing.scale300",
+  "position": "relative",
+}
+`;
+
+exports[`Pagination Styled Components DropdownMenu - basic renders: correct styles 1`] = `
+Object {
+  "left": 0,
+  "marginTop": "$theme.sizing.scale300",
+  "maxHeight": "200px",
+  "overflow": "auto",
+  "position": "absolute",
+  "right": 0,
+  "top": "auto",
+}
+`;
+
+exports[`Pagination Styled Components MaxLabel - basic renders: correct styles 1`] = `
+Object {
+  "fontFamily": "$theme.typography.font300.fontFamily",
+  "fontSize": "$theme.typography.font300.fontSize",
+  "fontWeight": "$theme.typography.font300.fontWeight",
+  "lineHeight": "$theme.typography.font300.lineHeight",
+  "marginLeft": "$theme.sizing.scale300",
+  "marginRight": "$theme.sizing.scale600",
+}
+`;
+
+exports[`Pagination Styled Components Root - basic renders: correct styles 1`] = `
+Object {
+  "alignItems": "center",
+  "display": "flex",
+}
+`;
diff --git a/src/pagination/__tests__/pagination.test.js b/src/pagination/__tests__/pagination.test.js
new file mode 100644
index 000000000..13f5d0b6a
--- /dev/null
+++ b/src/pagination/__tests__/pagination.test.js
@@ -0,0 +1,143 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+/* eslint-env browser */
+import React from 'react';
+import {mount} from 'enzyme';
+import * as StyledComponents from '../styled-components';
+import {Button} from '../../button';
+import Pagination from '../pagination';
+
+const originalAddEventListener = document.addEventListener;
+const originalRemoveEventListener = document.removeEventListener;
+
+function getSharedProps() {
+  return {
+    numPages: 3,
+    currentPage: 2,
+  };
+}
+
+describe('Pagination Stateless', () => {
+  beforeAll(() => {
+    // $FlowFixMe
+    document.addEventListener = jest.fn();
+    // $FlowFixMe
+    document.removeEventListener = jest.fn();
+  });
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  afterAll(() => {
+    // $FlowFixMe
+    document.addEventListener = originalAddEventListener;
+    // $FlowFixMe
+    document.removeEventListener = originalRemoveEventListener;
+  });
+  Object.keys(StyledComponents).forEach(componentName => {
+    test(`support component override - ${componentName}`, () => {
+      /* eslint-disable react/prop-types */
+      const MockComponent = ({
+        children,
+        $kind,
+        $size,
+        $shape,
+        $ref,
+        initialState,
+        onItemSelect,
+        ...restProps
+      }) => <div {...restProps}>{children}</div>;
+      const props = {
+        ...getSharedProps(),
+        overrides: {
+          [componentName]: {
+            component: MockComponent,
+            props: {
+              id: 'prop',
+            },
+          },
+        },
+      };
+      const component = mount(<Pagination {...props} />);
+      component.setState({isMenuOpen: true});
+      expect(component.find(StyledComponents[componentName])).not.toExist();
+      expect(component.find(MockComponent)).toExist();
+      expect(component.find(MockComponent).prop('id')).toEqual('prop');
+    });
+  });
+
+  test('dropdown button click', () => {
+    const component = mount(<Pagination {...getSharedProps()} />);
+    component.find(StyledComponents.DropdownButton).simulate('click');
+    expect(component.state('isMenuOpen')).toEqual(true);
+    expect(document.addEventListener.mock.calls[0][0]).toEqual('click');
+    expect(document.removeEventListener.mock.calls.length).toBe(0);
+
+    component.find(StyledComponents.DropdownButton).simulate('click');
+    expect(component.state('isMenuOpen')).toEqual(false);
+    expect(document.removeEventListener.mock.calls[0][0]).toEqual('click');
+  });
+
+  test('prev button click', () => {
+    const props = {
+      ...getSharedProps(),
+      onPageChange: jest.fn(),
+      onPrevClick: jest.fn(),
+    };
+    const component = mount(<Pagination {...props} />);
+    component
+      .find(Button)
+      .first()
+      .simulate('click');
+    expect(props.onPageChange.mock.calls[0]).toEqual([
+      props.currentPage - 1,
+      props.currentPage,
+    ]);
+    expect(props.onPrevClick.mock.calls.length).toBe(1);
+  });
+
+  test('next button click', () => {
+    const props = {
+      ...getSharedProps(),
+      onPageChange: jest.fn(),
+      onNextClick: jest.fn(),
+    };
+    const component = mount(<Pagination {...props} />);
+    component
+      .find(Button)
+      .last()
+      .simulate('click');
+    expect(props.onPageChange.mock.calls[0]).toEqual([
+      props.currentPage + 1,
+      props.currentPage,
+    ]);
+    expect(props.onNextClick.mock.calls.length).toBe(1);
+  });
+
+  test('getMenuOptions', () => {
+    const props = getSharedProps();
+    const component = mount(<Pagination {...props} />);
+    expect(component.instance().getMenuOptions(props.numPages)).toEqual([
+      {label: 1},
+      {label: 2},
+      {label: 3},
+    ]);
+  });
+
+  test('select item', () => {
+    const props = {
+      ...getSharedProps(),
+      onPageChange: jest.fn(),
+    };
+    const component = mount(<Pagination {...props} />);
+    component.instance().onDropdownButtonClick = jest.fn();
+    component.instance().onMenuItemSelect({label: 3});
+    expect(props.onPageChange.mock.calls[0]).toEqual([3, 2]);
+  });
+});
diff --git a/src/pagination/__tests__/stateful-container.test.js b/src/pagination/__tests__/stateful-container.test.js
new file mode 100644
index 000000000..40a9fec63
--- /dev/null
+++ b/src/pagination/__tests__/stateful-container.test.js
@@ -0,0 +1,87 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+/* eslint-env browser */
+import React from 'react';
+import {mount} from 'enzyme';
+import {STATE_CHANGE_TYPE} from '../constants';
+import StatefulContainer from '../stateful-container';
+
+const mockChildrenFn = jest.fn().mockImplementation(() => <div />);
+const mockOnPageChangeFn = jest.fn();
+const mockStateReducerFn = jest
+  .fn()
+  .mockImplementation((changeType, changes) => changes);
+
+function getSharedProps() {
+  return {
+    numPages: 7,
+    children: mockChildrenFn,
+    onPageChange: mockOnPageChangeFn,
+    stateReducer: mockStateReducerFn,
+  };
+}
+
+describe('Pagination StatefulContainer', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  test('renders and passes required props to children function', () => {
+    const component = mount(<StatefulContainer {...getSharedProps()} />);
+    expect(mockChildrenFn.mock.calls[0][0]).toEqual({
+      currentPage: 1,
+      onPageChange: component.instance().onPageChange,
+    });
+  });
+
+  test('with initialState', () => {
+    const props = {
+      ...getSharedProps(),
+      initialState: {
+        currentPage: 5,
+      },
+    };
+    const component = mount(<StatefulContainer {...props} />);
+    expect(component.state('currentPage')).toBe(5);
+  });
+
+  test('onPageChange called if and only if page has changed', () => {
+    const component = mount(<StatefulContainer {...getSharedProps()} />);
+    const instance = component.instance();
+    instance.onPageChange(5);
+    expect(component.state('currentPage')).toBe(5);
+    expect(mockOnPageChangeFn.mock.calls.length).toBe(1);
+    // Invoke again with same page
+    instance.onPageChange(5);
+    expect(mockOnPageChangeFn.mock.calls.length).toBe(1);
+  });
+
+  test('internalSetState calls stateReducer with the correct args', () => {
+    const component = mount(<StatefulContainer {...getSharedProps()} />);
+    component.instance().internalSetState(STATE_CHANGE_TYPE.changePage, {
+      currentPage: 2,
+    });
+    expect(mockStateReducerFn.mock.calls[0][0]).toEqual(
+      STATE_CHANGE_TYPE.changePage,
+    );
+    expect(mockStateReducerFn.mock.calls[0][1]).toEqual({currentPage: 2});
+  });
+
+  test('internalSetState should still call setState with no stateReducer', () => {
+    const props = {
+      ...getSharedProps(),
+      stateReducer: null,
+    };
+    // $FlowFixMe
+    const component = mount(<StatefulContainer {...props} />);
+    component.instance().internalSetState(STATE_CHANGE_TYPE.changePage, {
+      currentPage: 2,
+    });
+    expect(component.state('currentPage')).toBe(2);
+  });
+});
diff --git a/src/pagination/__tests__/stateful-pagination.test.js b/src/pagination/__tests__/stateful-pagination.test.js
new file mode 100644
index 000000000..975ae6a6a
--- /dev/null
+++ b/src/pagination/__tests__/stateful-pagination.test.js
@@ -0,0 +1,35 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+import React from 'react';
+import {mount} from 'enzyme';
+import StatefulPagination from '../stateful-pagination';
+import StatefulContainer from '../stateful-container';
+import Pagination from '../pagination';
+
+function getSharedProps() {
+  return {
+    numPages: 5,
+    initialState: {
+      currentPage: 2,
+    },
+    extraProp: 'extra',
+  };
+}
+
+describe('Pagination StatefulPagination', () => {
+  test('renders with props', () => {
+    const component = mount(<StatefulPagination {...getSharedProps()} />);
+    expect(component.find(StatefulContainer).length).toBe(1);
+    expect(component.find(StatefulContainer).prop('initialState')).toEqual({
+      currentPage: 2,
+    });
+    expect(component.find(Pagination).length).toBe(1);
+    expect(component.find(Pagination).prop('initialState')).not.toBeDefined();
+    expect(component.find(Pagination).prop('extraProp')).toEqual('extra');
+  });
+});
diff --git a/src/pagination/__tests__/styled-components.test.js b/src/pagination/__tests__/styled-components.test.js
new file mode 100644
index 000000000..f9cd1f9b2
--- /dev/null
+++ b/src/pagination/__tests__/styled-components.test.js
@@ -0,0 +1,38 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+import * as React from 'react';
+import {shallow} from 'enzyme';
+import * as StyledComponents from '../styled-components';
+
+function makeTest({
+  title,
+  component: Component,
+  props = {},
+  children = null,
+  snapshotName = 'correct styles',
+}: {
+  title: string,
+  component: React.ComponentType<*>,
+  props?: {},
+  children?: *,
+  snapshotName?: string,
+}) {
+  test(title, () => {
+    const shallowed = shallow(<Component {...props}>{children}</Component>);
+    expect(shallowed.instance().getStyles()).toMatchSnapshot(snapshotName);
+  });
+}
+
+describe('Pagination Styled Components', () => {
+  Object.keys(StyledComponents).forEach(componentName => {
+    makeTest({
+      title: `${componentName} - basic renders`,
+      component: StyledComponents[componentName],
+    });
+  });
+});
diff --git a/src/pagination/__tests__/utils.test.js b/src/pagination/__tests__/utils.test.js
new file mode 100644
index 000000000..d386e447e
--- /dev/null
+++ b/src/pagination/__tests__/utils.test.js
@@ -0,0 +1,17 @@
+/*
+Copyright (c) 2018 Uber Technologies, Inc.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+// @flow
+import {clamp} from '../utils';
+
+describe('Pagination Utils', () => {
+  test('clamp', () => {
+    expect(clamp(1, 0, 2)).toEqual(1);
+    expect(clamp(1, 2, 2)).toEqual(2);
+    expect(clamp(1, 3, 2)).toEqual(2);
+    expect(clamp(1, 3, 5)).toEqual(3);
+  });
+});