import type { HTMLAttributes, ReactNode } from 'react' import { useRef, useState } from 'react' import { IoChevronBack, IoChevronForward } from 'react-icons/io5' import { cx } from '../../utils/cx' import { usePressFeedback } from '../../hooks/usePressFeedback' import { useSlidingThumb } from '../../motion/useSlidingThumb' import { useContainerWidth } from '../../hooks/useContainerWidth' import { rowWidth } from '../../utils/rowWidth' import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect' import type { MaySize } from '../../types' /** The key lifts a little under the press, the way a segmented thumb does. */ const PRESS_SCALE = 1.14 /** xs is absent: a pager key that small stops being a touch target. */ export type PaginationSize = Exclude /** A rendered slot: a page number, or a gap standing in for the pages between. */ type Slot = number | 'gap-start' | 'gap-end' export interface PaginationProps extends Omit, 'onChange'> { /** The current page, 1-based. */ page: number pageCount: number onPageChange: (page: number) => void /** How many pages to show either side of the current one. @default 1 */ siblingCount?: number /** @default 'md' */ size?: PaginationSize /** * Collapse to "Page 3 of 12" between the two arrows. Twelve keys do not fit * across a phone, and a pager that scrolls sideways is worse than one that * counts. * * Left off, the pager decides for itself: it measures the keys it would * draw and collapses when they do not fit the space it has been given. Pass * `true` to force the count, or `false` to insist on the keys and accept * that a narrow container will have to scroll to reach them. */ compact?: boolean /** Accessible name for the pager. @default 'Pagination' */ 'aria-label'?: string } const range = (start: number, end: number): number[] => Array.from({ length: Math.max(0, end - start + 1) }, (_, i) => start + i) /** * Which keys to draw. * * The row is always the same width: two pinned ends and a run of pages between * them, with the spare slot at either end turning into a gap when there is * more beyond it. A pager that reflows under the finger as the ellipsis * appears and disappears is exactly what that constant width avoids. * * Because the gap swallows the page it replaced, it never stands in for a * single number — an ellipsis hiding one page is a lie that costs a tap. */ function slotsFor(page: number, pageCount: number, siblingCount: number): Slot[] { // first + last + both gaps + the current page and its siblings const windowSize = siblingCount * 2 + 5 if (pageCount <= windowSize) return range(1, pageCount) const span = windowSize - 3 let start = page - siblingCount - 1 let end = page + siblingCount + 1 // Pinned to whichever end the window has run past, keeping its length. if (start < 2) { start = 2 end = start + span } else if (end > pageCount - 1) { end = pageCount - 1 start = end - span } const middle: Slot[] = range(start, end) if (start > 2) middle[0] = 'gap-start' if (end < pageCount - 1) middle[middle.length - 1] = 'gap-end' return [1, ...middle, pageCount] } interface KeyProps { children: ReactNode label: string current?: boolean disabled?: boolean onClick: () => void className?: string keyRef?: (node: HTMLButtonElement | null) => void } /** One key of the pager. Always a real ` ) } /** * A numeric pager. * * The current page is a **fill**, never an outline — and the fill scales in on * a bouncy spring as it lands, so paging reads as the selection jumping to a * new key rather than two colours swapping. */ export function Pagination({ page, pageCount, onPageChange, siblingCount = 1, size = 'md', compact, className, 'aria-label': ariaLabel = 'Pagination', ...rest }: PaginationProps) { const rootRef = useRef(null) const width = useContainerWidth(rootRef) /* * What the full row of keys actually needs, measured from the keys. * * Not computed from the tokens: `--may-control-h` is in `rem`, so a reader * at 200% text needs a proportionally wider row and a hard-coded 428 would * collapse a pager that fits or keep one that does not. Not `scrollWidth` * either — that clamps to the client width whenever the content fits, so a * roomy pager would report its container's width as its requirement and * collapse on the next pixel of shrink. * * The keys carry `flex-shrink: 0`, so their own widths are their natural * ones even while the row is overflowing, which makes this readable in the * state that matters and stable in the one that does not. */ const [needed, setNeeded] = useState(null) const total = Math.max(1, Math.floor(pageCount)) const current = Math.min(Math.max(1, Math.floor(page)), total) const go = (next: number) => { const clamped = Math.min(Math.max(1, next), total) if (clamped !== current) onPageChange(clamped) } /* * An explicit prop always wins. Otherwise the row collapses exactly when it * would otherwise leave its container — which is what the note on * `.may-pagination__list` has always said is the answer, except that until * now the consumer had to know to ask for it. Nine md keys want 428px and a * phone hands them 320, so the default pager overflowed every phone. * * Never collapses before the first measurement: `null` means "not measured", * and guessing `compact` there would flash the count on every desktop load. */ const isCompact = compact ?? (width !== null && needed !== null && width < needed) const slots = isCompact ? [] : slotsFor(current, total, siblingCount) useIsomorphicLayoutEffect(() => { if (isCompact) return // nothing to measure; the stored requirement stands const list = rootRef.current?.querySelector('ul') if (!list || list.children.length === 0) return const measured = rowWidth(list) setNeeded((previous) => (previous === measured ? previous : measured)) }) /* * A filled key that SLIDES between slot positions instead of one key's fill * springing in as another's springs out. The thumb tracks the slot the * current page occupies, not the page number: the row of slots is a fixed * width, so when the window shifts the digits scroll under a thumb that holds * its place — and when the current page walks toward an end, the thumb slides * with it. Press-only: a page is tapped, not scrubbed. Inert when compact, * which draws no keys at all. */ const { trackRef, thumbRef, registerItem, onPointerDown } = useSlidingThumb< HTMLUListElement, HTMLButtonElement >({ itemCount: slots.length, selectedIndex: slots.findIndex((slot) => slot === current), roundEnds: true, pressScale: PRESS_SCALE, enabled: !isCompact, }) const previous = (
  • go(current - 1)} className="may-pagination__arrow" >
  • ) return ( ) }