From 34720f749501cecc6b211e507b2dfe91a54f59f4 Mon Sep 17 00:00:00 2001
From: Peter Kulko <93188219+PKulkoRaccoonGang@users.noreply.github.com>
Date: Fri, 8 Dec 2023 15:50:23 +0200
Subject: [PATCH 1/6] feat!: Chip component redesign (#2836)
---
src/Chip/Chip.test.jsx | 98 ++++++++++--
src/Chip/ChipIcon.tsx | 54 +++++++
src/Chip/README.md | 127 ++++++++++++++--
src/Chip/__snapshots__/Chip.test.jsx.snap | 174 +++++++++++-----------
src/Chip/_variables.scss | 47 +++---
src/Chip/constants.js | 5 +
src/Chip/index.scss | 141 ++++++++++++------
src/Chip/index.tsx | 121 +++++++++------
src/Chip/mixins.scss | 42 ++++++
src/ChipCarousel/_variables.scss | 4 +-
src/ChipCarousel/index.scss | 1 +
src/utils/propTypes/utils.js | 19 ++-
12 files changed, 601 insertions(+), 232 deletions(-)
create mode 100644 src/Chip/ChipIcon.tsx
create mode 100644 src/Chip/constants.js
create mode 100644 src/Chip/mixins.scss
diff --git a/src/Chip/Chip.test.jsx b/src/Chip/Chip.test.jsx
index fd933621fbf..f5e5367d181 100644
--- a/src/Chip/Chip.test.jsx
+++ b/src/Chip/Chip.test.jsx
@@ -4,6 +4,7 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Close } from '../../icons';
+import { STYLE_VARIANTS } from './constants';
import Chip from '.';
function TestChip(props) {
@@ -24,58 +25,123 @@ describe('', () => {
});
it('renders with props iconBefore', () => {
const tree = renderer.create((
-
+
)).toJSON();
expect(tree).toMatchSnapshot();
});
it('renders with props iconAfter', () => {
const tree = renderer.create((
-
+
)).toJSON();
expect(tree).toMatchSnapshot();
});
it('renders with props iconBefore and iconAfter', () => {
const tree = renderer.create((
- Chip
+
+ Chip
+
+ )).toJSON();
+ expect(tree).toMatchSnapshot();
+ });
+ it('renders div with "button" role when onClick is provided', () => {
+ const tree = renderer.create((
+ Chip
)).toJSON();
expect(tree).toMatchSnapshot();
});
});
describe('correct rendering', () => {
+ it('render a non-interactive element if onClick handlers are not provided', () => {
+ render();
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+ it('render an interactive element if onClick handler is provided', () => {
+ render();
+ expect(screen.queryByRole('button')).toBeInTheDocument();
+ });
it('renders with correct class when variant is added', () => {
- render();
- const chip = screen.getByTestId('chip');
+ render();
+ const chip = screen.getByRole('button');
expect(chip).toHaveClass('pgn__chip pgn__chip-dark');
});
it('renders with active class when disabled prop is added', () => {
- render();
- const chip = screen.getByTestId('chip');
+ render();
+ const chip = screen.getByRole('button');
expect(chip).toHaveClass('disabled');
});
it('renders with the client\'s className', () => {
const className = 'testClassName';
- render();
- const chip = screen.getByTestId('chip');
+ render();
+ const chip = screen.getByRole('button');
expect(chip).toHaveClass(className);
});
it('onIconAfterClick is triggered', async () => {
const func = jest.fn();
render(
- ,
+ ,
);
- const iconAfter = screen.getByTestId('icon-after');
+ const iconAfter = screen.getByLabelText('icon-after');
await userEvent.click(iconAfter);
- expect(func).toHaveBeenCalled();
+ expect(func).toHaveBeenCalledTimes(1);
});
it('onIconAfterKeyDown is triggered', async () => {
const func = jest.fn();
render(
- ,
+ ,
+ );
+ const iconAfter = screen.getByLabelText('icon-after');
+ await userEvent.click(iconAfter, '{enter}', { skipClick: true });
+ expect(func).toHaveBeenCalledTimes(1);
+ });
+ it('onIconBeforeClick is triggered', async () => {
+ const func = jest.fn();
+ render(
+ ,
+ );
+ const iconBefore = screen.getByLabelText('icon-before');
+ await userEvent.click(iconBefore);
+ expect(func).toHaveBeenCalledTimes(1);
+ });
+ it('onIconBeforeKeyDown is triggered', async () => {
+ const func = jest.fn();
+ render(
+ ,
);
- const iconAfter = screen.getByTestId('icon-after');
- await userEvent.type(iconAfter, '{enter}');
- expect(func).toHaveBeenCalled();
+ const iconBefore = screen.getByLabelText('icon-before');
+ await userEvent.click(iconBefore, '{enter}', { skipClick: true });
+ expect(func).toHaveBeenCalledTimes(1);
+ });
+ it('checks the absence of the `selected` class in the chip', async () => {
+ render();
+ const chip = screen.getByRole('button');
+ expect(chip).not.toHaveClass('selected');
+ });
+ it('checks the presence of the `selected` class in the chip', async () => {
+ render();
+ const chip = screen.getByRole('button');
+ expect(chip).toHaveClass('selected');
});
});
});
diff --git a/src/Chip/ChipIcon.tsx b/src/Chip/ChipIcon.tsx
new file mode 100644
index 00000000000..a32692c5ce4
--- /dev/null
+++ b/src/Chip/ChipIcon.tsx
@@ -0,0 +1,54 @@
+import React, { KeyboardEventHandler, MouseEventHandler } from 'react';
+import PropTypes from 'prop-types';
+import Icon from '../Icon';
+// @ts-ignore
+import IconButton from '../IconButton';
+// @ts-ignore
+import { STYLE_VARIANTS } from './constants';
+
+export interface ChipIconProps {
+ className: string,
+ src: React.ReactElement | Function,
+ onClick?: KeyboardEventHandler & MouseEventHandler,
+ alt?: string,
+ variant: string,
+ disabled?: boolean,
+}
+
+function ChipIcon({
+ className, src, onClick, alt, variant, disabled,
+}: ChipIconProps) {
+ if (onClick) {
+ return (
+
+ );
+ }
+
+ return ;
+}
+
+ChipIcon.propTypes = {
+ className: PropTypes.string.isRequired,
+ src: PropTypes.oneOfType([PropTypes.element, PropTypes.func]).isRequired,
+ onClick: PropTypes.func,
+ alt: PropTypes.string,
+ variant: PropTypes.string,
+ disabled: PropTypes.bool,
+};
+
+ChipIcon.defaultProps = {
+ onClick: undefined,
+ alt: undefined,
+ variant: STYLE_VARIANTS.LIGHT,
+ disabled: false,
+};
+
+export default ChipIcon;
diff --git a/src/Chip/README.md b/src/Chip/README.md
index 6497f9e7d3e..39155133272 100644
--- a/src/Chip/README.md
+++ b/src/Chip/README.md
@@ -16,34 +16,139 @@ notes: |
## Basic Usage
```jsx live
-
-
+
+
+
@@ -77,60 +82,49 @@ exports[`
snapshots renders with props iconBefore and iconAfter 1`] = `
`;
diff --git a/src/Chip/_variables.scss b/src/Chip/_variables.scss
index 90c2878e076..33a80ac4669 100644
--- a/src/Chip/_variables.scss
+++ b/src/Chip/_variables.scss
@@ -1,19 +1,28 @@
-$chip-padding-x: .5rem !default;
-$chip-padding-y: .125rem !default;
-$chip-padding-to-icon: 3px !default;
-$chip-icon-padding: .25rem !default;
-$chip-margin: .125rem !default;
-$chip-border-radius: .25rem !default;
-$chip-disable-opacity: .3 !default;
-$chip-icon-size: 1.25rem !default;
-
-$chip-theme-variants: (
- "light": (
- "background": $light-500,
- "color": $black,
- ),
- "dark": (
- "background": $dark-200,
- "color": $white,
- )
-) !default;
+$chip-padding-x: .5rem !default;
+$chip-padding-y: 1px !default;
+$chip-icon-margin: .25rem !default;
+$chip-margin: .125rem !default;
+$chip-border-radius: .375rem !default;
+$chip-disable-opacity: .3 !default;
+$chip-icon-size: 1.5rem !default;
+$chip-label-color: $primary-700 !default;
+$chip-border-color: $light-800 !default;
+$chip-outline-width: 3px !default;
+$chip-light-bg-color: $white !default;
+$chip-light-outline-color: $chip-label-color !default;
+$chip-light-selected-outline-distance: 3px !default;
+$chip-light-selected-focus-border-color: $dark-500 !default;
+$chip-light-hover-bg: $dark-500 !default;
+$chip-light-hover-border-color: $chip-light-hover-bg !default;
+$chip-light-hover-label-color: $chip-light-bg-color !default;
+$chip-light-hover-icon-color: $chip-light-hover-label-color !default;
+$chip-light-focus-outline-distance: .313rem !default;
+$chip-dark-bg: $primary-300 !default;
+$chip-dark-outline-color: $white !default;
+$chip-dark-selected-outline-distance: 3px !default;
+$chip-dark-selected-focus-border-color: $chip-dark-outline-color !default;
+$chip-dark-label-color: $chip-dark-outline-color !default;
+$chip-dark-hover-bg: $white !default;
+$chip-dark-hover-border-color: $chip-dark-hover-bg !default;
+$chip-dark-hover-label-color: $primary-500 !default;
+$chip-dark-focus-outline-distance: .313rem !default;
diff --git a/src/Chip/constants.js b/src/Chip/constants.js
new file mode 100644
index 00000000000..6259d0c8ddf
--- /dev/null
+++ b/src/Chip/constants.js
@@ -0,0 +1,5 @@
+// eslint-disable-next-line import/prefer-default-export
+export const STYLE_VARIANTS = {
+ DARK: 'dark',
+ LIGHT: 'light',
+};
diff --git a/src/Chip/index.scss b/src/Chip/index.scss
index d809b022fe9..abfa54040dd 100644
--- a/src/Chip/index.scss
+++ b/src/Chip/index.scss
@@ -1,98 +1,141 @@
@import "variables";
+@import "mixins";
.pgn__chip {
- background: $light-500;
border-radius: $chip-border-radius;
display: inline-flex;
+ justify-content: space-between;
+ align-items: center;
margin: $chip-margin;
- box-sizing: border-box;
+ border: 1px solid $chip-border-color;
+ padding: $chip-padding-y $chip-padding-x;
+ position: relative;
+ outline: none;
+ transition: all .3s;
.pgn__chip__label {
- font-size: $font-size-sm;
- padding: $chip-padding-y $chip-padding-x;
+ font-size: $font-size-xs;
+ line-height: 1.5rem;
+ font-weight: $font-weight-bold;
+ color: $chip-label-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- box-sizing: border-box;
- cursor: default;
- &.p-before {
- padding-left: $chip-padding-to-icon;
+ [dir="rtl"] & {
+ margin-left: $chip-icon-margin;
+ }
+ }
- [dir="rtl"] & {
- padding-left: $chip-padding-x;
- padding-right: $chip-padding-to-icon;
- }
+ .pgn__chip__icon-before {
+ margin-right: $chip-icon-margin;
+
+ [dir="rtl"] & {
+ margin-right: 0;
+ margin-left: .25rem;
}
+ }
- &.p-after {
- padding-right: $chip-padding-to-icon;
+ .pgn__chip__icon-after {
+ margin-left: $chip-icon-margin;
- [dir="rtl"] & {
- padding-right: $chip-padding-x;
- padding-left: $chip-padding-to-icon;
- }
+ [dir="rtl"] & {
+ margin-left: 0;
}
}
.pgn__chip__icon-before,
.pgn__chip__icon-after {
- align-items: center;
- display: flex;
- padding-left: $chip-icon-padding;
- padding-right: $chip-icon-padding;
- box-sizing: border-box;
- cursor: default;
-
- .pgn__icon {
+ &.btn-icon {
width: $chip-icon-size;
height: $chip-icon-size;
}
+ }
+
+ &.pgn__chip-light {
+ background-color: $chip-light-bg-color;
+
+ &.selected {
+ @include chip-outline(
+ $chip-light-outline-color,
+ calc($chip-light-selected-outline-distance * -1),
+ calc($chip-border-radius + $chip-outline-width),
+ $chip-light-selected-outline-distance
+ );
+
+ &:focus {
+ border: 1px solid $chip-light-selected-focus-border-color;
+ }
+ }
- &.active:hover,
- &.active:focus {
+ .pgn__chip__icon-before,
+ .pgn__chip__icon-after {
+ &.pgn__icon {
+ color: $chip-label-color;
+ }
+ }
+
+ &.interactive {
cursor: pointer;
- background: $black;
- * {
- color: $white;
- fill: $white;
+ @include chip-hover($dark-500, $white);
+
+ &:focus {
+ @include chip-outline(
+ $chip-light-selected-focus-border-color,
+ calc($chip-light-focus-outline-distance * -1),
+ calc($chip-border-radius + $chip-outline-width)
+ );
}
}
}
- .pgn__chip__icon-before {
- border-radius: $chip-border-radius 0 0 $chip-border-radius;
+ &.pgn__chip-dark {
+ background-color: $chip-dark-bg;
- [dir="rtl"] & {
- border-radius: 0 $chip-border-radius $chip-border-radius 0;
+ &.selected {
+ @include chip-outline($chip-dark-outline-color,
+ calc($chip-dark-selected-outline-distance * -1),
+ calc($chip-border-radius + $chip-outline-width),
+ $chip-dark-selected-outline-distance
+ );
+
+ &:focus {
+ border: 1px solid $chip-dark-selected-focus-border-color;
+ }
}
- }
- .pgn__chip__icon-after {
- border-radius: 0 $chip-border-radius $chip-border-radius 0;
+ .pgn__chip__label {
+ color: $chip-dark-label-color;
+ }
- [dir="rtl"] & {
- border-radius: $chip-border-radius 0 0 $chip-border-radius;
+ .pgn__chip__icon-before,
+ .pgn__chip__icon-after {
+ &.pgn__icon {
+ color: $chip-dark-outline-color;
+ }
}
- }
- @each $color, $styles in $chip-theme-variants {
- &.pgn__chip-#{$color} {
- background: map-get($styles, "background");
+ &.interactive {
+ cursor: pointer;
+
+ @include chip-hover($white, $primary-500);
- * {
- color: map-get($styles, "color");
- fill: map-get($styles, "color");
+ &:focus {
+ @include chip-outline(
+ $chip-dark-outline-color,
+ calc($chip-dark-focus-outline-distance * -1),
+ calc($chip-border-radius + $chip-outline-width)
+ );
}
}
}
&.disabled,
&:disabled {
- cursor: default;
opacity: $chip-disable-opacity;
pointer-events: none;
+ user-select: none;
&::before {
display: none;
diff --git a/src/Chip/index.tsx b/src/Chip/index.tsx
index 2966e0c7387..2a7d348816f 100644
--- a/src/Chip/index.tsx
+++ b/src/Chip/index.tsx
@@ -2,76 +2,97 @@ import React, { ForwardedRef, KeyboardEventHandler, MouseEventHandler } from 're
import PropTypes from 'prop-types';
import classNames from 'classnames';
// @ts-ignore
-import Icon from '../Icon';
+import { requiredWhen } from '../utils/propTypes';
+// @ts-ignore
+import { STYLE_VARIANTS } from './constants';
+// @ts-ignore
+import ChipIcon from './ChipIcon';
-const STYLE_VARIANTS = [
- 'light',
- 'dark',
-];
+export const CHIP_PGN_CLASS = 'pgn__chip';
export interface IChip {
children: React.ReactNode,
+ onClick?: KeyboardEventHandler & MouseEventHandler,
className?: string,
variant?: string,
iconBefore?: React.ReactElement | Function,
+ iconBeforeAlt?: string,
iconAfter?: React.ReactElement | Function,
+ iconAfterAlt?: string,
onIconBeforeClick?: KeyboardEventHandler & MouseEventHandler,
onIconAfterClick?: KeyboardEventHandler & MouseEventHandler,
disabled?: boolean,
+ isSelected?: boolean,
}
-export const CHIP_PGN_CLASS = 'pgn__chip';
-
const Chip = React.forwardRef(({
children,
className,
variant,
iconBefore,
+ iconBeforeAlt,
iconAfter,
+ iconAfterAlt,
onIconBeforeClick,
onIconAfterClick,
disabled,
+ isSelected,
+ onClick,
...props
-}: IChip, ref: ForwardedRef
) => (
-
- {iconBefore && (
-
-
-
- )}
+}: IChip, ref: ForwardedRef
) => {
+ const hasInteractiveIcons = !!(onIconBeforeClick || onIconAfterClick);
+ const isChipInteractive = !hasInteractiveIcons && !!onClick;
+
+ const interactionProps = isChipInteractive ? {
+ onClick,
+ onKeyPress: onClick,
+ tabIndex: 0,
+ role: 'button',
+ } : {};
+
+ return (
- {children}
-
- {iconAfter && (
+ {iconBefore && (
+
+ )}
-
+ {children}
- )}
-
-));
+ {iconAfter && (
+
+ )}
+
+ );
+});
Chip.propTypes = {
/** Specifies the content of the `Chip`. */
@@ -79,9 +100,11 @@ Chip.propTypes = {
/** Specifies an additional `className` to add to the base element. */
className: PropTypes.string,
/** The `Chip` style variant to use. */
- variant: PropTypes.oneOf(STYLE_VARIANTS),
+ variant: PropTypes.oneOf(['light', 'dark']),
/** Disables the `Chip`. */
disabled: PropTypes.bool,
+ /** Click handler for the whole Chip, has effect only when Chip does not have any interactive icons. */
+ onClick: PropTypes.func,
/**
* An icon component to render before the content.
* Example import of a Paragon icon component:
@@ -89,6 +112,8 @@ Chip.propTypes = {
* `import { Check } from '@edx/paragon/icons';`
*/
iconBefore: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+ /** Specifies icon alt text. */
+ iconBeforeAlt: requiredWhen(PropTypes.string, ['iconBefore', 'onIconBeforeClick']),
/** A click handler for the `Chip` icon before. */
onIconBeforeClick: PropTypes.func,
/**
@@ -98,18 +123,26 @@ Chip.propTypes = {
* `import { Check } from '@edx/paragon/icons';`
*/
iconAfter: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+ /** Specifies icon alt text. */
+ iconAfterAlt: requiredWhen(PropTypes.string, ['iconAfter', 'onIconAfterClick']),
/** A click handler for the `Chip` icon after. */
onIconAfterClick: PropTypes.func,
+ /** Indicates if `Chip` has been selected. */
+ isSelected: PropTypes.bool,
};
Chip.defaultProps = {
className: undefined,
- variant: 'light',
+ variant: STYLE_VARIANTS.LIGHT,
disabled: false,
+ onClick: undefined,
iconBefore: undefined,
iconAfter: undefined,
onIconBeforeClick: undefined,
onIconAfterClick: undefined,
+ isSelected: false,
+ iconAfterAlt: undefined,
+ iconBeforeAlt: undefined,
};
export default Chip;
diff --git a/src/Chip/mixins.scss b/src/Chip/mixins.scss
new file mode 100644
index 00000000000..a5f850aa8bc
--- /dev/null
+++ b/src/Chip/mixins.scss
@@ -0,0 +1,42 @@
+@mixin chip-outline($outline-color: $white, $distance-to-border: 0, $border-radius: 50%, $border-width: .125rem) {
+ &::before {
+ content: "";
+ position: absolute;
+ top: $distance-to-border;
+ right: $distance-to-border;
+ bottom: $distance-to-border;
+ left: $distance-to-border;
+ border: solid $border-width $outline-color;
+ border-radius: $border-radius;
+ }
+}
+
+@mixin chip-hover($base-color, $secondary-color) {
+ &:hover {
+ background-color: $base-color;
+ border-color: $base-color;
+
+ .pgn__chip__label {
+ color: $secondary-color;
+ }
+
+ .pgn__chip__icon-before,
+ .pgn__chip__icon-after {
+ &.pgn__icon,
+ &.btn-icon {
+ color: $secondary-color;
+ }
+
+ &.btn-icon:hover {
+ background-color: $secondary-color;
+ color: $base-color;
+ }
+
+ &.btn-icon:focus {
+ color: $secondary-color;
+ border: 2px solid $secondary-color;
+ background-color: $base-color;
+ }
+ }
+ }
+}
diff --git a/src/ChipCarousel/_variables.scss b/src/ChipCarousel/_variables.scss
index e033dc2fcdb..ef4ec9c7472 100644
--- a/src/ChipCarousel/_variables.scss
+++ b/src/ChipCarousel/_variables.scss
@@ -1 +1,3 @@
-$chip-carousel-controls-top-offset: -3px !default;
+$chip-carousel-controls-top-offset: .375rem !default;
+$chip-carousel-container-padding-x: .625rem !default;
+$chip-carousel-container-padding-y: .313rem !default;
diff --git a/src/ChipCarousel/index.scss b/src/ChipCarousel/index.scss
index 744acf9deaf..f36ae6303a8 100644
--- a/src/ChipCarousel/index.scss
+++ b/src/ChipCarousel/index.scss
@@ -11,6 +11,7 @@
&.pgn__chip-carousel-gap__#{$level} {
.pgn__overflow-scroll-overflow-container {
column-gap: $space;
+ padding: $chip-carousel-container-padding-x $chip-carousel-container-padding-y;
}
}
}
diff --git a/src/utils/propTypes/utils.js b/src/utils/propTypes/utils.js
index 33106532362..f6a9f262ad2 100644
--- a/src/utils/propTypes/utils.js
+++ b/src/utils/propTypes/utils.js
@@ -22,6 +22,16 @@ export const customPropTypeRequirement = (targetType, conditionFn, filterString)
}
);
+/**
+ * Checks if all specified properties are defined in the `props` object.
+ *
+ * @param {Object} props - The object in which the properties are checked.
+ * @param {string[]} otherPropNames - An array of strings representing the property names to be checked.
+ * @returns {boolean} `true` if all properties are defined and not equal to `undefined`, `false` otherwise.
+ */
+export const isEveryPropDefined = (props, otherPropNames) => otherPropNames
+ .every(propName => props[propName] !== undefined);
+
/**
* Returns a PropType entry with the given propType that is required if otherPropName
* is truthy.
@@ -34,8 +44,13 @@ export const customPropTypeRequirement = (targetType, conditionFn, filterString)
export const requiredWhen = (propType, otherPropName) => (
customPropTypeRequirement(
propType,
- (props) => props[otherPropName] === true,
- `${otherPropName} is truthy`,
+ (props) => {
+ if (Array.isArray(otherPropName)) {
+ return isEveryPropDefined(props, otherPropName);
+ }
+ return props[otherPropName] === true;
+ },
+ `${otherPropName} ${Array.isArray(otherPropName) ? 'are defined' : 'is truthy'}`,
)
);
From 9460520b86c0b379d7ee72f24ae40fac7ffba028 Mon Sep 17 00:00:00 2001
From: Peter Kulko <93188219+PKulkoRaccoonGang@users.noreply.github.com>
Date: Fri, 8 Dec 2023 15:54:03 +0200
Subject: [PATCH 2/6] refactor!: refactoring Pagination component (#2837)
---
src/Button/index.scss | 12 +
src/DataTable/TablePagination.jsx | 9 +-
src/DataTable/TablePaginationMinimal.jsx | 5 +
src/DataTable/tests/TablePagination.test.jsx | 12 +-
src/Pagination/DefaultPagination.jsx | 43 ++
src/Pagination/MinimalPagination.jsx | 11 +
src/Pagination/Pagination.test.jsx | 357 +++++++------
src/Pagination/PaginationContext.jsx | 191 +++++++
src/Pagination/README.md | 108 +++-
src/Pagination/ReducedPagination.jsx | 12 +
.../__snapshots__/Pagination.test.jsx.snap | 301 +++++++++++
src/Pagination/_variables.scss | 32 +-
src/Pagination/constants.js | 16 +-
src/Pagination/getPaginationRange.js | 4 +
src/Pagination/index.jsx | 468 ++----------------
src/Pagination/index.scss | 337 +++++--------
src/Pagination/subcomponents/Ellipsis.jsx | 13 +
.../subcomponents/NextPageButton.jsx | 64 +++
src/Pagination/subcomponents/PageButton.jsx | 33 ++
.../subcomponents/PageOfCountButton.jsx | 25 +
.../subcomponents/PaginationDropdown.jsx | 35 ++
.../subcomponents/PreviousPageButton.jsx | 64 +++
.../subcomponents/ScreenReaderText.jsx | 17 +
src/Pagination/subcomponents/index.js | 7 +
24 files changed, 1318 insertions(+), 858 deletions(-)
create mode 100644 src/Pagination/DefaultPagination.jsx
create mode 100644 src/Pagination/MinimalPagination.jsx
create mode 100644 src/Pagination/PaginationContext.jsx
create mode 100644 src/Pagination/ReducedPagination.jsx
create mode 100644 src/Pagination/__snapshots__/Pagination.test.jsx.snap
create mode 100644 src/Pagination/subcomponents/Ellipsis.jsx
create mode 100644 src/Pagination/subcomponents/NextPageButton.jsx
create mode 100644 src/Pagination/subcomponents/PageButton.jsx
create mode 100644 src/Pagination/subcomponents/PageOfCountButton.jsx
create mode 100644 src/Pagination/subcomponents/PaginationDropdown.jsx
create mode 100644 src/Pagination/subcomponents/PreviousPageButton.jsx
create mode 100644 src/Pagination/subcomponents/ScreenReaderText.jsx
create mode 100644 src/Pagination/subcomponents/index.js
diff --git a/src/Button/index.scss b/src/Button/index.scss
index 9580fcd5882..957e2dd1597 100644
--- a/src/Button/index.scss
+++ b/src/Button/index.scss
@@ -358,6 +358,12 @@ fieldset:disabled a.btn {
$btn-tertiary-color,
$btn-tertiary-color
);
+
+ &.disabled,
+ &:disabled {
+ color: $yiq-text-dark;
+ }
+
@include button-focus(theme-color("primary", "focus"));
}
@@ -380,6 +386,12 @@ fieldset:disabled a.btn {
$btn-inverse-tertiary-color,
$btn-inverse-tertiary-color
);
+
+ &.disabled,
+ &:disabled {
+ color: $yiq-text-light;
+ }
+
@include button-focus($white);
}
diff --git a/src/DataTable/TablePagination.jsx b/src/DataTable/TablePagination.jsx
index 42bb00acc5f..0497c4cf76d 100644
--- a/src/DataTable/TablePagination.jsx
+++ b/src/DataTable/TablePagination.jsx
@@ -14,10 +14,15 @@ function TablePagination() {
const pageIndex = state?.pageIndex;
return (
-
gotoPage(pageNum - 1)}
+ onPageSelect={(pageNum) => gotoPage(pageNum - 1)}
pageCount={pageCount}
+ icons={{
+ leftIcon: null,
+ rightIcon: null,
+ }}
/>
);
}
diff --git a/src/DataTable/TablePaginationMinimal.jsx b/src/DataTable/TablePaginationMinimal.jsx
index 615a74b3f4a..ce5a6f87a0d 100644
--- a/src/DataTable/TablePaginationMinimal.jsx
+++ b/src/DataTable/TablePaginationMinimal.jsx
@@ -1,6 +1,7 @@
import React, { useContext } from 'react';
import DataTableContext from './DataTableContext';
import Pagination from '../Pagination';
+import { ArrowBackIos, ArrowForwardIos } from '../../icons';
function TablePaginationMinimal() {
const {
@@ -21,6 +22,10 @@ function TablePaginationMinimal() {
pageCount={pageCount}
paginationLabel="table pagination"
onPageSelect={(pageNum) => gotoPage(pageNum - 1)}
+ icons={{
+ leftIcon: ArrowBackIos,
+ rightIcon: ArrowForwardIos,
+ }}
/>
);
}
diff --git a/src/DataTable/tests/TablePagination.test.jsx b/src/DataTable/tests/TablePagination.test.jsx
index da039952813..b2835878074 100644
--- a/src/DataTable/tests/TablePagination.test.jsx
+++ b/src/DataTable/tests/TablePagination.test.jsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { render, act } from '@testing-library/react';
+import { render, act, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TablePagination from '../TablePagination';
@@ -29,21 +29,21 @@ describe('', () => {
it(
'Shows dropdown button with the page count as label and performs actions when dropdown items are clicked',
async () => {
- const { getAllByTestId, getByRole } = render();
- const dropdownButton = getByRole('button', { name: /2 of 3/i });
+ render();
+ const dropdownButton = screen.getByRole('button', { name: /2 of 3/i });
expect(dropdownButton).toBeInTheDocument();
await act(async () => {
await userEvent.click(dropdownButton);
});
- const dropdownChoices = getAllByTestId('pagination-dropdown-item');
+ const dropdownChoices = screen.getAllByTestId('pagination-dropdown-item');
expect(dropdownChoices.length).toEqual(instance.pageCount);
await act(async () => {
- await userEvent.click(dropdownChoices[1], undefined, { skipPointerEventsCheck: true });
+ await userEvent.click(dropdownChoices[2], undefined, { skipPointerEventsCheck: true });
});
expect(instance.gotoPage).toHaveBeenCalledTimes(1);
- expect(instance.gotoPage).toHaveBeenCalledWith(1);
+ expect(instance.gotoPage).toHaveBeenCalledWith(2);
},
);
});
diff --git a/src/Pagination/DefaultPagination.jsx b/src/Pagination/DefaultPagination.jsx
new file mode 100644
index 00000000000..2ca7c1048b6
--- /dev/null
+++ b/src/Pagination/DefaultPagination.jsx
@@ -0,0 +1,43 @@
+import React, { useContext } from 'react';
+import { useMediaQuery } from 'react-responsive';
+import PaginationContext from './PaginationContext';
+import { ELLIPSIS } from './constants';
+import {
+ PreviousPageButton,
+ NextPageButton,
+ PageOfCountButton,
+ PageButton,
+ Ellipsis,
+} from './subcomponents';
+import breakpoints from '../utils/breakpoints';
+import newId from '../utils/newId';
+
+function PaginationPages() {
+ const { displayPages } = useContext(PaginationContext);
+ const isMobile = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth });
+
+ if (isMobile) {
+ return ;
+ }
+
+ return (
+ <>
+ {displayPages.map((pageIndex) => {
+ if (pageIndex === ELLIPSIS) {
+ return ;
+ }
+ return ;
+ })}
+ >
+ );
+}
+
+export default function DefaultPagination() {
+ return (
+
+ );
+}
diff --git a/src/Pagination/MinimalPagination.jsx b/src/Pagination/MinimalPagination.jsx
new file mode 100644
index 00000000000..4b89247509e
--- /dev/null
+++ b/src/Pagination/MinimalPagination.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import { PreviousPageButton, NextPageButton } from './subcomponents';
+
+export default function MinimalPagination() {
+ return (
+
+ );
+}
diff --git a/src/Pagination/Pagination.test.jsx b/src/Pagination/Pagination.test.jsx
index 98f13d30ab4..cfaf6019fc9 100644
--- a/src/Pagination/Pagination.test.jsx
+++ b/src/Pagination/Pagination.test.jsx
@@ -1,26 +1,40 @@
import React from 'react';
-import { render, act, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-
import { Context as ResponsiveContext } from 'react-responsive';
-
+import renderer from 'react-test-renderer';
+import {
+ render,
+ act,
+ screen,
+} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import '@testing-library/jest-dom';
import breakpoints from '../utils/breakpoints';
import Pagination from '.';
+import {
+ PAGINATION_VARIANTS,
+ ELLIPSIS,
+ PAGINATION_BUTTON_LABEL_CURRENT_PAGE,
+ PAGINATION_BUTTON_LABEL_NEXT,
+ PAGINATION_BUTTON_LABEL_PREV,
+ PAGINATION_BUTTON_LABEL_PAGE,
+} from './constants';
const baseProps = {
- state: { pageIndex: 1 },
+ currentPage: 1,
paginationLabel: 'pagination navigation',
pageCount: 5,
onPageSelect: () => {},
};
describe('', () => {
- it('renders', () => {
- const props = {
- ...baseProps,
- };
- const { container } = render();
- expect(container).toBeInTheDocument();
+ it('renders default variant', () => {
+ const tree = renderer.create().toJSON();
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders with inverse colors', () => {
+ const tree = renderer.create().toJSON();
+ expect(tree).toMatchSnapshot();
});
it('renders screen reader section', () => {
@@ -31,65 +45,94 @@ describe('', () => {
currentPage: 'Página actual',
pageOfCount: 'de',
};
+ const expectedSrText = `${buttonLabels.page} 1, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} ${baseProps.pageCount}`;
const props = {
...baseProps,
buttonLabels,
};
render();
- const srText = screen.getByText(`${buttonLabels.page} 1, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} ${baseProps.pageCount}`);
- expect(srText).toBeInTheDocument();
+ const srText = screen.getByText(expectedSrText);
+ expect(srText).toHaveClass('sr-only');
});
- describe('handles currentPage props properly', () => {
- it('overrides state currentPage when props currentPage changes', () => {
- const initialPage = 1;
- const newPage = 2;
- const props = {
- ...baseProps,
- currentPage: initialPage,
- };
- const { rerender } = render();
- expect(screen.getByText('Page 1, Current Page, of 5')).toBeInTheDocument();
- rerender();
- expect(screen.getByText('Page 2, Current Page, of 5')).toBeInTheDocument();
+ it('correctly handles initial page prop', () => {
+ render();
+ expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('3');
+ });
+
+ it('renders ellipsis if there are too many pages', () => {
+ render();
+ expect(screen.getByText(ELLIPSIS)).toBeInTheDocument();
+ });
+
+ describe('handles controlled and uncontrolled behaviour properly', () => {
+ it('does not internally change page on page click if currentPage is provided', () => {
+ render();
+ expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('1');
+
+ userEvent.click(screen.getByText(PAGINATION_BUTTON_LABEL_NEXT));
+ expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('1');
+
+ userEvent.click(screen.getByRole('button', { name: `${PAGINATION_BUTTON_LABEL_PAGE} 3` }));
+ expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('1');
});
- it('does not override state currentPage when props currentPage changes with existing value', () => {
- const currentPage = 2;
- const props = {
- ...baseProps,
- currentPage,
- };
- const { rerender } = render();
- expect(screen.getByText(`Page ${currentPage}, Current Page, of 5`)).toBeInTheDocument();
- rerender();
- expect(screen.getByText(`Page ${currentPage}, Current Page, of 5`)).toBeInTheDocument();
+ it('controls page selection internally if currentPage is not provided', () => {
+ render();
+ expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('1');
+
+ userEvent.click(screen.getByText(PAGINATION_BUTTON_LABEL_NEXT));
+ expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('2');
+
+ userEvent.click(screen.getByRole('button', { name: `${PAGINATION_BUTTON_LABEL_PAGE} 3` }));
+ expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('3');
+
+ userEvent.click(screen.getByText(PAGINATION_BUTTON_LABEL_PREV));
+ expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('2');
+ });
+
+ it('does not chang page if you click "next" button while on last page', () => {
+ render();
+ expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('5');
+ userEvent.click(screen.getByText(PAGINATION_BUTTON_LABEL_NEXT));
+ expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('5');
+ });
+
+ it('does not chang page if you click "previous" button while on first page', () => {
+ render();
+ expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('1');
+ userEvent.click(screen.getByText(PAGINATION_BUTTON_LABEL_PREV));
+ expect(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })).toHaveTextContent('1');
});
});
describe('handles focus properly', () => {
- it('should change focus to next button if previous page is first page', async () => {
+ it('should change focus to next button if previous page is first page', () => {
const props = {
...baseProps,
currentPage: 2,
+ buttonLabel: {
+ previous: 'Previous',
+ next: 'Next',
+ },
};
render();
- const previousButton = screen.getByLabelText(/Previous/);
- const nextButton = screen.getByLabelText(/Next/);
- await userEvent.click(previousButton);
- expect(document.activeElement).toEqual(nextButton);
+ userEvent.click(screen.getByText(PAGINATION_BUTTON_LABEL_PREV));
+ expect(screen.getByText(PAGINATION_BUTTON_LABEL_NEXT)).toHaveFocus();
});
- it('should change focus to previous button if next page is last page', async () => {
+ it('should change focus to previous button if next page is last page', () => {
const props = {
...baseProps,
currentPage: baseProps.pageCount - 1,
+ buttonLabel: {
+ previous: 'Previous',
+ next: 'Next',
+ },
};
render();
- const previousButton = screen.getByLabelText(/Previous/);
- const nextButton = screen.getByLabelText(/Next/);
- await userEvent.click(nextButton);
- expect(document.activeElement).toEqual(previousButton);
+ userEvent.click(screen.getByText(props.buttonLabel.next));
+ expect(screen.getByText(props.buttonLabel.previous)).toHaveFocus();
});
});
@@ -101,94 +144,113 @@ describe('', () => {
paginationLabel,
};
render();
- expect(screen.getByLabelText(paginationLabel)).toBeInTheDocument();
+ expect(screen.getByRole('navigation')).toHaveAttribute('aria-label', paginationLabel);
});
describe('should use correct number of pages', () => {
it('should show 5 buttons on desktop', () => {
- render(
+ render((
- ,
- );
+
+ ));
- const pageButtons = screen.getAllByLabelText(/^Page/);
- expect(pageButtons.length).toBe(5);
+ const buttonsAriaLabel = new RegExp(`^${PAGINATION_BUTTON_LABEL_PAGE}`);
+ expect(screen.queryAllByRole('button', { name: buttonsAriaLabel })).toHaveLength(5);
});
- it('should show 1 button on mobile', () => {
- // Use extra small window size to display the mobile version of Pagination.
- render(
+ it('should show page of count text instead of pag buttons on mobile', () => {
+ const buttonLabels = {
+ previous: 'Anterior',
+ next: 'Siguiente',
+ page: 'Página',
+ currentPage: 'Página actual',
+ pageOfCount: 'de',
+ };
+ const pageCount = 5;
+ const currentPage = 1;
+ const props = {
+ ...baseProps,
+ buttonLabels,
+ pageCount,
+ currentPage,
+ };
+
+ // Use extra small window size to display the mobile version of `Pagination`.
+ render((
-
- ,
- );
- const pageButtons = screen.getAllByLabelText(/^Page/);
- expect(pageButtons.length).toBe(1);
+
+
+ ));
+
+ const pageOfCountLabel = `${buttonLabels.page} ${currentPage}, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} ${pageCount}`;
+ const buttonsAriaLabel = new RegExp(`^${PAGINATION_BUTTON_LABEL_PAGE}`);
+ expect(screen.queryAllByRole('button', { name: buttonsAriaLabel })).toHaveLength(0);
+ expect(screen.queryByLabelText(pageOfCountLabel)).toBeInTheDocument();
});
});
describe('should fire callbacks properly', () => {
- it('should not fire onPageSelect when selecting current page', async () => {
+ it('should not fire onPageSelect when selecting current page', () => {
const spy = jest.fn();
const props = {
...baseProps,
onPageSelect: spy,
};
- render(
+ render((
- ,
- );
+
+ ));
- const previousButton = screen.getByLabelText(/Previous/);
- await userEvent.click(previousButton);
+ userEvent.click(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false }));
expect(spy).toHaveBeenCalledTimes(0);
});
- it('should fire onPageSelect callback when selecting new page', async () => {
+ it('should fire onPageSelect callback when selecting new page', () => {
const spy = jest.fn();
const props = {
...baseProps,
onPageSelect: spy,
};
- render(
+ render((
- ,
- );
+
+ ));
- const pageButtons = screen.getAllByLabelText(/^Page/);
- await userEvent.click(pageButtons[1]);
+ userEvent.click(screen.getByLabelText(`${PAGINATION_BUTTON_LABEL_PAGE} 2`));
expect(spy).toHaveBeenCalledTimes(1);
- await userEvent.click(pageButtons[2]);
+ userEvent.click(screen.getByLabelText(`${PAGINATION_BUTTON_LABEL_PAGE} 3`));
expect(spy).toHaveBeenCalledTimes(2);
});
});
});
describe('fires previous and next button click handlers', () => {
- it('previous button onClick', async () => {
+ it('previous button onClick', () => {
const spy = jest.fn();
const props = {
...baseProps,
- currentPage: 2,
onPageSelect: spy,
+ currentPage: 3,
};
render();
- await userEvent.click(screen.getByLabelText(/Previous/));
+ const expectedPrevButtonAriaLabel = `${PAGINATION_BUTTON_LABEL_PREV}, ${PAGINATION_BUTTON_LABEL_PAGE} 2`;
+ userEvent.click(screen.getByRole('button', { name: expectedPrevButtonAriaLabel }));
expect(spy).toHaveBeenCalledTimes(1);
});
- it('next button onClick', async () => {
+ it('next button onClick', () => {
const spy = jest.fn();
const props = {
...baseProps,
onPageSelect: spy,
};
render();
- await userEvent.click(screen.getByLabelText(/Next/));
+ const expectedNextButtonAriaLabel = `${PAGINATION_BUTTON_LABEL_NEXT}, ${PAGINATION_BUTTON_LABEL_PAGE} 2`;
+ userEvent.click(screen.getByRole('button', { name: expectedNextButtonAriaLabel }));
expect(spy).toHaveBeenCalledTimes(1);
});
});
@@ -201,112 +263,95 @@ describe('', () => {
currentPage: 'Página actual',
pageOfCount: 'de',
};
-
- let props = {
+ const props = {
...baseProps,
buttonLabels,
};
- /**
- * made a proxy component because setProps can only be used with root component and
- * Responsive Context Provider is needed to mock screen
- */
- // eslint-disable-next-line react/prop-types
- function Proxy({ currentPage, width }) {
- return (
-
-
-
- );
- }
-
- it('uses passed in previous button label', async () => {
- render(
- ,
- );
- expect(screen.getByText(buttonLabels.previous)).toBeInTheDocument();
+ it('uses passed in previous button label', () => {
+ const { rerender } = render();
+ // default label is used if we're on the first page
+ expect(screen.getByRole('button', { name: buttonLabels.previous })).toBeInTheDocument();
- await userEvent.click(screen.getByText(buttonLabels.next));
- expect(screen.getByLabelText(`${buttonLabels.previous}, ${buttonLabels.page} 4`)).toBeInTheDocument();
+ rerender();
+ // label should change if we're not on the first page
+ const expectedPrevButtonAriaLabel = `${buttonLabels.previous}, ${buttonLabels.page} 4`;
+ expect(screen.getByRole('button', { name: expectedPrevButtonAriaLabel })).toBeInTheDocument();
});
it('uses passed in next button label', () => {
- const { rerender } = render(
- ,
- );
- expect(screen.getByLabelText(`${buttonLabels.next}, ${buttonLabels.page} 2`)).toBeInTheDocument();
-
- rerender(
- ,
- );
- expect(screen.getByLabelText(buttonLabels.next)).toBeInTheDocument();
+ const { rerender } = render();
+ // label should change if we're not on the last page
+ const expectedNextButtonAriaLabel = `${buttonLabels.next}, ${buttonLabels.page} 2`;
+ expect(screen.getByRole('button', { name: expectedNextButtonAriaLabel })).toBeInTheDocument();
+
+ rerender();
+ // default label is used if we're on the last page
+ expect(screen.getByRole('button', { name: buttonLabels.next })).toBeInTheDocument();
});
it('uses passed in page button label', () => {
- const { rerender } = render(
+ const currentPageLabel = `${buttonLabels.page} 1, ${buttonLabels.currentPage}`;
+ const pageLabel = `${buttonLabels.page} 1`;
+
+ const { rerender } = render((
- ,
- );
- expect(screen.getByText(`${buttonLabels.page} 1, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} 5`)).toBeInTheDocument();
- expect(screen.getByLabelText(`${buttonLabels.page} 1, ${buttonLabels.currentPage}`)).toBeInTheDocument();
-
- rerender(
+
+ ));
+ expect(screen.getByText('1')).toHaveAttribute('aria-label', currentPageLabel);
+ rerender((
- ,
- );
- expect(screen.getByText(`${buttonLabels.page} 2, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} 5`)).toBeInTheDocument();
- expect(screen.getByLabelText(`${buttonLabels.page} 1`)).toBeInTheDocument();
-
- rerender(
- ,
- );
- expect(screen.getByText(`${buttonLabels.page} 1, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} 5`)).toBeInTheDocument();
+
+ ));
+ expect(screen.getByText('1')).toHaveAttribute('aria-label', pageLabel);
+
+ rerender((
+
+
+
+ ));
+
+ const pageOfCountLabel = `${buttonLabels.page} 1, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} 5`;
+ expect(screen.queryByLabelText(pageOfCountLabel)).toBeInTheDocument();
});
it('for the reduced variant shows dropdown button with the page count as label', async () => {
render();
- const dropdownButton = screen.getByRole('button', { name: /1 of 5/i, attributes: { 'aria-haspopup': 'true' } });
- expect(dropdownButton.textContent).toContain(`${baseProps.state.pageIndex} of ${baseProps.pageCount}`);
-
- await userEvent.click(dropdownButton);
+ const dropdownLabel = `${baseProps.currentPage} de ${baseProps.pageCount}`;
await act(async () => {
- const dropdownChoices = screen.getAllByTestId('pagination-dropdown-item');
- expect(dropdownChoices.length).toBe(baseProps.pageCount);
+ userEvent.click(screen.getByRole('button', { name: dropdownLabel }));
});
+ expect(screen.queryAllByRole('button', { name: /^\d+$/ }).length).toEqual(baseProps.pageCount);
});
it('renders only previous and next buttons in minimal variant', () => {
- render(
- pageNumber}
- pageCount={12}
- paginationLabel="Label"
- />,
- );
- const items = screen.getAllByRole('listitem');
- expect(items.length).toBe(2);
+ render();
+ expect(screen.queryAllByRole('button').length).toEqual(2);
});
- it('renders chevrons and buttons disabled when pageCount is 1 or 0 for all variants', () => {
- const variantTypes = ['default', 'secondary', 'reduced', 'minimal'];
- variantTypes.forEach((variantType) => {
- for (let i = 0; i < 3; i++) {
- props = {
- ...baseProps,
- variant: variantType,
- pageCount: i,
- };
- const { container } = render();
- const disabledButtons = container.querySelectorAll('button[disabled]');
- expect(props.pageCount).toEqual(i);
- expect(disabledButtons.length).toEqual(i === 2 ? 1 : 2);
- }
- });
- });
+ test.each(Object.values(PAGINATION_VARIANTS))(
+ 'renders chevrons and buttons disabled when pageCount is 1 || 0 for %s variant',
+ (variant) => {
+ const { rerender } = render();
+
+ const nextButtonLabel = new RegExp(PAGINATION_BUTTON_LABEL_NEXT, 'i');
+ const prevButtonLabel = new RegExp(PAGINATION_BUTTON_LABEL_PREV, 'i');
+
+ expect(screen.getByRole('button', { name: nextButtonLabel })).toBeDisabled();
+ expect(screen.getByRole('button', { name: prevButtonLabel })).toBeDisabled();
+
+ rerender();
+ expect(screen.getByRole('button', { name: nextButtonLabel })).toBeDisabled();
+ expect(screen.getByRole('button', { name: prevButtonLabel })).toBeDisabled();
+
+ rerender();
+ expect(screen.getByRole('button', { name: nextButtonLabel })).not.toBeDisabled();
+ expect(screen.getByRole('button', { name: prevButtonLabel })).toBeDisabled();
+ },
+ );
});
});
diff --git a/src/Pagination/PaginationContext.jsx b/src/Pagination/PaginationContext.jsx
new file mode 100644
index 00000000000..c6dbcffc029
--- /dev/null
+++ b/src/Pagination/PaginationContext.jsx
@@ -0,0 +1,191 @@
+import React, {
+ createContext,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import PropTypes from 'prop-types';
+import { PAGINATION_VARIANTS } from './constants';
+import getPaginationRange from './getPaginationRange';
+
+const PaginationContext = createContext({});
+
+function PaginationContextProvider({
+ children, onPageSelect, invertColors, maxPagesDisplayed,
+ buttonLabels, icons, variant,
+ pageCount, currentPage: controlledCurrentPage, initialPage,
+}) {
+ const [currentPage, setCurrentPage] = useState(controlledCurrentPage || initialPage);
+ const [pageButtonSelected, setPageButtonSelected] = useState(false);
+ const previousButtonRef = useRef(null);
+ const nextButtonRef = useRef(null);
+ const pageButtonRef = useRef([]);
+
+ useEffect(() => {
+ const currentPageRef = pageButtonRef[currentPage];
+
+ if (currentPageRef && pageButtonSelected) {
+ currentPageRef.focus();
+ setPageButtonSelected(false);
+ }
+ }, [currentPage, pageButtonSelected]);
+
+ const isUncontrolled = () => controlledCurrentPage === undefined;
+ const isPageButtonActive = (page) => page === currentPage;
+ const isOnFirstPage = () => (currentPage === 1 || pageCount === 0);
+ const isOnLastPage = () => currentPage === pageCount || pageCount === 0;
+ const isDefaultVariant = () => variant === PAGINATION_VARIANTS.default;
+
+ if (!isUncontrolled() && controlledCurrentPage !== currentPage) {
+ setCurrentPage(controlledCurrentPage);
+ }
+
+ const getPageButtonRefHandler = (pageNum) => (element) => { pageButtonRef.current[pageNum] = element; };
+
+ const handlePageSelect = (page) => {
+ if (page !== currentPage) {
+ if (isUncontrolled()) {
+ setCurrentPage(page);
+ }
+ setPageButtonSelected(true);
+ onPageSelect(page);
+ }
+ };
+
+ const handlePreviousButtonClick = () => {
+ onPageSelect(currentPage - 1);
+ if (currentPage === 2) {
+ nextButtonRef.current.focus();
+ }
+ if (isUncontrolled()) {
+ setCurrentPage((prevState) => prevState - 1);
+ }
+ };
+
+ const handleNextButtonClick = () => {
+ onPageSelect(currentPage + 1);
+ if (currentPage === pageCount - 1) {
+ previousButtonRef.current.focus();
+ }
+ if (isUncontrolled()) {
+ setCurrentPage((prevState) => prevState + 1);
+ }
+ };
+
+ const getAriaLabelForPreviousButton = () => {
+ let ariaLabel = `${buttonLabels.previous}`;
+
+ if (!isOnFirstPage()) {
+ ariaLabel += `, ${buttonLabels.page} ${currentPage - 1}`;
+ }
+
+ return ariaLabel;
+ };
+
+ const getAriaLabelForNextButton = () => {
+ let ariaLabel = `${buttonLabels.next}`;
+
+ if (!isOnLastPage()) {
+ ariaLabel += `, ${buttonLabels.page} ${currentPage + 1}`;
+ }
+
+ return ariaLabel;
+ };
+
+ const getAriaLabelForPageButton = (page) => {
+ let ariaLabel = `${buttonLabels.page} ${page}`;
+
+ if (isPageButtonActive(page)) {
+ ariaLabel += `, ${buttonLabels.currentPage}`;
+ }
+
+ return ariaLabel;
+ };
+
+ const getAriaLabelForPageOfCountButton = () => `${buttonLabels.page} ${currentPage}, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} ${pageCount}`;
+
+ const getScreenReaderText = () => `${buttonLabels.page} ${currentPage}, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} ${pageCount}`;
+ const getPageOfText = () => `${currentPage} ${buttonLabels.pageOfCount} ${pageCount}`;
+
+ const getPageButtonVariant = (page) => {
+ let buttonVariant = isPageButtonActive(page) ? 'primary' : 'tertiary';
+
+ if (invertColors) {
+ buttonVariant = `inverse-${buttonVariant}`;
+ }
+
+ return buttonVariant;
+ };
+
+ const getNextButtonIcon = () => icons.rightIcon;
+ const getPrevButtonIcon = () => icons.leftIcon;
+
+ const displayPages = getPaginationRange({
+ currentIndex: currentPage,
+ count: pageCount,
+ length: maxPagesDisplayed,
+ requireFirstAndLastPages: true,
+ });
+
+ const value = {
+ invertColors,
+ displayPages,
+ pageCount,
+ buttonLabels,
+ previousButtonRef,
+ nextButtonRef,
+ pageButtonRef,
+ getPrevButtonIcon,
+ getNextButtonIcon,
+ getAriaLabelForNextButton,
+ getAriaLabelForPageButton,
+ getAriaLabelForPreviousButton,
+ getAriaLabelForPageOfCountButton,
+ getPageButtonVariant,
+ handlePreviousButtonClick,
+ handleNextButtonClick,
+ handlePageSelect,
+ isOnFirstPage,
+ isOnLastPage,
+ isPageButtonActive,
+ isDefaultVariant,
+ getScreenReaderText,
+ getPageOfText,
+ getPageButtonRefHandler,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+PaginationContextProvider.propTypes = {
+ children: PropTypes.node.isRequired,
+ onPageSelect: PropTypes.func.isRequired,
+ pageCount: PropTypes.number.isRequired,
+ buttonLabels: PropTypes.shape({
+ previous: PropTypes.string,
+ next: PropTypes.string,
+ page: PropTypes.string,
+ currentPage: PropTypes.string,
+ pageOfCount: PropTypes.string,
+ }).isRequired,
+ currentPage: PropTypes.number,
+ maxPagesDisplayed: PropTypes.number.isRequired,
+ icons: PropTypes.shape({
+ leftIcon: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+ rightIcon: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+ }).isRequired,
+ variant: PropTypes.oneOf(Object.values(PAGINATION_VARIANTS)).isRequired,
+ invertColors: PropTypes.bool.isRequired,
+ initialPage: PropTypes.number.isRequired,
+};
+
+PaginationContextProvider.defaultProps = {
+ currentPage: undefined,
+};
+
+export { PaginationContextProvider };
+export default PaginationContext;
diff --git a/src/Pagination/README.md b/src/Pagination/README.md
index 3db95e620fd..13577ea9484 100644
--- a/src/Pagination/README.md
+++ b/src/Pagination/README.md
@@ -18,61 +18,102 @@ notes: |
Navigation between multiple pages of some set of results. Controls are provided to navigate through multiple pages of related data.
-## Basic usage (Default Size)
+## Default Size
+
+### Uncontrolled Usage
+
+```jsx live
+ console.log(`page ${page} selected`)}
+/>
+```
+
+### Controlled Usage
+
+```jsx live
+() => {
+ const [currentPage, setCurrentPage] = useState(1);
+
+ const handlePageSelect = (page) => setTimeout(() => setCurrentPage(page), 1000);
+
+ return (
+ handlePageSelect(page)}
+ />
+ );
+}
+```
+
+### Uncontrolled usage with initial page
```jsx live
console.log('page selected')}
+ initialPage={5}
+ onPageSelect={(page) => console.log(`page ${page} selected`)}
/>
```
-## Secondary
+### Secondary
```jsx live
console.log('page selected')}
+ onPageSelect={(page) => console.log(`page ${page} selected`)}
+ icons={{
+ leftIcon: ArrowBackIos,
+ rightIcon: ArrowForwardIos,
+ }}
/>
```
-## Reduced
+### Reduced
```jsx live
console.log('page selected')}
+ onPageSelect={(page) => console.log(`page ${page} selected`)}
/>
```
-## Minimal
+### Minimal
```jsx live
console.log('page selected')}
+ onPageSelect={(page) => console.log(`page ${page} selected`)}
+ icons={{
+ leftIcon: ArrowBackIos,
+ rightIcon: ArrowForwardIos,
+ }}
/>
```
-## Basic usage (Small Size)
+## Small Size
+### Default variant
```jsx live
console.log('page selected')}
+ onPageSelect={(page) => console.log(`page ${page} selected`)}
/>
```
-## Secondary (Small Size)
+### Secondary (Small Size)
```jsx live
console.log('page selected')}
+ onPageSelect={(page) => console.log(`page ${page} selected`)}
/>
```
-## Reduced (Small Size)
+### Reduced (Small Size)
```jsx live
console.log('page selected')}
+ onPageSelect={(page) => console.log(`page ${page} selected`)}
/>
```
-## Minimal (Small Size)
+### Minimal (Small Size)
```jsx live
console.log('page selected')}
+ onPageSelect={(page) => console.log(`page ${page} selected`)}
/>
```
@@ -116,21 +157,36 @@ Navigation between multiple pages of some set of results. Controls are provided
paginationLabel="pagination navigation"
pageCount={20}
invertColors
- onPageSelect={() => console.log('page selected')}
+ onPageSelect={(page) => console.log(`page ${page} selected`)}
+ />
+ console.log(`page ${page} selected`)}
+ icons={{
+ leftIcon: ArrowBackIos,
+ rightIcon: ArrowForwardIos,
+ }}
/>
console.log('page selected')}
+ onPageSelect={(page) => console.log(`page ${page} selected`)}
/>
console.log('page selected')}
+ onPageSelect={(page) => console.log(`page ${page} selected`)}
+ icons={{
+ leftIcon: ArrowBackIos,
+ rightIcon: ArrowForwardIos,
+ }}
/>
```
@@ -144,7 +200,15 @@ Navigation between multiple pages of some set of results. Controls are provided
pageCount={20}
invertColors
size="small"
- onPageSelect={() => console.log('page selected')}
+ onPageSelect={(page) => console.log(`page ${page} selected`)}
+ />
+