Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat(web-react): Uncontrolled Pagination component #1103

Merged
merged 1 commit into from
Oct 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/web-react/src/components/Pagination/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@
</Pagination>
```

## Uncontrolled Pagination

```jsx
<UncontrolledPagination
totalPages={10}
defaultPage={5}
onChange={(pageNumber) => {
console.log(pageNumber);
}}
/>
```

## Pagination props

| Name | Type | Default | Required | Description |
Expand Down Expand Up @@ -132,6 +144,18 @@ This component extends the `PaginationButtonLink` component.
| -------------------- | -------- | ------- | -------- | ------------------------------- |
| `accessibilityLabel` | `string` | `Next` | ✕ | Accessibility label of the link |

## UncontrolledPagination props

| Name | Type | Default | Required | Description |
| ---------------------------- | ------------------------------ | ---------- | -------- | ------------------------------------------------------------------- |
| `accessibilityLabel` | `string` | - | ✕ | Accessibility label of the page links |
| `accessibilityLabelNext` | `string` | `Next` | ✕ | Accessibility label of the next link |
| `accessibilityLabelPrevious` | `string` | `Previous` | ✕ | Accessibility label of the previous link |
| `defaultPage` | `number` | `1` | ✕ | The number of the page selected as current page at the first render |
| `onChange` | `(pageNumber: number) => void` | - | ✕ | On page change callback |
| `totalPages` | `number` | 0 | ✔ | Total count of pages |
| `visiblePages` | `number` | `5` | ✕ | Number of displayed pages |

## Icons Provider

To display the icons correctly, the component needs to be wrapped with IconsProvider.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from 'react';
import { SpiritUncontrolledPaginationProps } from '../../types/pagination';
import Pagination from './Pagination';
import PaginationItem from './PaginationItem';
import PaginationLink from './PaginationLink';
import PaginationLinkNext from './PaginationLinkNext';
import PaginationLinkPrevious from './PaginationLinkPrevious';
import { usePagination } from './usePagination';

export const UncontrolledPagination = (props: SpiritUncontrolledPaginationProps): JSX.Element => {
const {
accessibilityLabel,
accessibilityLabelPrevious = 'Previous',
accessibilityLabelNext = 'Next',
defaultPage = 1,
onChange,
totalPages = 0,
visiblePages = 5,
...rest
} = props;
const { currentPage, pages, handlePageChange } = usePagination({
defaultPage,
onChange,
totalPages,
visiblePages,
});

return (
<Pagination {...rest}>
{currentPage !== 1 && (
<PaginationLinkPrevious
accessibilityLabel={accessibilityLabelPrevious}
onClick={(event) => {
event.preventDefault();
handlePageChange(currentPage - 1);
}}
/>
)}
{pages?.map((pageNumber: number) => (
<PaginationItem key={pageNumber}>
<PaginationLink
accessibilityLabel={`${accessibilityLabel} ${pageNumber}`}
href="#"
isCurrent={currentPage === pageNumber}
pageNumber={pageNumber}
onClick={(event) => {
event.preventDefault();
handlePageChange(pageNumber);
}}
/>
</PaginationItem>
))}
{currentPage !== totalPages && (
<PaginationLinkNext
accessibilityLabel={accessibilityLabelNext}
onClick={(event) => {
event.preventDefault();
handlePageChange(currentPage + 1);
}}
/>
)}
</Pagination>
);
};

export default UncontrolledPagination;
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import UncontrolledPagination from '../UncontrolledPagination';

describe('UncontrolledPagination', () => {
const onPageChange = jest.fn();

beforeEach(() => {
onPageChange.mockClear();
});

it('renders pagination items with test page selected', () => {
render(
<UncontrolledPagination accessibilityLabel="test page" totalPages={10} defaultPage={5} onChange={onPageChange} />,
);

const items = screen.getAllByRole('button');
expect(items).toHaveLength(2);

const selectedPageItem = screen.getByText('test page 5').parentElement;
expect(selectedPageItem).toHaveClass('Pagination__link Pagination__link--current');
});

it('renders disabled items for the first and last page', () => {
const { container } = render(
<UncontrolledPagination accessibilityLabel="test page" totalPages={10} defaultPage={1} onChange={onPageChange} />,
);

const items = screen.getAllByRole('button');
expect(items).toHaveLength(1);

const firstPageItem = screen.getByText('test page 1').parentElement;
const lastItemPage = container.querySelector('.Button--square');

expect(firstPageItem).toHaveClass('Pagination__link Pagination__link--current');
expect(lastItemPage).toHaveClass('Button Button--secondary Button--medium Button--square');
});

it('calls the onPageChange function when an item is clicked', () => {
render(
<UncontrolledPagination accessibilityLabel="test page" totalPages={10} defaultPage={5} onChange={onPageChange} />,
);

const items = screen.getAllByRole('button');
expect(items).toHaveLength(2);

const nextPageItem = screen.getByText('test page 6');

fireEvent.click(nextPageItem);

expect(onPageChange).toHaveBeenCalledWith(6);
});
});
8 changes: 5 additions & 3 deletions packages/web-react/src/components/Pagination/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
export { default as Pagination } from './Pagination';
export { default as PaginationButtonLink } from './PaginationButtonLink';
export { default as PaginationItem } from './PaginationItem';
export { default as PaginationLink } from './PaginationLink';
export { default as PaginationButtonLink } from './PaginationButtonLink';
export { default as PaginationLinkPrevious } from './PaginationLinkPrevious';
export { default as PaginationLinkNext } from './PaginationLinkNext';
export * from './usePaginationStyleProps';
export { default as PaginationLinkPrevious } from './PaginationLinkPrevious';
export * from './UncontrolledPagination';
export { default as UncontrolledPagination } from './UncontrolledPagination';
export * from './constants';
export * from './usePaginationStyleProps';
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from '@storybook/react';
import React from 'react';

import { UncontrolledPagination } from '..';

const meta: Meta<typeof UncontrolledPagination> = {
title: 'Components/Pagination',
component: UncontrolledPagination,
parameters: {
layout: 'centered',
},
argTypes: {
defaultPage: {
control: 'number',
description: 'Default page for the first render; please reload the page to apply this setting.',
table: {
defaultValue: { summary: 1 },
},
},
totalPages: {
control: 'number',
},
visiblePages: {
control: 'number',
table: {
defaultValue: { summary: 5 },
},
},
},
args: {
defaultPage: 3,
totalPages: 10,
visiblePages: 5,
},
};

export default meta;
type Story = StoryObj<typeof UncontrolledPagination>;

export const UncontrolledPaginationPlayground: Story = {
name: 'UncontrolledPagination',
render: (args) => <UncontrolledPagination {...args} />,
};
48 changes: 48 additions & 0 deletions packages/web-react/src/components/Pagination/usePagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useCallback, useMemo, useState } from 'react';
import { UsePaginationProps } from '../../types/pagination';

export const usePagination = ({ totalPages, onChange, defaultPage, visiblePages }: UsePaginationProps) => {
const [currentPage, setCurrentPage] = useState(defaultPage <= 0 || defaultPage > totalPages ? 1 : defaultPage ?? 1);
const [pages, setPagesArray] = useState([visiblePages] ?? [5]);

useMemo(() => {
const currentVisiblePages = visiblePages > totalPages ? totalPages : visiblePages;
const firstPageChapter =
totalPages - currentPage < currentVisiblePages ? totalPages - (currentVisiblePages - 1) : currentPage;

setPagesArray(Array.from(Array(currentVisiblePages), (_, index) => index + firstPageChapter));
}, [visiblePages, currentPage, totalPages]);

const handlePageChange = useCallback(
(pageNumber: number) => {
setCurrentPage(pageNumber);
onChange && onChange(pageNumber);
},
[onChange],
);

const getPagination = () => {
const halfChap = Math.floor(visiblePages / 2);
let startPage = Math.max(1, currentPage - halfChap);
const endPage = Math.min(startPage + visiblePages - 1, totalPages);

if (totalPages - visiblePages < startPage - 1) {
const tmpStartPage = totalPages - visiblePages + 1;
startPage = tmpStartPage < 1 ? 1 : tmpStartPage;
}

return Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
};

useMemo(() => {
setPagesArray(getPagination());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return {
totalPages,
currentPage,
pages,
handlePageChange,
};
};
43 changes: 33 additions & 10 deletions packages/web-react/src/types/pagination.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { ElementType } from 'react';
import { SpiritButtonProps } from './button';
import {
ChildrenProps,
SpiritElementProps,
SpiritLItemElementProps,
SpiritPolymorphicElementPropsWithRef,
SpiritUListElementProps,
} from './shared';
import { SpiritButtonProps } from './button';

export type PaginationLinkDirectionType = 'previous' | 'next';

Expand All @@ -15,24 +16,27 @@ export interface PaginationProps extends SpiritElementProps {

export interface PaginationItemProps extends SpiritLItemElementProps {}

export interface AriaPaginationProps {
accessibilityLabel?: string;
}

export interface PaginationLinkBaseProps<E extends ElementType = 'a'> {
elementType?: E;
}

export interface PaginationLinkProps<E extends ElementType = 'a'> extends PaginationLinkBaseProps<E> {
export interface PaginationLinkProps<E extends ElementType = 'a'>
extends PaginationLinkBaseProps<E>,
AriaPaginationProps {
isCurrent?: boolean;
accessibilityLabel: string;
pageNumber: number;
}

export type PaginationButtonLinkProps<E extends ElementType = 'a'> = SpiritButtonProps<E> & {
direction: PaginationLinkDirectionType;
accessibilityLabel: string;
};
export type PaginationButtonLinkProps<E extends ElementType = 'a'> = SpiritButtonProps<E> &
AriaPaginationProps & {
direction: PaginationLinkDirectionType;
};

export type PaginationLinkPreviousNextProps<E extends ElementType = 'a'> = SpiritButtonProps<E> & {
accessibilityLabel?: string;
};
export type PaginationLinkPreviousNextProps<E extends ElementType = 'a'> = SpiritButtonProps<E> & AriaPaginationProps;

export interface SpiritPaginationProps extends PaginationProps {}

Expand All @@ -44,3 +48,22 @@ export type SpiritPaginationLinkProps<E extends ElementType = 'a'> = PaginationL
export type SpiritPaginationButtonLinkProps<E extends ElementType = 'a'> = PaginationButtonLinkProps<E>;

export type SpiritPaginationLinkPreviousNextProps<E extends ElementType = 'a'> = PaginationLinkPreviousNextProps<E>;

export interface UncontrolledPaginationProps {
accessibilityLabelNext?: string;
accessibilityLabelPrevious?: string;
defaultPage?: number;
visiblePages?: number;
onChange?: (pageNumber: number) => void;
totalPages: number;
literat marked this conversation as resolved.
Show resolved Hide resolved
}

export interface SpiritUncontrolledPaginationProps
extends AriaPaginationProps,
UncontrolledPaginationProps,
ChildrenProps {}

export interface UsePaginationProps extends UncontrolledPaginationProps {
defaultPage: number;
visiblePages: number;
}