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/react-pagination-component #1501

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions libs/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export * from './lib/table/components'
export * from './lib/icons'
export * from './lib/breadcrumb/breadcrumb'
export * from './lib/segmented-control/segmented-control'
export * from './lib/pagination/pagination'

// Backwards compatibility export
export { AlertRibbon as Alert } from './lib/alert-ribbon/alert-ribbon'
59 changes: 59 additions & 0 deletions libs/react/src/lib/pagination/pagination.stories.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
Meta,
Story,
Canvas,
Markdown,
ArgsTable,
Preview,
} from '@storybook/addon-docs'
import { Pagination } from './pagination'
import { useState } from 'react'

export const Template = (props) => {
const [pageIndex, setPageIndex] = useState(1)
return (
<Pagination
{...props}
pageIndex={pageIndex}
onClickPage={(index) => setPageIndex(index)}
/>
)
}

# Pagination

The `Pagination` component allows users to navigate through large sets of items by dividing them into pages.
It provides controls to switch between pages and displays the current page number.

<Meta title="Components/Pagination" component={Pagination} />

<Canvas>
<Story
name="Pagination"
args={{
length: 40,
pageSize: 5,
}}
>
{Template.bind({})}
</Story>
</Canvas>

### Small

<Canvas>
<Story
name="Small"
args={{
length: 10,
pageSize: 1,
small: true,
}}
>
{Template.bind({})}
</Story>
</Canvas>

Here are the props available for the `Pagination` component:

<ArgsTable of={Pagination} />
60 changes: 60 additions & 0 deletions libs/react/src/lib/pagination/pagination.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react'
import { Pagination } from './pagination'
import userEvent, { UserEvent } from '@testing-library/user-event'

describe('Pagination', () => {
it('Should render', () => {
render(<Pagination length={10} pageSize={1} offset={10} />)
expect(screen.getAllByRole('button')).toHaveLength(11)
})

it('Should display dots on right', () => {
render(<Pagination length={10} pageSize={1} />)
expect(screen.getAllByText('...')).toHaveLength(1)
})

it('Should display dots on left', () => {
render(<Pagination length={10} pageSize={1} pageIndex={10} />)
expect(screen.getAllByText('...')).toHaveLength(1)
})

it('Should display dots on both sides', () => {
render(<Pagination length={10} pageSize={1} pageIndex={4} />)
expect(screen.getAllByText('...')).toHaveLength(2)
})

it('Should fire onClickPage fn', async () => {
const user: UserEvent = userEvent.setup()
const mockOnClickPage: jest.Mock = jest.fn()
render(
<Pagination length={10} pageSize={1} onClickPage={mockOnClickPage} />,
)
await user.click(screen.getByText('2'))
expect(mockOnClickPage).toBeCalledWith(2)
})

it('Should navigate to next page', async () => {
const user: UserEvent = userEvent.setup()
const mockOnClickPage: jest.Mock = jest.fn()
render(
<Pagination length={10} pageSize={1} onClickPage={mockOnClickPage} />,
)
await user.click(screen.getByLabelText('Next Page'))
expect(mockOnClickPage).toBeCalledWith(2)
})

it('Should navigate to previous page', async () => {
const user: UserEvent = userEvent.setup()
const mockOnClickPage: jest.Mock = jest.fn()
render(
<Pagination
length={10}
pageSize={1}
pageIndex={2}
onClickPage={mockOnClickPage}
/>,
)
await user.click(screen.getByLabelText('Previous Page'))
expect(mockOnClickPage).toBeCalledWith(1)
})
})
163 changes: 163 additions & 0 deletions libs/react/src/lib/pagination/pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { MouseEvent, useMemo } from 'react'
import classNames from 'classnames'

export interface PaginationProps {
/** Determines if the pagination buttons should be small */
small?: boolean
/**The total number of data items to paginate */
length: number
/**The number of items to display per page, default 10 */
pageSize?: number
/** The current page index, start from 1 */
pageIndex?: number
/** The number of sibling pages to display around the current page, default 1 */
offset?: number
/**Callback function to handle page changes */
onClickPage?: (pageIndex: number) => void
}

const chevronArrowLeft = (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path d="M206.7 464.6l-183.1-191.1C18.22 267.1 16 261.1 16 256s2.219-11.97 6.688-16.59l183.1-191.1c9.152-9.594 24.34-9.906 33.9-.7187c9.625 9.125 9.938 24.37 .7187 33.91L73.24 256l168 175.4c9.219 9.5 8.906 24.78-.7187 33.91C231 474.5 215.8 474.2 206.7 464.6z" />
</svg>
)

const chevronArrowRight = (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path d="M113.3 47.41l183.1 191.1c4.469 4.625 6.688 10.62 6.688 16.59s-2.219 11.97-6.688 16.59l-183.1 191.1c-9.152 9.594-24.34 9.906-33.9 .7187c-9.625-9.125-9.938-24.38-.7187-33.91l168-175.4L78.71 80.6c-9.219-9.5-8.906-24.78 .7187-33.91C88.99 37.5 104.2 37.82 113.3 47.41z" />
</svg>
)

const ELLIPSIS = '...'

const getPageRange = (start: number, end: number) => {
const length = end - start + 1
return Array.from({ length }, (_, idx) => idx + start)
}

export const Pagination = ({
small,
length,
pageSize = 10,
pageIndex = 1,
offset = 1,
onClickPage,
}: PaginationProps) => {
const totalPageCount: number = useMemo(
() => Math.ceil(length / pageSize),
[length, pageSize],
)

const pageList: Array<string | number> | undefined = useMemo(() => {
if (offset + 5 >= totalPageCount) {
return getPageRange(1, totalPageCount)
}
const leftEllipsisIndex = Math.max(pageIndex - offset, 1)
const rightEllipsisIndex = Math.min(pageIndex + offset, totalPageCount)
const showLeftEllipsis = leftEllipsisIndex > 2
const showRightEllipsis = rightEllipsisIndex < totalPageCount - 2
const eachItemCount = 3 + 2 * offset

// show right elipsis
if (!showLeftEllipsis && showRightEllipsis) {
return [...getPageRange(1, eachItemCount), ELLIPSIS, totalPageCount]
}
// show left elipsis
if (showLeftEllipsis && !showRightEllipsis) {
return [
1,
ELLIPSIS,
...getPageRange(totalPageCount - eachItemCount + 1, totalPageCount),
]
}
// show both elipsis
if (showLeftEllipsis && showRightEllipsis) {
return [
1,
ELLIPSIS,
...getPageRange(leftEllipsisIndex, rightEllipsisIndex),
ELLIPSIS,
totalPageCount,
]
}
return []
}, [offset, pageIndex, totalPageCount])

const onPrev = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault()
onClickPage && onClickPage(Math.max(pageIndex - 1, 1))
}

const onNext = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault()
onClickPage && onClickPage(Math.min(pageIndex + 1, totalPageCount))
}

return (
<nav
className={classNames('pagination', small ? 'small' : 'large')}
role="navigation"
aria-label="Pagination"
>
<ul className="gds-reset">
{pageIndex !== 1 && (
<li>
{
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using an anchor tag w/o href causes problems with focus. The browser skips them all even if they role="button".

Is there a reason for using anchors tags?

Suggestion: Could these be an option to render button or a tags in this component? Using button will update the current page dynamically and using a would take you to a new page, user would also need to provide hrefs for each anchor tag.

Copy link
Contributor Author

@terrance456 terrance456 Aug 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally agree in using button but the chlorophyll styling was added only for anchor specific and angular component is also using anchor tags. So wasn't sure if we should change it. Maybe we can add in tabIndex=0 to solve the issue here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, looks like this is how it was done in the angular component.

Ok for now! (until replaced with a core web component)

href=""
onClick={onPrev}
aria-label="Previous Page"
className="gds-reset"
role="button"
>
{chevronArrowLeft}
</a>
}
</li>
)}
{pageList.map((page: string | number, index: number) =>
page === ELLIPSIS ? (
<li key={index} aria-hidden="true">
...
</li>
) : (
<li key={index}>
{
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a
href=""
onClick={(e) => {
e.preventDefault()
onClickPage && onClickPage(page as number)
}}
className="gds-reset"
role="button"
aria-current={page === pageIndex ? 'page' : undefined}
>
{page}
</a>
}
</li>
),
)}
{!!totalPageCount && pageIndex !== totalPageCount && (
<li>
{
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a
href=""
onClick={onNext}
role="button"
aria-label="Next Page"
className="gds-reset"
>
{chevronArrowRight}
</a>
}
</li>
)}
</ul>
</nav>
)
}
Loading