diff --git a/apps/storybook/src/Pagination.stories.tsx b/apps/storybook/src/Pagination.stories.tsx index 2854e258d..dd031e750 100644 --- a/apps/storybook/src/Pagination.stories.tsx +++ b/apps/storybook/src/Pagination.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryFn } from "@storybook/react"; import type { PaginationProps } from "flowbite-react"; -import { Pagination } from "flowbite-react"; +import { DefaultPaginationProps, Pagination, TablePaginationProps } from "flowbite-react"; import { useEffect, useState } from "react"; export default { @@ -15,7 +15,9 @@ export default { ], } as Meta; -const Template: StoryFn = ({ currentPage = 1, layout = "pagination", totalPages = 100, ...rest }) => { +const Template: StoryFn = (props) => { + const { currentPage = 1, layout = "pagination" } = props; + const [page, setPage] = useState(currentPage); const onPageChange = (page: number) => { @@ -26,6 +28,21 @@ const Template: StoryFn = ({ currentPage = 1, layout = "paginat setPage(currentPage); }, [currentPage]); + if (layout === "table") { + const { itemsPerPage = 10, totalItems = 100, ...rest } = props as TablePaginationProps; + return ( + + ); + } + + const { totalPages = 100, ...rest } = props as DefaultPaginationProps; return ( ); @@ -54,6 +71,8 @@ NavWithIcons.args = { export const Table = Template.bind({}); Table.args = { layout: "table", + itemsPerPage: 10, + totalItems: 100, }; export const TableWithIcons = Template.bind({}); @@ -61,4 +80,6 @@ TableWithIcons.storyName = "Table with icons"; TableWithIcons.args = { layout: "table", showIcons: true, + itemsPerPage: 10, + totalItems: 100, }; diff --git a/apps/web/examples/pagination/pagination.table.tsx b/apps/web/examples/pagination/pagination.table.tsx index 6eb3a04e0..dbfbbc421 100644 --- a/apps/web/examples/pagination/pagination.table.tsx +++ b/apps/web/examples/pagination/pagination.table.tsx @@ -17,7 +17,7 @@ export function Component() { return (
- +
); } @@ -30,7 +30,13 @@ export function Component() { return (
- +
); } diff --git a/apps/web/examples/pagination/pagination.tableWithIcons.tsx b/apps/web/examples/pagination/pagination.tableWithIcons.tsx index d6c80fd3a..e348c12a8 100644 --- a/apps/web/examples/pagination/pagination.tableWithIcons.tsx +++ b/apps/web/examples/pagination/pagination.tableWithIcons.tsx @@ -17,7 +17,7 @@ export function Component() { return (
- +
); } @@ -30,7 +30,14 @@ export function Component() { return (
- +
); } diff --git a/packages/ui/src/components/Pagination/Pagination.test.tsx b/packages/ui/src/components/Pagination/Pagination.test.tsx index 263bf3ab8..8367cd51c 100644 --- a/packages/ui/src/components/Pagination/Pagination.test.tsx +++ b/packages/ui/src/components/Pagination/Pagination.test.tsx @@ -97,15 +97,82 @@ describe("Pagination", () => { expect(pages()).toEqual([3, 4, 5, 6, 7]); }); - describe("Props", () => { - it('should not display numbered buttons when `layout="navigation"`', () => { - render( undefined} totalPages={5} />); + describe("Table Layout", () => { + it("should show the first visible item, the last visible item, and the total items", () => { + render(); + const { firstItem, lastItem, totalItems } = tablePaginationState(); + expect(firstItem).toEqual(1); + expect(lastItem).toEqual(10); + expect(totalItems).toEqual(95); + }); - expect(pages()).toHaveLength(0); + it("should increment the page offset by 1 when clicking next", async () => { + const user = userEvent.setup(); + render(); + await user.click(nextButton()); + const { firstItem, lastItem, totalItems } = tablePaginationState(); + expect(firstItem).toEqual(11); + expect(lastItem).toEqual(20); + expect(totalItems).toEqual(95); }); - it('should display numbered buttons when `layout="table"`', () => { - render( undefined} totalPages={5} />); + it("should disable previous button when on 1st page", () => { + render(); + const firstButton = buttons()[0]; + expect(firstButton).toBeDisabled(); + }); + + it("should enable previous button on subsequent pages", async () => { + const user = userEvent.setup(); + render(); + await user.click(nextButton()); + await user.click(nextButton()); + expect(tablePaginationState().firstItem).toEqual(21); + await user.click(previousButton()); + expect(tablePaginationState().firstItem).toEqual(11); + await user.click(previousButton()); + expect(tablePaginationState().firstItem).toEqual(1); + }); + + it("should disable next button when on last page", async () => { + const user = userEvent.setup(); + render(); + for (let i = 0; i < 9; ++i) { + await user.click(nextButton()); + } + + const { firstItem, lastItem, totalItems } = tablePaginationState(); + expect(firstItem).toEqual(91); + expect(lastItem).toEqual(95); + expect(totalItems).toEqual(95); + const lastButton = buttons()[buttons().length - 1]; + expect(lastButton).toBeDisabled(); + }); + + it("should show an empty page if totalItems is 0.", () => { + render(); + const { firstItem, lastItem, totalItems } = tablePaginationState(); + expect(firstItem).toEqual(0); + expect(lastItem).toEqual(0); + expect(totalItems).toEqual(0); + }); + + it("should show one page if totalItems is less than itemsPerPage.", () => { + render(); + const { firstItem, lastItem, totalItems } = tablePaginationState(); + expect(firstItem).toEqual(1); + expect(lastItem).toEqual(5); + expect(totalItems).toEqual(5); + const firstButton = buttons()[0]; + expect(firstButton).toBeDisabled(); + const lastButton = buttons()[buttons().length - 1]; + expect(lastButton).toBeDisabled(); + }); + }); + + describe("Props", () => { + it('should not display numbered buttons when `layout="navigation"`', () => { + render( undefined} totalPages={5} />); expect(pages()).toHaveLength(0); }); @@ -125,6 +192,142 @@ describe("Pagination", () => { expect(previousButton()).toHaveTextContent("Go back"); expect(nextButton()).toHaveTextContent("Go forward"); }); + + it("should throw an error if currentPage is not a positive integer", () => { + expect(() => render( undefined} totalPages={5} />)).toThrow( + "Invalid props: currentPage must be a positive integer", + ); + expect(() => render( undefined} totalPages={5} />)).toThrow( + "Invalid props: currentPage must be a positive integer", + ); + expect(() => render( undefined} totalPages={5} />)).toThrow( + "Invalid props: currentPage must be a positive integer", + ); + }); + + it("should throw an error if totalPages is not a positive integer", () => { + expect(() => render( undefined} totalPages={-1} />)).toThrow( + "Invalid props: totalPages must be a positive integer", + ); + expect(() => render( undefined} totalPages={0} />)).toThrow( + "Invalid props: totalPages must be a positive integer", + ); + expect(() => render( undefined} totalPages={1.5} />)).toThrow( + "Invalid props: totalPages must be a positive integer", + ); + }); + describe("TablePaginationProps", () => { + it('should not display numbered buttons when `layout="table"`', () => { + render(); + + expect(pages()).toHaveLength(0); + }); + + it("should throw an error if current page is not positive", () => { + expect(() => + render( + {}} + showIcons + itemsPerPage={0} + totalItems={95} + />, + ), + ).toThrow("Invalid props: currentPage must be a positive integer"); + expect(() => + render( + {}} + showIcons + itemsPerPage={0} + totalItems={95} + />, + ), + ).toThrow("Invalid props: currentPage must be a positive integer"); + expect(() => + render( + {}} + showIcons + itemsPerPage={0} + totalItems={95} + />, + ), + ).toThrow("Invalid props: currentPage must be a positive integer"); + }); + + it("should throw an error if itemsPerPage is not a positive integer", () => { + expect(() => + render( + {}} + showIcons + itemsPerPage={0} + totalItems={95} + />, + ), + ).toThrow("Invalid props: itemsPerPage must be a positive integer"); + expect(() => + render( + {}} + showIcons + itemsPerPage={-1} + totalItems={95} + />, + ), + ).toThrow("Invalid props: itemsPerPage must be a positive integer"); + expect(() => + render( + {}} + showIcons + itemsPerPage={1.5} + totalItems={95} + />, + ), + ).toThrow("Invalid props: itemsPerPage must be a positive integer"); + }); + + it("should throw an error if totalItems is not a non-negative integer", () => { + expect(() => + render( + {}} + showIcons + itemsPerPage={10} + totalItems={-1} + />, + ), + ).toThrow("Invalid props: totalItems must be a non-negative integer"); + expect(() => + render( + {}} + showIcons + itemsPerPage={10} + totalItems={1.5} + />, + ), + ).toThrow("Invalid props: totalItems must be a non-negative integer"); + }); + }); }); }); @@ -156,6 +359,75 @@ function PaginationTestTenElements() { return ; } +function PaginationTestTable() { + const [page, setPage] = useState(1); + + const onPageChange = (page: number) => { + setPage(page); + }; + + useEffect(() => { + setPage(page); + }, [page]); + + return ( + + ); +} + +function PaginationTestOnePageTable() { + const [page, setPage] = useState(1); + + const onPageChange = (page: number) => { + setPage(page); + }; + + useEffect(() => { + setPage(page); + }, [page]); + + return ( + + ); +} + +function PaginationTestEmptyTable() { + const [page, setPage] = useState(1); + + const onPageChange = (page: number) => { + setPage(page); + }; + + useEffect(() => { + setPage(page); + }, [page]); + + return ( + + ); +} + const buttons = () => screen.getAllByRole("button"); const pages = () => { @@ -175,6 +447,19 @@ const currentPage = () => { return Number.parseInt(currentPageElement?.textContent ?? "0"); }; +const tablePaginationState = () => { + const firstItemElement = screen + .getAllByRole("status") + .find((elem) => elem.getAttribute("aria-label") === "Table Pagination"); + const paginationValues = firstItemElement?.textContent?.match(/\d+/g); + if (!paginationValues || paginationValues?.length !== 3) return { firstItem: null, lastItem: null, totalItems: null }; + return { + firstItem: parseInt(paginationValues[0]), + lastItem: parseInt(paginationValues[1]), + totalItems: parseInt(paginationValues[2]), + }; +}; + const nextButton = () => buttons()[buttons().length - 1]; const previousButton = () => buttons()[0]; diff --git a/packages/ui/src/components/Pagination/Pagination.tsx b/packages/ui/src/components/Pagination/Pagination.tsx index 77db171db..a4fd4c9ee 100644 --- a/packages/ui/src/components/Pagination/Pagination.tsx +++ b/packages/ui/src/components/Pagination/Pagination.tsx @@ -44,18 +44,34 @@ export interface PaginationNavigationTheme { icon: string; } -export interface PaginationProps extends ComponentProps<"nav">, ThemingProps { - currentPage: number; +export interface BasePaginationProps extends ComponentProps<"nav">, ThemingProps { layout?: "navigation" | "pagination" | "table"; + currentPage: number; nextLabel?: string; onPageChange: (page: number) => void; previousLabel?: string; - renderPaginationButton?: (props: PaginationButtonProps) => ReactNode; showIcons?: boolean; +} + +export interface DefaultPaginationProps extends BasePaginationProps { + layout?: "navigation" | "pagination"; + renderPaginationButton?: (props: PaginationButtonProps) => ReactNode; totalPages: number; } +export interface TablePaginationProps extends BasePaginationProps { + layout: "table"; + itemsPerPage: number; + totalItems: number; +} + +export type PaginationProps = DefaultPaginationProps | TablePaginationProps; export const Pagination = forwardRef((props, ref) => { + if (props.layout === "table") return ; + return ; +}); + +const DefaultPagination = forwardRef((props, ref) => { const provider = useThemeProvider(); const theme = useResolveTheme( [paginationTheme, provider.theme?.pagination, props.theme], @@ -71,10 +87,18 @@ export const Pagination = forwardRef((props, ref) onPageChange, previousLabel = "Previous", renderPaginationButton = (props: PaginationButtonProps) => , - showIcons: showIcon = false, totalPages, + showIcons: showIcon = false, ...restProps - } = resolveProps(props, provider.props?.pagination); + } = resolveProps(props, provider.props?.pagination); + + if (!Number.isInteger(currentPage) || currentPage < 1) { + throw new Error("Invalid props: currentPage must be a positive integer"); + } + + if (!Number.isInteger(totalPages) || totalPages < 1) { + throw new Error("Invalid props: totalPages must be a positive integer"); + } const lastPage = Math.min(Math.max(layout === "pagination" ? currentPage + 2 : currentPage + 4, 5), totalPages); const firstPage = Math.max(1, lastPage - 4); @@ -89,13 +113,6 @@ export const Pagination = forwardRef((props, ref) return (