oeri

Pagination

Usage

import { Pagination } from "@/ui/pagination";

export function PaginationDemo() {
  return <Pagination total={16} defaultValue={11} />;
}

Properties

Sibling
Boundaries
Color
Regular ComponentComposite Component
"use client";
import React from "react";
import { Typography } from "@/ui/typography";
import { Pagination, PaginationFirst, PaginationItems, PaginationLast, PaginationNext, PaginationPrevious } from "@/ui/pagination";

export function PaginationAsLinkDemo() {
  const [activePage, setPage] = React.useState(8);
  const lastPage = 15;

  const getHrefForControl = (control: string, currentPage: number): string => {
    switch (control) {
      case "first":
        return `#page-1`;
      case "previous":
        return `#page-${Math.max(1, currentPage - 1)}`;
      case "next":
        return `#page-${Math.min(lastPage, currentPage + 1)}`;
      case "last":
        return `#page-${lastPage}`;
      default:
        return "#";
    }
  };
  return (
    <div className="m-auto size-full">
      <Typography prose="span">Regular Component</Typography>
      <Pagination
        withEdges
        total={lastPage}
        value={activePage}
        onChange={page => setTimeout(() => setPage(page), 150)}
        getItemProps={page => ({
          el: "a",
          href: `#page-${page}`
        })}
        getControlProps={control => ({
          el: "a",
          href: getHrefForControl(control, activePage)
        })}
      />

      <Typography prose="span">Composite Component</Typography>
      <Pagination
        total={10}
        getItemProps={page => ({
          el: "a",
          href: `#page-${page}`
        })}
      >
        <PaginationFirst el="a" href="#page-0" />
        <PaginationPrevious el="a" href="#page-1" />
        <PaginationItems />
        <PaginationNext el="a" href="#page-2" />
        <PaginationLast el="a" href="#page-10" />
      </Pagination>
    </div>
  );
}

Pagination Change Icons

Regular ComponentComposite Component
import React from "react";
import { Pagination } from "@/ui/pagination";
import { Typography } from "@/ui/typography";
import { ArrowSquareRoundRightIcon, ArrowSquareRoundLeftIcon, ChevronCircleRightIcon, ChevronCircleLeftIcon, Svg } from "@/icons/*";

function Dot() {
  return (
    <Svg>
      <circle cx="12.1" cy="12.1" r="1" />
    </Svg>
  );
}

export function PaginationChangeIconsDemo() {
  return (
    <div className="m-auto size-full">
      <Typography prose="span">Regular Component</Typography>
      <Pagination
        siblings={2}
        boundaries={2}
        withEdges
        total={15}
        defaultValue={8}
        icons={{
          first: ArrowSquareRoundLeftIcon,
          previous: ChevronCircleLeftIcon,
          next: ChevronCircleRightIcon,
          last: ArrowSquareRoundRightIcon,
          dots: Dot
        }}
      />

      <Typography prose="span">Composite Component</Typography>
      <Pagination siblings={2} boundaries={2} total={15} defaultValue={8}>
        <Pagination.First icon={ArrowSquareRoundLeftIcon} />
        <Pagination.Previous icon={ChevronCircleLeftIcon} />
        <Pagination.Items dotsIcon={Dot} />
        <Pagination.Next icon={ChevronCircleRightIcon} />
        <Pagination.Last icon={ArrowSquareRoundRightIcon} />
      </Pagination>
    </div>
  );
}

Pagination With Chunked Content

IDNAME
1fekqz1a09
2j4wa4w5ml
3syuje4p3n
44im3krr18
5tmtfnany2
"use client";
import { useState } from "react";
import { Pagination } from "@/ui/pagination";
import { dataRenderer, Table } from "@/ui/table";
import { Stack } from "@/ui/stack";

function randomId() {
  return `${Math.random().toString(36).slice(2, 11)}`;
}
function chunk<T>(array: T[], size: number): T[][] {
  if (!array.length) {
    return [];
  }
  const head = array.slice(0, size);
  const tail = array.slice(size);
  return [head, ...chunk(tail, size)];
}

const data = chunk(
  Array(30)
    .fill(0)
    .map((_, index) => ({ id: index, name: randomId() })),
  5
);

export function PaginationWithChunkedContentDemo() {
  const [activePage, setPage] = useState(1);

  const tableData = {
    head: ["ID", "NAME"],
    body: dataRenderer(data[activePage - 1], [
      (item) => item.id + 1,
      (item) => item.name,
    ])
  }

  return (
    <Stack>
      <Table data={tableData} />
      <Pagination total={data.length} value={activePage} onChange={setPage} />
    </Stack>
  );
}

Controlled

To control component state provide value and onChange props:

"use client";
import { useState } from "react";
import { Pagination } from "@/ui/pagination";
 
function ControlledPagination() {
  const [activePage, setPage] = useState(1);
  return <Pagination value={activePage} onChange={setPage} total={10} />;
}

Structure

Independent Components

import { Pagination, PaginationFirst, PaginationItems, PaginationLast, PaginationNext, PaginationPrevious } from "@/ui/pagination";
 
export function PaginationDemo() {
  const itemsProps = (page: number) => ({ el: "a", href: `#page-${page}` });
  return (
    <Pagination total={10} getItemProps={itemsProps}>
      <PaginationFirst el="a" href="#page-0" />
      <PaginationPrevious el="a" href="#page-1" />
      <PaginationItems />
      <PaginationNext el="a" href="#page-2" />
      <PaginationLast el="a" href="#page-10" />
    </Pagination>
  );
}

Compound Components

"use client";
import { Pagination } from "@/ui/pagination";
import { ArrowSquareRoundRightIcon, ArrowSquareRoundLeftIcon, ChevronCircleRightIcon, ChevronCircleLeftIcon, Svg } from "@/icons/*";
 
export function PaginationDemo() {
  return (
    <Pagination siblings={2} boundaries={2} total={15} defaultValue={8}>
      <Pagination.First icon={ArrowSquareRoundLeftIcon} />
      <Pagination.Previous icon={ChevronCircleLeftIcon} />
      <Pagination.Items dotsIcon={Dot} />
      <Pagination.Next icon={ChevronCircleRightIcon} />
      <Pagination.Last icon={ArrowSquareRoundRightIcon} />
    </Pagination>
  );
}

Declarative Props API

import { Pagination } from "@/ui/pagination";
 
export function PaginationDemo() {
  return <Pagination siblings={2} boundaries={2} withEdges color="orange" total={21} defaultValue={11} />;
}

API References

Styles API

type T = "root" | "control" | "dots";
Styles APITypeDefaultAnnotation
unstyled?Partial<Record<T, boolean>>falseif true, default styles will be removed
className?stringundefinedpass to root component <div>
classNames?Partial<Record<T, string>>undefined
style?CSSPropertiesundefinedpass to root component <div>
styles?Partial<Record<T, CSSProperties>>undefined

Props API

type Icons = { first?: PaginationIcon; previous?: PaginationIcon; next?: PaginationIcon; last?: PaginationIcon; dots?: PaginationIcon };
 
type Control = "first" | "previous" | "next" | "last";
Props APITypeDefaultAnnotation
icons?IconsdefaultControl icons component
gap?string| number8Key of gap between controls
size?(string & {}) | number32height and min-width of controls
totalnumber-Total number of pages, must be an integer
color?Property.ColorundefinedKey of `colors, active item color
value?numberundefinedActive page for controlled component, must be an integer in [0, total] interval
defaultValue?numberundefinedActive page for uncontrolled component, must be an integer in [0, total] interval
disabled?booleanfalseDetermines whether all controls should be disabled
hideWithOnePage?booleanfalseDetermines whether pagination should be hidden when only one page is available total={1}
withEdges?booleanfalseDetermines whether first/last controls should be rendered
withControls?booleantrueDetermines whether next/previous controls should be rendered
siblings?number1Number of siblings displayed on the left/right side of the selected page
boundaries?number1Number of elements visible on the left/right edges
onNextPage?() => voidundefinedCalled when next page control is clicked
onPreviousPage?() => voidundefinedCalled when previous page control is clicked
onFirstPage?() => voidundefinedCalled when first page control is clicked
onLastPage?() => voidundefinedCalled when last page control is clicked
onChange?(page: number) => voidundefinedCalled when page changes
getItemProps?(page: number) => Record<string, any>undefinedAdditional props passed down to controls
getControlProps?(control: Control) => Record<string, any>undefinedAdds props to next/previous/first/last controls

Source Codes

pagination.tsx
"use client";
import * as React from "react";
import { Group } from "./group";
import { usePagination } from "@/hooks/use-pagination";
import { cn, cvx, inferType, rem, type cvxProps } from "cretex";
import { getContrastColor } from "@/hooks/use-random-colors";

const classes = cvx({
  variants: {
    selector: {
      root: "mx-auto size-auto stylelayer-pagination",
      control: "pagination-control",
      dots: "pagination-dots"
    },
    active: {
      true: "active"
    }
  }
});

type __Selector = NonNullable<cvxProps<typeof classes>["selector"]>;
type Options = StylesNames<__Selector> & { active?: boolean };
type CSSProperties = React.CSSProperties & { [key: string]: any };
type NestedRecord<U extends [string, unknown], T extends string> = {
  [K in U as K[0]]?: Partial<Record<T, K[1]>>;
};
type Styles = ["unstyled", boolean] | ["classNames", string] | ["styles", CSSProperties];
type StylesNames<T extends string, Exclude extends string = never> = Omit<NestedRecord<Styles, T> & { className?: string; style?: CSSProperties }, Exclude>;
type ComponentProps<T extends React.ElementType, Exclude extends string = never> = StylesNames<__Selector> & {
  color?: React.CSSProperties["color"];
} & React.PropsWithoutRef<Omit<React.ComponentProps<T>, "style" | "color" | Exclude>>;
type CtxProps = __PaginationProps & {
  getStyles(selector: __Selector, options?: Options): inferType<typeof getStyles>;
  range: (number | "dots")[];
  active: number;
  onFirst: () => void;
  onNext: () => void;
  onPrevious: () => void;
  onLast: () => void;
};

function getStyles(selector: __Selector, options?: Options) {
  return {
    "data-pgn": cn(selector),
    className: cn(
      !options?.unstyled?.[selector] && classes({ selector, active: options?.active ? "true" : undefined }),
      options?.classNames?.[selector],
      options?.className
    ),
    style: {
      ...options?.styles?.[selector],
      ...options?.style
    }
  };
}

const ctx = React.createContext<CtxProps | undefined>(undefined);
const usePaginationCtx = () => React.useContext(ctx)!;

export interface __PaginationProps {
  total: number;
  onChange?: (page: number) => void;
  disabled?: boolean;
  getItemProps?: (page: number) => Record<string, any>;
}

export interface PaginationProps extends __PaginationProps, ComponentProps<typeof Group, "onChange" | "value" | "defaultValue" | "unstyled"> {
  siblings?: number;
  boundaries?: number;
  hideWithOnePage?: boolean;
  size?: (string & {}) | number;
  withEdges?: boolean;
  withControls?: boolean;
  value?: number;
  defaultValue?: number;
  icons?: {
    first?: PaginationIcon;
    previous?: PaginationIcon;
    next?: PaginationIcon;
    last?: PaginationIcon;
    dots?: PaginationIcon;
  };
  onNextPage?: () => void;
  onPreviousPage?: () => void;
  onFirstPage?: () => void;
  onLastPage?: () => void;
  getControlProps?: (control: "first" | "previous" | "next" | "last") => Record<string, any>;
}

export const Pagination = React.forwardRef<HTMLDivElement, PaginationProps>((_props, ref) => {
  const {
    unstyled,
    className,
    classNames,
    style,
    styles,
    gap,
    size,
    color,
    getControlProps,
    hideWithOnePage,
    total,
    value,
    defaultValue,
    onChange,
    disabled,
    onNextPage,
    onPreviousPage,
    onFirstPage,
    onLastPage,
    getItemProps,
    children,
    icons = {},
    siblings = 1,
    boundaries = 1,
    withEdges = false,
    withControls = true,
    role = "navigation",
    "aria-label": al = "pagination",
    ...props
  } = _props;

  const { range, setPage, next, previous, active, first, last } = usePagination({
    page: value,
    initialPage: defaultValue,
    onChange,
    total,
    siblings,
    boundaries
  });

  const handleNextPage = createEventHandler(onNextPage, next);
  const handlePreviousPage = createEventHandler(onPreviousPage, previous);
  const handleFirstPage = createEventHandler(onFirstPage, first);
  const handleLastPage = createEventHandler(onLastPage, last);

  const { first: firstIcon, previous: previousIcon, next: nextIcon, last: lastIcon, dots: dotsIcon } = icons;
  const rest = { unstyled, classNames, styles };

  if (total <= 0 || (hideWithOnePage && total === 1)) {
    return null;
  }

  const content = (
    <>
      {withEdges && <PaginationFirst icon={firstIcon} {...getControlProps?.("first")} />}
      {withControls && <PaginationPrevious icon={previousIcon} {...getControlProps?.("previous")} />}
      <PaginationItems {...{ dotsIcon }} />
      {withControls && <PaginationNext icon={nextIcon} {...getControlProps?.("next")} />}
      {withEdges && <PaginationLast icon={lastIcon} {...getControlProps?.("last")} />}
    </>
  );

  return (
    <ctx.Provider
      value={{
        getStyles,
        total,
        range,
        active,
        disabled,
        getItemProps,
        onChange: setPage,
        onNext: handleNextPage,
        onPrevious: handlePreviousPage,
        onFirst: handleFirstPage,
        onLast: handleLastPage
      }}
    >
      <PaginationRoot {...{ ref, role, gap, size, className, style, color, "aria-label": al, ...rest, ...props }}>{children || content}</PaginationRoot>
    </ctx.Provider>
  );
}) as PaginationComponent;
Pagination.displayName = "Pagination";

export interface PaginationRootProps extends ComponentProps<typeof Group, "value" | "onChange" | "unstyled"> {
  size?: number | string;
}
const PaginationRoot = React.forwardRef<HTMLDivElement, PaginationRootProps>((_props, ref) => {
  const { gap = 8, size = 32, color, role = "navigation", "aria-label": al = "pagination", className, classNames, style, styles, unstyled, ...props } = _props;
  return (
    <Group
      {...{
        ref,
        role,
        gap,
        "aria-label": al,
        ...getStyles("root", {
          unstyled,
          className,
          classNames,
          styles,
          style: {
            "--control-size": rem(size),
            "--active-bg": color ? color : undefined,
            "--active-color": getContrastColor(color),
            ...style
          }
        }),
        ...props
      }}
    />
  );
});
PaginationRoot.displayName = "Pagination/PaginationRoot";

type InheritedProps<T extends React.ElementType, OverrideProps = object> = OverrideProps &
  Omit<JSX.LibraryManagedAttributes<T, React.ComponentPropsWithoutRef<T>>, keyof OverrideProps>;
type PolymorphicType<T extends React.ElementType, Props = object> = InheritedProps<T, { el?: T | (React.ElementType & {}) } & Props>;
type PolymorphicRef<T extends React.ElementType> = React.ComponentPropsWithRef<T>["ref"];

type PaginationControlProps<T extends React.ElementType> = PolymorphicType<T> & StylesNames<__Selector>;

export const PaginationControl = React.forwardRef(function PaginationControl<T extends React.ElementType>(
  _props: PaginationControlProps<T> & {
    color?: React.CSSProperties["color"];
    active?: boolean;
    withPadding?: boolean;
  },
  ref: PolymorphicRef<T>
) {
  const { unstyled, classNames, className, style, styles, active, disabled, el, withPadding = true, ...props } = _props;
  const ctx = usePaginationCtx();
  const _disabled = disabled || ctx.disabled;
  const Component = (el || "button") as React.ElementType;

  return (
    <Component
      {...{
        ref,
        "data-active": active ? "true" : undefined,
        "data-padding": withPadding ? "true" : undefined,
        disabled: _disabled,
        ...ctx.getStyles("control", { unstyled, className, style, classNames, styles, active }),
        ...props
      }}
    />
  );
});
PaginationControl.displayName = "Pagination/PaginationControl";

type EdgeProps = {
  icon: React.FC<PaginationIconProps>;
  action: "onNext" | "onPrevious" | "onFirst" | "onLast";
  type: "next" | "previous";
};

const Edge = React.forwardRef(function Edge<T extends React.ElementType>(_props: PaginationControlProps<T>, ref: PolymorphicRef<T>) {
  const { icon: Icon, action, type, onClick, title, ...props } = _props as PaginationControlProps<T> & EdgeProps;
  const ctx = usePaginationCtx();
  const disabled = type === "next" ? ctx.active === ctx.total : ctx.active === 1;
  return (
    <PaginationControl
      {...{ ref, title: title || action.replace("on", ""), disabled: ctx.disabled || disabled, onClick: onClick || ctx[action], withPadding: false, ...props }}
    >
      <Icon />
    </PaginationControl>
  );
}) as React.ForwardRefExoticComponent<EdgeProps & PaginationControlProps<React.ElementType> & React.RefAttributes<React.ElementType>>;
Edge.displayName = "Pagination/Edge";

export type PaginationIconProps = React.ComponentPropsWithoutRef<"svg">;
export type PaginationIcon = React.FC<PaginationIconProps>;

function PaginationIcon(_props: PaginationIconProps) {
  return (
    <svg
      {...{
        ..._props,
        viewBox: "0 0 24 24",
        xmlns: "http://www.w3.org/2000/svg",
        fill: "none",
        stroke: "currentColor",
        strokeWidth: "2",
        strokeLinecap: "round",
        strokeLinejoin: "round"
      }}
    />
  );
}

export const PaginationFirstIcon = (props: PaginationIconProps) => (
  <PaginationIcon {...props}>
    <path d="m11 17-5-5 5-5" />
    <path d="m18 17-5-5 5-5" />
  </PaginationIcon>
);

export const PaginationNextIcon = (props: PaginationIconProps) => (
  <PaginationIcon {...props}>
    <path d="m9 18 6-6-6-6" />
  </PaginationIcon>
);

export const PaginationPreviousIcon = (props: PaginationIconProps) => (
  <PaginationIcon {...props}>
    <path d="m15 18-6-6 6-6" />
  </PaginationIcon>
);

export const PaginationLastIcon = (props: PaginationIconProps) => (
  <PaginationIcon {...props}>
    <path d="m6 17 5-5-5-5" />
    <path d="m13 17 5-5-5-5" />
  </PaginationIcon>
);

export const PaginationDotsIcon = (props: PaginationIconProps) => (
  <PaginationIcon {...props}>
    <circle cx="12" cy="12" r="1" />
    <circle cx="19" cy="12" r="1" />
    <circle cx="5" cy="12" r="1" />
  </PaginationIcon>
);

export interface PaginationItemsProps {
  dotsIcon?: PaginationIcon;
}

export const PaginationItems = React.forwardRef(function PaginationItems<T extends React.ElementType>(_props: PaginationActions<T>, ref: PolymorphicRef<T>) {
  const { dotsIcon = PaginationDotsIcon, el, ...props } = _props;
  const ctx = usePaginationCtx();

  const items = ctx.range.map((page, index) => {
    if (page === "dots") {
      return <PaginationDots key={index} icon={dotsIcon} />;
    }
    return (
      <PaginationControl
        key={index}
        {...{
          ref,
          el,
          active: page === ctx.active,
          "aria-current": page === ctx.active ? "page" : undefined,
          onClick: () => ctx.onChange?.(page),
          disabled: ctx.disabled,
          ...ctx.getItemProps?.(page),
          ...props
        }}
      >
        {page}
      </PaginationControl>
    );
  });

  return <>{items}</>;
});
PaginationItems.displayName = "Pagination/PaginationItems";

type PaginationActions<T extends React.ElementType> = PaginationControlProps<T> & {
  icon?: React.FC<PaginationIconProps>;
};

type EdgeElement = <T extends React.ElementType = "div">(_props: PaginationActions<T> & { ref?: PolymorphicRef<T> }) => React.ReactElement;

export const PaginationFirst = React.forwardRef(function PaginationFirst<T extends React.ElementType>(_props: PaginationActions<T>, ref: PolymorphicRef<T>) {
  const { icon = PaginationFirstIcon, ...props } = _props;
  return <Edge {...{ ref, icon, type: "previous", action: "onFirst", name: "PaginationFirst", ...props }} />;
}) as EdgeElement;

export const PaginationPrevious = React.forwardRef(function PaginationPrevious<T extends React.ElementType>(
  _props: PaginationActions<T>,
  ref: PolymorphicRef<T>
) {
  const { icon = PaginationPreviousIcon, ...props } = _props;
  return <Edge {...{ ref, icon, type: "previous", action: "onPrevious", name: "PaginationPrevious", ...props }} />;
}) as EdgeElement;

export const PaginationNext = React.forwardRef(function PaginationNext<T extends React.ElementType>(_props: PaginationActions<T>, ref: PolymorphicRef<T>) {
  const { icon = PaginationNextIcon, ...props } = _props;
  return <Edge {...{ ref, icon, type: "next", action: "onNext", name: "PaginationNext", ...props }} />;
}) as EdgeElement;

export const PaginationLast = React.forwardRef(function PaginationLast<T extends React.ElementType>(_props: PaginationActions<T>, ref: PolymorphicRef<T>) {
  const { icon = PaginationLastIcon, el, ...props } = _props;
  return <Edge {...{ ref, el, icon, type: "next", action: "onLast", name: "PaginationLast", ...props }} />;
}) as EdgeElement;

const PaginationDots = React.forwardRef<HTMLDivElement, PaginationActions<"div">>(function PaginationDots(_props, ref) {
  const { classNames, className, style, styles, icon: Icon, ...props } = _props;
  const ctx = usePaginationCtx();
  return <div {...{ ref, ...ctx.getStyles("dots", { className, style, styles, classNames }), ...props }}>{Icon && <Icon />}</div>;
});

type EventHandler<Event> = ((event?: Event) => void) | undefined;
export function createEventHandler<Event>(parentEventHandler: EventHandler<Event>, eventHandler: EventHandler<Event>) {
  return (event?: Event) => {
    parentEventHandler?.(event);
    eventHandler?.(event);
  };
}

// Export as a composite component
type PaginationComponent = React.ForwardRefExoticComponent<PaginationProps> & {
  Root: typeof Pagination;
  First: typeof PaginationFirst;
  Previous: typeof PaginationPrevious;
  Items: typeof PaginationItems;
  Next: typeof PaginationNext;
  Last: typeof PaginationLast;
};
// Attach sub-components
Pagination.Root = Pagination;
Pagination.First = PaginationFirst;
Pagination.Previous = PaginationPrevious;
Pagination.Items = PaginationItems;
Pagination.Next = PaginationNext;
Pagination.Last = PaginationLast;