feat(data-table): typeable rows-per-page control

The footer offered a fixed <select> of 5/10/25/50, so a table could not be
paged in anything larger without a code change. Replace it with a Mantine
Autocomplete: the presets go up to 500, and any other size can be typed.

A typed value is clamped to [1, MAX_PAGE_SIZE] on commit, with MAX_PAGE_SIZE
set to 500 to match @Max on the freight API's PaginationQueryDto and
MAX_PAGE_SIZE in its pagination.util. Raising it here alone would turn the
top preset into a 400.

Changing the size also resets to the first page -- page 12 of 50-row pages
does not exist once the page holds 500, and a manual-pagination consumer
would happily request it. Done as one setPagination call so consumers see a
single fetch rather than two.
This commit is contained in:
Nathnael
2026-08-20 09:06:06 +00:00
parent ac8f03f69a
commit ac585fbbd2

View File

@@ -1,6 +1,24 @@
import { useEffect, useMemo, useState } from "react";
import { Autocomplete } from "@mantine/core";
import { Button } from "../button";
import { DataTableFooterProps } from "./types";
/**
* Upper bound on a hand-typed page size. Not a free choice — the server has to
* accept it. It is kept in step with `@Max` on the freight API's
* `PaginationQueryDto.pageSize` and with `MAX_PAGE_SIZE` in its
* `common/utils/pagination.util.ts`; raising it here alone turns the top preset
* into a 400 on every endpoint that validates against those.
*/
export const MAX_PAGE_SIZE = 500;
/** Clamp a typed page size to a whole number in [1, MAX_PAGE_SIZE]; null if unusable. */
export function clampPageSize(raw: string | number): number | null {
const parsed = Math.trunc(Number(raw));
if (!Number.isFinite(parsed) || parsed < 1) return null;
return Math.min(parsed, MAX_PAGE_SIZE);
}
export interface DataTableFooterOptions {
pageSizeOptions?: number[];
showPageSizeSelector?: boolean;
@@ -25,7 +43,7 @@ interface DataTableFooterComponentProps<
}
const defaultOptions: DataTableFooterOptions = {
pageSizeOptions: [5, 10, 25, 50],
pageSizeOptions: [5, 10, 25, 50, 100, 200, 500],
showPageSizeSelector: true,
showRowCount: true,
showPagination: true,
@@ -56,8 +74,36 @@ export function DataTableFooter<TData>({
const start = totalCount === 0 ? 0 : pageIndex * pageSize + 1;
const end = Math.min((pageIndex + 1) * pageSize, totalCount);
// Back to the first page: page 12 of 50-row pages does not exist once the
// rows per page becomes 500, and a manual-pagination consumer would happily
// request it. One state update, so consumers see a single fetch.
const handlePageSizeChange = (newPageSize: number) => {
table?.setPageSize(newPageSize);
table?.setPagination({ pageIndex: 0, pageSize: newPageSize });
};
// The presets, with the active size folded in when it was typed by hand, so
// the dropdown always contains what the input shows.
const sizeOptions = useMemo(() => {
const presets = opts.pageSizeOptions ?? [];
const all = presets.includes(pageSize) ? presets : [...presets, pageSize];
return [...all].sort((a, b) => a - b).map(String);
}, [opts.pageSizeOptions, pageSize]);
const [sizeDraft, setSizeDraft] = useState(`${pageSize}`);
// Keep the input honest when the page size changes from anywhere else.
useEffect(() => {
setSizeDraft(`${pageSize}`);
}, [pageSize]);
const commitPageSize = (raw: string) => {
const size = clampPageSize(raw);
if (size === null) {
setSizeDraft(`${pageSize}`);
return;
}
setSizeDraft(`${size}`);
if (size !== pageSize) handlePageSizeChange(size);
};
return (
@@ -69,18 +115,23 @@ export function DataTableFooter<TData>({
<label htmlFor="page-size" className="font-medium text-slate-700">
{labels.rowsPerPage}
</label>
<select
<Autocomplete
id="page-size"
value={pageSize}
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
>
{opts.pageSizeOptions?.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
aria-label={labels.rowsPerPage}
size="sm"
w={96}
inputMode="numeric"
value={sizeDraft}
data={sizeOptions}
comboboxProps={{ position: "top", withinPortal: true }}
onChange={setSizeDraft}
onOptionSubmit={commitPageSize}
onBlur={() => commitPageSize(sizeDraft)}
onKeyDown={(e) => {
if (e.key === "Enter") commitPageSize(sizeDraft);
if (e.key === "Escape") setSizeDraft(`${pageSize}`);
}}
/>
</>
)}
{opts.showRowCount && (