feat(ui): expose pagination props in Table and useTable

Add support for custom pagination options in the Table component and
useTable hook:

- Add `pageSizeOptions` prop to customize page size dropdown options
- Add `onPageSizeChange`, `onNextPage`, and `onPreviousPage` callbacks
- Validate pageSize against options, warn and use first option if invalid
- Export `PageSizeOption` type for custom option labels
- Add visual story demonstrating custom page size options

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-01-14 17:46:50 +01:00
parent d15524f895
commit fe7fe696ea
13 changed files with 244 additions and 34 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Added support for custom pagination options in `useTable` hook and `Table` component. You can now configure `pageSizeOptions` to customize the page size dropdown, and hook into pagination events via `onPageSizeChange`, `onNextPage`, and `onPreviousPage` callbacks. When `pageSize` doesn't match any option, the first option is used and a warning is logged.
Affected components: Table, TablePagination
+23 -6
View File
@@ -1195,15 +1195,29 @@ export interface PagePagination extends TablePaginationProps {
}
// @public (undocumented)
export interface PaginationOptions {
export interface PageSizeOption {
// (undocumented)
getLabel?: TablePaginationProps['getLabel'];
label: string;
// (undocumented)
value: number;
}
// @public (undocumented)
export interface PaginationOptions
extends Partial<
Pick<
TablePaginationProps,
| 'pageSize'
| 'pageSizeOptions'
| 'onPageSizeChange'
| 'onNextPage'
| 'onPreviousPage'
| 'showPageSizeOptions'
| 'getLabel'
>
> {
// (undocumented)
initialOffset?: number;
// (undocumented)
pageSize?: number;
// (undocumented)
showPageSizeOptions?: boolean;
}
// @public
@@ -1559,6 +1573,7 @@ export interface TableItem {
// @public
export function TablePagination({
pageSize,
pageSizeOptions,
offset,
totalCount,
hasNextPage,
@@ -1603,6 +1618,8 @@ export interface TablePaginationProps {
// (undocumented)
pageSize: number;
// (undocumented)
pageSizeOptions?: number[] | PageSizeOption[];
// (undocumented)
showPageSizeOptions?: boolean;
// (undocumented)
totalCount?: number;
@@ -196,6 +196,7 @@ export function Table<T extends TableItem>({
{pagination.type === 'page' && (
<TablePagination
pageSize={pagination.pageSize}
pageSizeOptions={pagination.pageSizeOptions}
offset={pagination.offset}
totalCount={pagination.totalCount}
hasNextPage={pagination.hasNextPage}
@@ -0,0 +1,52 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { PageSizeOption } from '../../TablePagination/types';
import type { PaginationOptions } from './types';
const DEFAULT_PAGE_SIZE = 20;
function getOptionValue(option: number | PageSizeOption): number {
return typeof option === 'number' ? option : option.value;
}
/** @internal */
export function getEffectivePageSize(
paginationOptions: PaginationOptions,
): number {
const { pageSize, pageSizeOptions } = paginationOptions;
if (!pageSizeOptions) {
return pageSize ?? DEFAULT_PAGE_SIZE;
}
const firstValue = getOptionValue(pageSizeOptions[0]);
if (pageSize === undefined) {
return firstValue;
}
const isValid = pageSizeOptions.some(opt => getOptionValue(opt) === pageSize);
if (isValid) {
return pageSize;
}
console.warn(
`useTable: pageSize ${pageSize} is not in pageSizeOptions, using ${firstValue} instead`,
);
return firstValue;
}
@@ -45,11 +45,20 @@ export interface QueryOptions<TFilter> {
}
/** @public */
export interface PaginationOptions {
pageSize?: number;
export interface PaginationOptions
extends Partial<
Pick<
TablePaginationProps,
| 'pageSize'
| 'pageSizeOptions'
| 'onPageSizeChange'
| 'onNextPage'
| 'onPreviousPage'
| 'showPageSizeOptions'
| 'getLabel'
>
> {
initialOffset?: number;
showPageSizeOptions?: boolean;
getLabel?: TablePaginationProps['getLabel'];
}
/** @public */
@@ -22,6 +22,7 @@ import type {
UseTableCompleteOptions,
} from './types';
import { useStableCallback } from './useStableCallback';
import { getEffectivePageSize } from './getEffectivePageSize';
/** @internal */
export function useCompletePagination<T extends TableItem, TFilter>(
@@ -35,8 +36,8 @@ export function useCompletePagination<T extends TableItem, TFilter>(
filterFn,
searchFn,
} = options;
const { pageSize: defaultPageSize = 20, initialOffset = 0 } =
paginationOptions;
const { initialOffset = 0 } = paginationOptions;
const defaultPageSize = getEffectivePageSize(paginationOptions);
const getData = useStableCallback(getDataProp);
const { sort, filter, search } = query;
@@ -24,13 +24,14 @@ import type {
import { usePageCache } from './usePageCache';
import { useStableCallback } from './useStableCallback';
import { useDebouncedReload } from './useDebouncedReload';
import { getEffectivePageSize } from './getEffectivePageSize';
export function useCursorPagination<T extends TableItem, TFilter>(
options: UseTableCursorOptions<T, TFilter>,
query: QueryState<TFilter>,
): PaginationResult<T> & { reload: () => void } {
const { getData: getDataProp, paginationOptions = {} } = options;
const { pageSize: defaultPageSize = 20 } = paginationOptions;
const defaultPageSize = getEffectivePageSize(paginationOptions);
const getData = useStableCallback(getDataProp);
const { sort, filter, search } = query;
@@ -24,14 +24,15 @@ import type {
import { usePageCache } from './usePageCache';
import { useStableCallback } from './useStableCallback';
import { useDebouncedReload } from './useDebouncedReload';
import { getEffectivePageSize } from './getEffectivePageSize';
export function useOffsetPagination<T extends TableItem, TFilter>(
options: UseTableOffsetOptions<T, TFilter>,
query: QueryState<TFilter>,
): PaginationResult<T> & { reload: () => void } {
const { getData: getDataProp, paginationOptions = {} } = options;
const { pageSize: defaultPageSize = 20, initialOffset = 0 } =
paginationOptions;
const { initialOffset = 0 } = paginationOptions;
const defaultPageSize = getEffectivePageSize(paginationOptions);
const getData = useStableCallback(getDataProp);
const { sort, filter, search } = query;
@@ -34,7 +34,14 @@ function useTableProps<T extends TableItem>(
TableProps<T>,
'columnConfig' | 'rowConfig' | 'selection' | 'emptyState'
> {
const { showPageSizeOptions = true, getLabel } = paginationOptions;
const {
showPageSizeOptions = true,
pageSizeOptions,
onPageSizeChange: onPageSizeChangeCallback,
onNextPage: onNextPageCallback,
onPreviousPage: onPreviousPageCallback,
getLabel,
} = paginationOptions;
const previousDataRef = useRef(paginationResult.data);
if (paginationResult.data) {
@@ -48,18 +55,29 @@ function useTableProps<T extends TableItem>(
() => ({
type: 'page' as const,
pageSize: paginationResult.pageSize,
pageSizeOptions,
offset: paginationResult.offset,
totalCount: paginationResult.totalCount,
hasNextPage: paginationResult.hasNextPage,
hasPreviousPage: paginationResult.hasPreviousPage,
onNextPage: paginationResult.onNextPage,
onPreviousPage: paginationResult.onPreviousPage,
onPageSizeChange: paginationResult.onPageSizeChange,
onNextPage: () => {
paginationResult.onNextPage();
onNextPageCallback?.();
},
onPreviousPage: () => {
paginationResult.onPreviousPage();
onPreviousPageCallback?.();
},
onPageSizeChange: (size: number) => {
paginationResult.onPageSizeChange(size);
onPageSizeChangeCallback?.(size);
},
showPageSizeOptions,
getLabel,
}),
[
paginationResult.pageSize,
pageSizeOptions,
paginationResult.offset,
paginationResult.totalCount,
paginationResult.hasNextPage,
@@ -67,6 +85,9 @@ function useTableProps<T extends TableItem>(
paginationResult.onNextPage,
paginationResult.onPreviousPage,
paginationResult.onPageSizeChange,
onNextPageCallback,
onPreviousPageCallback,
onPageSizeChangeCallback,
],
);
@@ -338,3 +338,51 @@ export const StaleState: Story = {
);
},
};
export const CustomPageSizeOptions: Story = {
render: () => {
const columns: ColumnConfig<Data1Item>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => <CellText title={item.name} />,
},
{
id: 'owner',
label: 'Owner',
cell: item => <CellText title={item.owner.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
];
const { tableProps } = useTable({
mode: 'complete',
getData: () => data1,
paginationOptions: {
pageSize: 3,
pageSizeOptions: [
{ label: '2 per page', value: 2 },
{ label: '3 per page', value: 3 },
{ label: '5 per page', value: 5 },
{ label: '7 per page', value: 7 },
],
onPageSizeChange: size => {
console.log('Page size changed to:', size);
},
onNextPage: () => {
console.log('Navigated to next page');
},
onPreviousPage: () => {
console.log('Navigated to previous page');
},
},
});
return <Table columnConfig={columns} {...tableProps} />;
},
};
@@ -16,12 +16,43 @@
import clsx from 'clsx';
import { Text, ButtonIcon, Select } from '../..';
import type { TablePaginationProps } from './types';
import type { TablePaginationProps, PageSizeOption } from './types';
import { useStyles } from '../../hooks/useStyles';
import { TablePaginationDefinition } from './definition';
import styles from './TablePagination.module.css';
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react';
import { useId } from 'react';
import { useId, useMemo } from 'react';
const DEFAULT_PAGE_SIZE_OPTIONS: PageSizeOption[] = [
{ label: 'Show 5 results', value: 5 },
{ label: 'Show 10 results', value: 10 },
{ label: 'Show 20 results', value: 20 },
{ label: 'Show 30 results', value: 30 },
{ label: 'Show 40 results', value: 40 },
{ label: 'Show 50 results', value: 50 },
];
function getOptionValue(option: number | PageSizeOption): number {
return typeof option === 'number' ? option : option.value;
}
function isNumberArray(
options: number[] | PageSizeOption[],
): options is number[] {
return options.length > 0 && typeof options[0] === 'number';
}
function normalizePageSizeOptions(
options: number[] | PageSizeOption[],
): PageSizeOption[] {
if (isNumberArray(options)) {
return options.map(value => ({
label: `Show ${value} results`,
value,
}));
}
return options;
}
/**
* Pagination controls for Table components with page navigation and size selection.
@@ -30,6 +61,7 @@ import { useId } from 'react';
*/
export function TablePagination({
pageSize,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
offset,
totalCount,
hasNextPage,
@@ -42,15 +74,33 @@ export function TablePagination({
}: TablePaginationProps) {
const { classNames } = useStyles(TablePaginationDefinition, {});
const labelId = useId();
const normalizedOptions = useMemo(
() => normalizePageSizeOptions(pageSizeOptions),
[pageSizeOptions],
);
const effectivePageSize = useMemo(() => {
const isValid = pageSizeOptions.some(
opt => getOptionValue(opt) === pageSize,
);
if (isValid) {
return pageSize;
}
const firstValue = getOptionValue(pageSizeOptions[0]);
console.warn(
`TablePagination: pageSize ${pageSize} is not in pageSizeOptions, using ${firstValue} instead`,
);
return firstValue;
}, [pageSize, pageSizeOptions]);
const hasItems = totalCount !== undefined && totalCount !== 0;
let label = `${totalCount} items`;
if (getLabel) {
label = getLabel({ pageSize, offset, totalCount });
label = getLabel({ pageSize: effectivePageSize, offset, totalCount });
} else if (offset !== undefined) {
const fromCount = offset + 1;
const toCount = Math.min(offset + pageSize, totalCount ?? 0);
const toCount = Math.min(offset + effectivePageSize, totalCount ?? 0);
label = `${fromCount} - ${toCount} of ${totalCount}`;
}
@@ -62,16 +112,11 @@ export function TablePagination({
name="pageSize"
size="small"
aria-label="Select table page size"
placeholder="Show 10 results"
options={[
{ label: 'Show 5 results', value: '5' },
{ label: 'Show 10 results', value: '10' },
{ label: 'Show 20 results', value: '20' },
{ label: 'Show 30 results', value: '30' },
{ label: 'Show 40 results', value: '40' },
{ label: 'Show 50 results', value: '50' },
]}
defaultValue={pageSize.toString()}
options={normalizedOptions.map(opt => ({
label: opt.label,
value: String(opt.value),
}))}
defaultValue={effectivePageSize.toString()}
onChange={value => {
const newPageSize = Number(value);
onPageSizeChange?.(newPageSize);
@@ -15,5 +15,5 @@
*/
export { TablePagination } from './TablePagination';
export type { TablePaginationProps } from './types';
export type { TablePaginationProps, PageSizeOption } from './types';
export { TablePaginationDefinition } from './definition';
@@ -14,9 +14,16 @@
* limitations under the License.
*/
/** @public */
export interface PageSizeOption {
label: string;
value: number;
}
/** @public */
export interface TablePaginationProps {
pageSize: number;
pageSizeOptions?: number[] | PageSizeOption[];
offset?: number;
totalCount?: number;
hasNextPage: boolean;