Skip to content

Commit 682dcac

Browse files
author
Hector Arce De Las Heras
committed
Add Pagination Component
1 parent acf2972 commit 682dcac

File tree

15 files changed

+621
-0
lines changed

15 files changed

+621
-0
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { fireEvent, screen } from '@testing-library/react';
2+
import * as React from 'react';
3+
4+
import { axe } from 'jest-axe';
5+
6+
import { renderProvider } from '@/tests/renderProvider/renderProvider.utility';
7+
8+
import { Pagination } from '../pagination';
9+
10+
const mockProps = {
11+
dataTestId: 'Pagination',
12+
variant: 'DEFAULT',
13+
maxStepsNumber: 10,
14+
paginationLeftButtonControl: {
15+
icon: 'CHEVRON_LEFT',
16+
ariaLabel: 'left button',
17+
onClick: jest.fn(),
18+
},
19+
paginationRightButtonControl: {
20+
icon: 'CHEVRON_RIGHT',
21+
ariaLabel: 'right button',
22+
onClick: jest.fn(),
23+
},
24+
};
25+
26+
const onStepClick = jest.fn();
27+
28+
describe('Pagination Component', () => {
29+
it('Should have a valid HTML structure', async () => {
30+
const { container } = renderProvider(<Pagination {...mockProps} currentStep={0} />);
31+
32+
const results = await axe(container);
33+
34+
expect(container).toHTMLValidate();
35+
expect(results).toHaveNoViolations();
36+
});
37+
it('Should call paginationControl onClick when pressed', () => {
38+
renderProvider(<Pagination {...mockProps} currentStep={5} onStepClick={onStepClick} />);
39+
40+
const leftButton = screen.getByLabelText('left button');
41+
const rightButton = screen.getByLabelText('right button');
42+
const anyCounter = screen.getByTestId(`${mockProps.dataTestId}StepsContent`).children[0];
43+
44+
fireEvent.click(leftButton);
45+
expect(mockProps.paginationLeftButtonControl.onClick).toHaveBeenCalled();
46+
47+
fireEvent.click(rightButton);
48+
expect(mockProps.paginationRightButtonControl.onClick).toHaveBeenCalled();
49+
50+
fireEvent.click(anyCounter);
51+
expect(onStepClick).toHaveBeenCalled();
52+
});
53+
54+
it('Should have the left button disabled when the current position is the first', () => {
55+
renderProvider(<Pagination currentStep={0} {...mockProps} />);
56+
57+
const leftButton = screen.getByLabelText('left button');
58+
expect(leftButton).toBeDisabled();
59+
});
60+
61+
it('Should have the right button disabled when the current position is the last', () => {
62+
renderProvider(<Pagination currentStep={9} {...mockProps} />);
63+
64+
const rightButton = screen.getByLabelText('right button');
65+
expect(rightButton).toBeDisabled();
66+
});
67+
68+
it('Should accept a custom counter number', () => {
69+
const maxCountersNumber = 6;
70+
renderProvider(
71+
<Pagination {...mockProps} currentStep={10} maxCountersNumber={maxCountersNumber} />
72+
);
73+
74+
const childsNumber = screen.getByTestId(
75+
`${mockProps.dataTestId}StepsContent`
76+
).childElementCount;
77+
const ellipsis = screen.getAllByText('...').length;
78+
79+
expect(childsNumber).toBe(maxCountersNumber + ellipsis);
80+
});
81+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { PaginationButtonControl } from './paginationButtonControl';
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import * as React from 'react';
2+
3+
import { ElementOrIcon } from '@/components/elementOrIcon';
4+
5+
import { IPaginationButtonControl } from '../types/pagination';
6+
import { PaginationArrowIconStyleType } from '../types/paginationTheme';
7+
8+
interface IPaginationButtonControlProps {
9+
styles?: PaginationArrowIconStyleType;
10+
paginationButtonControl: IPaginationButtonControl;
11+
disabled: boolean;
12+
}
13+
14+
export const PaginationButtonControl = ({
15+
styles,
16+
paginationButtonControl,
17+
disabled,
18+
}: IPaginationButtonControlProps): JSX.Element => {
19+
const iconStyles = disabled && styles?.disabled ? styles.disabled : styles;
20+
const handleOnClick: React.MouseEventHandler<HTMLButtonElement> = event => {
21+
!disabled && paginationButtonControl.onClick?.(event);
22+
};
23+
return (
24+
<ElementOrIcon
25+
altText={paginationButtonControl?.ariaLabel}
26+
aria-controls={paginationButtonControl?.ariaControls}
27+
aria-label={paginationButtonControl?.ariaLabel}
28+
customIconStyles={iconStyles}
29+
disabled={disabled}
30+
icon={paginationButtonControl?.icon}
31+
onClick={handleOnClick}
32+
/>
33+
);
34+
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const buildBeforeCounters = (beforeNum, currentPosition) =>
2+
[...Array(beforeNum)].map((_, index) => currentPosition - (beforeNum - index));
3+
4+
const buildAfterCounters = (afterNum, currentPosition) =>
5+
[...Array(afterNum)].map((_, index) => currentPosition + 1 + index);
6+
7+
// eslint-disable-next-line complexity
8+
export const buildstepsNumber = (
9+
currentStep: number,
10+
maxSteps: number,
11+
themeMaxNumber: number | undefined = 0,
12+
propsMaxNumber: number | undefined
13+
): Array<string | number> => {
14+
let maxCounters =
15+
propsMaxNumber && propsMaxNumber > themeMaxNumber ? propsMaxNumber : themeMaxNumber;
16+
17+
let startWith: Array<string | number> = [];
18+
let endWith: Array<string | number> = [];
19+
let beforeCounters: Array<number> = [];
20+
let afterCounters: Array<number> = [];
21+
22+
let currentPosition = currentStep + 1;
23+
24+
if (currentPosition >= maxCounters) {
25+
maxCounters--;
26+
startWith = [1, '...'];
27+
}
28+
29+
if (Math.abs(maxSteps - currentPosition) >= maxCounters) {
30+
maxCounters--;
31+
endWith = ['...', maxSteps];
32+
}
33+
34+
const isLeftEdge = currentPosition <= maxCounters;
35+
const isRightEdge = Math.abs(maxSteps - currentPosition) < maxCounters;
36+
37+
if (isLeftEdge) {
38+
currentPosition = 1;
39+
afterCounters = buildAfterCounters(maxCounters - 1, currentPosition);
40+
} else if (isRightEdge) {
41+
currentPosition = maxSteps - maxCounters + 1;
42+
afterCounters = buildAfterCounters(maxCounters - 1, currentPosition);
43+
} else {
44+
const counterDivided = (maxCounters - 1) / 2;
45+
const beforeNum = Math.floor(counterDivided);
46+
const afterNum = Math.ceil(counterDivided);
47+
48+
beforeCounters = buildBeforeCounters(beforeNum, currentPosition);
49+
afterCounters = buildAfterCounters(afterNum, currentPosition);
50+
}
51+
52+
return [...startWith, ...beforeCounters, currentPosition, ...afterCounters, ...endWith];
53+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { buildstepsNumber } from './getMaxCountersNumber';

src/components/pagination/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './types';
2+
3+
export { Pagination } from './pagination';
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import styled from 'styled-components';
2+
3+
import { getStyles } from '@/utils';
4+
5+
import { PaginationState, PaginationStyledProps } from './types';
6+
7+
export const PaginationContainerStyled = styled.div<{
8+
styles: PaginationStyledProps;
9+
}>`
10+
${({ styles }) => getStyles(styles.container)};
11+
`;
12+
13+
export const PaginationPagesContainerStyled = styled.div<{
14+
styles: PaginationStyledProps;
15+
}>`
16+
${({ styles }) => getStyles(styles.pagesContainer)};
17+
`;
18+
19+
export const PaginationPageContainerStyled = styled.span<{
20+
styles: PaginationStyledProps;
21+
state: PaginationState;
22+
$isClickable: boolean;
23+
}>`
24+
${({ styles, state }) => getStyles(styles[state]?.pageContainer)};
25+
${({ styles, state, $isClickable }) =>
26+
$isClickable && getStyles(styles[state]?.pageContainer?.clickable)};
27+
`;
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import * as React from 'react';
2+
3+
import { useMediaDevice, useStyles } from '@/hooks';
4+
import { ErrorBoundary, FallbackComponent } from '@/provider/errorBoundary';
5+
6+
// helpers
7+
import { buildstepsNumber } from './helpers';
8+
import { PaginationStandAlone } from './paginationStandAlone';
9+
import { PaginationStyledProps } from './types';
10+
// interfaces
11+
import { IPagination, IPaginationStandAlone } from './types/pagination';
12+
13+
const PAGINATION_STYLES = 'PAGINATION_STYLES';
14+
15+
const PaginationComponent = React.forwardRef(
16+
<V extends string | unknown>(
17+
{ variant, currentStep, maxStepsNumber, maxCountersNumber, ctv, ...props }: IPagination<V>,
18+
ref: React.ForwardedRef<HTMLDivElement> | undefined | null
19+
): JSX.Element => {
20+
const styles = useStyles<PaginationStyledProps, V>(PAGINATION_STYLES, variant, ctv);
21+
const device = useMediaDevice();
22+
23+
const limitCurrentStep = Math.max(0, Math.min(currentStep, maxStepsNumber - 1));
24+
25+
const stepsNumber = buildstepsNumber(
26+
limitCurrentStep,
27+
maxStepsNumber,
28+
styles.paginationCountersNumber?.[device]?.counters,
29+
maxCountersNumber
30+
);
31+
const stepActive = stepsNumber.indexOf(limitCurrentStep + 1);
32+
33+
const leftDisabled = limitCurrentStep === 0;
34+
const rightDisabled = limitCurrentStep === maxStepsNumber - 1;
35+
36+
return (
37+
<PaginationStandAlone
38+
ref={ref}
39+
leftDisabled={leftDisabled}
40+
rightDisabled={rightDisabled}
41+
stepActive={stepActive}
42+
stepsNumber={stepsNumber}
43+
styles={styles}
44+
{...props}
45+
/>
46+
);
47+
}
48+
);
49+
PaginationComponent.displayName = 'PaginationComponent';
50+
51+
const PaginationBoundary = <V extends string | unknown>(
52+
props: IPagination<V>,
53+
ref: React.ForwardedRef<HTMLDivElement> | undefined | null
54+
): JSX.Element => (
55+
<ErrorBoundary
56+
fallBackComponent={
57+
<FallbackComponent>
58+
<PaginationStandAlone {...(props as unknown as IPaginationStandAlone)} ref={ref} />
59+
</FallbackComponent>
60+
}
61+
>
62+
<PaginationComponent {...props} ref={ref} />
63+
</ErrorBoundary>
64+
);
65+
66+
const Pagination = React.forwardRef(PaginationBoundary) as <V extends string | unknown>(
67+
props: React.PropsWithChildren<IPagination<V>> & {
68+
ref?: React.ForwardedRef<HTMLDivElement> | undefined | null;
69+
}
70+
) => ReturnType<typeof PaginationBoundary>;
71+
72+
export { Pagination };
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import * as React from 'react';
2+
3+
import { ButtonType } from '@/components/button';
4+
import { Text, TextComponentType } from '@/components/text';
5+
import { ROLES } from '@/types';
6+
7+
import { PaginationButtonControl } from './fragments';
8+
import {
9+
PaginationContainerStyled,
10+
PaginationPageContainerStyled,
11+
PaginationPagesContainerStyled,
12+
} from './pagination.styled';
13+
import { PaginationState } from './types';
14+
// interfaces
15+
import { IPaginationButtonControl, IPaginationStandAlone } from './types/pagination';
16+
17+
const PaginationStandAloneComponent = (
18+
{
19+
styles,
20+
stepActive,
21+
stepsNumber,
22+
onStepClick,
23+
paginationLeftButtonControl,
24+
paginationRightButtonControl,
25+
leftDisabled,
26+
rightDisabled,
27+
dataTestId,
28+
}: IPaginationStandAlone,
29+
ref: React.ForwardedRef<HTMLDivElement> | undefined | null
30+
): JSX.Element => {
31+
return (
32+
<PaginationContainerStyled ref={ref} data-testid={`${dataTestId}Container`} styles={styles}>
33+
<PaginationButtonControl
34+
disabled={leftDisabled}
35+
paginationButtonControl={paginationLeftButtonControl as IPaginationButtonControl}
36+
styles={styles.paginationLeftArrowIcon}
37+
/>
38+
<PaginationPagesContainerStyled data-testid={`${dataTestId}StepsContent`} styles={styles}>
39+
{stepsNumber.map((value, index) => {
40+
const state = stepActive === index ? PaginationState.SELECTED : PaginationState.DEFAULT;
41+
const isClickable = typeof value === 'number';
42+
return (
43+
<PaginationPageContainerStyled
44+
key={`PaginationContent-${index}`}
45+
$isClickable={isClickable}
46+
as={isClickable ? ROLES.BUTTON : undefined}
47+
state={state}
48+
styles={styles}
49+
type={isClickable ? ButtonType.BUTTON : undefined}
50+
onClick={isClickable ? onStepClick?.(value - 1) : undefined}
51+
>
52+
<Text component={TextComponentType.SPAN} customTypography={styles[state]?.page}>
53+
{value}
54+
</Text>
55+
</PaginationPageContainerStyled>
56+
);
57+
})}
58+
</PaginationPagesContainerStyled>
59+
<PaginationButtonControl
60+
disabled={rightDisabled}
61+
paginationButtonControl={paginationRightButtonControl as IPaginationButtonControl}
62+
styles={styles.paginationRightArrowIcon}
63+
/>
64+
</PaginationContainerStyled>
65+
);
66+
};
67+
68+
export const PaginationStandAlone = React.forwardRef(PaginationStandAloneComponent);

0 commit comments

Comments
 (0)