diff --git a/src/Button/index.scss b/src/Button/index.scss
index 9580fcd588..957e2dd159 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/Chip/Chip.test.jsx b/src/Chip/Chip.test.jsx
index fd933621fb..f5e5367d18 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 0000000000..a32692c5ce
--- /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 6497f9e7d3..3915513327 100644
--- a/src/Chip/README.md
+++ b/src/Chip/README.md
@@ -16,34 +16,139 @@ notes: |
## Basic Usage
```jsx live
-
+
New
New
- New
-
+
+```
+
+## Clickable Variant
+
+Use `onClick` prop to make the whole `Chip` clickable, this will also add appropriate styles to make `Chip` interactive.
+
+```jsx live
+ console.log('Click!')}>Click Me
+```
+
+## With isSelected prop
+
+```jsx live
+New
```
## With Icon Before and After
+### Basic Usage
+
+Use `iconBefore` and `iconAfter` props to provide icons for the `Chip`, note that you also can provide
+accessible names for these icons for screen reader support via `iconBeforeAlt` and `iconAfterAlt` respectively.
```jsx live
-
- New
+
+ Person
+ Close
console.log('Remove Chip')}
+ iconAfterAlt="icon-after"
+ iconBeforeAlt="icon-before"
>
- New
+ Both
+
+
+```
+
+### Clickable icon variant
+
+Provide click handlers for icons via `onIconAfterClick` and `onIconBeforeClick` props.
+
+```jsx live
+
+ console.log('onIconBeforeClick')}
+ >
+ Person
+
+ console.log('onIconAfterClick')}
+ iconAfterAlt="icon-after"
+ >
+ Close
console.log('Remove Chip')}
+ onIconAfterClick={() => console.log('onIconAfterClick')}
+ onIconBeforeClick={() => console.log('onIconBeforeClick')}
+ iconAfterAlt="icon-after"
+ iconBeforeAlt="icon-before"
+ >
+ Both
+
+ console.log('onIconAfterClick')}
+ onIconBeforeClick={() => console.log('onIconBeforeClick')}
+ iconAfterAlt="icon-after"
+ iconBeforeAlt="icon-before"
+ disabled
+ >
+ Both
+
+
+```
+
+**Note**: both `Chip` and its icons cannot be made interactive at the same time, e.g. if you provide both `onClick` and `onIconAfterClick` props,
+`onClick` will be ignored and only the icon will get interactive behaviour, see example below (this is done to avoid usability issues where users might click on the `Chip` itself by mistake when they meant to click the icon instead).
+
+```jsx live
+ console.log('onIconBeforeClick')}
+ onClick={() => console.log('onClick')}
+>
+ Person
+
+```
+
+### Inverse Pallete
+
+```jsx live
+
+ New
+ console.log('onIconAfterClick')}
+ iconAfterAlt="icon-after"
+ >
+ New 1
+
+ console.log('onIconAfterClick')}
+ iconAfterAlt="icon-after"
disabled
>
New
-
+
```
diff --git a/src/Chip/__snapshots__/Chip.test.jsx.snap b/src/Chip/__snapshots__/Chip.test.jsx.snap
index 4b706e50db..ce2f964cc2 100644
--- a/src/Chip/__snapshots__/Chip.test.jsx.snap
+++ b/src/Chip/__snapshots__/Chip.test.jsx.snap
@@ -1,5 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[` snapshots renders div with "button" role when onClick is provided 1`] = `
+
+`;
+
exports[` snapshots renders with props iconAfter 1`] = `
snapshots renders with props iconAfter 1`] = `
>
Test
-
+
+
+
`;
@@ -42,29 +51,25 @@ exports[` snapshots renders with props iconBefore 1`] = `
-
+
+
+
@@ -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 90c2878e07..33a80ac466 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 0000000000..6259d0c8dd
--- /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 d809b022fe..abfa54040d 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 189053d5d0..23abfde5c7 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 '@openedx/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 '@openedx/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 0000000000..a5f850aa8b
--- /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 e033dc2fcd..ef4ec9c747 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 744acf9dea..f36ae6303a 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/DataTable/TablePagination.jsx b/src/DataTable/TablePagination.jsx
index 42bb00acc5..0497c4cf76 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 615a74b3f4..ce5a6f87a0 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 da03995281..b283587807 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/Form/FormAutosuggest.jsx b/src/Form/FormAutosuggest.jsx
index 0e255d5553..84e1223359 100644
--- a/src/Form/FormAutosuggest.jsx
+++ b/src/Form/FormAutosuggest.jsx
@@ -1,9 +1,10 @@
import React, {
- useEffect, useState, useRef,
+ useEffect, useState, useRef, forwardRef, useImperativeHandle,
} from 'react';
import PropTypes from 'prop-types';
import { v4 as uuidv4 } from 'uuid';
import { useIntl } from 'react-intl';
+import { requiredWhen } from '../utils/propTypes';
import { KeyboardArrowUp, KeyboardArrowDown } from '../../icons';
import Icon from '../Icon';
import { FormGroupContextProvider, useFormGroupContext } from './FormGroupContext';
@@ -14,292 +15,353 @@ import Spinner from '../Spinner';
import useArrowKeyNavigation from '../hooks/useArrowKeyNavigation';
import messages from './messages';
-function FormAutosuggest({
- children,
- arrowKeyNavigationSelector,
- ignoredArrowKeysNames,
- screenReaderText,
- value,
- isLoading,
- errorMessageText,
- onChange,
- onSelected,
- helpMessage,
- ...props
-}) {
- const intl = useIntl();
- const formControlRef = useRef();
- const parentRef = useArrowKeyNavigation({
- selectors: arrowKeyNavigationSelector,
- ignoredKeys: ignoredArrowKeysNames,
- });
- const [isMenuClosed, setIsMenuClosed] = useState(true);
- const [isActive, setIsActive] = useState(false);
- const [state, setState] = useState({
- displayValue: value || '',
- errorMessage: '',
- dropDownItems: [],
- });
- const [activeMenuItemId, setActiveMenuItemId] = useState(null);
-
- const handleMenuItemFocus = (menuItemId) => {
- setActiveMenuItemId(menuItemId);
- };
-
- const handleItemClick = (e, onClick) => {
- const clickedValue = e.currentTarget.getAttribute('data-value');
-
- if (onSelected && clickedValue !== value) {
- onSelected(clickedValue);
- }
+const FormAutosuggest = forwardRef(
+ (
+ {
+ children,
+ arrowKeyNavigationSelector,
+ ignoredArrowKeysNames,
+ screenReaderText,
+ value,
+ isLoading,
+ isValueRequired,
+ valueRequiredErrorMessageText,
+ isSelectionRequired,
+ selectionRequiredErrorMessageText,
+ hasCustomError,
+ customErrorMessageText,
+ onChange,
+ helpMessage,
+ ...props
+ },
+ ref,
+ ) => {
+ const intl = useIntl();
+ const formControlRef = useRef();
+ const parentRef = useArrowKeyNavigation({
+ selectors: arrowKeyNavigationSelector,
+ ignoredKeys: ignoredArrowKeysNames,
+ });
+ const [isDropdownExpanded, setIsDropdownExpanded] = useState(false);
+ const [isActive, setIsActive] = useState(false);
+ const [hasValue, setHasValue] = useState(false);
+ const [hasSelection, setHasSelection] = useState(false);
+ const [displayValue, setDisplayValue] = useState(value?.userProvidedText || '');
+ const [dropdownItems, setDropdownItems] = useState([]);
+ const [activeMenuItemId, setActiveMenuItemId] = useState(null);
+ const [isValid, setIsValid] = useState(true);
+ const [errorMessage, setErrorMessage] = useState('');
+
+ const handleMenuItemFocus = (menuItemId) => {
+ setActiveMenuItemId(menuItemId);
+ };
- setState(prevState => ({
- ...prevState,
- dropDownItems: [],
- displayValue: clickedValue,
- }));
+ const collapseDropdown = () => {
+ setDropdownItems([]);
+ setIsDropdownExpanded(false);
+ setActiveMenuItemId(null);
+ };
- setIsMenuClosed(true);
+ const handleItemSelect = (e, onClick) => {
+ const selectedValue = e.currentTarget.getAttribute('data-value');
+ const selectedId = e.currentTarget.id;
- if (onClick) {
- onClick(e);
- }
- };
-
- function getItems(strToFind = '') {
- let childrenOpt = React.Children.map(children, (child) => {
- // eslint-disable-next-line no-shadow
- const { children, onClick, ...rest } = child.props;
- const menuItemId = uuidv4();
-
- return React.cloneElement(child, {
- ...rest,
- children,
- 'data-value': children,
- onClick: (e) => handleItemClick(e, onClick),
- id: menuItemId,
- onFocus: () => handleMenuItemFocus(menuItemId),
- });
- });
-
- if (strToFind.length > 0) {
- childrenOpt = childrenOpt
- .filter((opt) => (opt.props.children.toLowerCase().includes(strToFind.toLowerCase())));
- }
+ setHasValue(true);
+ setHasSelection(true);
+ setDisplayValue(selectedValue);
- return childrenOpt;
- }
+ if (onChange && (!value || (value && selectedValue !== value.selectionValue))) {
+ onChange({
+ userProvidedText: selectedValue,
+ selectionValue: selectedValue,
+ selectionId: selectedId,
+ });
+ }
- const handleExpand = () => {
- setIsMenuClosed(!isMenuClosed);
+ collapseDropdown();
- const newState = {
- dropDownItems: [],
+ if (onClick) {
+ onClick(e);
+ }
};
- if (isMenuClosed) {
- setIsActive(true);
- newState.dropDownItems = getItems(state.displayValue);
- newState.errorMessage = '';
- }
-
- setState(prevState => ({
- ...prevState,
- ...newState,
- }));
- };
-
- const iconToggle = (
- handleExpand(e, isMenuClosed)}
- />
- );
-
- const leaveControl = () => {
- setIsActive(false);
-
- setState(prevState => ({
- ...prevState,
- dropDownItems: [],
- errorMessage: !state.displayValue ? errorMessageText : '',
- }));
+ function getItems(strToFind = '') {
+ let childrenOpt = React.Children.map(children, (child) => {
+ const { children: childChildren, onClick, ...rest } = child.props;
+ const menuItemId = child.props.id ?? uuidv4();
+
+ return React.cloneElement(child, {
+ ...rest,
+ childChildren,
+ 'data-value': childChildren,
+ onClick: (e) => handleItemSelect(e, onClick),
+ id: menuItemId,
+ onFocus: () => handleMenuItemFocus(menuItemId),
+ });
+ });
- setIsMenuClosed(true);
- };
+ if (strToFind.length > 0) {
+ childrenOpt = childrenOpt
+ .filter((opt) => (opt.props.children.toLowerCase().includes(strToFind.toLowerCase())));
+ }
- const handleDocumentClick = (e) => {
- if (parentRef.current && !parentRef.current.contains(e.target) && isActive) {
- leaveControl();
+ return childrenOpt;
}
- };
- const keyDownHandler = e => {
- if (e.key === 'Escape' && isActive) {
- e.preventDefault();
+ const expandDropdown = () => {
+ setDropdownItems(getItems(displayValue));
+ setIsValid(true);
+ setErrorMessage('');
+ setIsDropdownExpanded(true);
+ };
- if (formControlRef) {
- formControlRef.current.focus();
+ const toggleDropdown = () => {
+ if (isDropdownExpanded) {
+ collapseDropdown();
+ } else {
+ expandDropdown();
}
+ };
- setState(prevState => ({
- ...prevState,
- dropDownItems: [],
- }));
+ const iconToggle = (
+
+ );
+
+ const enterControl = () => {
+ setIsActive(true);
+ };
- setIsMenuClosed(true);
- }
- if (e.key === 'Tab' && isActive) {
- leaveControl();
- }
- };
+ const updateErrorStateAndErrorMessage = () => {
+ if (hasCustomError) {
+ setIsValid(false);
+ setErrorMessage(customErrorMessageText);
+ return;
+ }
- useEffect(() => {
- document.addEventListener('keydown', keyDownHandler);
- document.addEventListener('click', handleDocumentClick, true);
+ if (isValueRequired && !hasValue) {
+ setIsValid(false);
+ setErrorMessage(valueRequiredErrorMessageText);
+ return;
+ }
+
+ if (hasValue && isSelectionRequired && !hasSelection) {
+ setIsValid(false);
+ setErrorMessage(selectionRequiredErrorMessageText);
+ return;
+ }
- return () => {
- document.removeEventListener('click', handleDocumentClick, true);
- document.removeEventListener('keydown', keyDownHandler);
+ setIsValid(true);
+ setErrorMessage('');
};
- });
-
- useEffect(() => {
- if (value || value === '') {
- setState(prevState => ({
- ...prevState,
- displayValue: value,
- }));
- }
- }, [value]);
- const setDisplayValue = (itemValue) => {
- const optValue = [];
+ useImperativeHandle(ref, () => ({
+ // expose updateErrorStateAndErrorMessage so consumers can trigger validation
+ // when changing the value of the control externally
+ updateErrorStateAndErrorMessage,
+ }));
- children.forEach(opt => {
- optValue.push(opt.props.children);
- });
+ const leaveControl = () => {
+ setIsActive(false);
+ collapseDropdown();
+ updateErrorStateAndErrorMessage();
+ };
- const normalized = itemValue.toLowerCase();
- const opt = optValue.find((o) => o.toLowerCase() === normalized);
+ const keyDownHandler = e => {
+ if (!isActive) {
+ return;
+ }
- setState(prevState => ({
- ...prevState,
- displayValue: opt || itemValue,
- }));
- };
+ if (e.key === 'Escape') {
+ e.preventDefault();
- const handleClick = (e) => {
- setIsActive(true);
- const dropDownItems = getItems(e.target.value);
+ if (formControlRef) {
+ formControlRef.current.focus();
+ }
- if (dropDownItems.length > 1) {
- setState(prevState => ({
- ...prevState,
- dropDownItems,
- errorMessage: '',
- }));
+ collapseDropdown();
+ return;
+ }
- setIsMenuClosed(false);
- }
- };
+ if (e.key === 'Tab') {
+ leaveControl();
+ }
+ };
- const handleOnChange = (e) => {
- const findStr = e.target.value;
+ const handleDocumentClick = (e) => {
+ if (parentRef.current && !parentRef.current.contains(e.target) && isActive) {
+ leaveControl();
+ }
+ };
+
+ useEffect(() => {
+ document.addEventListener('keydown', keyDownHandler);
+ document.addEventListener('click', handleDocumentClick, true);
- if (onChange) { onChange(findStr); }
+ return () => {
+ document.removeEventListener('click', handleDocumentClick, true);
+ document.removeEventListener('keydown', keyDownHandler);
+ };
+ });
- if (findStr.length) {
- const filteredItems = getItems(findStr);
- setState(prevState => ({
- ...prevState,
- dropDownItems: filteredItems,
- errorMessage: '',
- }));
+ useEffect(() => {
+ setDisplayValue(value ? value.userProvidedText ?? '' : '');
+ setHasValue(!!value && !!value.userProvidedText);
+ setHasSelection(!!value && !!value.selectionValue);
+ }, [value]);
- setIsMenuClosed(false);
- } else {
- setState(prevState => ({
- ...prevState,
- dropDownItems: [],
- }));
+ const handleTextboxClick = () => {
+ expandDropdown();
+ };
- setIsMenuClosed(true);
- }
+ const handleTextInput = (e) => {
+ const userProvidedText = e.target.value;
+
+ // If the user has removed all text from the textbox
+ if (!userProvidedText.length) {
+ // reset to a "no text, nothing selected" state
+ setDisplayValue('');
+ setHasValue(false);
+ setHasSelection(false);
+
+ // clear and close the dropdown
+ setDropdownItems([]);
+ collapseDropdown();
+
+ // if the consumer has provided an onChange handler
+ if (onChange) {
+ // send a default empty object
+ onChange({
+ userProvidedText: '',
+ selectionValue: '',
+ selectionId: '',
+ });
+ }
+ return;
+ }
- setDisplayValue(e.target.value);
- };
+ // the user has entered text, we have a value
+ setHasValue(true);
+
+ // filter dropdown based on entered text
+ const filteredItems = getItems(userProvidedText);
+ setDropdownItems(filteredItems);
+
+ // check for matches in the dropdown
+ const matchingDropdownItem = filteredItems.find((o) => (
+ o.props.children.toLowerCase() === userProvidedText.toLowerCase()
+ ));
+
+ // if we didn't find a match
+ if (!matchingDropdownItem) {
+ // no match means no selection
+ setHasSelection(false);
+
+ // set the text in the state
+ setDisplayValue(userProvidedText);
+
+ // if the consumer has provided an onChange handler
+ if (onChange) {
+ // send an object with the user provided text only
+ onChange({
+ userProvidedText,
+ selectionValue: '',
+ selectionId: '',
+ });
+ }
+ return;
+ }
- const { getControlProps } = useFormGroupContext();
- const controlProps = getControlProps(props);
+ // we found a match, we have a selection!
+ setHasSelection(true);
+
+ // set the display value based on the item in the dropdown
+ // this matters because we match case insensitively
+ setDisplayValue(matchingDropdownItem.props.children);
+
+ // if the consumer has provided an onChange handler
+ if (onChange) {
+ // send an object with the selected item values
+ onChange({
+ userProvidedText: matchingDropdownItem.props.children,
+ selectionValue: matchingDropdownItem.props.children,
+ selectionId: matchingDropdownItem.props.id,
+ });
+ }
+ };
- return (
-
-
- {`${state.dropDownItems.length} options found`}
-
-
- 0).toString()}
- aria-owns="pgn__form-autosuggest__dropdown-box"
- role="combobox"
- aria-autocomplete="list"
- autoComplete="off"
- value={state.displayValue}
- aria-invalid={state.errorMessage}
- aria-activedescendant={activeMenuItemId}
- onChange={handleOnChange}
- onClick={handleClick}
- trailingElement={iconToggle}
- data-testid="autosuggest-textbox-input"
- {...controlProps}
- />
-
- {helpMessage && !state.errorMessage && (
+ const { getControlProps } = useFormGroupContext();
+ const controlProps = getControlProps(props);
+
+ return (
+
+
+ {`${dropdownItems.length} options found`}
+
+
+ 0).toString()}
+ aria-owns="pgn__form-autosuggest__dropdown-box"
+ role="combobox"
+ aria-autocomplete="list"
+ autoComplete="off"
+ value={displayValue}
+ aria-invalid={errorMessage}
+ aria-activedescendant={activeMenuItemId}
+ onChange={handleTextInput}
+ onClick={handleTextboxClick}
+ trailingElement={iconToggle}
+ data-testid="autosuggest-textbox-input"
+ {...controlProps}
+ />
+
+ {helpMessage && isValid && (
{helpMessage}
- )}
+ )}
- {state.errorMessage && (
+ {!isValid && (
- {errorMessageText}
+ {errorMessage}
- )}
-
-
-
-
- );
-}
+ )}
+
+
+
+ );
+ },
+);
FormAutosuggest.defaultProps = {
arrowKeyNavigationSelector: 'a:not(:disabled),li:not(:disabled, .btn-icon),input:not(:disabled)',
@@ -308,11 +370,15 @@ FormAutosuggest.defaultProps = {
className: null,
floatingLabel: null,
onChange: null,
- onSelected: null,
helpMessage: '',
placeholder: '',
value: null,
- errorMessageText: null,
+ isValueRequired: false,
+ valueRequiredErrorMessageText: null,
+ isSelectionRequired: false,
+ selectionRequiredErrorMessageText: null,
+ hasCustomError: false,
+ customErrorMessageText: null,
readOnly: false,
children: null,
name: 'form-autosuggest',
@@ -340,9 +406,23 @@ FormAutosuggest.propTypes = {
/** Specifies the placeholder text for the input. */
placeholder: PropTypes.string,
/** Specifies values for the input. */
- value: PropTypes.string,
- /** Informs user has errors. */
- errorMessageText: PropTypes.string,
+ value: PropTypes.shape({
+ userProvidedText: PropTypes.string,
+ selectionValue: PropTypes.string,
+ selectionId: PropTypes.string,
+ }),
+ /** Specifies if empty values trigger an error state */
+ isValueRequired: PropTypes.bool,
+ /** Informs user they must input a value. */
+ valueRequiredErrorMessageText: requiredWhen(PropTypes.string, 'isValueRequired'),
+ /** Specifies if freeform values trigger an error state */
+ isSelectionRequired: PropTypes.bool,
+ /** Informs user they must make a selection. */
+ selectionRequiredErrorMessageText: requiredWhen(PropTypes.string, 'isSelectionRequired'),
+ /** Specifies the control is in a consumer provided error state */
+ hasCustomError: PropTypes.bool,
+ /** Informs user of other errors. */
+ customErrorMessageText: requiredWhen(PropTypes.string, 'hasCustomError'),
/** Specifies the name of the base input element. */
name: PropTypes.string,
/** Selected list item is read-only. */
@@ -351,8 +431,6 @@ FormAutosuggest.propTypes = {
children: PropTypes.node,
/** Specifies the screen reader text */
screenReaderText: PropTypes.string,
- /** Function that receives the selected value. */
- onSelected: PropTypes.func,
};
export default FormAutosuggest;
diff --git a/src/Form/form-autosuggest.mdx b/src/Form/form-autosuggest.mdx
index 1a474e21e5..f59bfdd314 100644
--- a/src/Form/form-autosuggest.mdx
+++ b/src/Form/form-autosuggest.mdx
@@ -19,52 +19,79 @@ Form auto-suggest enables users to manually select or type to find matching opti
```jsx live
() => {
- const [selected, setSelected] = useState('');
+ const [value, setValue] = useState({});
+ const [isValueRequired, setIsValueRequired] = useState(false);
+ const [isSelectionRequired, setIsSelectionRequired] = useState(false);
+ const [hasCustomValidation, setHasCustomValidation] = useState(false);
- return (
-
-
- Programming language
-
- setSelected(value)}
- >
- JavaScript
- Python
- Rube
- alert(e.currentTarget.getAttribute('data-value'))}>
- Option with custom onClick
-
-
-
- );
-};
-```
-
-## Search Usage
+ const hasCustomError = () => (hasCustomValidation ? value.selectionId !== 'c-option-id' : false);
-```jsx live
-() => {
- const [selected, setSelected] = useState('');
+ const autosuggestRef = useRef();
+ const forceUpdateErrorState = () => {
+ autosuggestRef.current.updateErrorStateAndErrorMessage();
+ };
return (
- setSelected(value)}
- >
- PHP
- Java
- Turbo Pascal
- Flask
-
+ <>
+
+
+ Programming language
+
+ setValue(v)}
+ isValueRequired={isValueRequired}
+ valueRequiredErrorMessageText="Error: value required"
+ isSelectionRequired={isSelectionRequired}
+ selectionRequiredErrorMessageText="Error: selection required"
+ hasCustomError={hasCustomError()}
+ customErrorMessageText="Error: selected language less than 50 years old"
+ >
+ JavaScript
+ Python
+ Ruby
+ C
+
+
+
+
+ setIsValueRequired(e.target.checked)}>Value Required
+ setIsSelectionRequired(e.target.checked)}>Selection Required
+ setHasCustomValidation(e.target.checked)}>Custom Validation
+
+
+
+
+ userProvidedText:
+ {value.userProvidedText}
+
+
+ selectionValue:
+ {value.selectionValue}
+
+
+ selectionId:
+ {value.selectionId}
+
+
+
+
+ User provided text
+ setValue({
+ userProvidedText: e.target.value,
+ selectionValue: '',
+ selectionId: '',
+ })}
+ value={value.userProvidedText}
+ />
+
+ Trigger validation
+
+ >
);
};
```
@@ -73,6 +100,9 @@ Form auto-suggest enables users to manually select or type to find matching opti
```jsx live
() => {
+ const [userProvidedText, setUserProvidedText] = useState('');
+ const [selectionValue, setSelectionValue] = useState('');
+ const [selectionId, setSelectionId] = useState('');
const [data, setData] = useState([]);
const [showLoading, setShowLoading] = useState(false);
@@ -88,8 +118,10 @@ Form auto-suggest enables users to manually select or type to find matching opti
});
}, []);
- const searchCoffee = (title) => {
- setShowLoading(true);
+ const searchCoffee = (title, id) => {
+ if (!id) {
+ setShowLoading(true);
+ }
fetch('https://api.sampleapis.com/coffee/hot')
.then(data => data.json())
.then(items => setTimeout(() => {
@@ -100,20 +132,45 @@ Form auto-suggest enables users to manually select or type to find matching opti
}, 1500));
};
+ const valueChanged = (value) => {
+ if (userProvidedText !== value.userProvidedText) {
+ searchCoffee(value.userProvidedText, value.selectionId);
+ }
+ setUserProvidedText(value.userProvidedText);
+ setSelectionValue(value.selectionValue);
+ setSelectionId(value.selectionId);
+ };
+
return (
-
-
- Café API
-
-
- {data.map((item, index) => {item.title} )}
-
-
+ <>
+
+
+ Café API
+
+
+ {data.map((item, index) => {item.title} )}
+
+
+
+
+ userProvidedText:
+ {userProvidedText}
+
+
+ selectionValue:
+ {selectionValue}
+
+
+ selectionId:
+ {selectionId}
+
+
+ >
);
};
```
diff --git a/src/Form/tests/FormAutosuggest.test.jsx b/src/Form/tests/FormAutosuggest.test.jsx
index dce226f71a..ead36d88cb 100644
--- a/src/Form/tests/FormAutosuggest.test.jsx
+++ b/src/Form/tests/FormAutosuggest.test.jsx
@@ -23,10 +23,15 @@ function FormAutosuggestTestComponent(props) {
name="FormAutosuggest"
floatingLabel="floatingLabel text"
helpMessage="Example help message"
- errorMessageText="Example error message"
- onSelected={props.onSelected}
+ valueRequiredErrorMessageText="Example value required error message"
+ selectionRequiredErrorMessageText="Example selection required error message"
+ customErrorMessageText="Example custom error message"
+ onChange={props.onChange}
+ isValueRequired={props.isValueRequired}
+ isSelectionRequired={props.isSelectionRequired}
+ hasCustomError={props.hasCustomError}
>
- Option 1
+ Option 1
Option 2
Learn from more than 160 member universities
@@ -47,15 +52,19 @@ function FormAutosuggestLabelTestComponent() {
}
FormAutosuggestTestComponent.defaultProps = {
- onSelected: jest.fn(),
+ onChange: jest.fn(),
onClick: jest.fn(),
+ isValueRequired: false,
+ isSelectionRequired: false,
+ hasCustomError: false,
};
FormAutosuggestTestComponent.propTypes = {
- /** Specifies onSelected event handler. */
- onSelected: PropTypes.func,
- /** Specifies onClick event handler. */
+ onChange: PropTypes.func,
onClick: PropTypes.func,
+ isValueRequired: PropTypes.bool,
+ isSelectionRequired: PropTypes.bool,
+ hasCustomError: PropTypes.bool,
};
describe('render behavior', () => {
@@ -76,7 +85,7 @@ describe('render behavior', () => {
});
it('renders the auto-populated value if it exists', () => {
- render( );
+ render( );
expect(screen.getByDisplayValue('Test Value')).toBeInTheDocument();
});
@@ -88,15 +97,42 @@ describe('render behavior', () => {
expect(list.length).toBe(3);
});
- it('renders with error msg', () => {
- const { getByText, getByTestId } = render( );
+ it('renders with value required error msg', () => {
+ const { getByText, getByTestId } = render( );
+ const input = getByTestId('autosuggest-textbox-input');
+
+ // if you click into the input and click outside, you should see the error message
+ userEvent.click(input);
+ userEvent.click(document.body);
+
+ const formControlFeedback = getByText('Example value required error message');
+
+ expect(formControlFeedback).toBeInTheDocument();
+ });
+
+ it('renders with selection required error msg', () => {
+ const { getByText, getByTestId } = render( );
+ const input = getByTestId('autosuggest-textbox-input');
+
+ // if you click into the input and click outside, you should see the error message
+ userEvent.click(input);
+ userEvent.type(input, '1');
+ userEvent.click(document.body);
+
+ const formControlFeedback = getByText('Example selection required error message');
+
+ expect(formControlFeedback).toBeInTheDocument();
+ });
+
+ it('renders with custom error msg', () => {
+ const { getByText, getByTestId } = render( );
const input = getByTestId('autosuggest-textbox-input');
// if you click into the input and click outside, you should see the error message
userEvent.click(input);
userEvent.click(document.body);
- const formControlFeedback = getByText('Example error message');
+ const formControlFeedback = getByText('Example custom error message');
expect(formControlFeedback).toBeInTheDocument();
});
@@ -147,17 +183,28 @@ describe('controlled behavior', () => {
expect(input.value).toEqual('Option 1');
});
- it('calls onSelected based on clicked option', () => {
- const onSelected = jest.fn();
- const { getByText, getByTestId } = render( );
+ it('calls onChange based on clicked option', () => {
+ const onChange = jest.fn();
+ const { getByText, getByTestId } = render( );
const input = getByTestId('autosuggest-textbox-input');
userEvent.click(input);
const menuItem = getByText('Option 1');
userEvent.click(menuItem);
- expect(onSelected).toHaveBeenCalledWith('Option 1');
- expect(onSelected).toHaveBeenCalledTimes(1);
+ expect(onChange).toHaveBeenCalledWith({ selectionId: 'option-1-id', selectionValue: 'Option 1', userProvidedText: 'Option 1' });
+ expect(onChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onChange when the textbox is cleared', () => {
+ const onChange = jest.fn();
+ const { getByTestId } = render( );
+ const input = getByTestId('autosuggest-textbox-input');
+
+ userEvent.type(input, '1');
+ userEvent.type(input, '{backspace}');
+
+ expect(onChange).toHaveBeenCalledWith({ selectionId: '', selectionValue: '', userProvidedText: '' });
});
it('calls the function passed to onClick when an option with it is selected', () => {
diff --git a/src/Pagination/DefaultPagination.jsx b/src/Pagination/DefaultPagination.jsx
new file mode 100644
index 0000000000..2ca7c1048b
--- /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 0000000000..4b89247509
--- /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 98f13d30ab..cfaf6019fc 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 0000000000..c6dbcffc02
--- /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 3db95e620f..13577ea948 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`)}
+ />
+ console.log(`page ${page} selected`)}
/>
console.log('page selected')}
+ onPageSelect={(page) => console.log(`page ${page} selected`)}
/>
console.log('page selected')}
+ onPageSelect={(page) => console.log(`page ${page} selected`)}
/>
```
diff --git a/src/Pagination/ReducedPagination.jsx b/src/Pagination/ReducedPagination.jsx
new file mode 100644
index 0000000000..453c4195b1
--- /dev/null
+++ b/src/Pagination/ReducedPagination.jsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { PreviousPageButton, NextPageButton, PaginationDropdown } from './subcomponents';
+
+export default function ReducedPagination() {
+ return (
+
+ );
+}
diff --git a/src/Pagination/__snapshots__/Pagination.test.jsx.snap b/src/Pagination/__snapshots__/Pagination.test.jsx.snap
new file mode 100644
index 0000000000..cf3993c5a3
--- /dev/null
+++ b/src/Pagination/__snapshots__/Pagination.test.jsx.snap
@@ -0,0 +1,301 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` renders default variant 1`] = `
+
+
+ Page 1, Current Page, of 5
+
+
+
+
+
+
+
+
+
+ Previous
+
+
+
+
+ 1
+
+
+
+
+ 2
+
+
+
+
+ 3
+
+
+
+
+ 4
+
+
+
+
+ 5
+
+
+
+
+ Next
+
+
+
+
+
+
+
+
+
+`;
+
+exports[` renders with inverse colors 1`] = `
+
+
+ Page 1, Current Page, of 5
+
+
+
+
+
+
+
+
+
+ Previous
+
+
+
+
+ 1
+
+
+
+
+ 2
+
+
+
+
+ 3
+
+
+
+
+ 4
+
+
+
+
+ 5
+
+
+
+
+ Next
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/Pagination/_variables.scss b/src/Pagination/_variables.scss
index c9482529ac..e03ddfd392 100644
--- a/src/Pagination/_variables.scss
+++ b/src/Pagination/_variables.scss
@@ -1,49 +1,19 @@
// Pagination
$pagination-padding-y: .625rem !default;
-$pagination-padding-x: 1rem !default;
-$pagination-padding-y-sm: .8rem !default;
-$pagination-padding-x-sm: .6rem !default;
-$pagination-padding-y-lg: .75rem !default;
-$pagination-padding-x-lg: 1.5rem !default;
$pagination-margin-x: .5rem !default;
$pagination-line-height: 1.5rem !default;
$pagination-font-size-sm: .875rem !default;
$pagination-icon-width: 2.25rem !default;
$pagination-icon-height: 2.25rem !default;
-$pagination-padding-icon: .5rem !default;
$pagination-toggle-border: .3125rem !default;
$pagination-toggle-border-sm: .25rem !default;
$pagination-secondary-height: 2.75rem !default;
$pagination-secondary-height-sm: 2.25rem !default;
-$pagination-color: $link-color !default;
-$pagination-color-inverse: $white !default;
-$pagination-bg: $white !default;
+$pagination-dropdown-color-inverse: $white !default;
$pagination-border-width: $border-width !default;
-$pagination-border-color: theme-color("gray", "border") !default;
-
-$pagination-focus-box-shadow: $input-btn-focus-box-shadow !default;
-$pagination-focus-outline: 0 !default;
-$pagination-focus-border-width: .125rem !default;
-$pagination-focus-color: $primary-500 !default;
-$pagination-focus-color-text: $black !default;
-
-$pagination-hover-color: $link-hover-color !default;
-$pagination-hover-bg: theme-color("gray", "background") !default;
-$pagination-hover-border-color: theme-color("gray", "border") !default;
-
-$pagination-active-color: $component-active-color !default;
-$pagination-active-bg: $component-active-bg !default;
-$pagination-active-border-color: $pagination-active-bg !default;
-
-$pagination-disabled-color: theme-color("gray", "light-text") !default;
-$pagination-disabled-bg: $white !default;
-$pagination-disabled-border-color: theme-color("gray", "disabled-border") !default;
-
-$pagination-border-radius-sm: $border-radius-sm !default;
-$pagination-border-radius-lg: $border-radius-lg !default;
$pagination-reduced-dropdown-max-height: 60vh !default;
$pagination-reduced-dropdown-min-width: 6rem !default;
diff --git a/src/Pagination/constants.js b/src/Pagination/constants.js
index e4b063b11e..472b68b527 100644
--- a/src/Pagination/constants.js
+++ b/src/Pagination/constants.js
@@ -1,2 +1,16 @@
-/* eslint-disable import/prefer-default-export */
export const ELLIPSIS = '...';
+
+export const PAGINATION_VARIANTS = {
+ default: 'default',
+ secondary: 'secondary',
+ reduced: 'reduced',
+ minimal: 'minimal',
+};
+
+export const PAGINATION_BUTTON_LABEL_PREV = 'Previous';
+export const PAGINATION_BUTTON_LABEL_NEXT = 'Next';
+export const PAGINATION_BUTTON_LABEL_PAGE = 'Page';
+export const PAGINATION_BUTTON_LABEL_CURRENT_PAGE = 'Current Page';
+export const PAGINATION_BUTTON_LABEL_PAGE_OF_COUNT = 'of';
+export const PAGINATION_BUTTON_ICON_BUTTON_NEXT_ALT = 'Go to next page';
+export const PAGINATION_BUTTON_ICON_BUTTON_PREV_ALT = 'Go to previous page';
diff --git a/src/Pagination/getPaginationRange.js b/src/Pagination/getPaginationRange.js
index 69c35cce5d..5a8910c1b5 100644
--- a/src/Pagination/getPaginationRange.js
+++ b/src/Pagination/getPaginationRange.js
@@ -6,6 +6,10 @@ const getPaginationRange = ({
length,
requireFirstAndLastPages = true,
}) => {
+ if (count === 0) {
+ return [];
+ }
+
const boundedLength = Math.min(count, length);
const unboundedStartIndex = currentIndex - Math.ceil(boundedLength / 2);
const zeroBoundedStartIndex = Math.max(0, unboundedStartIndex);
diff --git a/src/Pagination/index.jsx b/src/Pagination/index.jsx
index 000f1318cb..085f6247b5 100644
--- a/src/Pagination/index.jsx
+++ b/src/Pagination/index.jsx
@@ -1,420 +1,53 @@
-/* eslint-disable max-len */
+import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
-import React from 'react';
-import MediaQuery from 'react-responsive';
-
-import {
- ChevronLeft, ChevronRight, ArrowBackIos, ArrowForwardIos,
-} from '../../icons';
-import { greaterThan } from '../utils/propTypes';
-import Button from '../Button';
-import Dropdown from '../Dropdown';
-import IconButton from '../IconButton';
-import Icon from '../Icon';
-import breakpoints from '../utils/breakpoints';
-import newId from '../utils/newId';
-import { ELLIPSIS } from './constants';
-import getPaginationRange from './getPaginationRange';
-
-export const PAGINATION_BUTTON_LABEL_PREV = 'Previous';
-export const PAGINATION_BUTTON_LABEL_NEXT = 'Next';
-export const PAGINATION_BUTTON_LABEL_PAGE = 'Page';
-export const PAGINATION_BUTTON_LABEL_CURRENT_PAGE = 'Current Page';
-export const PAGINATION_BUTTON_LABEL_PAGE_OF_COUNT = 'of';
-export const PAGINATION_BUTTON_ICON_BUTTON_NEXT_ALT = 'Go to next page';
-export const PAGINATION_BUTTON_ICON_BUTTON_PREV_ALT = 'Go to previous page';
-
-const VARIANTS = {
- default: 'default',
- secondary: 'secondary',
- reduced: 'reduced',
- minimal: 'minimal',
-};
-
-function ReducedPagination({ currentPage, pageCount, handlePageSelect }) {
- if (pageCount <= 1) { return null; }
- return (
-
-
-
- {[...Array(pageCount).keys()].map(pageNum => (
- handlePageSelect(pageNum + 1)}
- key={pageNum}
- data-testid="pagination-dropdown-item"
- >
- {pageNum + 1}
-
- ))}
-
-
- );
-}
-
-class Pagination extends React.Component {
- constructor(props) {
- super(props);
-
- this.previousButtonRef = null;
- this.nextButtonRef = null;
-
- this.pageRefs = {};
-
- this.state = {
- currentPage: this.props.currentPage,
- pageButtonSelected: false,
- };
-
- this.handlePageSelect = this.handlePageSelect.bind(this);
- }
- shouldComponentUpdate(nextProps, nextState) {
- // Update only when the props and currentPage state changes to avoid re-render
- // if only the pageButtonSelected state is changed.
- return nextProps !== this.props || nextState.currentPage !== this.state.currentPage;
- }
-
- componentDidUpdate(prevProps, prevState) {
- const { currentPage, pageButtonSelected } = this.state;
- const currentPageRef = this.pageRefs[currentPage];
-
- if (currentPageRef && pageButtonSelected) {
- currentPageRef.focus();
- this.setPageButtonSelectedState(false);
- }
- /* eslint-disable react/no-did-update-set-state */
- if (
- this.state.currentPage === prevState.currentPage
- && (this.props.currentPage !== prevProps.currentPage
- || this.props.currentPage !== this.state.currentPage)
- ) {
- this.setState({
- currentPage: this.props.currentPage,
- });
- }
- }
-
- handlePageSelect(page) {
- if (page !== this.state.currentPage) {
- this.setState({
- currentPage: page,
- pageButtonSelected: true,
- });
- this.props.onPageSelect(page);
- }
- }
-
- handlePreviousNextButtonClick(page) {
- const { pageCount } = this.props;
-
- if (page === 1) {
- this.nextButtonRef.focus();
- } else if (page === pageCount) {
- this.previousButtonRef.focus();
- }
- this.setState({ currentPage: page });
- this.props.onPageSelect(page);
- }
-
- setPageButtonSelectedState(value) {
- this.setState({ pageButtonSelected: value });
- }
-
- renderEllipsisButton() {
- return (
-
-
- ...
-
-
- );
- }
-
- renderPageButton(page) {
- const { buttonLabels } = this.props;
- const active = page === this.state.currentPage || null;
-
- let ariaLabel = `${buttonLabels.page} ${page}`;
- if (active) {
- ariaLabel += `, ${buttonLabels.currentPage}`;
- }
+import ReducedPagination from './ReducedPagination';
+import MinimalPagination from './MinimalPagination';
+import DefaultPagination from './DefaultPagination';
+import { PaginationContextProvider } from './PaginationContext';
+import { PAGINATION_VARIANTS } from './constants';
+import { ScreenReaderText } from './subcomponents';
- return (
-
- { this.pageRefs[page] = element; }}
- onClick={() => { this.handlePageSelect(page); }}
- >
- {page.toString()}
-
-
- );
- }
-
- renderPageOfCountButton() {
- const { currentPage } = this.state;
- const { pageCount, buttonLabels } = this.props;
-
- const ariaLabel = `${buttonLabels.page} ${currentPage}, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} ${pageCount}`;
-
- const label = (
-
- {`${currentPage} `}
- {buttonLabels.pageOfCount}
- {` ${pageCount}`}
-
- );
-
- return (
-
-
- {label}
-
-
- );
- }
-
- renderPreviousButton() {
- const {
- buttonLabels, icons, variant, size, pageCount,
- } = this.props;
- const { currentPage } = this.state;
- const isFirstPage = currentPage === 1;
- const isDisabled = isFirstPage || pageCount === 0;
- const previousPage = isFirstPage ? null : currentPage - 1;
- const iconSize = (variant !== VARIANTS.reduced && size !== 'small') || variant === VARIANTS.minimal;
-
- let ariaLabel = `${buttonLabels.previous}`;
- if (previousPage) {
- ariaLabel += `, ${buttonLabels.page} ${previousPage}`;
- }
-
- return (
-
- {
- variant === VARIANTS.default
- ? (
- { this.handlePreviousNextButtonClick(previousPage); }}
- ref={(element) => { this.previousButtonRef = element; }}
- disabled={isDisabled}
- >
-
- {icons.leftIcon}
- {variant === VARIANTS.default ? buttonLabels.previous : null}
-
-
- )
- : (
- { this.handlePreviousNextButtonClick(previousPage); }}
- ref={(element) => { this.previousButtonRef = element; }}
- disabled={isDisabled}
- alt={PAGINATION_BUTTON_ICON_BUTTON_PREV_ALT}
- />
- )
- }
-
- );
- }
-
- renderNextButton() {
- const {
- buttonLabels, pageCount, icons, variant, size,
- } = this.props;
- const { currentPage } = this.state;
- const isLastPage = (currentPage === pageCount);
- const isDisabled = isLastPage || (pageCount <= 1);
- const nextPage = isLastPage ? null : currentPage + 1;
- const iconSize = (variant !== VARIANTS.reduced && size !== 'small') || variant === VARIANTS.minimal;
-
- let ariaLabel = `${buttonLabels.next}`;
- if (nextPage) {
- ariaLabel += `, ${buttonLabels.page} ${nextPage}`;
+import { greaterThan } from '../utils/propTypes';
+import { ChevronLeft, ChevronRight } from '../../icons';
+
+function Pagination(props) {
+ const {
+ invertColors,
+ variant,
+ size,
+ paginationLabel,
+ className,
+ } = props;
+
+ const renderPaginationComponent = () => {
+ if (variant === PAGINATION_VARIANTS.reduced) {
+ return ;
}
- return (
-
- {variant === VARIANTS.default ? (
- { this.handlePreviousNextButtonClick(nextPage); }}
- ref={(element) => { this.nextButtonRef = element; }}
- disabled={isDisabled}
- >
-
- {variant === VARIANTS.default ? buttonLabels.next : null}
- {icons.rightIcon}
-
-
- ) : (
- { this.handlePreviousNextButtonClick(nextPage); }}
- ref={(element) => { this.nextButtonRef = element; }}
- disabled={isDisabled}
- alt={PAGINATION_BUTTON_ICON_BUTTON_NEXT_ALT}
- />
- )}
-
- );
- }
-
- renderScreenReaderSection() {
- const { currentPage } = this.state;
- const { buttonLabels, pageCount } = this.props;
-
- const description = `${buttonLabels.page} ${currentPage}, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} ${pageCount}`;
-
- return (
-
- {description}
-
- );
- }
-
- renderPageButtons() {
- const { currentPage } = this.state;
- const { pageCount, maxPagesDisplayed } = this.props;
-
- const pages = getPaginationRange({
- currentIndex: currentPage,
- count: pageCount,
- length: maxPagesDisplayed,
- requireFirstAndLastPages: true,
- });
-
- if (pageCount <= 1) {
- return null;
+ if (variant === PAGINATION_VARIANTS.minimal) {
+ return ;
}
- return pages.map((pageIndex) => {
- if (pageIndex === ELLIPSIS) {
- return this.renderEllipsisButton();
- }
- return this.renderPageButton(pageIndex + 1);
- });
- }
-
- renderReducedPagination() {
- const { currentPage } = this.state;
- const { pageCount } = this.props;
- return (
-
- {this.renderPreviousButton()}
-
- {this.renderNextButton()}
-
- );
- }
+ return ;
+ };
- renderMinimalPaginations() {
- return (
-
- {this.renderPreviousButton()}
- {this.renderNextButton()}
-
- );
- }
-
- render() {
- const { variant, invertColors, size } = this.props;
- return (
+ return (
+
- {this.renderScreenReaderSection()}
- {variant === VARIANTS.default || variant === VARIANTS.secondary
- ? (
-
- {this.renderPreviousButton()}
-
- {this.renderPageOfCountButton()}
-
-
- {this.renderPageButtons()}
-
- {this.renderNextButton()}
-
- )
- : null}
- {variant === VARIANTS.reduced ? this.renderReducedPagination() : null}
- {variant === VARIANTS.minimal ? this.renderMinimalPaginations() : null}
+
+ {renderPaginationComponent()}
- );
- }
+
+ );
}
Pagination.propTypes = {
@@ -483,40 +116,35 @@ Pagination.propTypes = {
* string, symbol, etc. Default is chevrons rendered using fa-css.
*/
icons: PropTypes.shape({
- leftIcon: PropTypes.node,
- rightIcon: PropTypes.node,
+ leftIcon: PropTypes.elementType,
+ rightIcon: PropTypes.elementType,
}),
variant: PropTypes.oneOf(['default', 'secondary', 'reduced', 'minimal']),
invertColors: PropTypes.bool,
size: PropTypes.oneOf(['default', 'small']),
+ initialPage: PropTypes.number,
};
Pagination.defaultProps = {
icons: {
- leftIcon: ,
- rightIcon: ,
+ leftIcon: ChevronLeft,
+ rightIcon: ChevronRight,
},
buttonLabels: {
- previous: PAGINATION_BUTTON_LABEL_PREV,
- next: PAGINATION_BUTTON_LABEL_NEXT,
- page: PAGINATION_BUTTON_LABEL_PAGE,
- currentPage: PAGINATION_BUTTON_LABEL_CURRENT_PAGE,
- pageOfCount: PAGINATION_BUTTON_LABEL_PAGE_OF_COUNT,
+ previous: 'Previous',
+ next: 'Next',
+ page: 'Page',
+ currentPage: 'Current Page',
+ pageOfCount: 'of',
},
className: undefined,
- currentPage: 1,
+ initialPage: 1,
+ currentPage: undefined,
maxPagesDisplayed: 7,
variant: 'default',
invertColors: false,
size: 'default',
};
-ReducedPagination.propTypes = {
- currentPage: PropTypes.number.isRequired,
- pageCount: PropTypes.number.isRequired,
- handlePageSelect: PropTypes.func.isRequired,
-};
-
-Pagination.Reduced = ReducedPagination;
-
export default Pagination;
+export * from './constants';
diff --git a/src/Pagination/index.scss b/src/Pagination/index.scss
index 4b94a4884f..66706fbac7 100644
--- a/src/Pagination/index.scss
+++ b/src/Pagination/index.scss
@@ -1,14 +1,4 @@
@import "variables";
-@import "~bootstrap/scss/pagination";
-
-.pagination {
- align-items: center;
- margin: 0;
-
- .dropdown {
- z-index: 4;
- }
-}
%pagination-icon-button-right {
border-top-right-radius: 50%;
@@ -20,108 +10,70 @@
border-bottom-left-radius: 50%;
}
-.pagination-icon-button-right {
- @extend %pagination-icon-button-right;
-}
-
-.pagination-icon-button-left {
- @extend %pagination-icon-button-left;
-}
+.pagination {
+ display: flex;
+ margin: 0;
-.pagination-default {
- .page-link {
- &.previous .pgn__icon {
- margin-inline-start: 0;
- margin-inline-end: $pagination-margin-x;
- }
+ .dropdown {
+ z-index: 4;
+ }
- &.next .pgn__icon {
- margin-inline-start: $pagination-margin-x;
- margin-inline-end: 0;
- }
+ .page-of-count {
+ margin: 0 .5rem;
+ border: 0;
}
.page-item {
&:first-child .page-link {
- [dir="rtl"] & {
- border-radius: 0 $pagination-border-radius-lg $pagination-border-radius-lg 0;
- }
+ margin-left: 0;
+
+ @include border-left-radius($border-radius);
}
&:last-child .page-link {
- [dir="rtl"] & {
- border-radius: $pagination-border-radius-lg 0 0 $pagination-border-radius-lg;
- }
+ @include border-right-radius($border-radius);
}
- }
-}
-.page-link {
- border: none;
-
- &.btn-primary:not(:disabled):not(.disabled):focus {
- background-color: $pagination-bg;
- color: $pagination-focus-color-text;
- }
-
- &:focus {
- box-shadow: none;
- }
-
- &.btn-primary:focus::before {
- border: $pagination-focus-border-width solid $pagination-focus-color;
-
- @include button-size($btn-padding-y, $btn-padding-x, $btn-font-size, $btn-line-height, $btn-border-radius);
- }
-
- div {
- display: flex;
- }
+ &:first-child .btn-icon.page-link {
+ @extend %pagination-icon-button-left;
+ }
- [dir="rtl"] & {
- svg {
- transform: scale(-1);
+ &:last-child .btn-icon.page-link {
+ @extend %pagination-icon-button-right;
}
- }
- &:focus::before,
- &.focus::before {
- border-radius: 0;
- }
-}
+ &.active .page-link {
+ z-index: 3;
+ }
-.page-item {
- > .btn {
- transition: none;
- line-height: $pagination-line-height;
+ > .btn {
+ transition: none;
+ line-height: $pagination-line-height;
+ }
}
- &.active .page-link.btn-primary:not(:disabled):not(.disabled):focus {
- background-color: $pagination-focus-color;
- color: $pagination-bg;
- }
-}
+ @include list-unstyled();
+ @include border-radius();
-.pagination-small {
- .page-link {
- font-size: $pagination-font-size-sm;
- line-height: $pagination-line-height;
- padding: .375rem .78rem;
+ &-small {
+ .page-link {
+ font-size: $pagination-font-size-sm;
+ line-height: $pagination-line-height;
+ padding: .375rem .78rem;
- &.previous,
- &.next {
- padding: 0 $pagination-padding-y;
- line-height: $pagination-secondary-height-sm;
+ &.previous,
+ &.next {
+ padding: 0 $pagination-padding-y;
+ line-height: $pagination-secondary-height-sm;
- div {
- display: flex;
- align-items: center;
+ div {
+ display: flex;
+ align-items: center;
+ }
}
}
- }
- &:not(.pagination-default) {
- .page-link {
+ &:not(.pagination-default) .page-link {
&.previous,
&.next {
padding: 0;
@@ -129,176 +81,122 @@
}
}
}
-}
-.pagination-secondary {
- button.next,
- button.previous {
- height: $pagination-secondary-height;
- padding: 0 $pagination-padding-y;
- }
-
- &.pagination-small {
+ &-secondary {
button.next,
button.previous {
- height: $pagination-secondary-height-sm;
- line-height: $pagination-line-height;
+ height: $pagination-secondary-height;
+ padding: 0 $pagination-padding-y;
}
- }
- .page-item:first-child .page-link {
- @extend %pagination-icon-button-left;
- }
-
- .page-item:last-child .page-link {
- @extend %pagination-icon-button-right;
- }
-}
-
-.pagination-inverse {
- %dark-styles {
- background-color: transparent;
- color: $white;
+ &.pagination-small {
+ button.next,
+ button.previous {
+ height: $pagination-secondary-height-sm;
+ line-height: $pagination-line-height;
+ }
+ }
}
- .pgn__dark-styles {
- @extend %dark-styles;
+ .ellipsis {
+ border: 0;
+ margin-left: 0;
}
- .page-item {
- &.disabled .page-link {
- @extend %dark-styles;
+ &-inverse {
+ .ellipsis {
+ color: $white;
}
- &.active button.page-link {
- background-color: $pagination-bg;
- color: $pagination-color;
+ .dropdown .dropdown-toggle::after {
+ border-top: $pagination-toggle-border solid $pagination-dropdown-color-inverse;
}
+ }
- button.page-link {
- @extend %dark-styles;
+ &-reduced {
+ &-dropdown-menu {
+ overflow-y: auto;
+ max-height: $pagination-reduced-dropdown-max-height;
+ min-width: $pagination-reduced-dropdown-min-width;
- &:focus {
- box-shadow: none;
+ a {
+ text-align: center;
}
}
- &:not(.active):focus {
- box-shadow: $level-1-box-shadow;
+ .dropdown-toggle::after {
+ width: 0;
+ height: 0;
+ border-left: $pagination-toggle-border solid transparent;
+ border-right: $pagination-toggle-border solid transparent;
+ border-top: $pagination-toggle-border solid $gray-700;
+ transform: rotate(0);
+ inset-inline-start: .5rem;
+ top: 0;
+ margin-inline-end: 1rem;
}
- }
- .page-link {
- &:focus::before,
- &.focus::before {
- display: none;
+ button.next,
+ button.previous {
+ height: $pagination-secondary-height;
+ padding: 0 $pagination-padding-y;
}
- }
- .dropdown {
- .btn-tertiary {
- color: $pagination-color-inverse;
+ &.pagination-small {
+ .btn.dropdown-toggle {
+ font-size: $pagination-font-size-sm;
- &::after {
- border-top: $pagination-toggle-border solid $pagination-color-inverse;
+ &::after {
+ border-left-width: $pagination-toggle-border-sm;
+ border-right-width: $pagination-toggle-border-sm;
+ border-top-width: $pagination-toggle-border-sm;
+ }
}
- &:active,
- &:hover {
- background-color: transparent;
- }
-
- &:not(:disabled):not(.disabled):active {
- color: $pagination-color-inverse;
+ button.previous,
+ button.next {
+ line-height: $pagination-icon-height;
+ height: $pagination-icon-height;
}
}
}
- .show > .dropdown-toggle {
- background-color: transparent;
- }
-}
-
-.pgn__reduced-pagination-dropdown {
- overflow-y: auto;
- max-height: $pagination-reduced-dropdown-max-height;
- min-width: $pagination-reduced-dropdown-min-width;
-
- a {
- text-align: center;
- }
-}
-
-.pagination-reduced {
- .dropdown-toggle::after {
- width: 0;
- height: 0;
- border-left: $pagination-toggle-border solid transparent;
- border-right: $pagination-toggle-border solid transparent;
- border-top: $pagination-toggle-border solid $gray-700;
- transform: rotate(0);
- inset-inline-start: .5rem;
- top: 0;
- margin-inline-end: 1rem;
- }
-
- button.next,
- button.previous {
- height: $pagination-secondary-height;
- padding: 0 $pagination-padding-y;
- }
-
- &.pagination-small {
- .btn.dropdown-toggle {
- font-size: $pagination-font-size-sm;
-
- &::after {
- border-left-width: $pagination-toggle-border-sm;
- border-right-width: $pagination-toggle-border-sm;
- border-top-width: $pagination-toggle-border-sm;
- }
+ &-minimal {
+ .page-item:first-child {
+ margin-inline-end: .3rem;
}
- button.previous,
- button.next {
- line-height: $pagination-icon-height;
- height: $pagination-icon-height;
+ button.next,
+ button.previous {
+ padding: $pagination-padding-y;
+ height: $pagination-secondary-height;
}
- }
-
- .page-item:first-child .page-link {
- @extend %pagination-icon-button-left;
- }
- .page-item:last-child .page-link {
- @extend %pagination-icon-button-right;
+ &.pagination-small {
+ button.next,
+ button.previous {
+ padding: 0 $pagination-padding-y;
+ height: $pagination-secondary-height-sm;
+ }
+ }
}
}
-.pagination-minimal {
- .page-item:first-child {
- margin-inline-end: .3rem;
- }
-
- button.next,
- button.previous {
- padding: $pagination-padding-y;
- height: $pagination-secondary-height;
- }
+.page-link {
+ border: none;
+ margin-left: -$pagination-border-width;
- &.pagination-small {
- button.next,
- button.previous {
- padding: 0 $pagination-padding-y;
- height: $pagination-secondary-height-sm;
- }
+ &:focus {
+ z-index: 3;
}
- .page-item:first-child .page-link {
- @extend %pagination-icon-button-left;
+ div {
+ display: flex;
}
- .page-item:last-child .page-link {
- @extend %pagination-icon-button-right;
+ [dir="rtl"] & {
+ svg {
+ transform: scale(-1);
+ }
}
}
diff --git a/src/Pagination/subcomponents/Ellipsis.jsx b/src/Pagination/subcomponents/Ellipsis.jsx
new file mode 100644
index 0000000000..9c6e26ce98
--- /dev/null
+++ b/src/Pagination/subcomponents/Ellipsis.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import classNames from 'classnames';
+import { ELLIPSIS } from '../constants';
+
+export default function Ellipsis() {
+ return (
+
+
+ {ELLIPSIS}
+
+
+ );
+}
diff --git a/src/Pagination/subcomponents/NextPageButton.jsx b/src/Pagination/subcomponents/NextPageButton.jsx
new file mode 100644
index 0000000000..12a04fcd6c
--- /dev/null
+++ b/src/Pagination/subcomponents/NextPageButton.jsx
@@ -0,0 +1,64 @@
+import React, { useContext } from 'react';
+import classNames from 'classnames';
+import { PAGINATION_BUTTON_ICON_BUTTON_NEXT_ALT } from '../constants';
+import PaginationContext from '../PaginationContext';
+import Button from '../../Button';
+import IconButton from '../../IconButton';
+import Icon from '../../Icon';
+
+export default function NextPageButton() {
+ const {
+ invertColors,
+ getPageButtonVariant,
+ isDefaultVariant,
+ isOnLastPage,
+ getAriaLabelForNextButton,
+ handleNextButtonClick,
+ getNextButtonIcon,
+ buttonLabels,
+ nextButtonRef,
+ } = useContext(PaginationContext);
+
+ const isDisabled = isOnLastPage();
+ const icon = getNextButtonIcon();
+
+ if (isDefaultVariant()) {
+ return (
+
+
+ {buttonLabels.next}
+
+
+ );
+ }
+
+ if (!icon) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/Pagination/subcomponents/PageButton.jsx b/src/Pagination/subcomponents/PageButton.jsx
new file mode 100644
index 0000000000..3f2c4ad866
--- /dev/null
+++ b/src/Pagination/subcomponents/PageButton.jsx
@@ -0,0 +1,33 @@
+import React, { useContext } from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import Button from '../../Button';
+import PaginationContext from '../PaginationContext';
+
+export default function PageButton({ pageNum }) {
+ const {
+ isPageButtonActive,
+ getAriaLabelForPageButton,
+ getPageButtonVariant,
+ handlePageSelect,
+ getPageButtonRefHandler,
+ } = useContext(PaginationContext);
+
+ return (
+
+ handlePageSelect(pageNum)}
+ >
+ {pageNum}
+
+
+ );
+}
+
+PageButton.propTypes = {
+ pageNum: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+};
diff --git a/src/Pagination/subcomponents/PageOfCountButton.jsx b/src/Pagination/subcomponents/PageOfCountButton.jsx
new file mode 100644
index 0000000000..78c4f7313a
--- /dev/null
+++ b/src/Pagination/subcomponents/PageOfCountButton.jsx
@@ -0,0 +1,25 @@
+import React, { useContext } from 'react';
+import classNames from 'classnames';
+import PaginationContext from '../PaginationContext';
+
+export default function PageOfCountButton() {
+ const { getAriaLabelForPageOfCountButton, getPageOfText } = useContext(PaginationContext);
+
+ const ariaLabel = getAriaLabelForPageOfCountButton();
+ const label = getPageOfText();
+
+ return (
+
+
+ {label}
+
+
+ );
+}
diff --git a/src/Pagination/subcomponents/PaginationDropdown.jsx b/src/Pagination/subcomponents/PaginationDropdown.jsx
new file mode 100644
index 0000000000..9856b493db
--- /dev/null
+++ b/src/Pagination/subcomponents/PaginationDropdown.jsx
@@ -0,0 +1,37 @@
+import React, { useContext } from 'react';
+import PaginationContext from '../PaginationContext';
+import Dropdown from '../../Dropdown';
+
+export default function PaginationDropdown() {
+ const {
+ getPageOfText,
+ pageCount,
+ handlePageSelect,
+ getPageButtonVariant,
+ } = useContext(PaginationContext);
+
+ if (pageCount <= 1) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {[...Array(pageCount).keys()].map(pageNum => (
+ handlePageSelect(pageNum + 1)}
+ key={pageNum}
+ data-testid="pagination-dropdown-item"
+ >
+ {pageNum + 1}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/Pagination/subcomponents/PreviousPageButton.jsx b/src/Pagination/subcomponents/PreviousPageButton.jsx
new file mode 100644
index 0000000000..d0ab0f11bd
--- /dev/null
+++ b/src/Pagination/subcomponents/PreviousPageButton.jsx
@@ -0,0 +1,64 @@
+import React, { useContext } from 'react';
+import classNames from 'classnames';
+import { PAGINATION_BUTTON_ICON_BUTTON_PREV_ALT } from '../constants';
+import Button from '../../Button';
+import IconButton from '../../IconButton';
+import Icon from '../../Icon';
+import PaginationContext from '../PaginationContext';
+
+export default function PreviousPageButton() {
+ const {
+ invertColors,
+ getPageButtonVariant,
+ isDefaultVariant,
+ isOnFirstPage,
+ getAriaLabelForPreviousButton,
+ handlePreviousButtonClick,
+ getPrevButtonIcon,
+ buttonLabels,
+ previousButtonRef,
+ } = useContext(PaginationContext);
+
+ const isDisabled = isOnFirstPage();
+ const icon = getPrevButtonIcon();
+
+ if (isDefaultVariant()) {
+ return (
+
+
+ {buttonLabels.previous}
+
+
+ );
+ }
+
+ if (!icon) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/Pagination/subcomponents/ScreenReaderText.jsx b/src/Pagination/subcomponents/ScreenReaderText.jsx
new file mode 100644
index 0000000000..d67b34cbc5
--- /dev/null
+++ b/src/Pagination/subcomponents/ScreenReaderText.jsx
@@ -0,0 +1,17 @@
+import React, { useContext } from 'react';
+import PaginationContext from '../PaginationContext';
+
+export default function PaginationScreenReaderText() {
+ const { getScreenReaderText } = useContext(PaginationContext);
+
+ return (
+
+ {getScreenReaderText()}
+
+ );
+}
diff --git a/src/Pagination/subcomponents/index.js b/src/Pagination/subcomponents/index.js
new file mode 100644
index 0000000000..707481db8a
--- /dev/null
+++ b/src/Pagination/subcomponents/index.js
@@ -0,0 +1,7 @@
+export { default as Ellipsis } from './Ellipsis';
+export { default as NextPageButton } from './NextPageButton';
+export { default as PageButton } from './PageButton';
+export { default as PageOfCountButton } from './PageOfCountButton';
+export { default as PaginationDropdown } from './PaginationDropdown';
+export { default as PreviousPageButton } from './PreviousPageButton';
+export { default as ScreenReaderText } from './ScreenReaderText';
diff --git a/src/SearchField/SearchField.test.jsx b/src/SearchField/SearchField.test.jsx
index ac05fb5780..1f6cebba55 100644
--- a/src/SearchField/SearchField.test.jsx
+++ b/src/SearchField/SearchField.test.jsx
@@ -176,7 +176,7 @@ describe(' with basic usage', () => {
const inputElement = screen.getByRole('searchbox');
await userEvent.type(inputElement, 'foobar');
const buttonClear = screen.getByRole('button', { type: 'reset', variant: buttonProps.variant });
- expect(buttonClear).toHaveAttribute('variant', 'inline');
+ expect(buttonClear).toHaveClass(`btn-icon-${buttonProps.variant}`);
});
it('should pass props to the label', () => {
diff --git a/src/SearchField/SearchFieldAdvanced.jsx b/src/SearchField/SearchFieldAdvanced.jsx
index 75b261d05c..8788308d3f 100644
--- a/src/SearchField/SearchFieldAdvanced.jsx
+++ b/src/SearchField/SearchFieldAdvanced.jsx
@@ -6,8 +6,6 @@ import classNames from 'classnames';
import { Search, Close } from '../../icons';
import newId from '../utils/newId';
-import Icon from '../Icon';
-
export const SearchFieldContext = createContext();
const BUTTON_LOCATION_VARIANTS = [
@@ -194,8 +192,8 @@ SearchFieldAdvanced.defaultProps = {
clearButton: 'clear search',
},
icons: {
- clear: ,
- submit: ,
+ clear: Close,
+ submit: Search,
},
onBlur: () => {},
onChange: () => {},
diff --git a/src/SearchField/SearchFieldClearButton.jsx b/src/SearchField/SearchFieldClearButton.jsx
index bf667fa0de..f9d42a03aa 100644
--- a/src/SearchField/SearchFieldClearButton.jsx
+++ b/src/SearchField/SearchFieldClearButton.jsx
@@ -1,6 +1,8 @@
import React, { useContext } from 'react';
import { SearchFieldContext } from './SearchFieldAdvanced';
+import Icon from '../Icon';
+import IconButton from '../IconButton';
function SearchFieldClearButton(props) {
const {
@@ -18,11 +20,17 @@ function SearchFieldClearButton(props) {
};
return (
- // eslint-disable-next-line react/button-has-type
-
- {icons.clear}
- {screenReaderText.clearButton}
-
+
);
}
diff --git a/src/SearchField/SearchFieldSubmitButton.jsx b/src/SearchField/SearchFieldSubmitButton.jsx
index eff97e9004..0edcffb1fd 100644
--- a/src/SearchField/SearchFieldSubmitButton.jsx
+++ b/src/SearchField/SearchFieldSubmitButton.jsx
@@ -1,9 +1,10 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
-import classNames from 'classnames';
import { SearchFieldContext } from './SearchFieldAdvanced';
import Button from '../Button';
+import IconButton from '../IconButton';
+import Icon from '../Icon';
const STYLE_VARIANTS = [
'light',
@@ -40,16 +41,17 @@ function SearchFieldSubmitButton(props) {
{screenReaderText.submitButton}
) : (
-
- {icons.submit}
- {screenReaderText.submitButton}
-
+ />
);
}
diff --git a/src/SearchField/__snapshots__/SearchField.test.jsx.snap b/src/SearchField/__snapshots__/SearchField.test.jsx.snap
index 9cd5cf7612..e9964ae45d 100644
--- a/src/SearchField/__snapshots__/SearchField.test.jsx.snap
+++ b/src/SearchField/__snapshots__/SearchField.test.jsx.snap
@@ -28,32 +28,32 @@ exports[` with basic usage should match the snapshot 1`] = `
value=""
/>
-
-
-
-
-
- submit search
+
+
+
+
diff --git a/src/SearchField/index.jsx b/src/SearchField/index.jsx
index b3c34dc543..9d46f7d802 100644
--- a/src/SearchField/index.jsx
+++ b/src/SearchField/index.jsx
@@ -8,8 +8,6 @@ import SearchFieldInput from './SearchFieldInput';
import SearchFieldClearButton from './SearchFieldClearButton';
import SearchFieldSubmitButton from './SearchFieldSubmitButton';
-import Icon from '../Icon';
-
export const SEARCH_FIELD_SCREEN_READER_TEXT_LABEL = 'search';
export const SEARCH_FIELD_SCREEN_READER_TEXT_SUBMIT_BUTTON = 'submit search';
export const SEARCH_FIELD_SCREEN_READER_TEXT_CLEAR_BUTTON = 'clear search';
@@ -169,8 +167,8 @@ SearchField.defaultProps = {
clearButton: SEARCH_FIELD_SCREEN_READER_TEXT_CLEAR_BUTTON,
},
icons: {
- clear: ,
- submit: ,
+ clear: Close,
+ submit: Search,
},
onBlur: () => {},
onChange: () => {},
diff --git a/src/SearchField/index.scss b/src/SearchField/index.scss
index b381fc5275..db467466ae 100644
--- a/src/SearchField/index.scss
+++ b/src/SearchField/index.scss
@@ -91,14 +91,6 @@
&.pgn__searchfield--external {
border: none;
- .btn-primary {
- background: map-get($search-btn-variants, "light");
- }
-
- .btn-brand {
- background: map-get($search-btn-variants, "dark");
- }
-
&.has-focus {
box-shadow: none;
@@ -113,14 +105,6 @@
height: 100%;
}
}
-
- .btn-primary {
- background: map-get($search-btn-variants, "light");
- }
-
- .btn-brand {
- background: map-get($search-btn-variants, "dark");
- }
}
}
@@ -141,3 +125,9 @@
border-radius: 0;
margin-inline-start: $search-button-margin;
}
+
+.pgn__searchfield__iconbutton-submit,
+.pgn__searchfield__iconbutton-reset {
+ flex-shrink: 0;
+ margin-inline-end: map-get($spacers, 1);
+}
diff --git a/src/utils/propTypes/utils.js b/src/utils/propTypes/utils.js
index 3310653236..f6a9f262ad 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'}`,
)
);
diff --git a/www/src/components/CodeBlock.tsx b/www/src/components/CodeBlock.tsx
index d5b7460e1e..d00ca4c6eb 100644
--- a/www/src/components/CodeBlock.tsx
+++ b/www/src/components/CodeBlock.tsx
@@ -6,6 +6,7 @@ import React, {
useReducer,
useState,
useMemo,
+ useRef,
} from 'react';
import PropTypes from 'prop-types';
import { Link } from 'gatsby';
@@ -150,6 +151,7 @@ function CodeBlock({
useState,
useReducer,
useMemo,
+ useRef,
ExamplePropsForm,
MiyazakiCard,
HipsterIpsum,