Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/eight-ties-visit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@easypost/easy-ui": minor
---

support numbered pagination β€” page numbers with First and Last jump buttons, a windowed page range with static ellipses centered on the active page, an optional Rows Per Page control
124 changes: 106 additions & 18 deletions documentation/specs/Pagination.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,20 @@ export type PaginationDropdownProps = {
isDisabled?: boolean;
};

export type PaginationProps = {
export type PaginationSize = "sm" | "md";

// `PaginationProps` is a discriminated union: the basic (previous/next, optional
// dropdown) variant and the numbered variant are mutually exclusive. Each variant
// marks the other's props `?: never`, so mixing them is a type error.

type PaginationCommonProps = {
/** Accessible label for Pagination, used for aria-label. */
label: string;
/** Whether the Pagination component should be disabled. */
isDisabled?: boolean;
};

export type PaginationBasicProps = PaginationCommonProps & {
/**
* Whether there is a previous page to show.
* @default false
Expand All @@ -52,29 +65,107 @@ export type PaginationProps = {
* @default false
*/
hasNext?: boolean;
/** Callback when previous button is clicked. */
onPrevious?: () => void;
/** Callback when next button is clicked. */
onNext?: () => void;
/** Only accepts `<Pagination.Dropdown />`. */
children?: ReactElement<PaginationDropdownProps>;
page?: never;
count?: never;
onPageChange?: never;
showFirstLast?: never;
visiblePageCount?: never;
rowsPerPage?: never;
rowsPerPageOptions?: never;
onRowsPerPageChange?: never;
size?: never;
};

export type PaginationNumberedProps = PaginationCommonProps & {
/**
* Callback when previous button is clicked.
* The current page, 1-indexed. The consumer owns this value β€” pass a page
* within `[1, count]`; out-of-range values are clamped for display, not
* corrected upstream.
*/
onPrevious?: () => void;
page: number;
/**
* Callback when next button is clicked.
* The total number of pages. A count below 1 renders nothing.
*/
onNext?: () => void;
count: number;
/**
* Accessible label for Pagination, used as the
* aria-label.
* Callback when a page is selected β€” fired by the page numbers,
* previous/next, and First/Last controls.
*/
label: string;
onPageChange: (page: number) => void;
/**
* Whether the Pagination component should be disabled.
* Whether to render First and Last jump buttons.
* @default false
*/
isDisabled?: boolean;
showFirstLast?: boolean;
/**
* The children of `<Pagination />` component only
* accept `<Pagination.Dropdown />`.
* How many page-number buttons the window shows. The window stays centered
* on the active page (just left of center for even counts); hidden pages
* collapse behind a static ellipsis.
* @default 5
*/
children?: ReactElement<PaginationDropdownProps>;
visiblePageCount?: number;
/**
* The current rows-per-page value. Provide with `onRowsPerPageChange` to
* render the Rows Per Page control.
*/
rowsPerPage?: number;
/**
* The selectable rows-per-page values.
* @default [10, 25, 50, 100]
*/
rowsPerPageOptions?: number[];
/** Callback when a rows-per-page value is selected. */
onRowsPerPageChange?: (rowsPerPage: number) => void;
/**
* Size of the pagination controls.
* @default md
*/
size?: PaginationSize;
hasPrevious?: never;
hasNext?: never;
onPrevious?: never;
onNext?: never;
children?: never;
};

export type PaginationProps = PaginationBasicProps | PaginationNumberedProps;
```

_Numbered:_

When `page`, `count`, and `onPageChange` are provided, `<Pagination />` renders numbered
pages with previous/next controls. The window stays centered on the active page and collapses
hidden pages behind a static ellipsis; `visiblePageCount` sizes the window. `showFirstLast`
adds First/Last jump buttons, and `rowsPerPage`/`onRowsPerPageChange` add a Rows Per Page
control.

```tsx
import { Pagination } from "@easypost/easy-ui/Pagination";

export function Component() {
const [page, setPage] = React.useState(1);
const [rowsPerPage, setRowsPerPage] = React.useState(50);
return (
<Pagination
label="Example Numbered Pagination"
page={page}
count={20}
onPageChange={setPage}
showFirstLast
rowsPerPage={rowsPerPage}
onRowsPerPageChange={(next) => {
setRowsPerPage(next);
setPage(1);
}}
/>
);
}
```

### Example Usage
Expand Down Expand Up @@ -152,9 +243,6 @@ export function PaginationButton(props: PaginationButtonProps) {

export function PaginationDropdown(props: PaginationDropdownProps) {
const { page, count, onSelect, isDisabled } = props;
function numberToArray(num: number) {
return Array.from({ length: num }, (_, i) => i + 1);
}
return (
<Menu isDisabled={isDisabled}>
<Menu.Trigger>
Expand All @@ -170,8 +258,8 @@ export function PaginationDropdown(props: PaginationDropdownProps) {
</UnstyledButton>
</Menu.Trigger>
<Menu.Overlay onAction={(key) => onSelect(Number(key))}>
{numberToArray(count).map((pageNumber) => (
<Menu.Item key={pageNumber} arial-label={`page ${pageNumber}`}>
{Array.from({ length: count }, (_, i) => i + 1).map((pageNumber) => (
<Menu.Item key={pageNumber} aria-label={`page ${pageNumber}`}>
<Text
variant="body2"
color="neutral.900"
Expand Down
24 changes: 24 additions & 0 deletions easy-ui-react/src/Pagination/Pagination.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,30 @@ A `<Pagination />` supports a dropdown menu for users to select specific page by

<Canvas of={PaginationStories.WithPageDropdown} />

## Numbered

A `<Pagination />` renders numbered pages with previous/next controls when `page`, `count`, and `onPageChange` are provided. The window stays centered on the active page and collapses hidden pages behind a static ellipsis. Size the window with `visiblePageCount`.

<Canvas of={PaginationStories.Numbered} />

## Numbered with First and Last

Set `showFirstLast` to add First and Last jump buttons to numbered pagination. Prefer this for large page counts: the hidden pages sit behind a non-interactive ellipsis, so First/Last let keyboard and screen-reader users jump to the ends instead of stepping one page at a time.

<Canvas of={PaginationStories.NumberedWithFirstLast} />

## Small

Numbered pagination supports a compact size through `size="sm"`.

<Canvas of={PaginationStories.NumberedSmall} />

## Rows Per Page

Provide `rowsPerPage` and `onRowsPerPageChange` to render a Rows Per Page control alongside numbered pagination. Customize the choices with `rowsPerPageOptions`.

<Canvas of={PaginationStories.NumberedWithRowsPerPage} />

## Disabled

A `<Pagination />` can be disabled using `isDisabled`.
Expand Down
82 changes: 82 additions & 0 deletions easy-ui-react/src/Pagination/Pagination.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,85 @@
border-right: design-token("shape.border_width.1") solid
design-token("color.neutral.200");
}

.numbered .item {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: design-token("space.5");
height: design-token("space.5");
padding: 0 design-token("space.1.5");
color: design-token("color.primary.700");
background-color: design-token("color.neutral.000");
border: design-token("shape.border_width.1") solid
design-token("color.neutral.200");
border-radius: design-token("shape.border_radius.md");

&:not(:disabled) {
cursor: pointer;
}

&:disabled {
color: design-token("color.neutral.400");
cursor: default;
}

&:hover:not(:disabled):not(.selected) {
background-color: design-token("color.neutral.100");
}
}

.numbered .selected {
color: design-token("color.neutral.000");
background-color: design-token("color.primary.500");
border-color: design-token("color.primary.500");
}

.numbered .itemSm {
min-width: design-token("space.4");
height: design-token("space.4");
padding: 0 design-token("space.1");
}

// Static, non-interactive truncation indicator.
.numbered .ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: design-token("space.4");
user-select: none;
}

// Rows Per Page control β€” sits in the grid's right track, pinned to the far
// edge while the nav stays centered in the middle track.
.rowsPerPage {
display: flex;
align-items: center;
justify-self: end;
gap: design-token("space.1");
white-space: nowrap;
}

.rowsPerPageButton {
padding: design-token("space.0.5") design-token("space.1");
color: design-token("color.primary.700");
border: design-token("shape.border_width.1") solid
design-token("color.neutral.200");
border-radius: design-token("shape.border_radius.md");

&:not(:disabled) {
cursor: pointer;
}

&:disabled {
color: design-token("color.neutral.400");
}

&:hover:not(:disabled) {
background-color: design-token("color.neutral.100");
}
}

.rowsPerPageButtonSm {
padding: design-token("space.0.25") design-token("space.1");
}
79 changes: 79 additions & 0 deletions easy-ui-react/src/Pagination/Pagination.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,82 @@ export const Disabled: Story = {
</Pagination>
),
};

export const Numbered: Story = {
render: () => {
const [page, setPage] = React.useState(1);
return (
<Pagination
label="Example Numbered Pagination"
page={page}
count={5}
onPageChange={setPage}
/>
);
},
};

export const NumberedWithFirstLast: Story = {
render: () => {
const [page, setPage] = React.useState(1);
return (
<Pagination
label="Example Numbered Pagination with First and Last"
page={page}
count={20}
onPageChange={setPage}
showFirstLast
/>
);
},
};

export const NumberedSmall: Story = {
render: () => {
const [page, setPage] = React.useState(6);
return (
<Pagination
label="Example Small Numbered Pagination"
page={page}
count={20}
onPageChange={setPage}
showFirstLast
size="sm"
/>
);
},
};

export const NumberedDisabled: Story = {
render: () => (
<Pagination
label="Example Disabled Numbered Pagination"
page={3}
count={20}
onPageChange={() => console.log("Page change")}
showFirstLast
isDisabled
/>
),
};

export const NumberedWithRowsPerPage: Story = {
render: () => {
const [page, setPage] = React.useState(1);
const [rowsPerPage, setRowsPerPage] = React.useState(50);
return (
<Pagination
label="Example Numbered Pagination with Rows Per Page"
page={page}
count={20}
onPageChange={setPage}
showFirstLast
rowsPerPage={rowsPerPage}
onRowsPerPageChange={(next) => {
setRowsPerPage(next);
setPage(1);
}}
/>
);
},
};
Loading
Loading