Skip to content

Commit 4cba0b0

Browse files
authored
feat: add pagination position feature with top, bottom, and both options (jbetancur#1318)
1 parent 70d1ef7 commit 4cba0b0

9 files changed

Lines changed: 248 additions & 3 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import React, { useState } from 'react';
2+
import DataTable from '../ThemedDataTable';
3+
import { type TableColumn } from 'react-data-table-component';
4+
5+
interface Row {
6+
id: number;
7+
name: string;
8+
department: string;
9+
salary: number;
10+
status: string;
11+
}
12+
13+
const ALL_DATA: Row[] = Array.from({ length: 20 }, (_, i) => ({
14+
id: i + 1,
15+
name:
16+
[
17+
'Aria Chen',
18+
'Marcus Webb',
19+
'Priya Kapoor',
20+
'Jordan Ellis',
21+
'Sam Rivera',
22+
'Taylor Brooks',
23+
'Casey Morgan',
24+
'Alex Kim',
25+
'Morgan Lee',
26+
'Drew Park',
27+
][i % 10] + (i >= 10 ? ` ${Math.floor(i / 10) + 1}` : ''),
28+
department: ['Engineering', 'Product', 'Design', 'Analytics', 'Sales', 'HR'][i % 6],
29+
salary: 80000 + i * 3700,
30+
status: ['Active', 'Remote', 'On Leave', 'Contractor'][i % 4],
31+
}));
32+
33+
const columns: TableColumn<Row>[] = [
34+
{ name: 'Name', selector: r => r.name, sortable: true },
35+
{ name: 'Department', selector: r => r.department, sortable: true },
36+
{
37+
name: 'Salary',
38+
selector: r => r.salary,
39+
sortable: true,
40+
format: r => `$${r.salary.toLocaleString()}`,
41+
right: true,
42+
},
43+
{ name: 'Status', selector: r => r.status },
44+
];
45+
46+
type Position = 'top' | 'bottom' | 'both';
47+
48+
export default function PaginationPositionDemo() {
49+
const [position, setPosition] = useState<Position>('bottom');
50+
51+
return (
52+
<div className="space-y-3">
53+
<div className="flex items-center gap-2 text-sm">
54+
<span className="text-gray-500">Position:</span>
55+
{(['top', 'bottom', 'both'] as Position[]).map(p => (
56+
<button
57+
key={p}
58+
onClick={() => setPosition(p)}
59+
className={`px-2.5 py-1 rounded-md text-xs font-medium border transition-colors ${
60+
position === p
61+
? 'bg-brand-600 text-white border-brand-600'
62+
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
63+
}`}
64+
>
65+
{p}
66+
</button>
67+
))}
68+
</div>
69+
<DataTable
70+
columns={columns}
71+
data={ALL_DATA}
72+
pagination
73+
paginationPosition={position}
74+
paginationPerPage={5}
75+
paginationRowsPerPageOptions={[5, 10, 20]}
76+
highlightOnHover
77+
striped
78+
/>
79+
</div>
80+
);
81+
}

apps/docs/src/pages/docs/api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ Complete reference for every prop, type, and export in `react-data-table-compone
8484
|---|---|---|---|
8585
| `pagination` | `boolean` | `false` | Enable built-in pagination controls. |
8686
| `paginationPerPage` | `number` | `10` | Rows shown per page. |
87+
| `paginationPosition` | `'top' \| 'bottom' \| 'both'` | `'bottom'` | Where the pagination bar renders. `'both'` shows it above and below the table simultaneously. |
8788
| `paginationRowsPerPageOptions` | `number[]` | `[10,15,20,25,30]` | Options in the rows-per-page dropdown. |
8889
| `paginationDefaultPage` | `number` | `1` | Initial active page. |
8990
| `paginationPage` | `number` | - | Controlled active page. When provided, the table navigates to this page whenever the value changes. Use together with `onChangePage` to keep them in sync. |
@@ -149,6 +150,7 @@ Column-level footers live on each [`TableColumn<T>`](#tablecolumnt) as the `foot
149150
| `onRowMiddleClicked` | `(row, event) => void` | Called when a row is middle-clicked (scroll-click). Use with `onRowClicked` to implement open-in-new-tab behaviour. |
150151
| `onRowMouseEnter` | `(row, event) => void` | Called when the pointer enters a row. |
151152
| `onRowMouseLeave` | `(row, event) => void` | Called when the pointer leaves a row. |
153+
| `onScroll` | `(event) => void` | Called when the user scrolls the table body. Works with both `fixedHeader` enabled and disabled. |
152154

153155
### Column features
154156

apps/docs/src/pages/docs/pagination.astro

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro';
33
import Demo from '../../components/Demo.astro';
44
import CodeBlock from '../../components/CodeBlock.astro';
55
import PaginationDemo from '../../components/demos/PaginationDemo.tsx';
6+
import PaginationPositionDemo from '../../components/demos/PaginationPositionDemo.tsx';
67
import ServerSidePaginationDemo from '../../components/demos/ServerSidePaginationDemo.tsx';
78
import ServerSideSortPaginationDemo from '../../components/demos/ServerSideSortPaginationDemo.tsx';
89
import ServerSideDemo from '../../components/demos/ServerSideDemo.tsx';
@@ -66,6 +67,25 @@ export default function App() {
6667
<PaginationDemo client:load />
6768
</Demo>
6869

70+
<h2>Pagination position</h2>
71+
<p>
72+
By default the pagination bar renders below the table. Use <code>paginationPosition</code> to
73+
move it above the table (<code>"top"</code>) or show it in both places (<code>"both"</code>).
74+
</p>
75+
76+
<Demo
77+
title="Pagination position"
78+
description="Toggle between top, bottom, and both to see the pagination bar reposition."
79+
code={`<DataTable
80+
columns={columns}
81+
data={data}
82+
pagination
83+
paginationPosition="top" // "top" | "bottom" | "both"
84+
/>`}
85+
>
86+
<PaginationPositionDemo client:load />
87+
</Demo>
88+
6989
<h2>Server-side pagination</h2>
7090
<p>
7191
When your dataset is too large to load at once, delegate pagination to the server.
@@ -512,6 +532,12 @@ function MyPagination({ rowsPerPage, rowCount, currentPage, onChangePage }: Pagi
512532
<td><code>[10, 25, 50, 100]</code></td>
513533
<td>Options shown in the rows-per-page dropdown.</td>
514534
</tr>
535+
<tr>
536+
<td><code>paginationPosition</code></td>
537+
<td><code>'top' | 'bottom' | 'both'</code></td>
538+
<td><code>'bottom'</code></td>
539+
<td>Where the pagination bar renders relative to the table. <code>'both'</code> renders it above and below simultaneously.</td>
540+
</tr>
515541
<tr>
516542
<td><code>paginationServer</code></td>
517543
<td><code>boolean</code></td>

src/DataTable.css

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1135,7 +1135,6 @@
11351135
color: var(--rdt-color-text-secondary, rgba(0, 0, 0, 0.54));
11361136
background-color: var(--rdt-color-bg, #fff);
11371137
min-height: var(--rdt-row-height, 52px);
1138-
border-top: 1px solid var(--rdt-color-divider, rgba(0, 0, 0, 0.12));
11391138
}
11401139

11411140
.rdt_paginationButton {

src/__tests__/DataTable.test.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,26 @@ describe('DataTable:RowMouseEnterAndLeave', () => {
475475
fireEvent.mouseLeave(container.querySelector('div[id="cell-1-1"]') as HTMLElement);
476476
expect(onRowMouseLeaveMock).toHaveBeenCalled();
477477
});
478+
479+
test('should call onScroll when the responsive wrapper is scrolled', () => {
480+
const onScrollMock = vi.fn();
481+
const mock = dataMock({});
482+
const { container } = render(<DataTable data={mock.data} columns={mock.columns} onScroll={onScrollMock} />);
483+
484+
fireEvent.scroll(container.querySelector('.rdt_responsiveWrapper') as HTMLElement);
485+
expect(onScrollMock).toHaveBeenCalledTimes(1);
486+
});
487+
488+
test('should call onScroll when fixedHeader is enabled', () => {
489+
const onScrollMock = vi.fn();
490+
const mock = dataMock({});
491+
const { container } = render(
492+
<DataTable data={mock.data} columns={mock.columns} fixedHeader onScroll={onScrollMock} />,
493+
);
494+
495+
fireEvent.scroll(container.querySelector('.rdt_responsiveWrapperFixed') as HTMLElement);
496+
expect(onScrollMock).toHaveBeenCalledTimes(1);
497+
});
478498
});
479499

480500
describe('DataTable::progress/nodata', () => {
@@ -2005,6 +2025,95 @@ describe('DataTable::Pagination', () => {
20052025

20062026
expect(container.querySelector('div[id="row-1"]')).not.toBeNull();
20072027
});
2028+
describe('paginationPosition', () => {
2029+
test('should render pagination below the table by default', () => {
2030+
const mock = dataMock();
2031+
const { container } = render(<DataTable data={mock.data} columns={mock.columns} pagination />);
2032+
2033+
const wrapper = container.firstElementChild as HTMLElement;
2034+
const children = Array.from(wrapper.children);
2035+
const tableIdx = children.findIndex(el => el.classList.contains('rdt_TableResponsive') || el.tagName === 'DIV');
2036+
const paginationEl = container.querySelector('nav');
2037+
const paginationParent = paginationEl?.parentElement;
2038+
const paginationParentIdx = children.indexOf(paginationParent as Element);
2039+
2040+
expect(paginationParentIdx).toBeGreaterThan(tableIdx);
2041+
});
2042+
2043+
test('should render pagination above the table when paginationPosition="top"', () => {
2044+
const mock = dataMock();
2045+
const { container } = render(
2046+
<DataTable data={mock.data} columns={mock.columns} pagination paginationPosition="top" />,
2047+
);
2048+
2049+
const wrapper = container.firstElementChild as HTMLElement;
2050+
const children = Array.from(wrapper.children);
2051+
const responsiveWrapper = container.querySelector('.rdt_responsiveWrapper');
2052+
const responsiveIdx = children.indexOf(responsiveWrapper as Element);
2053+
const navEls = container.querySelectorAll('nav');
2054+
2055+
expect(navEls).toHaveLength(1);
2056+
const paginationParentIdx = children.indexOf(navEls[0].parentElement as Element);
2057+
expect(paginationParentIdx).toBeGreaterThanOrEqual(0);
2058+
expect(paginationParentIdx).toBeLessThan(responsiveIdx);
2059+
});
2060+
2061+
test('should render pagination above and below the table when paginationPosition="both"', () => {
2062+
const mock = dataMock();
2063+
const { container } = render(
2064+
<DataTable data={mock.data} columns={mock.columns} pagination paginationPosition="both" />,
2065+
);
2066+
2067+
const navEls = container.querySelectorAll('nav');
2068+
expect(navEls).toHaveLength(2);
2069+
});
2070+
2071+
test('should not render pagination when paginationPosition="top" and pagination is disabled', () => {
2072+
const mock = dataMock();
2073+
const { container } = render(<DataTable data={mock.data} columns={mock.columns} paginationPosition="top" />);
2074+
2075+
expect(container.querySelector('nav')).toBeNull();
2076+
});
2077+
2078+
test('should navigate correctly when using paginationPosition="top"', () => {
2079+
const mock = dataMock();
2080+
const { container } = render(
2081+
<DataTable
2082+
data={mock.data}
2083+
columns={mock.columns}
2084+
pagination
2085+
paginationPosition="top"
2086+
paginationPerPage={1}
2087+
paginationRowsPerPageOptions={[1, 2]}
2088+
/>,
2089+
);
2090+
2091+
fireEvent.click(container.querySelector('button#pagination-next-page') as HTMLButtonElement);
2092+
2093+
expect(container.querySelector('div[id="row-1"]')).toBeNull();
2094+
expect(container.querySelector('div[id="row-2"]')).not.toBeNull();
2095+
});
2096+
2097+
test('both pagination bars stay in sync when paginationPosition="both"', () => {
2098+
const mock = dataMock();
2099+
const { container } = render(
2100+
<DataTable
2101+
data={mock.data}
2102+
columns={mock.columns}
2103+
pagination
2104+
paginationPosition="both"
2105+
paginationPerPage={1}
2106+
paginationRowsPerPageOptions={[1, 2]}
2107+
/>,
2108+
);
2109+
2110+
const nextButtons = container.querySelectorAll('button#pagination-next-page');
2111+
fireEvent.click(nextButtons[0] as HTMLButtonElement);
2112+
2113+
expect(container.querySelector('div[id="row-1"]')).toBeNull();
2114+
expect(container.querySelector('div[id="row-2"]')).not.toBeNull();
2115+
});
2116+
});
20082117
});
20092118

20102119
describe('DataTable::subHeader', () => {

src/components/DataTable.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ function DataTableInner<T>(props: TableProps<T>, ref: React.ForwardedRef<DataTab
6161
paginationPage,
6262
paginationResetDefaultPage = defaultProps.paginationResetDefaultPage,
6363
paginationPerPage = defaultProps.paginationPerPage,
64+
paginationPosition = defaultProps.paginationPosition,
6465
paginationRowsPerPageOptions = defaultProps.paginationRowsPerPageOptions,
6566
paginationComponent = defaultProps.paginationComponent,
6667
paginationComponentOptions = defaultProps.paginationComponentOptions,
@@ -84,6 +85,7 @@ function DataTableInner<T>(props: TableProps<T>, ref: React.ForwardedRef<DataTab
8485
onRowMiddleClicked = defaultProps.onRowMiddleClicked,
8586
onRowMouseEnter = defaultProps.onRowMouseEnter,
8687
onRowMouseLeave = defaultProps.onRowMouseLeave,
88+
onScroll,
8789
onSort = defaultProps.onSort,
8890
sortFunction = defaultProps.sortFunction,
8991
sortServer = defaultProps.sortServer,
@@ -462,13 +464,30 @@ function DataTableInner<T>(props: TableProps<T>, ref: React.ForwardedRef<DataTab
462464
</Subheader>
463465
)}
464466

467+
{enabledPagination && (paginationPosition === 'top' || paginationPosition === 'both') && (
468+
<TablePaginationFooter
469+
Pagination={Pagination}
470+
onChangePage={handleChangePage}
471+
onChangeRowsPerPage={handleChangeRowsPerPage}
472+
rowCount={paginationTotalRows || filteredSortedData.length}
473+
currentPage={currentPage}
474+
rowsPerPage={rowsPerPage}
475+
direction={direction}
476+
paginationRowsPerPageOptions={paginationRowsPerPageOptions}
477+
paginationIcons={paginationIcons}
478+
paginationComponentOptions={paginationComponentOptions}
479+
position="top"
480+
/>
481+
)}
482+
465483
<ResponsiveWrapper
466484
ref={scrollWrapperRef}
467485
$responsive={responsive}
468486
$fixedHeader={fixedHeader}
469487
$fixedHeaderScrollHeight={fixedHeaderScrollHeight}
470488
$hiddenScrollbar={hasPinnedColumns}
471489
className={className}
490+
onScroll={onScroll}
472491
{...wrapperProps}
473492
>
474493
<Wrapper>
@@ -527,7 +546,7 @@ function DataTableInner<T>(props: TableProps<T>, ref: React.ForwardedRef<DataTab
527546
/>
528547
)}
529548

530-
{enabledPagination && (
549+
{enabledPagination && (paginationPosition === 'bottom' || paginationPosition === 'both') && (
531550
<TablePaginationFooter
532551
Pagination={Pagination}
533552
onChangePage={handleChangePage}

src/components/TablePaginationFooter.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface TablePaginationFooterProps {
1313
paginationRowsPerPageOptions: number[];
1414
paginationIcons?: PaginationIcons;
1515
paginationComponentOptions: PaginationOptions;
16+
position?: 'top' | 'bottom' | 'both';
1617
}
1718

1819
function TablePaginationFooter({
@@ -26,9 +27,13 @@ function TablePaginationFooter({
2627
paginationRowsPerPageOptions,
2728
paginationIcons,
2829
paginationComponentOptions,
30+
position,
2931
}: TablePaginationFooterProps): JSX.Element {
32+
const border = '1px solid var(--rdt-color-divider, rgba(0, 0, 0, 0.12))';
33+
const wrapperStyle = position === 'top' ? { borderBottom: border } : { borderTop: border };
34+
3035
return (
31-
<div>
36+
<div style={wrapperStyle}>
3237
<Pagination
3338
onChangePage={onChangePage}
3439
onChangeRowsPerPage={onChangeRowsPerPage}

src/defaultProps.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export const defaultProps = {
9696
paginationResetDefaultPage: false,
9797
paginationTotalRows: 0,
9898
paginationPerPage: 10,
99+
paginationPosition: 'bottom' as const,
99100
paginationRowsPerPageOptions: [10, 15, 20, 25, 30],
100101
paginationComponent: null,
101102
paginationComponentOptions: {},

src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ type PaginationProps = {
139139
paginationIcons?: PaginationIcons;
140140
paginationPerPage?: number;
141141
paginationPage?: number;
142+
paginationPosition?: 'top' | 'bottom' | 'both';
142143
paginationResetDefaultPage?: boolean;
143144
paginationRowsPerPageOptions?: number[];
144145
paginationServer?: boolean;
@@ -198,6 +199,8 @@ type BaseTableProps<T> = {
198199
onRowMiddleClicked?: (row: T, e: React.MouseEvent) => void;
199200
onRowMouseEnter?: (row: T, e: React.MouseEvent) => void;
200201
onRowMouseLeave?: (row: T, e: React.MouseEvent) => void;
202+
/** Called when the user scrolls the table body. Works with both `fixedHeader` enabled and disabled. */
203+
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
201204
/** Enable drag-to-resize handles on column headers */
202205
resizable?: boolean;
203206
/**

0 commit comments

Comments
 (0)