diff --git a/packages/components/src/components/Pagination/IconChevronLeft.tsx b/packages/components/src/components/Pagination/IconChevronLeft.tsx new file mode 100644 index 00000000..c18b50ab --- /dev/null +++ b/packages/components/src/components/Pagination/IconChevronLeft.tsx @@ -0,0 +1,20 @@ +export function IconChevronLeft({ className }: { className?: string }) { + return ( + + + + ) +} diff --git a/packages/components/src/components/Pagination/IconChevronRight.tsx b/packages/components/src/components/Pagination/IconChevronRight.tsx new file mode 100644 index 00000000..7169b260 --- /dev/null +++ b/packages/components/src/components/Pagination/IconChevronRight.tsx @@ -0,0 +1,20 @@ +export function IconChevronRight({ className }: { className?: string }) { + return ( + + + + ) +} diff --git a/packages/components/src/components/Pagination/Pagination.stories.tsx b/packages/components/src/components/Pagination/Pagination.stories.tsx new file mode 100644 index 00000000..365bffbd --- /dev/null +++ b/packages/components/src/components/Pagination/Pagination.stories.tsx @@ -0,0 +1,119 @@ +import { Meta, StoryObj } from '@storybook/react-vite' +import { useState } from 'react' + +import { + Pagination, + PaginationContainer, + PaginationNextButton, + PaginationPages, + PaginationPrevButton, +} from './index' + +type Story = StoryObj + +const meta: Meta = { + title: 'Devfive/Pagination', + component: Pagination, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export const Default: Story = { + args: { + totalPages: 10, + defaultPage: 1, + }, +} + +export const PropsBasedSimple: Story = { + args: { + totalPages: 10, + defaultPage: 1, + }, + render: (args) => , +} + +export const WithoutPrevNext: Story = { + args: { + totalPages: 10, + defaultPage: 1, + showPrevNext: false, + }, + render: (args) => , +} + +export const CompositionBased: Story = { + args: { + totalPages: 10, + defaultPage: 1, + }, + render: (args) => ( + + + + + + + + ), +} + +export const Controlled: Story = { + render: () => { + const [currentPage, setCurrentPage] = useState(1) + + return ( +
+
Current Page: {currentPage}
+ +
+ ) + }, +} + +export const ManyPages: Story = { + args: { + totalPages: 100, + defaultPage: 50, + }, +} + +export const FewPages: Story = { + args: { + totalPages: 5, + defaultPage: 1, + }, +} + +export const WithoutFirstLast: Story = { + args: { + totalPages: 20, + defaultPage: 10, + showFirstLast: false, + }, +} + +export const CustomClassName: Story = { + args: { + totalPages: 10, + defaultPage: 1, + className: 'custom-pagination', + }, + render: (args) => ( +
+ + +
+ ), +} + +export default meta diff --git a/packages/components/src/components/Pagination/__tests__/index.browser.test.tsx b/packages/components/src/components/Pagination/__tests__/index.browser.test.tsx new file mode 100644 index 00000000..80961a0d --- /dev/null +++ b/packages/components/src/components/Pagination/__tests__/index.browser.test.tsx @@ -0,0 +1,217 @@ +import { fireEvent, render } from '@testing-library/react' + +import { + Pagination, + PaginationContainer, + PaginationNextButton, + PaginationPageButton, + PaginationPages, + PaginationPrevButton, +} from '..' + +describe('Pagination', () => { + it('should render with composition pattern', () => { + const { container } = render( + + + + + + + , + ) + expect(container).toMatchSnapshot() + }) + + it('should render with props-based pattern', () => { + const { container } = render() + expect(container).toMatchSnapshot() + expect(container.querySelector('[aria-label="Previous page"]')).toBeInTheDocument() + expect(container.querySelector('[aria-label="Next page"]')).toBeInTheDocument() + expect(container.querySelector('[aria-label="Page 1"]')).toBeInTheDocument() + }) + + it('should render without prev/next buttons when showPrevNext is false', () => { + const { container } = render() + expect(container.querySelector('[aria-label="Previous page"]')).not.toBeInTheDocument() + expect(container.querySelector('[aria-label="Next page"]')).not.toBeInTheDocument() + expect(container.querySelector('[aria-label="Page 1"]')).toBeInTheDocument() + }) + + it('should apply custom className to container', () => { + const { container } = render() + expect(container.querySelector('.custom-class')).toBeInTheDocument() + }) + + it('should throw error if children are used outside of Pagination', () => { + expect(() => { + render() + }).toThrow('usePagination must be used within a Pagination component') + }) + + it('should call onPageChange when page is changed', () => { + const onPageChange = vi.fn() + const { container } = render( + + + + + , + ) + const button = container.querySelector('[aria-label="Page 2"]') + fireEvent.click(button!) + expect(onPageChange).toHaveBeenCalledWith(2) + }) + + it('should change internal page when onPageChange is not provided', () => { + const { container } = render( + + + + + , + ) + const button = container.querySelector('[aria-label="Page 2"]') + fireEvent.click(button!) + expect(button).toHaveAttribute('aria-current', 'page') + }) + + it('should disable prev button when on first page', () => { + const { container } = render( + + + + + , + ) + const prevButton = container.querySelector('[aria-label="Previous page"]') + expect(prevButton).toHaveAttribute('disabled') + }) + + it('should disable next button when on last page', () => { + const { container } = render( + + + + + , + ) + const nextButton = container.querySelector('[aria-label="Next page"]') + expect(nextButton).toHaveAttribute('disabled') + }) + + it('should navigate to next page when next button is clicked', () => { + const onPageChange = vi.fn() + const { container } = render( + + + + + , + ) + const nextButton = container.querySelector('[aria-label="Next page"]') + fireEvent.click(nextButton!) + expect(onPageChange).toHaveBeenCalledWith(2) + }) + + it('should navigate to previous page when prev button is clicked', () => { + const onPageChange = vi.fn() + const { container } = render( + + + + + , + ) + const prevButton = container.querySelector('[aria-label="Previous page"]') + fireEvent.click(prevButton!) + expect(onPageChange).toHaveBeenCalledWith(4) + }) + + it('should render all pages when total is 7 or less', () => { + const { container } = render( + + + , + ) + const buttons = container.querySelectorAll('[aria-label^="Page"]') + expect(buttons.length).toBe(5) + }) + + it('should render ellipsis for many pages', () => { + const { container } = render( + + + , + ) + const ellipsis = container.querySelector('[aria-hidden="true"]') + expect(ellipsis).toBeInTheDocument() + expect(ellipsis?.textContent).toBe('...') + }) + + it('should mark active page button', () => { + const { container } = render( + + + , + ) + const button = container.querySelector('[aria-label="Page 3"]') + expect(button).toHaveAttribute('aria-current', 'page') + }) + + it('should export components', async () => { + const index = await import('../index') + expect({ ...index }).toEqual({ + Pagination: expect.any(Function), + PaginationContainer: expect.any(Function), + PaginationEllipsis: expect.any(Function), + PaginationNextButton: expect.any(Function), + PaginationPageButton: expect.any(Function), + PaginationPages: expect.any(Function), + PaginationPrevButton: expect.any(Function), + usePagination: expect.any(Function), + }) + }) + + it('should not exceed totalPages when navigating', () => { + const onPageChange = vi.fn() + const { container } = render( + + + + + , + ) + const nextButton = container.querySelector('[aria-label="Next page"]') + expect(nextButton).toHaveAttribute('disabled') + }) + + it('should not go below 1 when navigating', () => { + const onPageChange = vi.fn() + const { container } = render( + + + + + , + ) + const prevButton = container.querySelector('[aria-label="Previous page"]') + expect(prevButton).toHaveAttribute('disabled') + }) +}) diff --git a/packages/components/src/components/Pagination/index.tsx b/packages/components/src/components/Pagination/index.tsx new file mode 100644 index 00000000..73924834 --- /dev/null +++ b/packages/components/src/components/Pagination/index.tsx @@ -0,0 +1,326 @@ +'use client' + +import { css, Flex } from '@devup-ui/react' +import clsx from 'clsx' +import { ComponentProps, createContext, useContext, useMemo, useState } from 'react' + +import { Button } from '../Button' +import { IconChevronLeft } from './IconChevronLeft' +import { IconChevronRight } from './IconChevronRight' + +type PaginationContextType = { + currentPage: number + totalPages: number + setPage: (page: number) => void + siblingCount: number + showFirstLast: boolean +} + +const PaginationContext = createContext(null) + +export const usePagination = () => { + const context = useContext(PaginationContext) + if (!context) { + throw new Error('usePagination must be used within a Pagination component') + } + return context +} + +type PaginationProps = { + children?: React.ReactNode + defaultPage?: number + currentPage?: number + totalPages: number + onPageChange?: (page: number) => void + siblingCount?: number + showFirstLast?: boolean + showPrevNext?: boolean + className?: string +} + +function Pagination({ + children, + defaultPage = 1, + currentPage: currentPageProp, + totalPages, + onPageChange, + siblingCount = 1, + showFirstLast = true, + showPrevNext = true, + className, +}: PaginationProps) { + const [internalPage, setInternalPage] = useState(defaultPage) + + const currentPage = currentPageProp ?? internalPage + + const handlePageChange = (page: number) => { + const sanitizedPage = Math.min(Math.max(page, 1), totalPages) + if (onPageChange) { + onPageChange(sanitizedPage) + } else { + setInternalPage(sanitizedPage) + } + } + + const content = children ?? ( + + {showPrevNext && } + + {showPrevNext && } + + ) + + return ( + + {content} + + ) +} + +function PaginationContainer({ className, ...props }: ComponentProps<'div'>) { + return ( + + ) +} + +function PaginationPrevButton({ + className, + ...props +}: ComponentProps) { + const { currentPage, setPage } = usePagination() + const disabled = currentPage <= 1 + + return ( + + ) +} + +function PaginationNextButton({ + className, + ...props +}: ComponentProps) { + const { currentPage, totalPages, setPage } = usePagination() + const disabled = currentPage >= totalPages + + return ( + + ) +} + +type PaginationPageButtonProps = ComponentProps & { + page: number +} + +function PaginationPageButton({ + page, + className, + ...props +}: PaginationPageButtonProps) { + const { currentPage, setPage } = usePagination() + const isActive = currentPage === page + + return ( + + ) +} + +function PaginationEllipsis({ className, ...props }: ComponentProps<'div'>) { + return ( + + ) +} + +function PaginationPages({ className, ...props }: ComponentProps<'div'>) { + const { currentPage, totalPages, siblingCount, showFirstLast } = + usePagination() + + const pageNumbers = useMemo(() => { + const pages: (number | 'ellipsis')[] = [] + + if (totalPages <= 7) { + // Show all pages if total is 7 or less + for (let i = 1; i <= totalPages; i++) { + pages.push(i) + } + return pages + } + + // Always show first page + if (showFirstLast) { + pages.push(1) + } + + const leftSiblingIndex = Math.max(currentPage - siblingCount, 1) + const rightSiblingIndex = Math.min(currentPage + siblingCount, totalPages) + + const shouldShowLeftEllipsis = leftSiblingIndex > 2 + const shouldShowRightEllipsis = rightSiblingIndex < totalPages - 1 + + if (!shouldShowLeftEllipsis && shouldShowRightEllipsis) { + // No left ellipsis, show right ellipsis + for (let i = 1; i <= Math.min(5, totalPages - 1); i++) { + if (!showFirstLast || i > 1) { + pages.push(i) + } + } + pages.push('ellipsis') + } else if (shouldShowLeftEllipsis && !shouldShowRightEllipsis) { + // Show left ellipsis, no right ellipsis + pages.push('ellipsis') + for (let i = Math.max(totalPages - 4, 2); i < totalPages; i++) { + pages.push(i) + } + } else if (shouldShowLeftEllipsis && shouldShowRightEllipsis) { + // Show both ellipsis + pages.push('ellipsis') + for (let i = leftSiblingIndex; i <= rightSiblingIndex; i++) { + pages.push(i) + } + pages.push('ellipsis') + } else { + // No ellipsis needed + for (let i = 2; i < totalPages; i++) { + pages.push(i) + } + } + + // Always show last page + if (showFirstLast) { + pages.push(totalPages) + } + + return pages + }, [currentPage, totalPages, siblingCount, showFirstLast]) + + return ( + + {pageNumbers.map((page, index) => + page === 'ellipsis' ? ( + + ) : ( + + ), + )} + + ) +} + +export { + Pagination, + PaginationContainer, + PaginationEllipsis, + PaginationNextButton, + PaginationPageButton, + PaginationPages, + PaginationPrevButton, +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 26c083da..7026b6c1 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -19,5 +19,15 @@ export { useStepper, } from './components/Stepper' export { Toggle } from './components/Toggle' +export { + Pagination, + PaginationContainer, + PaginationEllipsis, + PaginationNextButton, + PaginationPageButton, + PaginationPages, + PaginationPrevButton, + usePagination, +} from './components/Pagination' export { SelectContext, useSelect } from './contexts/useSelect' export type { SelectType, SelectValue } from './types/select'