feat(ui): add controlled mode to DataTable components

This commit is contained in:
alejandrobailo
2026-01-23 11:54:31 +01:00
parent 078e0aeab7
commit d8498bf4e4
3 changed files with 279 additions and 88 deletions

View File

@@ -26,6 +26,16 @@ interface DataTablePaginationProps {
disableScroll?: boolean;
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */
paramPrefix?: string;
/*
* Controlled mode: Use these props to manage pagination via React state
* instead of URL params. Useful for tables in drawers/modals to avoid
* triggering page re-renders when paginating.
*/
controlledPage?: number;
controlledPageSize?: number;
onPageChange?: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
}
const NAV_BUTTON_STYLES = {
@@ -38,16 +48,25 @@ export function DataTablePagination({
metadata,
disableScroll = false,
paramPrefix = "",
controlledPage,
controlledPageSize,
onPageChange,
onPageSizeChange,
}: DataTablePaginationProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
// Determine if we're in controlled mode
const isControlled = controlledPage !== undefined && onPageChange;
// Determine param names based on prefix
const pageParam = paramPrefix ? `${paramPrefix}Page` : "page";
const pageSizeParam = paramPrefix ? `${paramPrefix}PageSize` : "pageSize";
const initialPageSize = searchParams.get(pageSizeParam) ?? "10";
const initialPageSize = isControlled
? String(controlledPageSize ?? 10)
: (searchParams.get(pageSizeParam) ?? "10");
const [selectedPageSize, setSelectedPageSize] = useState(initialPageSize);
@@ -60,10 +79,12 @@ export function DataTablePagination({
itemsPerPageOptions,
} = getPaginationInfo(metadata);
// For prefixed pagination, read current page from URL instead of metadata
const currentPage = paramPrefix
? parseInt(searchParams.get(pageParam) || "1", 10)
: metaCurrentPage;
// For controlled mode, use controlled values; for prefixed, read from URL; otherwise use metadata
const currentPage = isControlled
? controlledPage
: paramPrefix
? parseInt(searchParams.get(pageParam) || "1", 10)
: metaCurrentPage;
const createPageUrl = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams);
@@ -87,6 +108,20 @@ export function DataTablePagination({
return `${pathname}?${params.toString()}`;
};
// Handle page navigation for controlled mode
const handlePageChange = (pageNumber: number) => {
if (isControlled) {
onPageChange(pageNumber);
} else {
const url = createPageUrl(pageNumber);
if (disableScroll) {
router.push(url, { scroll: false });
} else {
router.push(url);
}
}
};
const isFirstPage = currentPage === 1;
const isLastPage = currentPage === totalPages;
@@ -104,6 +139,12 @@ export function DataTablePagination({
onValueChange={(value) => {
setSelectedPageSize(value);
if (isControlled) {
onPageSizeChange?.(parseInt(value, 10));
onPageChange(1); // Reset to first page
return;
}
const params = new URLSearchParams(searchParams);
// Preserve all important parameters
@@ -154,82 +195,145 @@ export function DataTablePagination({
Page {currentPage} of {totalPages}
</span>
<div className="flex items-center gap-3">
<Link
aria-label="Go to first page"
className={cn(
NAV_BUTTON_STYLES.base,
isFirstPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
href={
isFirstPage
? pathname + "?" + searchParams.toString()
: createPageUrl(1)
}
scroll={!disableScroll}
aria-disabled={isFirstPage}
onClick={(e) => isFirstPage && e.preventDefault()}
>
<ChevronFirst className="size-6" aria-hidden="true" />
</Link>
<Link
aria-label="Go to previous page"
className={cn(
NAV_BUTTON_STYLES.base,
isFirstPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
href={
isFirstPage
? pathname + "?" + searchParams.toString()
: createPageUrl(currentPage - 1)
}
scroll={!disableScroll}
aria-disabled={isFirstPage}
onClick={(e) => isFirstPage && e.preventDefault()}
>
<ChevronLeft className="size-6" aria-hidden="true" />
</Link>
<Link
aria-label="Go to next page"
className={cn(
NAV_BUTTON_STYLES.base,
isLastPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
href={
isLastPage
? pathname + "?" + searchParams.toString()
: createPageUrl(currentPage + 1)
}
scroll={!disableScroll}
aria-disabled={isLastPage}
onClick={(e) => isLastPage && e.preventDefault()}
>
<ChevronRight className="size-6" aria-hidden="true" />
</Link>
<Link
aria-label="Go to last page"
className={cn(
NAV_BUTTON_STYLES.base,
isLastPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
href={
isLastPage
? pathname + "?" + searchParams.toString()
: createPageUrl(totalPages)
}
scroll={!disableScroll}
aria-disabled={isLastPage}
onClick={(e) => isLastPage && e.preventDefault()}
>
<ChevronLast className="size-6" aria-hidden="true" />
</Link>
{isControlled ? (
<>
<button
type="button"
aria-label="Go to first page"
className={cn(
NAV_BUTTON_STYLES.base,
isFirstPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
disabled={isFirstPage}
onClick={() => handlePageChange(1)}
>
<ChevronFirst className="size-6" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Go to previous page"
className={cn(
NAV_BUTTON_STYLES.base,
isFirstPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
disabled={isFirstPage}
onClick={() => handlePageChange(currentPage - 1)}
>
<ChevronLeft className="size-6" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Go to next page"
className={cn(
NAV_BUTTON_STYLES.base,
isLastPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
disabled={isLastPage}
onClick={() => handlePageChange(currentPage + 1)}
>
<ChevronRight className="size-6" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Go to last page"
className={cn(
NAV_BUTTON_STYLES.base,
isLastPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
disabled={isLastPage}
onClick={() => handlePageChange(totalPages)}
>
<ChevronLast className="size-6" aria-hidden="true" />
</button>
</>
) : (
<>
<Link
aria-label="Go to first page"
className={cn(
NAV_BUTTON_STYLES.base,
isFirstPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
href={
isFirstPage
? pathname + "?" + searchParams.toString()
: createPageUrl(1)
}
scroll={!disableScroll}
aria-disabled={isFirstPage}
onClick={(e) => isFirstPage && e.preventDefault()}
>
<ChevronFirst className="size-6" aria-hidden="true" />
</Link>
<Link
aria-label="Go to previous page"
className={cn(
NAV_BUTTON_STYLES.base,
isFirstPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
href={
isFirstPage
? pathname + "?" + searchParams.toString()
: createPageUrl(currentPage - 1)
}
scroll={!disableScroll}
aria-disabled={isFirstPage}
onClick={(e) => isFirstPage && e.preventDefault()}
>
<ChevronLeft className="size-6" aria-hidden="true" />
</Link>
<Link
aria-label="Go to next page"
className={cn(
NAV_BUTTON_STYLES.base,
isLastPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
href={
isLastPage
? pathname + "?" + searchParams.toString()
: createPageUrl(currentPage + 1)
}
scroll={!disableScroll}
aria-disabled={isLastPage}
onClick={(e) => isLastPage && e.preventDefault()}
>
<ChevronRight className="size-6" aria-hidden="true" />
</Link>
<Link
aria-label="Go to last page"
className={cn(
NAV_BUTTON_STYLES.base,
isLastPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
href={
isLastPage
? pathname + "?" + searchParams.toString()
: createPageUrl(totalPages)
}
scroll={!disableScroll}
aria-disabled={isLastPage}
onClick={(e) => isLastPage && e.preventDefault()}
>
<ChevronLast className="size-6" aria-hidden="true" />
</Link>
</>
)}
</div>
</div>
</>

View File

@@ -13,14 +13,26 @@ const SEARCH_DEBOUNCE_MS = 500;
interface DataTableSearchProps {
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsSearch") */
paramPrefix?: string;
/*
* Controlled mode: Use these props to manage search via React state
* instead of URL params. Useful for tables in drawers/modals to avoid
* triggering page re-renders when searching.
*/
controlledValue?: string;
onSearchChange?: (value: string) => void;
}
export const DataTableSearch = ({ paramPrefix = "" }: DataTableSearchProps) => {
export const DataTableSearch = ({
paramPrefix = "",
controlledValue,
onSearchChange,
}: DataTableSearchProps) => {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const { updateFilter } = useUrlFilters();
const [value, setValue] = useState("");
const [internalValue, setInternalValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isFocused, setIsFocused] = useState(false);
@@ -28,6 +40,15 @@ export const DataTableSearch = ({ paramPrefix = "" }: DataTableSearchProps) => {
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Use controlled value if provided, otherwise internal state
const isControlled = controlledValue !== undefined && onSearchChange;
const value = isControlled ? controlledValue : internalValue;
const setValue = isControlled
? (_v: string) => {
/* no-op for controlled, handled in handleChange */
}
: setInternalValue;
// Determine param names based on prefix
const searchParam = paramPrefix ? `${paramPrefix}Search` : "filter[search]";
const pageParam = paramPrefix ? `${paramPrefix}Page` : "page";
@@ -35,18 +56,35 @@ export const DataTableSearch = ({ paramPrefix = "" }: DataTableSearchProps) => {
// Keep expanded if there's a value or input is focused
const shouldStayExpanded = value.length > 0 || isFocused;
// Sync with URL on mount
// Sync with URL on mount (only for uncontrolled mode)
useEffect(() => {
if (isControlled) return;
const searchFromUrl = searchParams.get(searchParam) || "";
setValue(searchFromUrl);
setInternalValue(searchFromUrl);
// If there's a search value, start expanded
if (searchFromUrl) {
setIsExpanded(true);
}
}, [searchParams, searchParam]);
}, [searchParams, searchParam, isControlled]);
// Handle input change with debounce
const handleChange = (newValue: string) => {
// For controlled mode, update internal display immediately and debounce callback
if (isControlled) {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
setIsLoading(true);
debounceTimeoutRef.current = setTimeout(() => {
onSearchChange(newValue);
setIsLoading(false);
}, SEARCH_DEBOUNCE_MS);
// Update display immediately for responsive feel
onSearchChange(newValue);
setIsLoading(false);
return;
}
setValue(newValue);
if (debounceTimeoutRef.current) {

View File

@@ -44,6 +44,37 @@ interface DataTableProviderProps<TData, TValue> {
showSearch?: boolean;
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */
paramPrefix?: string;
/*
* Controlled Mode Props
* ---------------------
* By default, DataTable uses URL params for pagination/search (via paramPrefix).
* This causes Next.js page re-renders on every interaction.
*
* For tables inside drawers/modals, use controlled mode instead:
* - Pass controlledPage, controlledPageSize, controlledSearch as state values
* - Pass onPageChange, onPageSizeChange, onSearchChange as state setters
* - This keeps state local, avoiding URL changes and unnecessary page re-renders
*
* Example:
* const [page, setPage] = useState(1);
* const [search, setSearch] = useState("");
* <DataTable
* controlledPage={page}
* onPageChange={setPage}
* controlledSearch={search}
* onSearchChange={setSearch}
* isLoading={isLoading}
* />
*/
controlledSearch?: string;
onSearchChange?: (value: string) => void;
controlledPage?: number;
controlledPageSize?: number;
onPageChange?: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
/** Show loading state with opacity overlay (for controlled mode) */
isLoading?: boolean;
}
export function DataTable<TData, TValue>({
@@ -57,13 +88,21 @@ export function DataTable<TData, TValue>({
getRowCanSelect,
showSearch = false,
paramPrefix = "",
controlledSearch,
onSearchChange,
controlledPage,
controlledPageSize,
onPageChange,
onPageSizeChange,
isLoading = false,
}: DataTableProviderProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
// Get transition state from context for loading indicator
const filterTransition = useFilterTransitionOptional();
const isPending = filterTransition?.isPending ?? false;
// Use either context-based pending state or controlled isLoading prop
const isPending = (filterTransition?.isPending ?? false) || isLoading;
const table = useReactTable({
data,
@@ -107,7 +146,13 @@ export function DataTable<TData, TValue>({
{showToolbar && (
<div className="flex items-center justify-between">
<div>
{showSearch && <DataTableSearch paramPrefix={paramPrefix} />}
{showSearch && (
<DataTableSearch
paramPrefix={paramPrefix}
controlledValue={controlledSearch}
onSearchChange={onSearchChange}
/>
)}
</div>
{metadata && (
<span className="text-text-neutral-secondary text-sm">
@@ -163,6 +208,10 @@ export function DataTable<TData, TValue>({
metadata={metadata}
disableScroll={disableScroll}
paramPrefix={paramPrefix}
controlledPage={controlledPage}
controlledPageSize={controlledPageSize}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
/>
)}
</div>