diff --git a/packages/propel/src/components/pagination/index.tsx b/packages/propel/src/components/pagination/index.tsx new file mode 100644 index 00000000..c2982d82 --- /dev/null +++ b/packages/propel/src/components/pagination/index.tsx @@ -0,0 +1,327 @@ +import { cva, cx } from "class-variance-authority"; +import { ArrowLeft, ArrowRight, ChevronDown, LoaderCircle, MoreHorizontal } from "lucide-react"; +import * as React from "react"; +import { Dropdown, DropdownContent, DropdownItem, DropdownTrigger } from "../dropdown/index"; + +// Pagination is a single composite navigation control rather than a variant matrix: +// the Figma "variant" axis (All pages visible / Near start / Middle / Near end) is +// purely a function of where the current page sits within the total, so it's derived +// at render time from `page`/`pageCount` — never a prop. What designers can toggle is +// genuinely additive: an optional per-page selector and an optional range label. +// +// Tokens (Figma node 4762-503): +// - page-number button: 24px square, radius/sm (4px), text/13, transparent bg that +// fills to `layer-transparent-hover` on hover and `layer-transparent-active` when +// it is the current page; disabled/loading dim to the placeholder/disabled colors. +// - prev/next: 24px square icon buttons, radius/md (6px), 16px arrows. The arrows are +// directional, so they mirror in RTL via `rtl:-scale-x-100`. +// - ellipsis: a non-interactive 24px slot holding a 14px more-horizontal glyph. +// - per-page selector: a `layer-3` pill, 24px tall, radius/md, "50" + chevron-down, +// followed by "per page" tertiary text. The pill is the trigger for a propel +// Dropdown (single-select) whose menu lists the page-size options; picking one +// reports it through `pageSize.onChange`. + +// Shared 24px slot used by page numbers, the prev/next buttons and the ellipsis. +// 24px tall with a 24px minimum width so single digits stay square (per Figma), +// but the slot grows for wider content — multi-digit page numbers like `100` get +// their own width plus horizontal padding rather than clipping a fixed square. +// `radius/sm` for page numbers, `radius/md` for the arrow buttons. +const slotBase = cx( + "inline-flex h-6 w-auto min-w-6 shrink-0 items-center justify-center px-1", + "text-13 text-primary outline-none", +); + +const pageButtonVariants = cva( + cx( + slotBase, + "rounded-sm bg-layer-transparent", + "hover:bg-layer-transparent-hover", + "focus-visible:ring-2 focus-visible:ring-accent-strong", + "disabled:pointer-events-none disabled:text-disabled", + ), + { + variants: { + // The current page reads as pressed: it sits on the `transparent-active` fill + // (Figma "Selected") and is not interactive. It's marked `disabled` to block + // re-navigation, but the Figma "Selected" state keeps `text/primary` — so it + // must override the `disabled:text-disabled` dim that the base applies. + current: { + true: "bg-layer-transparent-active disabled:text-primary", + false: "", + }, + }, + }, +); + +const arrowButtonVariants = cva( + cx( + slotBase, + "rounded-md bg-layer-transparent text-icon-secondary", + "hover:bg-layer-transparent-hover", + "focus-visible:ring-2 focus-visible:ring-accent-strong", + "disabled:pointer-events-none disabled:text-icon-disabled", + // Prev/next arrows are directional — mirror them under RTL so "previous" always + // points toward the start of the run. + "[&_svg]:size-4 [&_svg]:shrink-0 rtl:[&_svg]:-scale-x-100", + ), +); + +// The per-page selector trigger (Figma `4762-503`): a `layer-3` pill, 24px tall, +// `radius/md`, holding the current size + a chevron-down. It's the trigger for the +// page-size Dropdown, so it gets a focus ring and rotates its chevron while the menu +// is open. +const perPageTriggerClass = cx( + "inline-flex h-6 min-w-10 cursor-default items-center justify-center gap-1 rounded-md px-2", + "bg-layer-3 text-13 font-medium text-secondary outline-none", + "hover:bg-layer-3-hover", + "focus-visible:ring-2 focus-visible:ring-accent-strong", + "[&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:text-icon-secondary", + "[&[data-popup-open]_svg]:rotate-180", +); + +/** A non-interactive gap marker between distant page numbers. */ +function PaginationEllipsis() { + return ( +
  • + +
  • + ); +} + +// Builds the sequence of visible page tokens. Always shows the first and last page; +// shows up to one neighbour either side of the current page; inserts an ellipsis +// only where the run skips 2+ pages. A gap of exactly one page renders that page +// number instead, since a `…` standing in for a single page just hides a reachable +// page. This reproduces the four Figma layouts: +// 1 2 3 4 (all visible) +// 1 2 3 … 100 (near start) +// 1 … 44 45 46 … 100 (middle) +// 1 … 98 99 100 (near end) +type PageToken = number | "ellipsis-start" | "ellipsis-end"; + +function buildPageTokens(page: number, pageCount: number): PageToken[] { + // Few enough pages to show them all. + if (pageCount <= 7) { + return Array.from({ length: pageCount }, (_, i) => i + 1); + } + // A window of three consecutive pages around the current one (current ± 1). When + // that window butts up against an end, it shifts inward so the end side always + // shows three numbers — reproducing Figma's `1 2 3 … 100` / `1 … 98 99 100`. + let windowStart = page - 1; + let windowEnd = page + 1; + if (windowStart < 2) { + // Near the start: the run abuts the `1` anchor, so show `1 2 3 … last`. + windowStart = 2; + windowEnd = 3; + } + if (windowEnd > pageCount - 1) { + // Near the end: the run abuts the `last` anchor, so show `1 … last-2 last-1 last`. + windowEnd = pageCount - 1; + windowStart = pageCount - 2; + } + const tokens: PageToken[] = [1]; + // Bridge the gap between the `1` anchor and the window. An ellipsis is only worth + // it when 2+ pages are hidden; a lone skipped page (here, page 2) is rendered as + // its own number so it isn't buried under `…` — `1 2 3 4 5 …` instead of `1 … 3 …`. + if (windowStart === 3) tokens.push(2); + else if (windowStart > 3) tokens.push("ellipsis-start"); + for (let p = windowStart; p <= windowEnd; p++) tokens.push(p); + // Likewise on the trailing side: a single skipped page (pageCount - 1) is shown + // rather than hidden behind a trailing ellipsis. + if (windowEnd === pageCount - 2) tokens.push(pageCount - 1); + else if (windowEnd < pageCount - 2) tokens.push("ellipsis-end"); + tokens.push(pageCount); + return tokens; +} + +export type PaginationLabels = { + /** Accessible name for the `