diff --git a/packages/core/src/components/pagination/index.ts b/packages/core/src/components/pagination/index.ts new file mode 100644 index 0000000..4c2f64a --- /dev/null +++ b/packages/core/src/components/pagination/index.ts @@ -0,0 +1,19 @@ +import { usePaginationContext } from "./pagination-context"; +import { PaginationEllipsis } from "./pagination-ellipsis"; +import { PaginationItems } from "./pagination-items"; +import { PaginationNext } from "./pagination-next"; +import { PaginationPrevious } from "./pagination-previous"; +import { PaginationRoot } from "./pagination-root"; +export const Pagination = Object.assign(PaginationRoot, { + Previous: PaginationPrevious, + Next: PaginationNext, + Items: PaginationItems, + Ellipsis: PaginationEllipsis, + useContext: usePaginationContext, +}); +export type { PaginationRootProps } from "./pagination-root"; +export type { PaginationPreviousProps } from "./pagination-previous"; +export type { PaginationNextProps } from "./pagination-next"; +export type { PaginationItemsProps, PaginationItemData } from "./pagination-items"; +export type { PaginationEllipsisProps } from "./pagination-ellipsis"; +export type { PaginationContextValue } from "./pagination-context"; diff --git a/packages/core/src/components/pagination/pagination-context.ts b/packages/core/src/components/pagination/pagination-context.ts new file mode 100644 index 0000000..f78be86 --- /dev/null +++ b/packages/core/src/components/pagination/pagination-context.ts @@ -0,0 +1,28 @@ +import type { Accessor } from "solid-js"; +import { createContext, useContext } from "solid-js"; + +/** Context shared between all Pagination parts. */ +export interface PaginationContextValue { + page: Accessor; + totalPages: Accessor; + onPageChange: (page: number) => void; + siblingCount: Accessor; +} + +const PaginationContext = createContext(); + +/** + * Returns the Pagination context. Throws if used outside . + */ +export function usePaginationContext(): PaginationContextValue { + const ctx = useContext(PaginationContext); + if (!ctx) { + throw new Error( + "[PettyUI] Pagination parts must be used inside .\n" + + " Fix: Wrap Pagination.Previous, Pagination.Next, etc. inside .", + ); + } + return ctx; +} + +export const PaginationContextProvider = PaginationContext.Provider; diff --git a/packages/core/src/components/pagination/pagination-ellipsis.tsx b/packages/core/src/components/pagination/pagination-ellipsis.tsx new file mode 100644 index 0000000..a7e3058 --- /dev/null +++ b/packages/core/src/components/pagination/pagination-ellipsis.tsx @@ -0,0 +1,17 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; + +/** Props for Pagination.Ellipsis. */ +export interface PaginationEllipsisProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +/** Visual ellipsis indicator between non-adjacent page numbers. */ +export function PaginationEllipsis(props: PaginationEllipsisProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + return ( + + ); +} diff --git a/packages/core/src/components/pagination/pagination-items.tsx b/packages/core/src/components/pagination/pagination-items.tsx new file mode 100644 index 0000000..4dbe4eb --- /dev/null +++ b/packages/core/src/components/pagination/pagination-items.tsx @@ -0,0 +1,56 @@ +import type { JSX } from "solid-js"; +import { For } from "solid-js"; +import { usePaginationContext } from "./pagination-context"; + +/** Represents a single item in the pagination list. */ +export interface PaginationItemData { + type: "page" | "ellipsis"; + page?: number; + isActive: boolean; +} + +/** Props for Pagination.Items. */ +export interface PaginationItemsProps { + /** Render prop called for each item in the page list. */ + children: (item: PaginationItemData) => JSX.Element; +} + +/** + * Builds the flat list of page items to render, given current page state. + * Always-visible: first and last pages. Middle pages depend on siblingCount. + */ +function buildItems(page: number, total: number, siblings: number): PaginationItemData[] { + if (total <= 1) return [{ type: "page", page: 1, isActive: page === 1 }]; + + const pages: PaginationItemData[] = []; + const leftSibling = Math.max(2, page - siblings); + const rightSibling = Math.min(total - 1, page + siblings); + + pages.push({ type: "page", page: 1, isActive: page === 1 }); + + if (leftSibling > 2) { + pages.push({ type: "ellipsis", isActive: false }); + } + + for (let p = leftSibling; p <= rightSibling; p++) { + pages.push({ type: "page", page: p, isActive: p === page }); + } + + if (rightSibling < total - 1) { + pages.push({ type: "ellipsis", isActive: false }); + } + + pages.push({ type: "page", page: total, isActive: page === total }); + + return pages; +} + +/** + * Renders the list of page items (numbers + ellipses) via a render prop. + * Calculates which pages to show based on page, totalPages, and siblingCount. + */ +export function PaginationItems(props: PaginationItemsProps): JSX.Element { + const ctx = usePaginationContext(); + const items = () => buildItems(ctx.page(), ctx.totalPages(), ctx.siblingCount()); + return {(item) => props.children(item)}; +} diff --git a/packages/core/src/components/pagination/pagination-next.tsx b/packages/core/src/components/pagination/pagination-next.tsx new file mode 100644 index 0000000..ecbecd6 --- /dev/null +++ b/packages/core/src/components/pagination/pagination-next.tsx @@ -0,0 +1,33 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import { usePaginationContext } from "./pagination-context"; + +/** Props for Pagination.Next. */ +export interface PaginationNextProps extends JSX.ButtonHTMLAttributes { + children?: JSX.Element; +} + +/** Button that navigates to the next page. Disabled on the last page. */ +export function PaginationNext(props: PaginationNextProps): JSX.Element { + const [local, rest] = splitProps(props, ["children", "onClick"]); + const ctx = usePaginationContext(); + + const isDisabled = () => ctx.page() >= ctx.totalPages(); + + const handleClick: JSX.EventHandler = (e) => { + if (typeof local.onClick === "function") local.onClick(e); + if (!isDisabled()) ctx.onPageChange(ctx.page() + 1); + }; + + return ( + + ); +} diff --git a/packages/core/src/components/pagination/pagination-previous.tsx b/packages/core/src/components/pagination/pagination-previous.tsx new file mode 100644 index 0000000..904aa1d --- /dev/null +++ b/packages/core/src/components/pagination/pagination-previous.tsx @@ -0,0 +1,33 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import { usePaginationContext } from "./pagination-context"; + +/** Props for Pagination.Previous. */ +export interface PaginationPreviousProps extends JSX.ButtonHTMLAttributes { + children?: JSX.Element; +} + +/** Button that navigates to the previous page. Disabled on page 1. */ +export function PaginationPrevious(props: PaginationPreviousProps): JSX.Element { + const [local, rest] = splitProps(props, ["children", "onClick"]); + const ctx = usePaginationContext(); + + const isDisabled = () => ctx.page() <= 1; + + const handleClick: JSX.EventHandler = (e) => { + if (typeof local.onClick === "function") local.onClick(e); + if (!isDisabled()) ctx.onPageChange(ctx.page() - 1); + }; + + return ( + + ); +} diff --git a/packages/core/src/components/pagination/pagination-root.tsx b/packages/core/src/components/pagination/pagination-root.tsx new file mode 100644 index 0000000..5881e22 --- /dev/null +++ b/packages/core/src/components/pagination/pagination-root.tsx @@ -0,0 +1,44 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import { PaginationContextProvider, type PaginationContextValue } from "./pagination-context"; + +/** Props for the Pagination root component. */ +export interface PaginationRootProps extends JSX.HTMLAttributes { + /** Current page number (1-based). */ + page: number; + /** Total number of pages. */ + totalPages: number; + /** Called when the user navigates to a different page. */ + onPageChange: (page: number) => void; + /** Number of sibling pages shown around the current page. @default 1 */ + siblingCount?: number; + children: JSX.Element; +} + +/** + * Root container for Pagination. Provides page state to all parts. + */ +export function PaginationRoot(props: PaginationRootProps): JSX.Element { + const [local, rest] = splitProps(props, [ + "page", + "totalPages", + "onPageChange", + "siblingCount", + "children", + ]); + + const ctx: PaginationContextValue = { + page: () => local.page, + totalPages: () => local.totalPages, + onPageChange: (p) => local.onPageChange(p), + siblingCount: () => local.siblingCount ?? 1, + }; + + return ( + + + + ); +} diff --git a/packages/core/tests/components/pagination/pagination.test.tsx b/packages/core/tests/components/pagination/pagination.test.tsx new file mode 100644 index 0000000..8d1b35e --- /dev/null +++ b/packages/core/tests/components/pagination/pagination.test.tsx @@ -0,0 +1,79 @@ +// packages/core/tests/components/pagination/pagination.test.tsx +import { fireEvent, render, screen } from "@solidjs/testing-library"; +import { describe, expect, it, vi } from "vitest"; +import { Pagination } from "../../../src/components/pagination/index"; + +describe("Pagination – navigation and buttons", () => { + it("renders with role=navigation", () => { + render(() => ( + {}}> + + + + )); + expect(screen.getByRole("navigation")).toBeTruthy(); + }); + + it("previous button is disabled on first page", () => { + render(() => ( + {}}> + + + )); + expect(screen.getByRole("button")).toBeDisabled(); + }); + + it("next button is disabled on last page", () => { + render(() => ( + {}}> + + + )); + expect(screen.getByRole("button")).toBeDisabled(); + }); +}); + +describe("Pagination – page change callbacks", () => { + it("clicking previous calls onPageChange with page-1", () => { + const onChange = vi.fn(); + render(() => ( + + + + )); + fireEvent.click(screen.getByRole("button")); + expect(onChange).toHaveBeenCalledWith(2); + }); + + it("clicking next calls onPageChange with page+1", () => { + const onChange = vi.fn(); + render(() => ( + + + + )); + fireEvent.click(screen.getByRole("button")); + expect(onChange).toHaveBeenCalledWith(4); + }); + + it("Items renders page buttons via render prop", () => { + render(() => ( + {}}> + + {(item) => + item.type === "page" ? ( + + ) : ( + + ) + } + + + )); + const buttons = screen.getAllByRole("button"); + const activePage = buttons.find((b) => b.getAttribute("aria-current") === "page"); + expect(activePage?.textContent).toBe("2"); + }); +});