diff --git a/.github/workflows/close-empty-issues.yml b/.github/workflows/close-empty-issues.yml new file mode 100644 index 00000000..594fb810 --- /dev/null +++ b/.github/workflows/close-empty-issues.yml @@ -0,0 +1,45 @@ +name: Close empty issues + +on: + issues: + types: [opened, edited] + +jobs: + check: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/github-script@v7 + with: + script: | + const body = context.payload.issue.body ?? ''; + + // Strip HTML comments (template instructions), whitespace, and checkboxes + const stripped = body + .replace(//g, '') + .replace(/- \[[ x]\]/g, '') + .trim(); + + // Close if body is missing or under 50 meaningful characters + if (stripped.length >= 50) return; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + body: [ + "Thanks for opening an issue. It looks like the description is empty or incomplete.", + "", + "Please fill out the issue template with enough detail to reproduce or understand the problem — a description, steps to reproduce, and your version. Issues without this information are difficult to act on and may be closed.", + "", + "Feel free to reopen once you've added more detail.", + ].join('\n'), + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + state: 'closed', + }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16a8c037..040ddbc7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,13 +12,12 @@ on: - minor - major -permissions: - contents: write - id-token: write - jobs: release: runs-on: ubuntu-latest + permissions: + contents: write + id-token: write steps: - uses: actions/checkout@v4 with: diff --git a/apps/docs/src/components/demos/PaginationPositionDemo.tsx b/apps/docs/src/components/demos/PaginationPositionDemo.tsx new file mode 100644 index 00000000..5d806b27 --- /dev/null +++ b/apps/docs/src/components/demos/PaginationPositionDemo.tsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import DataTable from '../ThemedDataTable'; +import { type TableColumn } from 'react-data-table-component'; + +interface Row { + id: number; + name: string; + department: string; + salary: number; + status: string; +} + +const ALL_DATA: Row[] = Array.from({ length: 20 }, (_, i) => ({ + id: i + 1, + name: + [ + 'Aria Chen', + 'Marcus Webb', + 'Priya Kapoor', + 'Jordan Ellis', + 'Sam Rivera', + 'Taylor Brooks', + 'Casey Morgan', + 'Alex Kim', + 'Morgan Lee', + 'Drew Park', + ][i % 10] + (i >= 10 ? ` ${Math.floor(i / 10) + 1}` : ''), + department: ['Engineering', 'Product', 'Design', 'Analytics', 'Sales', 'HR'][i % 6], + salary: 80000 + i * 3700, + status: ['Active', 'Remote', 'On Leave', 'Contractor'][i % 4], +})); + +const columns: TableColumn[] = [ + { name: 'Name', selector: r => r.name, sortable: true }, + { name: 'Department', selector: r => r.department, sortable: true }, + { + name: 'Salary', + selector: r => r.salary, + sortable: true, + format: r => `$${r.salary.toLocaleString()}`, + right: true, + }, + { name: 'Status', selector: r => r.status }, +]; + +type Position = 'top' | 'bottom' | 'both'; + +export default function PaginationPositionDemo() { + const [position, setPosition] = useState('bottom'); + + return ( +
+
+ Position: + {(['top', 'bottom', 'both'] as Position[]).map(p => ( + + ))} +
+ +
+ ); +} diff --git a/apps/docs/src/pages/docs/api.md b/apps/docs/src/pages/docs/api.md index 1e852b8a..798f5028 100644 --- a/apps/docs/src/pages/docs/api.md +++ b/apps/docs/src/pages/docs/api.md @@ -84,6 +84,7 @@ Complete reference for every prop, type, and export in `react-data-table-compone |---|---|---|---| | `pagination` | `boolean` | `false` | Enable built-in pagination controls. | | `paginationPerPage` | `number` | `10` | Rows shown per page. | +| `paginationPosition` | `'top' \| 'bottom' \| 'both'` | `'bottom'` | Where the pagination bar renders. `'both'` shows it above and below the table simultaneously. | | `paginationRowsPerPageOptions` | `number[]` | `[10,15,20,25,30]` | Options in the rows-per-page dropdown. | | `paginationDefaultPage` | `number` | `1` | Initial active page. | | `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`](#tablecolumnt) as the `foot | `onRowMiddleClicked` | `(row, event) => void` | Called when a row is middle-clicked (scroll-click). Use with `onRowClicked` to implement open-in-new-tab behaviour. | | `onRowMouseEnter` | `(row, event) => void` | Called when the pointer enters a row. | | `onRowMouseLeave` | `(row, event) => void` | Called when the pointer leaves a row. | +| `onScroll` | `(event) => void` | Called when the user scrolls the table body. Works with both `fixedHeader` enabled and disabled. | ### Column features diff --git a/apps/docs/src/pages/docs/pagination.astro b/apps/docs/src/pages/docs/pagination.astro index 9057bbde..d4713bc8 100644 --- a/apps/docs/src/pages/docs/pagination.astro +++ b/apps/docs/src/pages/docs/pagination.astro @@ -3,6 +3,7 @@ import DocsLayout from '../../layouts/DocsLayout.astro'; import Demo from '../../components/Demo.astro'; import CodeBlock from '../../components/CodeBlock.astro'; import PaginationDemo from '../../components/demos/PaginationDemo.tsx'; +import PaginationPositionDemo from '../../components/demos/PaginationPositionDemo.tsx'; import ServerSidePaginationDemo from '../../components/demos/ServerSidePaginationDemo.tsx'; import ServerSideSortPaginationDemo from '../../components/demos/ServerSideSortPaginationDemo.tsx'; import ServerSideDemo from '../../components/demos/ServerSideDemo.tsx'; @@ -66,6 +67,25 @@ export default function App() { +

Pagination position

+

+ By default the pagination bar renders below the table. Use paginationPosition to + move it above the table ("top") or show it in both places ("both"). +

+ + `} + > + + +

Server-side pagination

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 [10, 25, 50, 100] Options shown in the rows-per-page dropdown. + + paginationPosition + 'top' | 'bottom' | 'both' + 'bottom' + Where the pagination bar renders relative to the table. 'both' renders it above and below simultaneously. + paginationServer boolean diff --git a/src/DataTable.css b/src/DataTable.css index 7160dbe4..9f93c883 100644 --- a/src/DataTable.css +++ b/src/DataTable.css @@ -1135,7 +1135,6 @@ color: var(--rdt-color-text-secondary, rgba(0, 0, 0, 0.54)); background-color: var(--rdt-color-bg, #fff); min-height: var(--rdt-row-height, 52px); - border-top: 1px solid var(--rdt-color-divider, rgba(0, 0, 0, 0.12)); } .rdt_paginationButton { diff --git a/src/__tests__/DataTable.test.tsx b/src/__tests__/DataTable.test.tsx index cf884629..c3a132f4 100644 --- a/src/__tests__/DataTable.test.tsx +++ b/src/__tests__/DataTable.test.tsx @@ -475,6 +475,26 @@ describe('DataTable:RowMouseEnterAndLeave', () => { fireEvent.mouseLeave(container.querySelector('div[id="cell-1-1"]') as HTMLElement); expect(onRowMouseLeaveMock).toHaveBeenCalled(); }); + + test('should call onScroll when the responsive wrapper is scrolled', () => { + const onScrollMock = vi.fn(); + const mock = dataMock({}); + const { container } = render(); + + fireEvent.scroll(container.querySelector('.rdt_responsiveWrapper') as HTMLElement); + expect(onScrollMock).toHaveBeenCalledTimes(1); + }); + + test('should call onScroll when fixedHeader is enabled', () => { + const onScrollMock = vi.fn(); + const mock = dataMock({}); + const { container } = render( + , + ); + + fireEvent.scroll(container.querySelector('.rdt_responsiveWrapperFixed') as HTMLElement); + expect(onScrollMock).toHaveBeenCalledTimes(1); + }); }); describe('DataTable::progress/nodata', () => { @@ -2005,6 +2025,95 @@ describe('DataTable::Pagination', () => { expect(container.querySelector('div[id="row-1"]')).not.toBeNull(); }); + describe('paginationPosition', () => { + test('should render pagination below the table by default', () => { + const mock = dataMock(); + const { container } = render(); + + const wrapper = container.firstElementChild as HTMLElement; + const children = Array.from(wrapper.children); + const tableIdx = children.findIndex(el => el.classList.contains('rdt_TableResponsive') || el.tagName === 'DIV'); + const paginationEl = container.querySelector('nav'); + const paginationParent = paginationEl?.parentElement; + const paginationParentIdx = children.indexOf(paginationParent as Element); + + expect(paginationParentIdx).toBeGreaterThan(tableIdx); + }); + + test('should render pagination above the table when paginationPosition="top"', () => { + const mock = dataMock(); + const { container } = render( + , + ); + + const wrapper = container.firstElementChild as HTMLElement; + const children = Array.from(wrapper.children); + const responsiveWrapper = container.querySelector('.rdt_responsiveWrapper'); + const responsiveIdx = children.indexOf(responsiveWrapper as Element); + const navEls = container.querySelectorAll('nav'); + + expect(navEls).toHaveLength(1); + const paginationParentIdx = children.indexOf(navEls[0].parentElement as Element); + expect(paginationParentIdx).toBeGreaterThanOrEqual(0); + expect(paginationParentIdx).toBeLessThan(responsiveIdx); + }); + + test('should render pagination above and below the table when paginationPosition="both"', () => { + const mock = dataMock(); + const { container } = render( + , + ); + + const navEls = container.querySelectorAll('nav'); + expect(navEls).toHaveLength(2); + }); + + test('should not render pagination when paginationPosition="top" and pagination is disabled', () => { + const mock = dataMock(); + const { container } = render(); + + expect(container.querySelector('nav')).toBeNull(); + }); + + test('should navigate correctly when using paginationPosition="top"', () => { + const mock = dataMock(); + const { container } = render( + , + ); + + fireEvent.click(container.querySelector('button#pagination-next-page') as HTMLButtonElement); + + expect(container.querySelector('div[id="row-1"]')).toBeNull(); + expect(container.querySelector('div[id="row-2"]')).not.toBeNull(); + }); + + test('both pagination bars stay in sync when paginationPosition="both"', () => { + const mock = dataMock(); + const { container } = render( + , + ); + + const nextButtons = container.querySelectorAll('button#pagination-next-page'); + fireEvent.click(nextButtons[0] as HTMLButtonElement); + + expect(container.querySelector('div[id="row-1"]')).toBeNull(); + expect(container.querySelector('div[id="row-2"]')).not.toBeNull(); + }); + }); }); describe('DataTable::subHeader', () => { diff --git a/src/components/DataTable.tsx b/src/components/DataTable.tsx index 4cb20111..4ef8246f 100644 --- a/src/components/DataTable.tsx +++ b/src/components/DataTable.tsx @@ -61,6 +61,7 @@ function DataTableInner(props: TableProps, ref: React.ForwardedRef(props: TableProps, ref: React.ForwardedRef(props: TableProps, ref: React.ForwardedRef )} + {enabledPagination && (paginationPosition === 'top' || paginationPosition === 'both') && ( + + )} + (props: TableProps, ref: React.ForwardedRef @@ -527,7 +546,7 @@ function DataTableInner(props: TableProps, ref: React.ForwardedRef )} - {enabledPagination && ( + {enabledPagination && (paginationPosition === 'bottom' || paginationPosition === 'both') && ( +

= { onRowMiddleClicked?: (row: T, e: React.MouseEvent) => void; onRowMouseEnter?: (row: T, e: React.MouseEvent) => void; onRowMouseLeave?: (row: T, e: React.MouseEvent) => void; + /** Called when the user scrolls the table body. Works with both `fixedHeader` enabled and disabled. */ + onScroll?: (e: React.UIEvent) => void; /** Enable drag-to-resize handles on column headers */ resizable?: boolean; /**