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:
parent
789f4e0328
commit
a9a9506f98
19
packages/core/src/components/pagination/index.ts
Normal file
19
packages/core/src/components/pagination/index.ts
Normal 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";
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
56
packages/core/src/components/pagination/pagination-items.tsx
Normal file
56
packages/core/src/components/pagination/pagination-items.tsx
Normal 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>;
|
||||
}
|
||||
33
packages/core/src/components/pagination/pagination-next.tsx
Normal file
33
packages/core/src/components/pagination/pagination-next.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
44
packages/core/src/components/pagination/pagination-root.tsx
Normal file
44
packages/core/src/components/pagination/pagination-root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user