Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/propel/src/components/pagination/pagination.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,39 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import * as React from "react";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";

import {
PaginationArrowButton,
PaginationEllipsis,
PaginationItem,
PaginationList,
PaginationPageButton,
PaginationPerPage,
PaginationPerPageIndicator,
PaginationPerPageLabel,
PaginationPerPageTrigger,
PaginationRange,
PaginationRangeCurrent,
PaginationSpinner,
} from "../../ui/pagination/index";
import { Pagination } from "./index";

const meta = {
title: "Components/Pagination",
component: Pagination,
subcomponents: {
PaginationList,
PaginationItem,
PaginationPageButton,
PaginationArrowButton,
PaginationEllipsis,
PaginationSpinner,
PaginationPerPage,
PaginationPerPageTrigger,
PaginationPerPageIndicator,
PaginationPerPageLabel,
PaginationRange,
PaginationRangeCurrent,
},
parameters: {
design: {
type: "figma",
Expand Down
98 changes: 52 additions & 46 deletions packages/propel/src/components/pagination/pagination.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { ArrowLeft, ArrowRight, ChevronDown, LoaderCircle } from "lucide-react";
import { ArrowLeft, ArrowRight, ChevronDown, LoaderCircle, MoreHorizontal } from "lucide-react";
import * as React from "react";

import { Menu, MenuTrigger } from "../../ui/menu/index";
import {
Pagination as PaginationRoot,
PaginationArrowButton,
PaginationEllipsis,
PaginationItem,
PaginationList,
PaginationPageButton,
PaginationPerPage,
PaginationPerPageIndicator,
PaginationPerPageLabel,
PaginationPerPageTrigger,
PaginationRange,
PaginationRangeCurrent,
PaginationSpinner,
} from "../../ui/pagination/index";
import { MenuContent, MenuItem } from "../menu/index";

Expand All @@ -16,17 +25,8 @@ import { MenuContent, MenuItem } from "../menu/index";
// 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
// Menu (single-select) whose menu lists the page-size options; picking one
// reports it through `pageSize.onValueChange`.
// This components tier only COMPOSES the `ui/pagination` parts (each a single styled
// element); all chrome lives in their cva — there is no className/cva/cx here.

// 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
Expand Down Expand Up @@ -89,7 +89,7 @@ export type PaginationLabels = {
/** Visible text on the per-page selector trigger, given the size. Defaults to `"50"`. */
perPageValue: (pageSize: number) => React.ReactNode;
/** Trailing text after the per-page selector. Defaults to `"per page"`. */
perPage: React.ReactNode;
perPage: string;
};

const DEFAULT_LABELS: PaginationLabels = {
Expand Down Expand Up @@ -165,22 +165,25 @@ export function Pagination({
const atEnd = page >= pageCount;

return (
<nav aria-label={l.root} className="flex items-center gap-4" {...props}>
<PaginationRoot aria-label={l.root} {...props}>
{pageSize ? (
<div className="flex min-w-0 flex-1 items-center gap-2">
<PaginationPerPage>
{/*
The selector is the propel Menu (single-select): the `layer-3` pill is
its trigger and the menu lists the page sizes, the current one marked with a
trailing check. The trigger carries the accessible name (the visible size +
the visually-hidden "per page"), and picking a size reports it through
`pageSize.onValueChange`. Keyboard works via the Menu (Enter/ArrowDown opens,
arrows move, Enter selects).
trailing check. The trigger carries the accessible name "<size> per page",
and picking a size reports it through `pageSize.onValueChange`. Keyboard
works via the Menu (Enter/ArrowDown opens, arrows move, Enter selects).
*/}
<Menu>
<MenuTrigger render={<PaginationPerPageTrigger />}>
<span>{l.perPageValue(pageSize.value)}</span>
<span className="sr-only">{l.perPage}</span>
<ChevronDown aria-hidden className="transition-transform" />
<MenuTrigger
render={<PaginationPerPageTrigger />}
aria-label={`${pageSize.value} ${l.perPage}`}
>
{l.perPageValue(pageSize.value)}
<PaginationPerPageIndicator>
<ChevronDown />
</PaginationPerPageIndicator>
</MenuTrigger>
<MenuContent width="anchor" align="center">
{pageSize.options.map((option) => (
Expand All @@ -194,38 +197,42 @@ export function Pagination({
))}
</MenuContent>
</Menu>
<span aria-hidden className="text-13 whitespace-nowrap text-tertiary">
{l.perPage}
</span>
</div>
<PaginationPerPageLabel>{l.perPage}</PaginationPerPageLabel>
</PaginationPerPage>
) : null}

{range ? (
<p className="text-12 whitespace-nowrap text-tertiary">
<span className="text-primary">{range.current}</span>
<span>{" of "}</span>
<span>{range.total}</span>
</p>
<PaginationRange>
<PaginationRangeCurrent>{range.current}</PaginationRangeCurrent>
{" of "}
{range.total}
</PaginationRange>
) : null}

<ul className="flex items-center gap-1.5">
<li>
<PaginationList>
<PaginationItem>
<PaginationArrowButton
aria-label={l.previous}
disabled={atStart}
onClick={() => onPageChange(page - 1)}
>
<ArrowLeft aria-hidden />
</PaginationArrowButton>
</li>
</PaginationItem>

{tokens.map((token) => {
if (token === "ellipsis-start" || token === "ellipsis-end") {
return <PaginationEllipsis key={token} />;
return (
<PaginationItem key={token}>
<PaginationEllipsis>
<MoreHorizontal />
</PaginationEllipsis>
</PaginationItem>
);
}
const isCurrent = token === page;
return (
<li key={token}>
<PaginationItem key={token}>
<PaginationPageButton
aria-label={l.page(token)}
aria-current={isCurrent ? "page" : undefined}
Expand All @@ -234,28 +241,27 @@ export function Pagination({
onClick={() => onPageChange(token)}
>
{isCurrent && loading ? (
<LoaderCircle
aria-hidden
className="size-3.5 shrink-0 animate-spin text-icon-placeholder"
/>
<PaginationSpinner>
<LoaderCircle />
</PaginationSpinner>
) : (
token
)}
</PaginationPageButton>
</li>
</PaginationItem>
);
})}

<li>
<PaginationItem>
<PaginationArrowButton
aria-label={l.next}
disabled={atEnd}
onClick={() => onPageChange(page + 1)}
>
<ArrowRight aria-hidden />
</PaginationArrowButton>
</li>
</ul>
</nav>
</PaginationItem>
</PaginationList>
</PaginationRoot>
);
}
37 changes: 32 additions & 5 deletions packages/propel/src/ui/pagination/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,40 @@
export { Pagination, type PaginationProps } from "./pagination";
export { PaginationArrowButton, type PaginationArrowButtonProps } from "./pagination-arrow-button";
export { PaginationEllipsis } from "./pagination-ellipsis";
export { PaginationEllipsis, type PaginationEllipsisProps } from "./pagination-ellipsis";
export { PaginationItem, type PaginationItemProps } from "./pagination-item";
export { PaginationList, type PaginationListProps } from "./pagination-list";
export { PaginationPageButton, type PaginationPageButtonProps } from "./pagination-page-button";
export { PaginationPerPage, type PaginationPerPageProps } from "./pagination-per-page";
export {
PaginationPerPageIndicator,
type PaginationPerPageIndicatorProps,
} from "./pagination-per-page-indicator";
export {
PaginationPerPageLabel,
type PaginationPerPageLabelProps,
} from "./pagination-per-page-label";
export {
PaginationPerPageTrigger,
type PaginationPerPageTriggerProps,
} from "./pagination-per-page-trigger";
export { PaginationRange, type PaginationRangeProps } from "./pagination-range";
export {
PaginationRangeCurrent,
type PaginationRangeCurrentProps,
} from "./pagination-range-current";
export { PaginationSpinner, type PaginationSpinnerProps } from "./pagination-spinner";
export {
arrowButtonVariants,
pageButtonVariants,
perPageTriggerVariants,
slotBase,
paginationArrowButtonVariants,
paginationEllipsisVariants,
paginationItemVariants,
paginationListVariants,
paginationPageButtonVariants,
paginationPerPageIndicatorVariants,
paginationPerPageLabelVariants,
paginationPerPageTriggerVariants,
paginationPerPageVariants,
paginationRangeCurrentVariants,
paginationRangeVariants,
paginationSpinnerVariants,
paginationVariants,
} from "./variants";
9 changes: 5 additions & 4 deletions packages/propel/src/ui/pagination/pagination-arrow-button.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import type * as React from "react";

import { arrowButtonVariants } from "./variants";
import { paginationArrowButtonVariants } from "./variants";

export type PaginationArrowButtonProps = Omit<
React.ComponentProps<"button">,
"className" | "style"
>;

/**
* A styled prev/next arrow button. Applies `arrowButtonVariants()`; pass the (directional,
* RTL-mirrored) arrow icon as `children` and wire `aria-label`/`disabled`/`onClick` through props.
* A styled prev/next arrow button. Applies `paginationArrowButtonVariants()`; pass the
* (directional, RTL-mirrored) arrow icon as `children` and wire `aria-label`/`disabled`/`onClick`
* through props.
*/
export function PaginationArrowButton(props: PaginationArrowButtonProps) {
return <button type="button" className={arrowButtonVariants()} {...props} />;
return <button type="button" className={paginationArrowButtonVariants()} {...props} />;
}
24 changes: 14 additions & 10 deletions packages/propel/src/ui/pagination/pagination-ellipsis.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { cx } from "class-variance-authority";
import { MoreHorizontal } from "lucide-react";
import type * as React from "react";

import { slotBase } from "./variants";
import { paginationEllipsisVariants } from "./variants";

/** A non-interactive gap marker between distant page numbers. */
export function PaginationEllipsis() {
return (
<li aria-hidden className={cx(slotBase, "text-icon-placeholder")}>
<MoreHorizontal className="size-3.5 shrink-0" />
</li>
);
export type PaginationEllipsisProps = Omit<
React.ComponentPropsWithoutRef<"span">,
"className" | "style"
>;

/**
* A non-interactive gap marker between distant page numbers. Renders whatever icon you pass (sized
* to the slot's `--node-size`, 14px) — never baking a specific glyph in. Decorative, so
* `aria-hidden`.
*/
export function PaginationEllipsis(props: PaginationEllipsisProps) {
return <span aria-hidden className={paginationEllipsisVariants()} {...props} />;
}
10 changes: 10 additions & 0 deletions packages/propel/src/ui/pagination/pagination-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type * as React from "react";

import { paginationItemVariants } from "./variants";

export type PaginationItemProps = Omit<React.ComponentProps<"li">, "className" | "style">;

/** One slot in the pagination list, holding a page button, an arrow button, or the ellipsis. */
export function PaginationItem(props: PaginationItemProps) {
return <li className={paginationItemVariants()} {...props} />;
}
13 changes: 13 additions & 0 deletions packages/propel/src/ui/pagination/pagination-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type * as React from "react";

import { paginationListVariants } from "./variants";

export type PaginationListProps = Omit<React.ComponentProps<"ul">, "className" | "style">;

/**
* The ordered list of page controls: the previous button, page numbers and ellipses, and the next
* button.
*/
export function PaginationList(props: PaginationListProps) {
return <ul className={paginationListVariants()} {...props} />;
}
8 changes: 4 additions & 4 deletions packages/propel/src/ui/pagination/pagination-page-button.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type * as React from "react";

import { pageButtonVariants } from "./variants";
import { paginationPageButtonVariants } from "./variants";

export type PaginationPageButtonProps = Omit<
React.ComponentProps<"button">,
Expand All @@ -11,9 +11,9 @@ export type PaginationPageButtonProps = Omit<
};

/**
* A styled page-number button. Applies `pageButtonVariants({ current })`; pass the page number (or
* a loading spinner) as `children` and wire `aria-current`/`onClick` through props.
* A styled page-number button. Applies `paginationPageButtonVariants({ current })`; pass the page
* number (or a loading spinner) as `children` and wire `aria-current`/`onClick` through props.
*/
export function PaginationPageButton({ current, ...props }: PaginationPageButtonProps) {
return <button type="button" className={pageButtonVariants({ current })} {...props} />;
return <button type="button" className={paginationPageButtonVariants({ current })} {...props} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type * as React from "react";

import { paginationPerPageIndicatorVariants } from "./variants";

export type PaginationPerPageIndicatorProps = Omit<
React.ComponentPropsWithoutRef<"span">,
"className" | "style"
>;

/**
* The chevron inside the per-page trigger. Renders whatever icon you pass (sized to `--node-size`,
* 14px) and rotates a half-turn while the menu is open. Decorative — the trigger carries the
* accessible name and state — so `aria-hidden`.
*/
export function PaginationPerPageIndicator(props: PaginationPerPageIndicatorProps) {
return <span aria-hidden className={paginationPerPageIndicatorVariants()} {...props} />;
}
16 changes: 16 additions & 0 deletions packages/propel/src/ui/pagination/pagination-per-page-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type * as React from "react";

import { paginationPerPageLabelVariants } from "./variants";

export type PaginationPerPageLabelProps = Omit<
React.ComponentPropsWithoutRef<"span">,
"className" | "style"
>;

/**
* The trailing "per page" text after the selector pill (Figma `50 v per page`). Decorative, so
* `aria-hidden`.
*/
export function PaginationPerPageLabel(props: PaginationPerPageLabelProps) {
return <span aria-hidden className={paginationPerPageLabelVariants()} {...props} />;
}
Loading