/**
* Pagination — moving through a result set one page at a time.
*
* The component owns the arithmetic and nothing else. Give it how many pages
* there are and which one you are on, and it works out which numbers to show,
* where the gaps fall, and which controls are dead at the ends; it never fetches
* and never slices, because the data is yours.
*
* ```tsx
*
* ```
*
* Three presentations, because a phone is not a desktop:
*
* - `numbers` — the full run, with the middle folded into ellipses. Fits about
* seven targets across a phone, which is what `siblings` and `boundaries`
* are tuned for.
* - `compact` — the two arrows with `3 / 12` between them. Digits and a slash,
* so there is no sentence to translate.
* - `simple` — labelled Previous and Next, for a flow you walk rather than
* jump around in: a wizard, an article, a set of onboarding cards.
*
* Every target is at least 44pt in both axes, and the small size keeps that
* reach with `hitSlop` rather than by growing — the row gets denser, the
* fingers do not get smaller.
*
* An ellipsis is a button, not punctuation. A dead 44pt target in the middle of
* a row of live ones is a thing people tap and then think is broken, so tapping
* one jumps `pageJump` pages that way.
*
* Given children, the root becomes a row with them on the leading edge and the
* controls on the trailing one — which is where `Pagination.Status` goes:
*
* ```tsx
*
*
*
* ```
*/
import {
createContext,
forwardRef,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from 'react';
import { View, type ViewProps } from 'react-native';
import { tv, type VariantProps } from 'tailwind-variants';
import { useCSSVariable } from 'uniwind';
import { ChevronLeftIcon, ChevronRightIcon, EllipsisIcon } from '../../icons';
import { useDirection } from '../../hooks/use-direction';
import {
AnimatedPressable,
type AnimatedPressableProps,
} from '../../primitives/animated-pressable';
import { Text, type TextProps, textChildren } from '../../primitives/text';
import { cn } from '../../utils/cn';
import { selectionTick } from '../../utils/haptics';
export type PaginationVariant = 'numbers' | 'compact' | 'simple';
export type PaginationSize = 'sm' | 'default';
/**
* What the small size gives back as padding. 44pt is the smallest target a
* finger reliably hits, and the row is allowed to get tighter than that only
* because the reach is restored outside the paint — so this is exactly the
* difference between the small target's 32pt and that 44.
*/
const SMALL_HIT_SLOP = { top: 6, bottom: 6, left: 6, right: 6 } as const;
const paginationVariants = tv({
slots: {
root: 'flex-row items-center',
list: 'flex-row items-center',
item: 'items-center justify-center rounded-lg',
itemLabel: 'font-medium tabular-nums',
ellipsis: 'items-center justify-center',
status: 'text-muted-foreground tabular-nums',
summary: 'text-muted-foreground tabular-nums',
},
variants: {
size: {
default: {
list: 'gap-1',
item: 'h-11 min-w-11 px-1',
itemLabel: 'text-sm',
ellipsis: 'h-11 w-11',
status: 'text-sm',
summary: 'text-sm',
},
sm: {
list: 'gap-0.5',
item: 'h-8 min-w-8 px-1',
itemLabel: 'text-xs',
ellipsis: 'h-8 w-8',
status: 'text-xs',
summary: 'text-xs',
},
},
/** Whether the item is the page you are on. */
current: {
true: { item: 'bg-primary', itemLabel: 'text-primary-foreground' },
false: { itemLabel: 'text-foreground' },
},
disabled: {
true: { item: 'opacity-[0.4]' },
},
},
defaultVariants: {
size: 'default',
current: false,
},
});
const iconSize: Record = { default: 20, sm: 16 };
/**
* The state every part reads. A `Pagination.Previous` dropped into a row of
* your own still knows which page it is on and whether it is at the start,
* because that answer lives here rather than in the props it was given.
*/
interface PaginationContextValue {
page: number;
count: number;
size: PaginationSize;
disabled: boolean;
goTo: (page: number) => void;
}
const PaginationContext = createContext(null);
function usePagination(part: string): PaginationContextValue {
const context = useContext(PaginationContext);
if (!context) {
throw new Error(`${part} must be used inside .`);
}
return context;
}
const range = (start: number, end: number): number[] =>
end < start ? [] : Array.from({ length: end - start + 1 }, (_, i) => start + i);
export type PaginationItemValue = number | 'start-ellipsis' | 'end-ellipsis';
/**
* Which numbers to draw, and where the gaps go.
*
* The run is a fixed width rather than a sliding window: the same number of
* targets whichever page you are on, so the row does not reflow under the
* finger as you step through it and the arrow you were aiming at stays put.
* `boundaries` pins the ends, `siblings` pads the middle, and a gap of exactly
* one page is drawn as that page instead of an ellipsis — a `…` that hides a
* single number is longer than the number.
*
* Exported because a caller sometimes needs the same run for a control of their
* own, and two implementations of this would drift.
*/
export function paginationRange({
count,
page,
siblings = 1,
boundaries = 1,
}: {
count: number;
page: number;
siblings?: number;
boundaries?: number;
}): PaginationItemValue[] {
const startPages = range(1, Math.min(boundaries, count));
const endPages = range(Math.max(count - boundaries + 1, boundaries + 1), count);
const siblingsStart = Math.max(
Math.min(page - siblings, count - boundaries - siblings * 2 - 1),
boundaries + 2
);
const siblingsEnd = Math.min(
Math.max(page + siblings, boundaries + siblings * 2 + 2),
endPages.length > 0 ? (endPages[0] as number) - 2 : count - 1
);
return [
...startPages,
...(siblingsStart > boundaries + 2
? (['start-ellipsis'] as PaginationItemValue[])
: boundaries + 1 < count - boundaries
? [boundaries + 1]
: []),
...range(siblingsStart, siblingsEnd),
...(siblingsEnd < count - boundaries - 1
? (['end-ellipsis'] as PaginationItemValue[])
: count - boundaries > boundaries
? [count - boundaries]
: []),
...endPages,
];
}
export interface PaginationProps
extends Omit,
VariantProps {
className?: string;
/**
* How many pages there are. Pages are numbered from 1, so this is also the
* last page's number.
*/
count: number;
/** The page being shown. Pass it to control the component. */
page?: number;
/** The page to start on when the component keeps its own. Defaults to 1. */
defaultPage?: number;
/** Called with the page that was asked for, already clamped to `count`. */
onPageChange?: (page: number) => void;
/** Which presentation to draw. */
variant?: PaginationVariant;
size?: PaginationSize;
/**
* How many pages to keep either side of the current one. Raise it on a
* tablet, where there is room for a longer run.
*/
siblings?: number;
/** How many pages to keep pinned at each end of the run. */
boundaries?: number;
/**
* Show the previous and next arrows. Turning them off leaves the numbers
* alone, so only do it where something else moves the page — a swipe, a
* scroller reaching its end.
*/
controls?: boolean;
/** How far tapping an ellipsis jumps. */
pageJump?: number;
/** Greys out and deafens the whole row — for a page that is still loading. */
disabled?: boolean;
/** Labels the row for a screen reader. Defaults to "Pagination". */
accessibilityLabel?: string;
/** Leading content — a `Pagination.Status`, a page-size control of your own. */
children?: ReactNode;
}
const PaginationRoot = forwardRef(
(
{
className,
count,
page: controlledPage,
defaultPage = 1,
onPageChange,
variant = 'numbers',
size = 'default',
siblings = 1,
boundaries = 1,
controls = true,
pageJump = 5,
disabled = false,
accessibilityLabel = 'Pagination',
children,
...props
},
ref
) => {
const [internalPage, setInternalPage] = useState(defaultPage);
const isControlled = controlledPage !== undefined;
// Clamped on the way out as well as on the way in: `count` can shrink under
// a controlled page — a filter narrowing the result set — and a page number
// past the end would light nothing and disable both arrows at once.
const page = Math.min(Math.max(isControlled ? controlledPage : internalPage, 1), count);
const goTo = useCallback(
(next: number) => {
const clamped = Math.min(Math.max(next, 1), count);
if (clamped === page || disabled) return;
// On the frame of the press rather than after the fetch: the tick is
// what ties the feeling to the tap, and the rows arrive whenever they
// arrive.
selectionTick();
if (!isControlled) setInternalPage(clamped);
onPageChange?.(clamped);
},
[count, page, disabled, isControlled, onPageChange]
);
const context = useMemo(
() => ({ page, count, size, disabled, goTo }),
[page, count, size, disabled, goTo]
);
const items = useMemo(
() =>
variant === 'numbers'
? paginationRange({ count, page, siblings, boundaries })
: [],
[variant, count, page, siblings, boundaries]
);
const { root, list } = paginationVariants({ size });
return (
{textChildren(children, (text) => (
{text}
))}
{controls ? : null}
{variant === 'numbers'
? items.map((item, index) =>
typeof item === 'number' ? (
) : (
)
)
: null}
{variant === 'compact' ? : null}
{controls ? : null}
);
}
);
PaginationRoot.displayName = 'Pagination';
export interface PaginationItemProps
extends Omit {
className?: string;
/** The page this target goes to. */
page: number;
/** Styles the number. */
labelClassName?: string;
children?: ReactNode;
}
/**
* One numbered target. Announced as a selected button when it is the page you
* are on, so the current page is spoken as state rather than only painted.
*/
const PaginationItem = forwardRef(
({ className, labelClassName, page, children, onPress, ...props }, ref) => {
const { page: current, size, disabled, goTo } = usePagination('Pagination.Item');
const isCurrent = page === current;
const { item, itemLabel } = paginationVariants({ size, current: isCurrent, disabled });
return (
{
onPress?.(event);
goTo(page);
}}
className={item({ className })}
>
{textChildren(children ?? String(page), (text) => (
{text}
))}
);
}
);
PaginationItem.displayName = 'Pagination.Item';
export interface PaginationPreviousProps extends Omit {
className?: string;
/** Write the word beside the arrow, rather than leaving it as a glyph. */
label?: boolean;
}
export interface PaginationNextProps extends Omit {
className?: string;
/** Write the word beside the arrow, rather than leaving it as a glyph. */
label?: boolean;
}
type PaginationArrowProps = PaginationPreviousProps;
/**
* The two arrows, which are the same button pointed the other way.
*
* Which glyph is "back" is a question about the reading direction, not about
* the component: in a right-to-left layout the previous page is to the right.
* Yoga mirrors the row on its own; the arrowhead inside it has to be chosen.
*/
function useArrow(step: -1 | 1, part: string) {
const { page, count, size, disabled, goTo } = usePagination(part);
const rtl = useDirection() === 'rtl';
const color = useCSSVariable('--color-foreground');
const target = page + step;
const spent = disabled || target < 1 || target > count;
const Glyph = (step === -1) === rtl ? ChevronRightIcon : ChevronLeftIcon;
return {
Glyph,
size,
spent,
color: typeof color === 'string' ? color : '#0a0a0a',
press: () => goTo(target),
};
}
function PaginationArrow({
step,
part,
word,
className,
label,
disabled,
onPress,
...props
}: PaginationArrowProps & { step: -1 | 1; part: string; word: string }) {
const { Glyph, size, spent, color, press } = useArrow(step, part);
const isDisabled = Boolean(spent || disabled);
const { item, itemLabel } = paginationVariants({ size, disabled: isDisabled });
return (
{
onPress?.(event);
press();
}}
className={item({
className: cn('flex-row gap-1', label && (size === 'sm' ? 'px-2' : 'px-3'), className),
})}
>
{step === -1 ? : null}
{label ? {word} : null}
{step === 1 ? : null}
);
}
/** Back one page. Dead on the first page rather than looping to the last. */
const PaginationPrevious = ({ ...props }: PaginationPreviousProps) => (
);
PaginationPrevious.displayName = 'Pagination.Previous';
/** Forward one page. Dead on the last page rather than looping to the first. */
const PaginationNext = ({ ...props }: PaginationNextProps) => (
);
PaginationNext.displayName = 'Pagination.Next';
export interface PaginationEllipsisProps
extends Omit {
className?: string;
/** Which way the gap runs: `-1` towards page 1, `1` towards the last page. */
direction?: -1 | 1;
/** How many pages a tap covers. */
jump?: number;
}
/**
* The gap in the run, and a way across it. Tapping jumps `jump` pages towards
* the end the gap is on — which is why the two ellipses are told apart: a jump
* that always went forwards would strand anyone reading the row right to left.
*/
const PaginationEllipsis = forwardRef(
({ className, direction = 1, jump = 5, disabled: disabledProp, onPress, ...props }, ref) => {
const { page, size, disabled, goTo } = usePagination('Pagination.Ellipsis');
const color = useCSSVariable('--color-muted-foreground');
const { ellipsis } = paginationVariants({ size });
const isDisabled = Boolean(disabled || disabledProp);
return (
{
onPress?.(event);
goTo(page + direction * jump);
}}
className={ellipsis({ className })}
>
);
}
);
PaginationEllipsis.displayName = 'Pagination.Ellipsis';
export interface PaginationSummaryProps extends TextProps {
className?: string;
}
/**
* `3 / 12` — where you are, in the space two arrows leave between them.
*
* Digits and a slash rather than "Page 3 of 12", because a phone has no room
* for the sentence and the sentence would need translating. `Pagination.Status`
* is the place for words.
*/
const PaginationSummary = forwardRef, PaginationSummaryProps>(
({ className, ...props }, ref) => {
const { page, count, size } = usePagination('Pagination.Summary');
const { summary } = paginationVariants({ size });
return (
{page} / {count}
);
}
);
PaginationSummary.displayName = 'Pagination.Summary';
export interface PaginationStatusProps extends TextProps {
className?: string;
/** Which page the span is counted from. Read from the root when left out. */
page?: number;
/** How many rows a page holds. Required for the span to be worked out. */
pageSize?: number;
/** How many rows there are altogether. */
total?: number;
children?: ReactNode;
}
/**
* The line that says how much of the set you are looking at — `1–20 of 240`.
*
* Worth having beside a table because a page number alone does not answer the
* question people actually have, which is how much is left. Given `children`
* it renders those instead, for a set whose size is not known yet.
*/
const PaginationStatus = forwardRef, PaginationStatusProps>(
({ className, page: ownPage, pageSize, total, children, ...props }, ref) => {
const context = useContext(PaginationContext);
const size = context?.size ?? 'default';
const page = ownPage ?? context?.page ?? 1;
const { status } = paginationVariants({ size });
const span = useMemo(() => {
if (children !== undefined) return null;
if (pageSize === undefined || total === undefined) return null;
if (total <= 0) return 'None';
const from = (page - 1) * pageSize + 1;
// The last page is nearly always short; counting a full one past the end
// is the classic off-by-a-page-size.
const to = Math.min(page * pageSize, total);
return `${from}–${to} of ${total}`;
}, [children, page, pageSize, total]);
return (
{span ?? textChildren(children)}
);
}
);
PaginationStatus.displayName = 'Pagination.Status';
export const Pagination = Object.assign(PaginationRoot, {
Item: PaginationItem,
Previous: PaginationPrevious,
Next: PaginationNext,
Ellipsis: PaginationEllipsis,
Summary: PaginationSummary,
Status: PaginationStatus,
});