From 970b102013bee1d1f7f72c7d1cbdac5318bfef69 Mon Sep 17 00:00:00 2001 From: Adam Stankiewicz Date: Fri, 19 Jan 2024 10:38:17 -0500 Subject: [PATCH] feat!: release updates to Chip, SearchField, Pagination, Form.Autosuggest (#2995) BREAKING CHANGE: Many of the SCSS variables (i.e., tokens) surrounding `Chip` were removed. Consumers should verify no longer using any of the removed SCSS variables in custom Paragon brands/themes. BREAKING CHANGE: Many of the SCSS variables (i.e., tokens) surrounding `Pagination` were removed. Consumers should verify no longer using any of the removed SCSS variables in custom Paragon brands/themes. BREAKING CHANGE: `icons` prop on `SearchField` now accepts the icon src instead of an `Icon` component. BREAKING CHANGE: `icons` prop on `Pagination` now accepts the icon src instead of an `Icon` component. BREAKING CHANGE: `value` prop of `Form.Autosuggest` is now an object instead of a string BREAKING CHANGE: `Form.Autosuggest` now uses `onChange` instead of `onSelected` BREAKING CHANGE: `Form.Autosuggest` now takes in different error messages for value/selection required, and custom errors --- src/Button/index.scss | 12 + src/Chip/Chip.test.jsx | 98 ++- src/Chip/ChipIcon.tsx | 54 ++ src/Chip/README.md | 127 +++- src/Chip/__snapshots__/Chip.test.jsx.snap | 174 +++--- src/Chip/_variables.scss | 47 +- src/Chip/constants.js | 5 + src/Chip/index.scss | 141 +++-- src/Chip/index.tsx | 121 ++-- src/Chip/mixins.scss | 42 ++ src/ChipCarousel/_variables.scss | 4 +- src/ChipCarousel/index.scss | 1 + src/DataTable/TablePagination.jsx | 9 +- src/DataTable/TablePaginationMinimal.jsx | 5 + src/DataTable/tests/TablePagination.test.jsx | 12 +- src/Form/FormAutosuggest.jsx | 586 ++++++++++-------- src/Form/form-autosuggest.mdx | 171 +++-- src/Form/tests/FormAutosuggest.test.jsx | 79 ++- src/Pagination/DefaultPagination.jsx | 43 ++ src/Pagination/MinimalPagination.jsx | 11 + src/Pagination/Pagination.test.jsx | 357 ++++++----- src/Pagination/PaginationContext.jsx | 191 ++++++ src/Pagination/README.md | 108 +++- src/Pagination/ReducedPagination.jsx | 12 + .../__snapshots__/Pagination.test.jsx.snap | 301 +++++++++ src/Pagination/_variables.scss | 32 +- src/Pagination/constants.js | 16 +- src/Pagination/getPaginationRange.js | 4 + src/Pagination/index.jsx | 468 ++------------ src/Pagination/index.scss | 338 ++++------ src/Pagination/subcomponents/Ellipsis.jsx | 13 + .../subcomponents/NextPageButton.jsx | 64 ++ src/Pagination/subcomponents/PageButton.jsx | 33 + .../subcomponents/PageOfCountButton.jsx | 25 + .../subcomponents/PaginationDropdown.jsx | 37 ++ .../subcomponents/PreviousPageButton.jsx | 64 ++ .../subcomponents/ScreenReaderText.jsx | 17 + src/Pagination/subcomponents/index.js | 7 + src/SearchField/SearchField.test.jsx | 2 +- src/SearchField/SearchFieldAdvanced.jsx | 6 +- src/SearchField/SearchFieldClearButton.jsx | 18 +- src/SearchField/SearchFieldSubmitButton.jsx | 18 +- .../__snapshots__/SearchField.test.jsx.snap | 42 +- src/SearchField/index.jsx | 6 +- src/SearchField/index.scss | 22 +- src/utils/propTypes/utils.js | 19 +- www/src/components/CodeBlock.tsx | 2 + 47 files changed, 2488 insertions(+), 1476 deletions(-) create mode 100644 src/Chip/ChipIcon.tsx create mode 100644 src/Chip/constants.js create mode 100644 src/Chip/mixins.scss create mode 100644 src/Pagination/DefaultPagination.jsx create mode 100644 src/Pagination/MinimalPagination.jsx create mode 100644 src/Pagination/PaginationContext.jsx create mode 100644 src/Pagination/ReducedPagination.jsx create mode 100644 src/Pagination/__snapshots__/Pagination.test.jsx.snap create mode 100644 src/Pagination/subcomponents/Ellipsis.jsx create mode 100644 src/Pagination/subcomponents/NextPageButton.jsx create mode 100644 src/Pagination/subcomponents/PageButton.jsx create mode 100644 src/Pagination/subcomponents/PageOfCountButton.jsx create mode 100644 src/Pagination/subcomponents/PaginationDropdown.jsx create mode 100644 src/Pagination/subcomponents/PreviousPageButton.jsx create mode 100644 src/Pagination/subcomponents/ScreenReaderText.jsx create mode 100644 src/Pagination/subcomponents/index.js diff --git a/src/Button/index.scss b/src/Button/index.scss index 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`] = ` +
+
+ Test +
+
+`; + 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`] = `
-
- - - - - -
+ + +
Test
-
- - - - - -
+ + +
`; 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} - )} - - -
    - {isLoading ? ( -
    - -
    - ) : state.dropDownItems.length > 0 && state.dropDownItems} -
-
- ); -} + )} +
+
    + {isLoading ? ( +
    + +
    + ) : dropdownItems.length > 0 && dropdownItems} +
+
+ ); + }, +); 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} + /> + + + + ); }; ``` @@ -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`] = ` + +`; + +exports[` renders with inverse colors 1`] = ` + +`; 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 ( - - - {currentPage} of {pageCount} - - - {[...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 ( -
  • - -
  • - ); - } - - 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} - 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} - 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 ( + - ); - } + + ); } 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 ( +
  • + +
  • + ); + } + + 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 ( +
  • + +
  • + ); +} + +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 ( +
  • + + + {getPageOfText()} + + + {[...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 ( +
  • + +
  • + ); + } + + 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 - + ); } 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} ) : ( - + /> ); } 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="" /> 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,