diff --git a/src/Button/index.scss b/src/Button/index.scss index 957e2dd159..9580fcd588 100644 --- a/src/Button/index.scss +++ b/src/Button/index.scss @@ -358,12 +358,6 @@ fieldset:disabled a.btn { $btn-tertiary-color, $btn-tertiary-color ); - - &.disabled, - &:disabled { - color: $yiq-text-dark; - } - @include button-focus(theme-color("primary", "focus")); } @@ -386,12 +380,6 @@ 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 f5e5367d18..fd933621fb 100644 --- a/src/Chip/Chip.test.jsx +++ b/src/Chip/Chip.test.jsx @@ -4,7 +4,6 @@ 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) { @@ -25,123 +24,58 @@ 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 - - )).toJSON(); - expect(tree).toMatchSnapshot(); - }); - it('renders div with "button" role when onClick is provided', () => { - const tree = renderer.create(( - Chip + 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.getByRole('button'); + render(); + const chip = screen.getByTestId('chip'); expect(chip).toHaveClass('pgn__chip pgn__chip-dark'); }); it('renders with active class when disabled prop is added', () => { - render(); - const chip = screen.getByRole('button'); + render(); + const chip = screen.getByTestId('chip'); expect(chip).toHaveClass('disabled'); }); it('renders with the client\'s className', () => { const className = 'testClassName'; - render(); - const chip = screen.getByRole('button'); + render(); + const chip = screen.getByTestId('chip'); expect(chip).toHaveClass(className); }); it('onIconAfterClick is triggered', async () => { const func = jest.fn(); render( - , + , ); - const iconAfter = screen.getByLabelText('icon-after'); + const iconAfter = screen.getByTestId('icon-after'); await userEvent.click(iconAfter); - expect(func).toHaveBeenCalledTimes(1); + expect(func).toHaveBeenCalled(); }); 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 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'); + const iconAfter = screen.getByTestId('icon-after'); + await userEvent.type(iconAfter, '{enter}'); + expect(func).toHaveBeenCalled(); }); }); }); diff --git a/src/Chip/ChipIcon.tsx b/src/Chip/ChipIcon.tsx deleted file mode 100644 index a32692c5ce..0000000000 --- a/src/Chip/ChipIcon.tsx +++ /dev/null @@ -1,54 +0,0 @@ -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 3915513327..6497f9e7d3 100644 --- a/src/Chip/README.md +++ b/src/Chip/README.md @@ -16,139 +16,34 @@ notes: | ## Basic Usage ```jsx live - +
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 + 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 - - Person - Close - - 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('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 +
+ New console.log('onIconAfterClick')} - iconAfterAlt="icon-after" + onIconAfterClick={() => console.log('Remove Chip')} > - New 1 + New console.log('onIconAfterClick')} - iconAfterAlt="icon-after" + onIconAfterClick={() => console.log('Remove Chip')} disabled > New - +
``` diff --git a/src/Chip/__snapshots__/Chip.test.jsx.snap b/src/Chip/__snapshots__/Chip.test.jsx.snap index ce2f964cc2..4b706e50db 100644 --- a/src/Chip/__snapshots__/Chip.test.jsx.snap +++ b/src/Chip/__snapshots__/Chip.test.jsx.snap @@ -1,21 +1,5 @@ // 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
- - - - - + + + + + `; @@ -51,25 +42,29 @@ exports[` snapshots renders with props iconBefore 1`] = `
- - - - - + + + + +
@@ -82,49 +77,60 @@ exports[` snapshots renders with props iconBefore and iconAfter 1`] = `
- - - - - + + + + +
Test
- - - - - + + + + +
`; diff --git a/src/Chip/_variables.scss b/src/Chip/_variables.scss index 33a80ac466..90c2878e07 100644 --- a/src/Chip/_variables.scss +++ b/src/Chip/_variables.scss @@ -1,28 +1,19 @@ -$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; +$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; diff --git a/src/Chip/constants.js b/src/Chip/constants.js deleted file mode 100644 index 6259d0c8dd..0000000000 --- a/src/Chip/constants.js +++ /dev/null @@ -1,5 +0,0 @@ -// 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 abfa54040d..d809b022fe 100644 --- a/src/Chip/index.scss +++ b/src/Chip/index.scss @@ -1,141 +1,98 @@ @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; - border: 1px solid $chip-border-color; - padding: $chip-padding-y $chip-padding-x; - position: relative; - outline: none; - transition: all .3s; + box-sizing: border-box; .pgn__chip__label { - font-size: $font-size-xs; - line-height: 1.5rem; - font-weight: $font-weight-bold; - color: $chip-label-color; + font-size: $font-size-sm; + padding: $chip-padding-y $chip-padding-x; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + box-sizing: border-box; + cursor: default; - [dir="rtl"] & { - margin-left: $chip-icon-margin; - } - } - - .pgn__chip__icon-before { - margin-right: $chip-icon-margin; + &.p-before { + padding-left: $chip-padding-to-icon; - [dir="rtl"] & { - margin-right: 0; - margin-left: .25rem; + [dir="rtl"] & { + padding-left: $chip-padding-x; + padding-right: $chip-padding-to-icon; + } } - } - .pgn__chip__icon-after { - margin-left: $chip-icon-margin; + &.p-after { + padding-right: $chip-padding-to-icon; - [dir="rtl"] & { - margin-left: 0; + [dir="rtl"] & { + padding-right: $chip-padding-x; + padding-left: $chip-padding-to-icon; + } } } .pgn__chip__icon-before, .pgn__chip__icon-after { - &.btn-icon { + align-items: center; + display: flex; + padding-left: $chip-icon-padding; + padding-right: $chip-icon-padding; + box-sizing: border-box; + cursor: default; + + .pgn__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; - } - } - .pgn__chip__icon-before, - .pgn__chip__icon-after { - &.pgn__icon { - color: $chip-label-color; - } - } - - &.interactive { + &.active:hover, + &.active:focus { cursor: pointer; + background: $black; - @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) - ); + * { + color: $white; + fill: $white; } } } - &.pgn__chip-dark { - background-color: $chip-dark-bg; - - &.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 - ); + .pgn__chip__icon-before { + border-radius: $chip-border-radius 0 0 $chip-border-radius; - &:focus { - border: 1px solid $chip-dark-selected-focus-border-color; - } + [dir="rtl"] & { + border-radius: 0 $chip-border-radius $chip-border-radius 0; } + } - .pgn__chip__label { - color: $chip-dark-label-color; - } + .pgn__chip__icon-after { + border-radius: 0 $chip-border-radius $chip-border-radius 0; - .pgn__chip__icon-before, - .pgn__chip__icon-after { - &.pgn__icon { - color: $chip-dark-outline-color; - } + [dir="rtl"] & { + border-radius: $chip-border-radius 0 0 $chip-border-radius; } + } - &.interactive { - cursor: pointer; - - @include chip-hover($white, $primary-500); + @each $color, $styles in $chip-theme-variants { + &.pgn__chip-#{$color} { + background: map-get($styles, "background"); - &:focus { - @include chip-outline( - $chip-dark-outline-color, - calc($chip-dark-focus-outline-distance * -1), - calc($chip-border-radius + $chip-outline-width) - ); + * { + color: map-get($styles, "color"); + fill: map-get($styles, "color"); } } } &.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 23abfde5c7..189053d5d0 100644 --- a/src/Chip/index.tsx +++ b/src/Chip/index.tsx @@ -2,97 +2,76 @@ import React, { ForwardedRef, KeyboardEventHandler, MouseEventHandler } from 're import PropTypes from 'prop-types'; import classNames from 'classnames'; // @ts-ignore -import { requiredWhen } from '../utils/propTypes'; -// @ts-ignore -import { STYLE_VARIANTS } from './constants'; -// @ts-ignore -import ChipIcon from './ChipIcon'; +import Icon from '../Icon'; -export const CHIP_PGN_CLASS = 'pgn__chip'; +const STYLE_VARIANTS = [ + 'light', + 'dark', +]; 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) => { - const hasInteractiveIcons = !!(onIconBeforeClick || onIconAfterClick); - const isChipInteractive = !hasInteractiveIcons && !!onClick; - - const interactionProps = isChipInteractive ? { - onClick, - onKeyPress: onClick, - tabIndex: 0, - role: 'button', - } : {}; - - return ( +}: IChip, ref: ForwardedRef) => ( +
+ {iconBefore && ( +
+ +
+ )}
- {iconBefore && ( - - )} + {children} +
+ {iconAfter && (
- {children} +
- {iconAfter && ( - - )} -
- ); -}); + )} + +)); Chip.propTypes = { /** Specifies the content of the `Chip`. */ @@ -100,11 +79,9 @@ Chip.propTypes = { /** Specifies an additional `className` to add to the base element. */ className: PropTypes.string, /** The `Chip` style variant to use. */ - variant: PropTypes.oneOf(['light', 'dark']), + variant: PropTypes.oneOf(STYLE_VARIANTS), /** 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: @@ -112,8 +89,6 @@ 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, /** @@ -123,26 +98,18 @@ 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: STYLE_VARIANTS.LIGHT, + variant: '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 deleted file mode 100644 index a5f850aa8b..0000000000 --- a/src/Chip/mixins.scss +++ /dev/null @@ -1,42 +0,0 @@ -@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 ef4ec9c747..e033dc2fcd 100644 --- a/src/ChipCarousel/_variables.scss +++ b/src/ChipCarousel/_variables.scss @@ -1,3 +1 @@ -$chip-carousel-controls-top-offset: .375rem !default; -$chip-carousel-container-padding-x: .625rem !default; -$chip-carousel-container-padding-y: .313rem !default; +$chip-carousel-controls-top-offset: -3px !default; diff --git a/src/ChipCarousel/index.scss b/src/ChipCarousel/index.scss index f36ae6303a..744acf9dea 100644 --- a/src/ChipCarousel/index.scss +++ b/src/ChipCarousel/index.scss @@ -11,7 +11,6 @@ &.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 0497c4cf76..42bb00acc5 100644 --- a/src/DataTable/TablePagination.jsx +++ b/src/DataTable/TablePagination.jsx @@ -14,15 +14,10 @@ function TablePagination() { const pageIndex = state?.pageIndex; return ( - gotoPage(pageNum - 1)} + handlePageSelect={(pageNum) => gotoPage(pageNum - 1)} pageCount={pageCount} - icons={{ - leftIcon: null, - rightIcon: null, - }} /> ); } diff --git a/src/DataTable/TablePaginationMinimal.jsx b/src/DataTable/TablePaginationMinimal.jsx index ce5a6f87a0..615a74b3f4 100644 --- a/src/DataTable/TablePaginationMinimal.jsx +++ b/src/DataTable/TablePaginationMinimal.jsx @@ -1,7 +1,6 @@ import React, { useContext } from 'react'; import DataTableContext from './DataTableContext'; import Pagination from '../Pagination'; -import { ArrowBackIos, ArrowForwardIos } from '../../icons'; function TablePaginationMinimal() { const { @@ -22,10 +21,6 @@ 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 b283587807..da03995281 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, screen } from '@testing-library/react'; +import { render, act } 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 () => { - render(); - const dropdownButton = screen.getByRole('button', { name: /2 of 3/i }); + const { getAllByTestId, getByRole } = render(); + const dropdownButton = getByRole('button', { name: /2 of 3/i }); expect(dropdownButton).toBeInTheDocument(); await act(async () => { await userEvent.click(dropdownButton); }); - const dropdownChoices = screen.getAllByTestId('pagination-dropdown-item'); + const dropdownChoices = getAllByTestId('pagination-dropdown-item'); expect(dropdownChoices.length).toEqual(instance.pageCount); await act(async () => { - await userEvent.click(dropdownChoices[2], undefined, { skipPointerEventsCheck: true }); + await userEvent.click(dropdownChoices[1], undefined, { skipPointerEventsCheck: true }); }); expect(instance.gotoPage).toHaveBeenCalledTimes(1); - expect(instance.gotoPage).toHaveBeenCalledWith(2); + expect(instance.gotoPage).toHaveBeenCalledWith(1); }, ); }); diff --git a/src/Form/FormAutosuggest.jsx b/src/Form/FormAutosuggest.jsx index 84e1223359..0e255d5553 100644 --- a/src/Form/FormAutosuggest.jsx +++ b/src/Form/FormAutosuggest.jsx @@ -1,10 +1,9 @@ import React, { - useEffect, useState, useRef, forwardRef, useImperativeHandle, + useEffect, useState, useRef, } 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'; @@ -15,353 +14,292 @@ import Spinner from '../Spinner'; import useArrowKeyNavigation from '../hooks/useArrowKeyNavigation'; import messages from './messages'; -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); - }; - - const collapseDropdown = () => { - setDropdownItems([]); - setIsDropdownExpanded(false); - setActiveMenuItemId(null); - }; +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 handleItemSelect = (e, onClick) => { - const selectedValue = e.currentTarget.getAttribute('data-value'); - const selectedId = e.currentTarget.id; + setState(prevState => ({ + ...prevState, + dropDownItems: [], + displayValue: clickedValue, + })); - setHasValue(true); - setHasSelection(true); - setDisplayValue(selectedValue); + setIsMenuClosed(true); - if (onChange && (!value || (value && selectedValue !== value.selectionValue))) { - onChange({ - userProvidedText: selectedValue, - selectionValue: selectedValue, - selectionId: selectedId, - }); - } + 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), + }); + }); - collapseDropdown(); + if (strToFind.length > 0) { + childrenOpt = childrenOpt + .filter((opt) => (opt.props.children.toLowerCase().includes(strToFind.toLowerCase()))); + } - if (onClick) { - onClick(e); - } - }; + return childrenOpt; + } - 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), - }); - }); + const handleExpand = () => { + setIsMenuClosed(!isMenuClosed); - if (strToFind.length > 0) { - childrenOpt = childrenOpt - .filter((opt) => (opt.props.children.toLowerCase().includes(strToFind.toLowerCase()))); - } + const newState = { + dropDownItems: [], + }; - return childrenOpt; + if (isMenuClosed) { + setIsActive(true); + newState.dropDownItems = getItems(state.displayValue); + newState.errorMessage = ''; } - const expandDropdown = () => { - setDropdownItems(getItems(displayValue)); - setIsValid(true); - setErrorMessage(''); - setIsDropdownExpanded(true); - }; + setState(prevState => ({ + ...prevState, + ...newState, + })); + }; + + const iconToggle = ( + handleExpand(e, isMenuClosed)} + /> + ); + + const leaveControl = () => { + setIsActive(false); + + setState(prevState => ({ + ...prevState, + dropDownItems: [], + errorMessage: !state.displayValue ? errorMessageText : '', + })); - const toggleDropdown = () => { - if (isDropdownExpanded) { - collapseDropdown(); - } else { - expandDropdown(); - } - }; + setIsMenuClosed(true); + }; - const iconToggle = ( - - ); - - const enterControl = () => { - setIsActive(true); - }; + const handleDocumentClick = (e) => { + if (parentRef.current && !parentRef.current.contains(e.target) && isActive) { + leaveControl(); + } + }; - const updateErrorStateAndErrorMessage = () => { - if (hasCustomError) { - setIsValid(false); - setErrorMessage(customErrorMessageText); - return; - } + const keyDownHandler = e => { + if (e.key === 'Escape' && isActive) { + e.preventDefault(); - if (isValueRequired && !hasValue) { - setIsValid(false); - setErrorMessage(valueRequiredErrorMessageText); - return; + if (formControlRef) { + formControlRef.current.focus(); } - if (hasValue && isSelectionRequired && !hasSelection) { - setIsValid(false); - setErrorMessage(selectionRequiredErrorMessageText); - return; - } + setState(prevState => ({ + ...prevState, + dropDownItems: [], + })); - setIsValid(true); - setErrorMessage(''); - }; + setIsMenuClosed(true); + } + if (e.key === 'Tab' && isActive) { + leaveControl(); + } + }; - useImperativeHandle(ref, () => ({ - // expose updateErrorStateAndErrorMessage so consumers can trigger validation - // when changing the value of the control externally - updateErrorStateAndErrorMessage, - })); + useEffect(() => { + document.addEventListener('keydown', keyDownHandler); + document.addEventListener('click', handleDocumentClick, true); - const leaveControl = () => { - setIsActive(false); - collapseDropdown(); - updateErrorStateAndErrorMessage(); + return () => { + document.removeEventListener('click', handleDocumentClick, true); + document.removeEventListener('keydown', keyDownHandler); }; + }); + + useEffect(() => { + if (value || value === '') { + setState(prevState => ({ + ...prevState, + displayValue: value, + })); + } + }, [value]); - const keyDownHandler = e => { - if (!isActive) { - return; - } + const setDisplayValue = (itemValue) => { + const optValue = []; - if (e.key === 'Escape') { - e.preventDefault(); + children.forEach(opt => { + optValue.push(opt.props.children); + }); - if (formControlRef) { - formControlRef.current.focus(); - } + const normalized = itemValue.toLowerCase(); + const opt = optValue.find((o) => o.toLowerCase() === normalized); - collapseDropdown(); - return; - } + setState(prevState => ({ + ...prevState, + displayValue: opt || itemValue, + })); + }; - if (e.key === 'Tab') { - leaveControl(); - } - }; + const handleClick = (e) => { + setIsActive(true); + const dropDownItems = getItems(e.target.value); - const handleDocumentClick = (e) => { - if (parentRef.current && !parentRef.current.contains(e.target) && isActive) { - leaveControl(); - } - }; + if (dropDownItems.length > 1) { + setState(prevState => ({ + ...prevState, + dropDownItems, + errorMessage: '', + })); - useEffect(() => { - document.addEventListener('keydown', keyDownHandler); - document.addEventListener('click', handleDocumentClick, true); + setIsMenuClosed(false); + } + }; - return () => { - document.removeEventListener('click', handleDocumentClick, true); - document.removeEventListener('keydown', keyDownHandler); - }; - }); + const handleOnChange = (e) => { + const findStr = e.target.value; - useEffect(() => { - setDisplayValue(value ? value.userProvidedText ?? '' : ''); - setHasValue(!!value && !!value.userProvidedText); - setHasSelection(!!value && !!value.selectionValue); - }, [value]); + if (onChange) { onChange(findStr); } - const handleTextboxClick = () => { - expandDropdown(); - }; + if (findStr.length) { + const filteredItems = getItems(findStr); + setState(prevState => ({ + ...prevState, + dropDownItems: filteredItems, + errorMessage: '', + })); - 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; - } + setIsMenuClosed(false); + } else { + setState(prevState => ({ + ...prevState, + dropDownItems: [], + })); - // 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; - } + setIsMenuClosed(true); + } - // 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, - }); - } - }; + setDisplayValue(e.target.value); + }; - 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 && ( + const { getControlProps } = useFormGroupContext(); + const controlProps = getControlProps(props); + + 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 && ( {helpMessage} - )} + )} - {!isValid && ( + {state.errorMessage && ( - {errorMessage} + {errorMessageText} - )} - -
    - {isLoading ? ( -
    - -
    - ) : dropdownItems.length > 0 && dropdownItems} -
-
- ); - }, -); + )} +
+ +
    + {isLoading ? ( +
    + +
    + ) : state.dropDownItems.length > 0 && state.dropDownItems} +
+
+ ); +} FormAutosuggest.defaultProps = { arrowKeyNavigationSelector: 'a:not(:disabled),li:not(:disabled, .btn-icon),input:not(:disabled)', @@ -370,15 +308,11 @@ FormAutosuggest.defaultProps = { className: null, floatingLabel: null, onChange: null, + onSelected: null, helpMessage: '', placeholder: '', value: null, - isValueRequired: false, - valueRequiredErrorMessageText: null, - isSelectionRequired: false, - selectionRequiredErrorMessageText: null, - hasCustomError: false, - customErrorMessageText: null, + errorMessageText: null, readOnly: false, children: null, name: 'form-autosuggest', @@ -406,23 +340,9 @@ FormAutosuggest.propTypes = { /** Specifies the placeholder text for the input. */ placeholder: PropTypes.string, /** Specifies values for the input. */ - 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'), + value: PropTypes.string, + /** Informs user has errors. */ + errorMessageText: PropTypes.string, /** Specifies the name of the base input element. */ name: PropTypes.string, /** Selected list item is read-only. */ @@ -431,6 +351,8 @@ 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 f59bfdd314..1a474e21e5 100644 --- a/src/Form/form-autosuggest.mdx +++ b/src/Form/form-autosuggest.mdx @@ -19,79 +19,52 @@ Form auto-suggest enables users to manually select or type to find matching opti ```jsx live () => { - const [value, setValue] = useState({}); - const [isValueRequired, setIsValueRequired] = useState(false); - const [isSelectionRequired, setIsSelectionRequired] = useState(false); - const [hasCustomValidation, setHasCustomValidation] = useState(false); + const [selected, setSelected] = useState(''); - const hasCustomError = () => (hasCustomValidation ? value.selectionId !== 'c-option-id' : false); + return ( + + +

Programming language

+
+ setSelected(value)} + > + JavaScript + Python + Rube + alert(e.currentTarget.getAttribute('data-value'))}> + Option with custom onClick + + +
+ ); +}; +``` - const autosuggestRef = useRef(); - const forceUpdateErrorState = () => { - autosuggestRef.current.updateErrorStateAndErrorMessage(); - }; +## Search Usage + +```jsx live +() => { + const [selected, setSelected] = useState(''); return ( - <> - - -

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} - /> - - - - + setSelected(value)} + > + PHP + Java + Turbo Pascal + Flask + ); }; ``` @@ -100,9 +73,6 @@ 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); @@ -118,10 +88,8 @@ Form auto-suggest enables users to manually select or type to find matching opti }); }, []); - const searchCoffee = (title, id) => { - if (!id) { - setShowLoading(true); - } + const searchCoffee = (title) => { + setShowLoading(true); fetch('https://api.sampleapis.com/coffee/hot') .then(data => data.json()) .then(items => setTimeout(() => { @@ -132,45 +100,20 @@ 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})} - -
- - -
userProvidedText:
-
{userProvidedText}
-
- -
selectionValue:
-
{selectionValue}
-
- -
selectionId:
-
{selectionId}
-
-
- + + +

Café API

+
+ + {data.map((item, index) => {item.title})} + +
); }; ``` diff --git a/src/Form/tests/FormAutosuggest.test.jsx b/src/Form/tests/FormAutosuggest.test.jsx index ead36d88cb..dce226f71a 100644 --- a/src/Form/tests/FormAutosuggest.test.jsx +++ b/src/Form/tests/FormAutosuggest.test.jsx @@ -23,15 +23,10 @@ function FormAutosuggestTestComponent(props) { name="FormAutosuggest" floatingLabel="floatingLabel text" helpMessage="Example help message" - 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} + errorMessageText="Example error message" + onSelected={props.onSelected} > - Option 1 + Option 1 Option 2 Learn from more than 160 member universities @@ -52,19 +47,15 @@ function FormAutosuggestLabelTestComponent() { } FormAutosuggestTestComponent.defaultProps = { - onChange: jest.fn(), + onSelected: jest.fn(), onClick: jest.fn(), - isValueRequired: false, - isSelectionRequired: false, - hasCustomError: false, }; FormAutosuggestTestComponent.propTypes = { - onChange: PropTypes.func, + /** Specifies onSelected event handler. */ + onSelected: PropTypes.func, + /** Specifies onClick event handler. */ onClick: PropTypes.func, - isValueRequired: PropTypes.bool, - isSelectionRequired: PropTypes.bool, - hasCustomError: PropTypes.bool, }; describe('render behavior', () => { @@ -85,7 +76,7 @@ describe('render behavior', () => { }); it('renders the auto-populated value if it exists', () => { - render(); + render(); expect(screen.getByDisplayValue('Test Value')).toBeInTheDocument(); }); @@ -97,42 +88,15 @@ describe('render behavior', () => { expect(list.length).toBe(3); }); - 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(); + it('renders with 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 custom error message'); + const formControlFeedback = getByText('Example error message'); expect(formControlFeedback).toBeInTheDocument(); }); @@ -183,28 +147,17 @@ describe('controlled behavior', () => { expect(input.value).toEqual('Option 1'); }); - it('calls onChange based on clicked option', () => { - const onChange = jest.fn(); - const { getByText, getByTestId } = render(); + it('calls onSelected based on clicked option', () => { + const onSelected = jest.fn(); + const { getByText, getByTestId } = render(); const input = getByTestId('autosuggest-textbox-input'); userEvent.click(input); const menuItem = getByText('Option 1'); userEvent.click(menuItem); - 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: '' }); + expect(onSelected).toHaveBeenCalledWith('Option 1'); + expect(onSelected).toHaveBeenCalledTimes(1); }); 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 deleted file mode 100644 index 2ca7c1048b..0000000000 --- a/src/Pagination/DefaultPagination.jsx +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 4b89247509..0000000000 --- a/src/Pagination/MinimalPagination.jsx +++ /dev/null @@ -1,11 +0,0 @@ -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 cfaf6019fc..98f13d30ab 100644 --- a/src/Pagination/Pagination.test.jsx +++ b/src/Pagination/Pagination.test.jsx @@ -1,40 +1,26 @@ import React from 'react'; -import { Context as ResponsiveContext } from 'react-responsive'; -import renderer from 'react-test-renderer'; -import { - render, - act, - screen, -} from '@testing-library/react'; +import { render, act, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import '@testing-library/jest-dom'; + +import { Context as ResponsiveContext } from 'react-responsive'; + 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 = { - currentPage: 1, + state: { pageIndex: 1 }, paginationLabel: 'pagination navigation', pageCount: 5, onPageSelect: () => {}, }; describe('', () => { - 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', () => { + const props = { + ...baseProps, + }; + const { container } = render(); + expect(container).toBeInTheDocument(); }); it('renders screen reader section', () => { @@ -45,94 +31,65 @@ 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(expectedSrText); - expect(srText).toHaveClass('sr-only'); - }); - - 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(); + const srText = screen.getByText(`${buttonLabels.page} 1, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} ${baseProps.pageCount}`); + expect(srText).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('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'); + 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('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'); + 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(); }); }); describe('handles focus properly', () => { - it('should change focus to next button if previous page is first page', () => { + it('should change focus to next button if previous page is first page', async () => { const props = { ...baseProps, currentPage: 2, - buttonLabel: { - previous: 'Previous', - next: 'Next', - }, }; render(); - userEvent.click(screen.getByText(PAGINATION_BUTTON_LABEL_PREV)); - expect(screen.getByText(PAGINATION_BUTTON_LABEL_NEXT)).toHaveFocus(); + const previousButton = screen.getByLabelText(/Previous/); + const nextButton = screen.getByLabelText(/Next/); + await userEvent.click(previousButton); + expect(document.activeElement).toEqual(nextButton); }); - it('should change focus to previous button if next page is last page', () => { + it('should change focus to previous button if next page is last page', async () => { const props = { ...baseProps, currentPage: baseProps.pageCount - 1, - buttonLabel: { - previous: 'Previous', - next: 'Next', - }, }; render(); - userEvent.click(screen.getByText(props.buttonLabel.next)); - expect(screen.getByText(props.buttonLabel.previous)).toHaveFocus(); + const previousButton = screen.getByLabelText(/Previous/); + const nextButton = screen.getByLabelText(/Next/); + await userEvent.click(nextButton); + expect(document.activeElement).toEqual(previousButton); }); }); @@ -144,113 +101,94 @@ describe('', () => { paginationLabel, }; render(); - expect(screen.getByRole('navigation')).toHaveAttribute('aria-label', paginationLabel); + expect(screen.getByLabelText(paginationLabel)).toBeInTheDocument(); }); describe('should use correct number of pages', () => { it('should show 5 buttons on desktop', () => { - render(( + render( - - )); + , + ); - const buttonsAriaLabel = new RegExp(`^${PAGINATION_BUTTON_LABEL_PAGE}`); - expect(screen.queryAllByRole('button', { name: buttonsAriaLabel })).toHaveLength(5); + const pageButtons = screen.getAllByLabelText(/^Page/); + expect(pageButtons.length).toBe(5); }); - 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(( + it('should show 1 button on mobile', () => { + // Use extra small window size to display the mobile version of Pagination. + render( - - - )); - - 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(); + + , + ); + const pageButtons = screen.getAllByLabelText(/^Page/); + expect(pageButtons.length).toBe(1); }); }); describe('should fire callbacks properly', () => { - it('should not fire onPageSelect when selecting current page', () => { + it('should not fire onPageSelect when selecting current page', async () => { const spy = jest.fn(); const props = { ...baseProps, onPageSelect: spy, }; - render(( + render( - - )); + , + ); - userEvent.click(screen.getByLabelText(PAGINATION_BUTTON_LABEL_CURRENT_PAGE, { exact: false })); + const previousButton = screen.getByLabelText(/Previous/); + await userEvent.click(previousButton); expect(spy).toHaveBeenCalledTimes(0); }); - it('should fire onPageSelect callback when selecting new page', () => { + it('should fire onPageSelect callback when selecting new page', async () => { const spy = jest.fn(); const props = { ...baseProps, onPageSelect: spy, }; - render(( + render( - - )); + , + ); - userEvent.click(screen.getByLabelText(`${PAGINATION_BUTTON_LABEL_PAGE} 2`)); + const pageButtons = screen.getAllByLabelText(/^Page/); + await userEvent.click(pageButtons[1]); expect(spy).toHaveBeenCalledTimes(1); - userEvent.click(screen.getByLabelText(`${PAGINATION_BUTTON_LABEL_PAGE} 3`)); + await userEvent.click(pageButtons[2]); expect(spy).toHaveBeenCalledTimes(2); }); }); }); describe('fires previous and next button click handlers', () => { - it('previous button onClick', () => { + it('previous button onClick', async () => { const spy = jest.fn(); const props = { ...baseProps, + currentPage: 2, onPageSelect: spy, - currentPage: 3, }; render(); - const expectedPrevButtonAriaLabel = `${PAGINATION_BUTTON_LABEL_PREV}, ${PAGINATION_BUTTON_LABEL_PAGE} 2`; - userEvent.click(screen.getByRole('button', { name: expectedPrevButtonAriaLabel })); + await userEvent.click(screen.getByLabelText(/Previous/)); expect(spy).toHaveBeenCalledTimes(1); }); - it('next button onClick', () => { + it('next button onClick', async () => { const spy = jest.fn(); const props = { ...baseProps, onPageSelect: spy, }; render(); - const expectedNextButtonAriaLabel = `${PAGINATION_BUTTON_LABEL_NEXT}, ${PAGINATION_BUTTON_LABEL_PAGE} 2`; - userEvent.click(screen.getByRole('button', { name: expectedNextButtonAriaLabel })); + await userEvent.click(screen.getByLabelText(/Next/)); expect(spy).toHaveBeenCalledTimes(1); }); }); @@ -263,95 +201,112 @@ describe('', () => { currentPage: 'Página actual', pageOfCount: 'de', }; - const props = { + + let props = { ...baseProps, buttonLabels, }; - 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(); + /** + * 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 ( + + + + ); + } - 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 previous button label', async () => { + render( + , + ); + expect(screen.getByText(buttonLabels.previous)).toBeInTheDocument(); + + await userEvent.click(screen.getByText(buttonLabels.next)); + expect(screen.getByLabelText(`${buttonLabels.previous}, ${buttonLabels.page} 4`)).toBeInTheDocument(); }); it('uses passed in next button label', () => { - 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(); + const { rerender } = render( + , + ); + expect(screen.getByLabelText(`${buttonLabels.next}, ${buttonLabels.page} 2`)).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByLabelText(buttonLabels.next)).toBeInTheDocument(); }); it('uses passed in page button label', () => { - const currentPageLabel = `${buttonLabels.page} 1, ${buttonLabels.currentPage}`; - const pageLabel = `${buttonLabels.page} 1`; - - const { rerender } = render(( + const { rerender } = render( - - )); - expect(screen.getByText('1')).toHaveAttribute('aria-label', currentPageLabel); - rerender(( + , + ); + 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', pageLabel); - - rerender(( - - - - )); - - const pageOfCountLabel = `${buttonLabels.page} 1, ${buttonLabels.currentPage}, ${buttonLabels.pageOfCount} 5`; - expect(screen.queryByLabelText(pageOfCountLabel)).toBeInTheDocument(); + , + ); + 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(); }); it('for the reduced variant shows dropdown button with the page count as label', async () => { render(); - const dropdownLabel = `${baseProps.currentPage} de ${baseProps.pageCount}`; + 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); await act(async () => { - userEvent.click(screen.getByRole('button', { name: dropdownLabel })); + const dropdownChoices = screen.getAllByTestId('pagination-dropdown-item'); + expect(dropdownChoices.length).toBe(baseProps.pageCount); }); - expect(screen.queryAllByRole('button', { name: /^\d+$/ }).length).toEqual(baseProps.pageCount); }); it('renders only previous and next buttons in minimal variant', () => { - render(); - expect(screen.queryAllByRole('button').length).toEqual(2); + render( + pageNumber} + pageCount={12} + paginationLabel="Label" + />, + ); + const items = screen.getAllByRole('listitem'); + expect(items.length).toBe(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(); - }, - ); + 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); + } + }); + }); }); }); diff --git a/src/Pagination/PaginationContext.jsx b/src/Pagination/PaginationContext.jsx deleted file mode 100644 index c6dbcffc02..0000000000 --- a/src/Pagination/PaginationContext.jsx +++ /dev/null @@ -1,191 +0,0 @@ -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 13577ea948..3db95e620f 100644 --- a/src/Pagination/README.md +++ b/src/Pagination/README.md @@ -18,102 +18,61 @@ notes: | Navigation between multiple pages of some set of results. Controls are provided to navigate through multiple pages of related data. -## 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 +## Basic usage (Default Size) ```jsx live console.log(`page ${page} selected`)} + onPageSelect={() => console.log('page selected')} /> ``` -### Secondary +## Secondary ```jsx live console.log(`page ${page} selected`)} - icons={{ - leftIcon: ArrowBackIos, - rightIcon: ArrowForwardIos, - }} + onPageSelect={() => console.log('page selected')} /> ``` -### Reduced +## Reduced ```jsx live console.log(`page ${page} selected`)} + onPageSelect={() => console.log('page selected')} /> ``` -### Minimal +## Minimal ```jsx live console.log(`page ${page} selected`)} - icons={{ - leftIcon: ArrowBackIos, - rightIcon: ArrowForwardIos, - }} + onPageSelect={() => console.log('page selected')} /> ``` -## Small Size +## Basic usage (Small Size) -### Default variant ```jsx live console.log(`page ${page} selected`)} + onPageSelect={() => console.log('page selected')} /> ``` -### Secondary (Small Size) +## Secondary (Small Size) ```jsx live console.log(`page ${page} selected`)} + onPageSelect={() => console.log('page selected')} /> ``` -### Reduced (Small Size) +## Reduced (Small Size) ```jsx live console.log(`page ${page} selected`)} + onPageSelect={() => console.log('page selected')} /> ``` -### Minimal (Small Size) +## Minimal (Small Size) ```jsx live console.log(`page ${page} selected`)} + onPageSelect={() => console.log('page selected')} /> ``` @@ -157,36 +116,21 @@ Navigation between multiple pages of some set of results. Controls are provided paginationLabel="pagination navigation" pageCount={20} invertColors - onPageSelect={(page) => console.log(`page ${page} selected`)} - /> - console.log(`page ${page} selected`)} - icons={{ - leftIcon: ArrowBackIos, - rightIcon: ArrowForwardIos, - }} + onPageSelect={() => console.log('page selected')} /> console.log(`page ${page} selected`)} + onPageSelect={() => console.log('page selected')} /> console.log(`page ${page} selected`)} - icons={{ - leftIcon: ArrowBackIos, - rightIcon: ArrowForwardIos, - }} + onPageSelect={() => console.log('page selected')} /> ``` @@ -200,15 +144,7 @@ Navigation between multiple pages of some set of results. Controls are provided pageCount={20} invertColors size="small" - onPageSelect={(page) => console.log(`page ${page} selected`)} - /> - console.log(`page ${page} selected`)} + onPageSelect={() => console.log('page selected')} /> console.log(`page ${page} selected`)} + onPageSelect={() => console.log('page selected')} /> console.log(`page ${page} selected`)} + onPageSelect={() => console.log('page selected')} /> ``` diff --git a/src/Pagination/ReducedPagination.jsx b/src/Pagination/ReducedPagination.jsx deleted file mode 100644 index 453c4195b1..0000000000 --- a/src/Pagination/ReducedPagination.jsx +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index cf3993c5a3..0000000000 --- a/src/Pagination/__snapshots__/Pagination.test.jsx.snap +++ /dev/null @@ -1,301 +0,0 @@ -// 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 e03ddfd392..c9482529ac 100644 --- a/src/Pagination/_variables.scss +++ b/src/Pagination/_variables.scss @@ -1,19 +1,49 @@ // 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-dropdown-color-inverse: $white !default; +$pagination-color: $link-color !default; +$pagination-color-inverse: $white !default; +$pagination-bg: $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 472b68b527..e4b063b11e 100644 --- a/src/Pagination/constants.js +++ b/src/Pagination/constants.js @@ -1,16 +1,2 @@ +/* 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 5a8910c1b5..69c35cce5d 100644 --- a/src/Pagination/getPaginationRange.js +++ b/src/Pagination/getPaginationRange.js @@ -6,10 +6,6 @@ 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 085f6247b5..000f1318cb 100644 --- a/src/Pagination/index.jsx +++ b/src/Pagination/index.jsx @@ -1,53 +1,420 @@ -import React from 'react'; +/* eslint-disable max-len */ import classNames from 'classnames'; import PropTypes from 'prop-types'; +import React from 'react'; +import MediaQuery from 'react-responsive'; -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'; - +import { + ChevronLeft, ChevronRight, ArrowBackIos, ArrowForwardIos, +} from '../../icons'; 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 ; +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, + }); + } + } - if (variant === PAGINATION_VARIANTS.minimal) { - return ; + handlePageSelect(page) { + if (page !== this.state.currentPage) { + this.setState({ + currentPage: page, + pageButtonSelected: true, + }); + this.props.onPageSelect(page); } + } - return ; - }; + handlePreviousNextButtonClick(page) { + const { pageCount } = this.props; - return ( - + 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}`; + } + + 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}`; + } + + 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; + } + 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()} +
    + ); + } + + renderMinimalPaginations() { + return ( +
      + {this.renderPreviousButton()} + {this.renderNextButton()} +
    + ); + } + + render() { + const { variant, invertColors, size } = this.props; + return ( -
    - ); + ); + } } Pagination.propTypes = { @@ -116,35 +483,40 @@ Pagination.propTypes = { * string, symbol, etc. Default is chevrons rendered using fa-css. */ icons: PropTypes.shape({ - leftIcon: PropTypes.elementType, - rightIcon: PropTypes.elementType, + leftIcon: PropTypes.node, + rightIcon: PropTypes.node, }), variant: PropTypes.oneOf(['default', 'secondary', 'reduced', 'minimal']), invertColors: PropTypes.bool, size: PropTypes.oneOf(['default', 'small']), - initialPage: PropTypes.number, }; Pagination.defaultProps = { icons: { - leftIcon: ChevronLeft, - rightIcon: ChevronRight, + leftIcon: , + rightIcon: , }, buttonLabels: { - previous: 'Previous', - next: 'Next', - page: 'Page', - currentPage: 'Current Page', - pageOfCount: 'of', + 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, }, className: undefined, - initialPage: 1, - currentPage: undefined, + currentPage: 1, 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 66706fbac7..4b94a4884f 100644 --- a/src/Pagination/index.scss +++ b/src/Pagination/index.scss @@ -1,4 +1,14 @@ @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%; @@ -10,70 +20,108 @@ border-bottom-left-radius: 50%; } -.pagination { - display: flex; - margin: 0; +.pagination-icon-button-right { + @extend %pagination-icon-button-right; +} - .dropdown { - z-index: 4; - } +.pagination-icon-button-left { + @extend %pagination-icon-button-left; +} - .page-of-count { - margin: 0 .5rem; - border: 0; +.pagination-default { + .page-link { + &.previous .pgn__icon { + margin-inline-start: 0; + margin-inline-end: $pagination-margin-x; + } + + &.next .pgn__icon { + margin-inline-start: $pagination-margin-x; + margin-inline-end: 0; + } } .page-item { &:first-child .page-link { - margin-left: 0; - - @include border-left-radius($border-radius); + [dir="rtl"] & { + border-radius: 0 $pagination-border-radius-lg $pagination-border-radius-lg 0; + } } &:last-child .page-link { - @include border-right-radius($border-radius); + [dir="rtl"] & { + border-radius: $pagination-border-radius-lg 0 0 $pagination-border-radius-lg; + } } + } +} - &:first-child .btn-icon.page-link { - @extend %pagination-icon-button-left; - } +.page-link { + border: none; - &:last-child .btn-icon.page-link { - @extend %pagination-icon-button-right; - } + &.btn-primary:not(:disabled):not(.disabled):focus { + background-color: $pagination-bg; + color: $pagination-focus-color-text; + } - &.active .page-link { - z-index: 3; - } + &:focus { + box-shadow: none; + } - > .btn { - transition: none; - line-height: $pagination-line-height; + &.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; + } + + [dir="rtl"] & { + svg { + transform: scale(-1); } } - @include list-unstyled(); - @include border-radius(); + &:focus::before, + &.focus::before { + border-radius: 0; + } +} - &-small { - .page-link { - font-size: $pagination-font-size-sm; - line-height: $pagination-line-height; - padding: .375rem .78rem; +.page-item { + > .btn { + transition: none; + line-height: $pagination-line-height; + } - &.previous, - &.next { - padding: 0 $pagination-padding-y; - line-height: $pagination-secondary-height-sm; + &.active .page-link.btn-primary:not(:disabled):not(.disabled):focus { + background-color: $pagination-focus-color; + color: $pagination-bg; + } +} - div { - display: flex; - align-items: center; - } +.pagination-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; + + div { + display: flex; + align-items: center; } } + } - &:not(.pagination-default) .page-link { + &:not(.pagination-default) { + .page-link { &.previous, &.next { padding: 0; @@ -81,122 +129,176 @@ } } } +} - &-secondary { +.pagination-secondary { + button.next, + button.previous { + height: $pagination-secondary-height; + padding: 0 $pagination-padding-y; + } + + &.pagination-small { button.next, button.previous { - height: $pagination-secondary-height; - padding: 0 $pagination-padding-y; + height: $pagination-secondary-height-sm; + line-height: $pagination-line-height; } + } - &.pagination-small { - button.next, - button.previous { - height: $pagination-secondary-height-sm; - line-height: $pagination-line-height; - } - } + .page-item:first-child .page-link { + @extend %pagination-icon-button-left; } - .ellipsis { - border: 0; - margin-left: 0; + .page-item:last-child .page-link { + @extend %pagination-icon-button-right; } +} - &-inverse { - .ellipsis { - color: $white; +.pagination-inverse { + %dark-styles { + background-color: transparent; + color: $white; + } + + .pgn__dark-styles { + @extend %dark-styles; + } + + .page-item { + &.disabled .page-link { + @extend %dark-styles; } - .dropdown .dropdown-toggle::after { - border-top: $pagination-toggle-border solid $pagination-dropdown-color-inverse; + &.active button.page-link { + background-color: $pagination-bg; + color: $pagination-color; } - } - &-reduced { - &-dropdown-menu { - overflow-y: auto; - max-height: $pagination-reduced-dropdown-max-height; - min-width: $pagination-reduced-dropdown-min-width; + button.page-link { + @extend %dark-styles; - a { - text-align: center; + &:focus { + box-shadow: none; } } - .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; + &:not(.active):focus { + box-shadow: $level-1-box-shadow; } + } - button.next, - button.previous { - height: $pagination-secondary-height; - padding: 0 $pagination-padding-y; + .page-link { + &:focus::before, + &.focus::before { + display: none; } + } - &.pagination-small { - .btn.dropdown-toggle { - font-size: $pagination-font-size-sm; + .dropdown { + .btn-tertiary { + color: $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; - } + &::after { + border-top: $pagination-toggle-border solid $pagination-color-inverse; } - button.previous, - button.next { - line-height: $pagination-icon-height; - height: $pagination-icon-height; + &:active, + &:hover { + background-color: transparent; + } + + &:not(:disabled):not(.disabled):active { + color: $pagination-color-inverse; } } } - &-minimal { - .page-item:first-child { - margin-inline-end: .3rem; - } + .show > .dropdown-toggle { + background-color: transparent; + } +} - button.next, - button.previous { - padding: $pagination-padding-y; - height: $pagination-secondary-height; - } +.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; - &.pagination-small { - button.next, - button.previous { - padding: 0 $pagination-padding-y; - height: $pagination-secondary-height-sm; + &::after { + border-left-width: $pagination-toggle-border-sm; + border-right-width: $pagination-toggle-border-sm; + border-top-width: $pagination-toggle-border-sm; } } + + button.previous, + button.next { + line-height: $pagination-icon-height; + height: $pagination-icon-height; + } } -} -.page-link { - border: none; - margin-left: -$pagination-border-width; + .page-item:first-child .page-link { + @extend %pagination-icon-button-left; + } - &:focus { - z-index: 3; + .page-item:last-child .page-link { + @extend %pagination-icon-button-right; } +} - div { - display: flex; +.pagination-minimal { + .page-item:first-child { + margin-inline-end: .3rem; } - [dir="rtl"] & { - svg { - transform: scale(-1); + button.next, + button.previous { + padding: $pagination-padding-y; + height: $pagination-secondary-height; + } + + &.pagination-small { + button.next, + button.previous { + padding: 0 $pagination-padding-y; + height: $pagination-secondary-height-sm; } } + + .page-item:first-child .page-link { + @extend %pagination-icon-button-left; + } + + .page-item:last-child .page-link { + @extend %pagination-icon-button-right; + } } diff --git a/src/Pagination/subcomponents/Ellipsis.jsx b/src/Pagination/subcomponents/Ellipsis.jsx deleted file mode 100644 index 9c6e26ce98..0000000000 --- a/src/Pagination/subcomponents/Ellipsis.jsx +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 12a04fcd6c..0000000000 --- a/src/Pagination/subcomponents/NextPageButton.jsx +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index 3f2c4ad866..0000000000 --- a/src/Pagination/subcomponents/PageButton.jsx +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 78c4f7313a..0000000000 --- a/src/Pagination/subcomponents/PageOfCountButton.jsx +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 9856b493db..0000000000 --- a/src/Pagination/subcomponents/PaginationDropdown.jsx +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index d0ab0f11bd..0000000000 --- a/src/Pagination/subcomponents/PreviousPageButton.jsx +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index d67b34cbc5..0000000000 --- a/src/Pagination/subcomponents/ScreenReaderText.jsx +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 707481db8a..0000000000 --- a/src/Pagination/subcomponents/index.js +++ /dev/null @@ -1,7 +0,0 @@ -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 1f6cebba55..ac05fb5780 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).toHaveClass(`btn-icon-${buttonProps.variant}`); + expect(buttonClear).toHaveAttribute('variant', 'inline'); }); it('should pass props to the label', () => { diff --git a/src/SearchField/SearchFieldAdvanced.jsx b/src/SearchField/SearchFieldAdvanced.jsx index 8788308d3f..75b261d05c 100644 --- a/src/SearchField/SearchFieldAdvanced.jsx +++ b/src/SearchField/SearchFieldAdvanced.jsx @@ -6,6 +6,8 @@ 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 = [ @@ -192,8 +194,8 @@ SearchFieldAdvanced.defaultProps = { clearButton: 'clear search', }, icons: { - clear: Close, - submit: Search, + clear: , + submit: , }, onBlur: () => {}, onChange: () => {}, diff --git a/src/SearchField/SearchFieldClearButton.jsx b/src/SearchField/SearchFieldClearButton.jsx index f9d42a03aa..bf667fa0de 100644 --- a/src/SearchField/SearchFieldClearButton.jsx +++ b/src/SearchField/SearchFieldClearButton.jsx @@ -1,8 +1,6 @@ import React, { useContext } from 'react'; import { SearchFieldContext } from './SearchFieldAdvanced'; -import Icon from '../Icon'; -import IconButton from '../IconButton'; function SearchFieldClearButton(props) { const { @@ -20,17 +18,11 @@ 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 0edcffb1fd..eff97e9004 100644 --- a/src/SearchField/SearchFieldSubmitButton.jsx +++ b/src/SearchField/SearchFieldSubmitButton.jsx @@ -1,10 +1,9 @@ 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', @@ -41,17 +40,16 @@ function SearchFieldSubmitButton(props) { {screenReaderText.submitButton} ) : ( - + > + {icons.submit} + {screenReaderText.submitButton} + ); } diff --git a/src/SearchField/__snapshots__/SearchField.test.jsx.snap b/src/SearchField/__snapshots__/SearchField.test.jsx.snap index e9964ae45d..9cd5cf7612 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 9d46f7d802..b3c34dc543 100644 --- a/src/SearchField/index.jsx +++ b/src/SearchField/index.jsx @@ -8,6 +8,8 @@ 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'; @@ -167,8 +169,8 @@ SearchField.defaultProps = { clearButton: SEARCH_FIELD_SCREEN_READER_TEXT_CLEAR_BUTTON, }, icons: { - clear: Close, - submit: Search, + clear: , + submit: , }, onBlur: () => {}, onChange: () => {}, diff --git a/src/SearchField/index.scss b/src/SearchField/index.scss index db467466ae..b381fc5275 100644 --- a/src/SearchField/index.scss +++ b/src/SearchField/index.scss @@ -91,6 +91,14 @@ &.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; @@ -105,6 +113,14 @@ height: 100%; } } + + .btn-primary { + background: map-get($search-btn-variants, "light"); + } + + .btn-brand { + background: map-get($search-btn-variants, "dark"); + } } } @@ -125,9 +141,3 @@ 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 f6a9f262ad..3310653236 100644 --- a/src/utils/propTypes/utils.js +++ b/src/utils/propTypes/utils.js @@ -22,16 +22,6 @@ 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. @@ -44,13 +34,8 @@ export const isEveryPropDefined = (props, otherPropNames) => otherPropNames export const requiredWhen = (propType, otherPropName) => ( customPropTypeRequirement( propType, - (props) => { - if (Array.isArray(otherPropName)) { - return isEveryPropDefined(props, otherPropName); - } - return props[otherPropName] === true; - }, - `${otherPropName} ${Array.isArray(otherPropName) ? 'are defined' : 'is truthy'}`, + (props) => props[otherPropName] === true, + `${otherPropName} is truthy`, ) ); diff --git a/www/src/components/CodeBlock.tsx b/www/src/components/CodeBlock.tsx index d00ca4c6eb..d5b7460e1e 100644 --- a/www/src/components/CodeBlock.tsx +++ b/www/src/components/CodeBlock.tsx @@ -6,7 +6,6 @@ import React, { useReducer, useState, useMemo, - useRef, } from 'react'; import PropTypes from 'prop-types'; import { Link } from 'gatsby'; @@ -151,7 +150,6 @@ function CodeBlock({ useState, useReducer, useMemo, - useRef, ExamplePropsForm, MiyazakiCard, HipsterIpsum,