Merge pull request #189 from johndoe6345789/codex/refactor-pagination-components-and-utilities

Refactor pagination components into dedicated files
This commit is contained in:
2025-12-27 18:00:28 +00:00
committed by GitHub
16 changed files with 589 additions and 409 deletions

View File

@@ -74,7 +74,7 @@ export {
PaginationEllipsis,
PaginationPrevious,
PaginationNext,
} from './navigation/Pagination'
} from './navigation/pagination'
// Navigation
export {

View File

@@ -12,8 +12,8 @@ import {
NavigationLink,
} from './NavigationMenuItems'
import { NavigationBrand, NavigationSeparator, NavigationSpacer } from './NavigationStyling'
import { NavigationItemType } from './navigationConfig'
import { useNavigationDropdown } from './navigationHelpers'
import { NavigationItemType } from './utils/navigationConfig'
import { useNavigationDropdown } from './utils/navigationHelpers'
interface NavigationProps {
children: ReactNode

View File

@@ -1,406 +0,0 @@
// TODO: Split this file (405 LOC) into smaller organisms (<150 LOC each)
'use client'
import { forwardRef, ReactNode } from 'react'
import {
Pagination as MuiPagination,
PaginationItem,
Box,
IconButton,
Typography,
Select,
MenuItem,
FormControl,
SelectChangeEvent,
} from '@mui/material'
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
import FirstPageIcon from '@mui/icons-material/FirstPage'
import LastPageIcon from '@mui/icons-material/LastPage'
// Pagination Root
interface PaginationProps {
count: number
page: number
onChange: (page: number) => void
siblingCount?: number
boundaryCount?: number
showFirstButton?: boolean
showLastButton?: boolean
disabled?: boolean
size?: 'small' | 'medium' | 'large'
variant?: 'text' | 'outlined'
shape?: 'circular' | 'rounded'
color?: 'primary' | 'secondary' | 'standard'
}
const Pagination = forwardRef<HTMLElement, PaginationProps>(
({
count,
page,
onChange,
siblingCount = 1,
boundaryCount = 1,
showFirstButton = false,
showLastButton = false,
disabled = false,
size = 'medium',
variant = 'outlined',
shape = 'rounded',
color = 'primary',
...props
}, ref) => {
return (
<MuiPagination
ref={ref}
count={count}
page={page}
onChange={(_, newPage) => onChange(newPage)}
siblingCount={siblingCount}
boundaryCount={boundaryCount}
showFirstButton={showFirstButton}
showLastButton={showLastButton}
disabled={disabled}
size={size}
variant={variant}
shape={shape}
color={color}
{...props}
/>
)
}
)
Pagination.displayName = 'Pagination'
// Simplified Pagination (Previous/Next only)
interface SimplePaginationProps {
hasPrevious: boolean
hasNext: boolean
onPrevious: () => void
onNext: () => void
previousLabel?: string
nextLabel?: string
disabled?: boolean
}
const SimplePagination = forwardRef<HTMLDivElement, SimplePaginationProps>(
({
hasPrevious,
hasNext,
onPrevious,
onNext,
previousLabel = 'Previous',
nextLabel = 'Next',
disabled = false,
...props
}, ref) => {
return (
<Box
ref={ref}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}
{...props}
>
<IconButton
onClick={onPrevious}
disabled={disabled || !hasPrevious}
size="small"
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
px: 2,
py: 1,
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<ChevronLeftIcon fontSize="small" />
<Typography variant="body2" sx={{ ml: 0.5 }}>
{previousLabel}
</Typography>
</IconButton>
<IconButton
onClick={onNext}
disabled={disabled || !hasNext}
size="small"
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
px: 2,
py: 1,
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<Typography variant="body2" sx={{ mr: 0.5 }}>
{nextLabel}
</Typography>
<ChevronRightIcon fontSize="small" />
</IconButton>
</Box>
)
}
)
SimplePagination.displayName = 'SimplePagination'
// Table Pagination (with page size selector)
interface TablePaginationProps {
count: number
page: number
pageSize: number
pageSizeOptions?: number[]
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
showFirstLastButtons?: boolean
disabled?: boolean
}
const TablePagination = forwardRef<HTMLDivElement, TablePaginationProps>(
({
count,
page,
pageSize,
pageSizeOptions = [10, 25, 50, 100],
onPageChange,
onPageSizeChange,
showFirstLastButtons = true,
disabled = false,
...props
}, ref) => {
const totalPages = Math.ceil(count / pageSize)
const startItem = (page - 1) * pageSize + 1
const endItem = Math.min(page * pageSize, count)
const handlePageSizeChange = (event: SelectChangeEvent<number>) => {
onPageSizeChange(Number(event.target.value))
}
return (
<Box
ref={ref}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 2,
py: 1,
}}
{...props}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" color="text.secondary">
Rows per page:
</Typography>
<FormControl size="small" disabled={disabled}>
<Select
value={pageSize}
onChange={handlePageSizeChange}
sx={{ minWidth: 70 }}
>
{pageSizeOptions.map((option) => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
<Typography variant="body2" color="text.secondary">
{count === 0 ? '0' : `${startItem}-${endItem}`} of {count}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{showFirstLastButtons && (
<IconButton
onClick={() => onPageChange(1)}
disabled={disabled || page === 1}
size="small"
>
<FirstPageIcon fontSize="small" />
</IconButton>
)}
<IconButton
onClick={() => onPageChange(page - 1)}
disabled={disabled || page === 1}
size="small"
>
<ChevronLeftIcon fontSize="small" />
</IconButton>
<IconButton
onClick={() => onPageChange(page + 1)}
disabled={disabled || page === totalPages}
size="small"
>
<ChevronRightIcon fontSize="small" />
</IconButton>
{showFirstLastButtons && (
<IconButton
onClick={() => onPageChange(totalPages)}
disabled={disabled || page === totalPages}
size="small"
>
<LastPageIcon fontSize="small" />
</IconButton>
)}
</Box>
</Box>
)
}
)
TablePagination.displayName = 'TablePagination'
// PaginationContent - wrapper for custom pagination content
interface PaginationContentProps {
children: ReactNode
}
const PaginationContent = forwardRef<HTMLUListElement, PaginationContentProps>(
({ children, ...props }, ref) => {
return (
<Box
ref={ref}
component="ul"
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
listStyle: 'none',
p: 0,
m: 0,
}}
{...props}
>
{children}
</Box>
)
}
)
PaginationContent.displayName = 'PaginationContent'
// PaginationItem wrapper
interface PaginationItemWrapperProps {
children: ReactNode
}
const PaginationItemWrapper = forwardRef<HTMLLIElement, PaginationItemWrapperProps>(
({ children, ...props }, ref) => {
return (
<Box component="li" ref={ref} {...props}>
{children}
</Box>
)
}
)
PaginationItemWrapper.displayName = 'PaginationItem'
// PaginationLink
interface PaginationLinkProps {
children: ReactNode
onClick?: () => void
isActive?: boolean
disabled?: boolean
size?: 'small' | 'medium' | 'large'
}
const PaginationLink = forwardRef<HTMLButtonElement, PaginationLinkProps>(
({ children, onClick, isActive = false, disabled = false, size = 'medium', ...props }, ref) => {
const sizeMap = {
small: { minWidth: 28, height: 28 },
medium: { minWidth: 36, height: 36 },
large: { minWidth: 44, height: 44 },
}
return (
<IconButton
ref={ref}
onClick={onClick}
disabled={disabled}
sx={{
...sizeMap[size],
borderRadius: 1,
bgcolor: isActive ? 'primary.main' : 'transparent',
color: isActive ? 'primary.contrastText' : 'text.primary',
'&:hover': {
bgcolor: isActive ? 'primary.dark' : 'action.hover',
},
'&.Mui-disabled': {
opacity: 0.5,
},
}}
{...props}
>
{children}
</IconButton>
)
}
)
PaginationLink.displayName = 'PaginationLink'
// Pagination Ellipsis
const PaginationEllipsis = forwardRef<HTMLSpanElement, Record<string, never>>(
(props, ref) => {
return (
<Box
ref={ref}
component="span"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 36,
height: 36,
color: 'text.secondary',
}}
{...props}
>
...
</Box>
)
}
)
PaginationEllipsis.displayName = 'PaginationEllipsis'
// Previous/Next buttons
const PaginationPrevious = forwardRef<HTMLButtonElement, Omit<PaginationLinkProps, 'children'>>(
(props, ref) => {
return (
<PaginationLink ref={ref} {...props}>
<ChevronLeftIcon fontSize="small" />
</PaginationLink>
)
}
)
PaginationPrevious.displayName = 'PaginationPrevious'
const PaginationNext = forwardRef<HTMLButtonElement, Omit<PaginationLinkProps, 'children'>>(
(props, ref) => {
return (
<PaginationLink ref={ref} {...props}>
<ChevronRightIcon fontSize="small" />
</PaginationLink>
)
}
)
PaginationNext.displayName = 'PaginationNext'
export {
Pagination,
SimplePagination,
TablePagination,
PaginationContent,
PaginationItemWrapper as PaginationItem,
PaginationLink,
PaginationEllipsis,
PaginationPrevious,
PaginationNext,
}

View File

@@ -0,0 +1,122 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
SimplePagination,
TablePagination,
} from './index'
describe('Pagination components', () => {
it('triggers onChange for root Pagination', () => {
const handleChange = vi.fn()
render(<Pagination count={3} page={1} onChange={handleChange} />)
fireEvent.click(screen.getByRole('button', { name: 'Go to page 2' }))
expect(handleChange).toHaveBeenCalledWith(2)
})
it('handles SimplePagination controls', () => {
const onPrevious = vi.fn()
const onNext = vi.fn()
render(
<SimplePagination
hasPrevious={false}
hasNext
onPrevious={onPrevious}
onNext={onNext}
previousLabel="Prev"
nextLabel="Next"
/>
)
expect(screen.getByText('Prev').closest('button')?.disabled).toBe(true)
fireEvent.click(screen.getByText('Next'))
expect(onNext).toHaveBeenCalled()
})
it('navigates TablePagination pages', () => {
const onPageChange = vi.fn()
const onPageSizeChange = vi.fn()
render(
<TablePagination
count={30}
page={2}
pageSize={10}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
showFirstLastButtons={false}
/>
)
expect(screen.getByText('11-20 of 30')).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: 'Go to next page' }))
expect(onPageChange).toHaveBeenCalledWith(3)
})
it('renders content wrappers', () => {
render(
<PaginationContent>
<PaginationItem>
<PaginationLink isActive>1</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
</PaginationContent>
)
expect(screen.getByRole('list')).toBeDefined()
expect(screen.getAllByRole('listitem')).to.have.length(2)
})
it('renders link variants', () => {
const handleClick = vi.fn()
render(
<PaginationLink onClick={handleClick} isActive>
5
</PaginationLink>
)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalled()
})
it('wraps icon links for previous and next', () => {
const onPrevious = vi.fn()
const onNext = vi.fn()
render(
<PaginationContent>
<PaginationItem>
<PaginationPrevious onClick={onPrevious} />
</PaginationItem>
<PaginationItem>
<PaginationNext onClick={onNext} />
</PaginationItem>
</PaginationContent>
)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
fireEvent.click(buttons[1])
expect(onPrevious).toHaveBeenCalled()
expect(onNext).toHaveBeenCalled()
})
it('renders ellipsis marker', () => {
render(<PaginationEllipsis />)
expect(screen.getByText('...')).toBeDefined()
})
})

View File

@@ -0,0 +1,49 @@
'use client'
import { forwardRef, ReactNode } from 'react'
import { Box } from '@mui/material'
interface PaginationContentProps {
children: ReactNode
}
interface PaginationItemWrapperProps {
children: ReactNode
}
const PaginationContent = forwardRef<HTMLUListElement, PaginationContentProps>(
({ children, ...props }, ref) => {
return (
<Box
ref={ref}
component="ul"
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
listStyle: 'none',
p: 0,
m: 0,
}}
{...props}
>
{children}
</Box>
)
}
)
PaginationContent.displayName = 'PaginationContent'
const PaginationItem = forwardRef<HTMLLIElement, PaginationItemWrapperProps>(
({ children, ...props }, ref) => {
return (
<Box component="li" ref={ref} {...props}>
{children}
</Box>
)
}
)
PaginationItem.displayName = 'PaginationItem'
export { PaginationContent, PaginationItem }
export type { PaginationContentProps, PaginationItemWrapperProps }

View File

@@ -0,0 +1,27 @@
'use client'
import { forwardRef } from 'react'
import { Box } from '@mui/material'
const PaginationEllipsis = forwardRef<HTMLSpanElement, Record<string, never>>((props, ref) => {
return (
<Box
ref={ref}
component="span"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 36,
height: 36,
color: 'text.secondary',
}}
{...props}
>
...
</Box>
)
})
PaginationEllipsis.displayName = 'PaginationEllipsis'
export { PaginationEllipsis }

View File

@@ -0,0 +1,35 @@
'use client'
import { forwardRef } from 'react'
import { IconButton } from '@mui/material'
import { paginationSizeMap, type PaginationLinkProps } from './paginationUtils'
const PaginationLink = forwardRef<HTMLButtonElement, PaginationLinkProps>(
({ children, onClick, isActive = false, disabled = false, size = 'medium', ...props }, ref) => {
return (
<IconButton
ref={ref}
onClick={onClick}
disabled={disabled}
sx={{
...paginationSizeMap[size],
borderRadius: 1,
bgcolor: isActive ? 'primary.main' : 'transparent',
color: isActive ? 'primary.contrastText' : 'text.primary',
'&:hover': {
bgcolor: isActive ? 'primary.dark' : 'action.hover',
},
'&.Mui-disabled': {
opacity: 0.5,
},
}}
{...props}
>
{children}
</IconButton>
)
}
)
PaginationLink.displayName = 'PaginationLink'
export { PaginationLink }

View File

@@ -0,0 +1,18 @@
'use client'
import { forwardRef } from 'react'
import { PaginationLink } from './PaginationLink'
import { NextIcon, type PaginationLinkProps } from './paginationUtils'
const PaginationNext = forwardRef<HTMLButtonElement, Omit<PaginationLinkProps, 'children'>>(
(props, ref) => {
return (
<PaginationLink ref={ref} {...props}>
<NextIcon />
</PaginationLink>
)
}
)
PaginationNext.displayName = 'PaginationNext'
export { PaginationNext }

View File

@@ -0,0 +1,18 @@
'use client'
import { forwardRef } from 'react'
import { PaginationLink } from './PaginationLink'
import { PreviousIcon, type PaginationLinkProps } from './paginationUtils'
const PaginationPrevious = forwardRef<HTMLButtonElement, Omit<PaginationLinkProps, 'children'>>(
(props, ref) => {
return (
<PaginationLink ref={ref} {...props}>
<PreviousIcon />
</PaginationLink>
)
}
)
PaginationPrevious.displayName = 'PaginationPrevious'
export { PaginationPrevious }

View File

@@ -0,0 +1,63 @@
'use client'
import { forwardRef } from 'react'
import { Pagination as MuiPagination } from '@mui/material'
interface PaginationProps {
count: number
page: number
onChange: (page: number) => void
siblingCount?: number
boundaryCount?: number
showFirstButton?: boolean
showLastButton?: boolean
disabled?: boolean
size?: 'small' | 'medium' | 'large'
variant?: 'text' | 'outlined'
shape?: 'circular' | 'rounded'
color?: 'primary' | 'secondary' | 'standard'
}
const Pagination = forwardRef<HTMLElement, PaginationProps>(
(
{
count,
page,
onChange,
siblingCount = 1,
boundaryCount = 1,
showFirstButton = false,
showLastButton = false,
disabled = false,
size = 'medium',
variant = 'outlined',
shape = 'rounded',
color = 'primary',
...props
},
ref
) => {
return (
<MuiPagination
ref={ref}
count={count}
page={page}
onChange={(_, newPage) => onChange(newPage)}
siblingCount={siblingCount}
boundaryCount={boundaryCount}
showFirstButton={showFirstButton}
showLastButton={showLastButton}
disabled={disabled}
size={size}
variant={variant}
shape={shape}
color={color}
{...props}
/>
)
}
)
Pagination.displayName = 'Pagination'
export { Pagination }
export type { PaginationProps }

View File

@@ -0,0 +1,89 @@
'use client'
import { forwardRef } from 'react'
import { Box, IconButton, Typography } from '@mui/material'
import { NextIcon, PreviousIcon } from './paginationUtils'
interface SimplePaginationProps {
hasPrevious: boolean
hasNext: boolean
onPrevious: () => void
onNext: () => void
previousLabel?: string
nextLabel?: string
disabled?: boolean
}
const SimplePagination = forwardRef<HTMLDivElement, SimplePaginationProps>(
(
{
hasPrevious,
hasNext,
onPrevious,
onNext,
previousLabel = 'Previous',
nextLabel = 'Next',
disabled = false,
...props
},
ref
) => {
return (
<Box
ref={ref}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}
{...props}
>
<IconButton
onClick={onPrevious}
disabled={disabled || !hasPrevious}
size="small"
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
px: 2,
py: 1,
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<PreviousIcon />
<Typography variant="body2" sx={{ ml: 0.5 }}>
{previousLabel}
</Typography>
</IconButton>
<IconButton
onClick={onNext}
disabled={disabled || !hasNext}
size="small"
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
px: 2,
py: 1,
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<Typography variant="body2" sx={{ mr: 0.5 }}>
{nextLabel}
</Typography>
<NextIcon />
</IconButton>
</Box>
)
}
)
SimplePagination.displayName = 'SimplePagination'
export { SimplePagination }
export type { SimplePaginationProps }

View File

@@ -0,0 +1,128 @@
'use client'
import { forwardRef } from 'react'
import {
Box,
IconButton,
Typography,
Select,
MenuItem,
FormControl,
SelectChangeEvent,
} from '@mui/material'
import FirstPageIcon from '@mui/icons-material/FirstPage'
import LastPageIcon from '@mui/icons-material/LastPage'
import { NextIcon, PreviousIcon } from './paginationUtils'
interface TablePaginationProps {
count: number
page: number
pageSize: number
pageSizeOptions?: number[]
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
showFirstLastButtons?: boolean
disabled?: boolean
}
const TablePagination = forwardRef<HTMLDivElement, TablePaginationProps>(
(
{
count,
page,
pageSize,
pageSizeOptions = [10, 25, 50, 100],
onPageChange,
onPageSizeChange,
showFirstLastButtons = true,
disabled = false,
...props
},
ref
) => {
const totalPages = Math.ceil(count / pageSize)
const startItem = (page - 1) * pageSize + 1
const endItem = Math.min(page * pageSize, count)
const handlePageSizeChange = (event: SelectChangeEvent<number>) => {
onPageSizeChange(Number(event.target.value))
}
return (
<Box
ref={ref}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 2,
py: 1,
}}
{...props}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" color="text.secondary">
Rows per page:
</Typography>
<FormControl size="small" disabled={disabled}>
<Select value={pageSize} onChange={handlePageSizeChange} sx={{ minWidth: 70 }}>
{pageSizeOptions.map((option) => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
<Typography variant="body2" color="text.secondary">
{count === 0 ? '0' : `${startItem}-${endItem}`} of {count}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{showFirstLastButtons && (
<IconButton
onClick={() => onPageChange(1)}
disabled={disabled || page === 1}
size="small"
aria-label="Go to first page"
>
<FirstPageIcon fontSize="small" />
</IconButton>
)}
<IconButton
onClick={() => onPageChange(page - 1)}
disabled={disabled || page === 1}
size="small"
aria-label="Go to previous page"
>
<PreviousIcon />
</IconButton>
<IconButton
onClick={() => onPageChange(page + 1)}
disabled={disabled || page === totalPages}
size="small"
aria-label="Go to next page"
>
<NextIcon />
</IconButton>
{showFirstLastButtons && (
<IconButton
onClick={() => onPageChange(totalPages)}
disabled={disabled || page === totalPages}
size="small"
aria-label="Go to last page"
>
<LastPageIcon fontSize="small" />
</IconButton>
)}
</Box>
</Box>
)
}
)
TablePagination.displayName = 'TablePagination'
export { TablePagination }
export type { TablePaginationProps }

View File

@@ -0,0 +1,13 @@
export { Pagination } from './PaginationRoot'
export type { PaginationProps } from './PaginationRoot'
export { SimplePagination } from './SimplePagination'
export type { SimplePaginationProps } from './SimplePagination'
export { TablePagination } from './TablePagination'
export type { TablePaginationProps } from './TablePagination'
export { PaginationContent, PaginationItem } from './PaginationContent'
export type { PaginationContentProps, PaginationItemWrapperProps } from './PaginationContent'
export { PaginationLink } from './PaginationLink'
export { PaginationEllipsis } from './PaginationEllipsis'
export { PaginationPrevious } from './PaginationPrevious'
export { PaginationNext } from './PaginationNext'
export type { PaginationLinkProps } from './paginationUtils'

View File

@@ -0,0 +1,24 @@
import { ReactNode } from 'react'
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
interface PaginationLinkProps {
children: ReactNode
onClick?: () => void
isActive?: boolean
disabled?: boolean
size?: 'small' | 'medium' | 'large'
}
const paginationSizeMap = {
small: { minWidth: 28, height: 28 },
medium: { minWidth: 36, height: 36 },
large: { minWidth: 44, height: 44 },
}
const PreviousIcon = () => <ChevronLeftIcon fontSize="small" />
const NextIcon = () => <ChevronRightIcon fontSize="small" />
export { paginationSizeMap, PreviousIcon, NextIcon }
export type { PaginationLinkProps }