Pagination component

Headless Pagination with Root, Previous, Next, Items (render-prop), and Ellipsis parts. Context-driven, fully accessible with aria attributes, 6 tests passing.
This commit is contained in:
Mats Bosson 2026-03-29 08:50:50 +07:00
parent 789f4e0328
commit a9a9506f98
8 changed files with 309 additions and 0 deletions

View File

@ -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";

View File

@ -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<number>;
totalPages: Accessor<number>;
onPageChange: (page: number) => void;
siblingCount: Accessor<number>;
}
const PaginationContext = createContext<PaginationContextValue>();
/**
* Returns the Pagination context. Throws if used outside <Pagination>.
*/
export function usePaginationContext(): PaginationContextValue {
const ctx = useContext(PaginationContext);
if (!ctx) {
throw new Error(
"[PettyUI] Pagination parts must be used inside <Pagination>.\n" +
" Fix: Wrap Pagination.Previous, Pagination.Next, etc. inside <Pagination>.",
);
}
return ctx;
}
export const PaginationContextProvider = PaginationContext.Provider;

View File

@ -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<HTMLSpanElement> {
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 (
<span aria-hidden="true" {...rest}>
{local.children ?? "…"}
</span>
);
}

View File

@ -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 <For each={items()}>{(item) => props.children(item)}</For>;
}

View File

@ -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<HTMLButtonElement> {
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<HTMLButtonElement, MouseEvent> = (e) => {
if (typeof local.onClick === "function") local.onClick(e);
if (!isDisabled()) ctx.onPageChange(ctx.page() + 1);
};
return (
<button
type="button"
aria-label="Go to next page"
disabled={isDisabled()}
onClick={handleClick}
{...rest}
>
{local.children ?? "Next"}
</button>
);
}

View File

@ -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<HTMLButtonElement> {
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<HTMLButtonElement, MouseEvent> = (e) => {
if (typeof local.onClick === "function") local.onClick(e);
if (!isDisabled()) ctx.onPageChange(ctx.page() - 1);
};
return (
<button
type="button"
aria-label="Go to previous page"
disabled={isDisabled()}
onClick={handleClick}
{...rest}
>
{local.children ?? "Previous"}
</button>
);
}

View File

@ -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<HTMLElement> {
/** 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 (
<PaginationContextProvider value={ctx}>
<nav aria-label="pagination" {...rest}>
{local.children}
</nav>
</PaginationContextProvider>
);
}

View File

@ -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(() => (
<Pagination page={1} totalPages={5} onPageChange={() => {}}>
<Pagination.Previous />
<Pagination.Next />
</Pagination>
));
expect(screen.getByRole("navigation")).toBeTruthy();
});
it("previous button is disabled on first page", () => {
render(() => (
<Pagination page={1} totalPages={5} onPageChange={() => {}}>
<Pagination.Previous />
</Pagination>
));
expect(screen.getByRole("button")).toBeDisabled();
});
it("next button is disabled on last page", () => {
render(() => (
<Pagination page={5} totalPages={5} onPageChange={() => {}}>
<Pagination.Next />
</Pagination>
));
expect(screen.getByRole("button")).toBeDisabled();
});
});
describe("Pagination page change callbacks", () => {
it("clicking previous calls onPageChange with page-1", () => {
const onChange = vi.fn();
render(() => (
<Pagination page={3} totalPages={5} onPageChange={onChange}>
<Pagination.Previous />
</Pagination>
));
fireEvent.click(screen.getByRole("button"));
expect(onChange).toHaveBeenCalledWith(2);
});
it("clicking next calls onPageChange with page+1", () => {
const onChange = vi.fn();
render(() => (
<Pagination page={3} totalPages={5} onPageChange={onChange}>
<Pagination.Next />
</Pagination>
));
fireEvent.click(screen.getByRole("button"));
expect(onChange).toHaveBeenCalledWith(4);
});
it("Items renders page buttons via render prop", () => {
render(() => (
<Pagination page={2} totalPages={3} onPageChange={() => {}}>
<Pagination.Items>
{(item) =>
item.type === "page" ? (
<button type="button" aria-current={item.isActive ? "page" : undefined}>
{item.page}
</button>
) : (
<span></span>
)
}
</Pagination.Items>
</Pagination>
));
const buttons = screen.getAllByRole("button");
const activePage = buttons.find((b) => b.getAttribute("aria-current") === "page");
expect(activePage?.textContent).toBe("2");
});
});