Skip to content
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
117 changes: 117 additions & 0 deletions soroscan-frontend/__tests__/ui-pagination.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import * as React from "react"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { Pagination } from "../components/ui/pagination"
import "@testing-library/jest-dom"

describe("Pagination Component", () => {
it("should render correct number of pages when totalPages <= 7", () => {
const onPageChange = jest.fn()
render(<Pagination currentPage={1} totalPages={5} onPageChange={onPageChange} />)

expect(screen.getByRole("button", { name: "Page 1" })).toBeInTheDocument()
expect(screen.getByRole("button", { name: "Go to page 5" })).toBeInTheDocument()
// 5 page buttons + 4 navigation buttons = 9 buttons
expect(screen.getAllByRole("button")).toHaveLength(9)
})

it("should disable First and Previous buttons on the first page", () => {
const onPageChange = jest.fn()
render(<Pagination currentPage={1} totalPages={10} onPageChange={onPageChange} />)

expect(screen.getByRole("button", { name: "Go to first page" })).toBeDisabled()
expect(screen.getByRole("button", { name: "Go to previous page" })).toBeDisabled()
expect(screen.getByRole("button", { name: "Go to next page" })).not.toBeDisabled()
expect(screen.getByRole("button", { name: "Go to last page" })).not.toBeDisabled()
})

it("should disable Next and Last buttons on the last page", () => {
const onPageChange = jest.fn()
render(<Pagination currentPage={10} totalPages={10} onPageChange={onPageChange} />)

expect(screen.getByRole("button", { name: "Go to first page" })).not.toBeDisabled()
expect(screen.getByRole("button", { name: "Go to previous page" })).not.toBeDisabled()
expect(screen.getByRole("button", { name: "Go to next page" })).toBeDisabled()
expect(screen.getByRole("button", { name: "Go to last page" })).toBeDisabled()
})

it("should call onPageChange with correct page number when a page button is clicked", async () => {
const user = userEvent.setup()
const onPageChange = jest.fn()
render(<Pagination currentPage={1} totalPages={10} onPageChange={onPageChange} />)

await user.click(screen.getByRole("button", { name: "Go to page 3" }))
expect(onPageChange).toHaveBeenCalledWith(3)
})

it("should call onPageChange with correct page number when navigation buttons are clicked", async () => {
const user = userEvent.setup()
const onPageChange = jest.fn()
render(<Pagination currentPage={5} totalPages={10} onPageChange={onPageChange} />)

await user.click(screen.getByRole("button", { name: "Go to first page" }))
expect(onPageChange).toHaveBeenCalledWith(1)

await user.click(screen.getByRole("button", { name: "Go to previous page" }))
expect(onPageChange).toHaveBeenCalledWith(4)

await user.click(screen.getByRole("button", { name: "Go to next page" }))
expect(onPageChange).toHaveBeenCalledWith(6)

await user.click(screen.getByRole("button", { name: "Go to last page" }))
expect(onPageChange).toHaveBeenCalledWith(10)
})

it("should render page size selector if pageSize and onPageSizeChange are provided", () => {
const onPageChange = jest.fn()
const onPageSizeChange = jest.fn()
render(
<Pagination
currentPage={1}
totalPages={10}
onPageChange={onPageChange}
pageSize={20}
onPageSizeChange={onPageSizeChange}
/>
)

expect(screen.getByRole("combobox", { name: "Select page size" })).toBeInTheDocument()
expect(screen.getByText("20 / page")).toBeInTheDocument()
})

it("should call onPageSizeChange when a new page size is selected", async () => {
const user = userEvent.setup()
const onPageChange = jest.fn()
const onPageSizeChange = jest.fn()
render(
<Pagination
currentPage={1}
totalPages={10}
onPageChange={onPageChange}
pageSize={10}
onPageSizeChange={onPageSizeChange}
/>
)

// Open dropdown
const combobox = screen.getByRole("combobox", { name: "Select page size" })
await user.click(combobox)

// Select 50 / page
const option = screen.getByRole("option", { name: "50 / page" })
await user.click(option)

expect(onPageSizeChange).toHaveBeenCalledWith(50)
})

it("should apply proper ARIA attributes to the current page", () => {
const onPageChange = jest.fn()
render(<Pagination currentPage={3} totalPages={5} onPageChange={onPageChange} />)

const activePage = screen.getByRole("button", { name: "Page 3" })
expect(activePage).toHaveAttribute("aria-current", "page")

const inactivePage = screen.getByRole("button", { name: "Go to page 4" })
expect(inactivePage).not.toHaveAttribute("aria-current")
})
})
160 changes: 160 additions & 0 deletions soroscan-frontend/components/ui/pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "./button"
import { Dropdown } from "./dropdown"

export interface PaginationProps extends React.HTMLAttributes<HTMLElement> {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
pageSize?: number
onPageSizeChange?: (size: number) => void
pageSizeOptions?: number[]
}

export function Pagination({
currentPage,
totalPages,
onPageChange,
pageSize,
onPageSizeChange,
pageSizeOptions = [10, 20, 50, 100],
className,
...props
}: PaginationProps) {
// Page number generation logic
const getPages = () => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1)
}

if (currentPage <= 4) {
return [1, 2, 3, 4, 5, "ellipsis1", totalPages]
}

if (currentPage >= totalPages - 3) {
return [
1,
"ellipsis1",
totalPages - 4,
totalPages - 3,
totalPages - 2,
totalPages - 1,
totalPages,
]
}

return [
1,
"ellipsis1",
currentPage - 1,
currentPage,
currentPage + 1,
"ellipsis2",
totalPages,
]
}

const pages = getPages()

const dropdownOptions = pageSizeOptions.map((size) => ({
label: `${size} / page`,
value: size.toString(),
}))

return (
<nav
role="navigation"
aria-label="pagination"
className={cn("flex flex-wrap items-center gap-4", className)}
{...props}
>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(1)}
disabled={currentPage <= 1}
aria-label="Go to first page"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
aria-label="Go to previous page"
>
<ChevronLeft className="h-4 w-4" />
</Button>

<div className="flex items-center gap-1 px-2">
{pages.map((page, i) => {
if (page === "ellipsis1" || page === "ellipsis2") {
return (
<span
key={`ellipsis-${i}`}
aria-hidden
className="flex h-9 w-9 items-center justify-center"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
}

const pageNumber = page as number
const isActive = pageNumber === currentPage

return (
<Button
key={pageNumber}
variant={isActive ? "default" : "outline"}
size="icon"
onClick={() => onPageChange(pageNumber)}
aria-current={isActive ? "page" : undefined}
aria-label={
isActive ? `Page ${pageNumber}` : `Go to page ${pageNumber}`
}
>
{pageNumber}
</Button>
)
})}
</div>

<Button
variant="outline"
size="icon"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
aria-label="Go to next page"
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(totalPages)}
disabled={currentPage >= totalPages}
aria-label="Go to last page"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>

{pageSize && onPageSizeChange && (
<div className="flex items-center gap-2 text-sm text-muted-foreground w-32">
<Dropdown
options={dropdownOptions}
value={pageSize.toString()}
onChange={(val) => onPageSizeChange(parseInt(val, 10))}
aria-label="Select page size"
/>
</div>
)}
</nav>
)
}
79 changes: 79 additions & 0 deletions soroscan-frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions soroscan-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^20",
"@types/react": "^19",
Expand Down
3 changes: 1 addition & 2 deletions soroscan-frontend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@
],
"paths": {
"@/*": ["./*"]
},
"types": ["jest", "node"]
}
},
"include": [
"next-env.d.ts",
Expand Down
Loading