/* Copyright 2026 Marimo. All rights reserved. */ "use no memo"; import type { Table } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ChevronDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, } from "lucide-react"; import React from "react"; import { useLocale } from "react-aria"; import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Tooltip } from "@/components/ui/tooltip"; import { cn } from "@/utils/cn"; import { Events } from "@/utils/events"; import { prettyNumber } from "@/utils/numbers"; import { PluralWord } from "@/utils/pluralize"; interface DataTablePaginationProps { table: Table; tableLoading?: boolean; showPageSizeSelector?: boolean; } export const DataTablePagination = ({ table, tableLoading, showPageSizeSelector, }: DataTablePaginationProps) => { const { locale } = useLocale(); const currentPage = Math.min( table.getState().pagination.pageIndex + 1, table.getPageCount(), ); const totalPages = table.getPageCount(); const pageSize = table.getState().pagination.pageSize; const handlePageChange = (pageChangeFn: () => void) => { // Frequent page changes can reset the page index, so we wait until the previous change has completed if (!tableLoading) { pageChangeFn(); } }; // Ensure unique page sizes const pageSizeSet = new Set([5, 10, 25, 50, 100, pageSize]); const pageSizes = [...pageSizeSet].toSorted((a, b) => a - b); const renderPageSizeSelector = () => { return (
Rows per page {[...pageSizes].map((size) => ( table.setPageSize(size)} onMouseDown={Events.preventFocus} > {size} ))}
); }; return (
{showPageSizeSelector && renderPageSizeSelector()}
Page handlePageChange(() => table.setPageIndex(page)) } /> of {prettyNumber(totalPages, locale)}
); }; const PAGE_ITEM_HEIGHT = 32; /** * Compute contiguous ranges of page numbers whose string starts with `prefix`, * without scanning every page. O(log10(totalPages)). * * For prefix "5", totalPages=500: [[5,5], [50,59], [500,500]] */ export function matchingPageRanges( prefix: string, totalPages: number, ): [number, number][] { const n = Number.parseInt(prefix, 10); if (Number.isNaN(n) || n <= 0 || String(n) !== prefix) { return []; } const ranges: [number, number][] = []; let power = 1; while (n * power <= totalPages) { const start = n * power; const end = Math.min((n + 1) * power - 1, totalPages); ranges.push([start, end]); power *= 10; } return ranges; } interface PageMapping { count: number; pageAtIndex: (index: number) => number; indexOfPage: (page: number) => number; } function createPageMapping(search: string, totalPages: number): PageMapping { if (search === "") { return { count: totalPages, pageAtIndex: (i) => i + 1, indexOfPage: (p) => p - 1, }; } const ranges = matchingPageRanges(search, totalPages); let count = 0; for (const [s, e] of ranges) { count += e - s + 1; } return { count, pageAtIndex: (i) => { let offset = 0; for (const [start, end] of ranges) { const size = end - start + 1; if (i < offset + size) { return start + (i - offset); } offset += size; } return -1; }, indexOfPage: (p) => { let offset = 0; for (const [start, end] of ranges) { if (p >= start && p <= end) { return offset + (p - start); } offset += end - start + 1; } return -1; }, }; } export const PageSelector = ({ currentPage, totalPages, onPageChange, }: { currentPage: number; totalPages: number; onPageChange: (page: number) => void; }) => { const [open, setOpen] = React.useState(false); const [search, setSearch] = React.useState(""); const mapping = React.useMemo( () => createPageMapping(search, totalPages), [search, totalPages], ); const handleSelect = (page: number) => { onPageChange(page - 1); setSearch(""); setOpen(false); }; const listHeight = Math.min(mapping.count * PAGE_ITEM_HEIGHT, 240); return ( 1 ? open : false} onOpenChange={(next) => { setOpen(next); if (!next) { setSearch(""); } }} > { // Allow navigation/editing keys, block non-numeric input const allowed = [ "Backspace", "Delete", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Tab", "Enter", "Escape", ]; if (!allowed.includes(e.key) && !/^\d$/.test(e.key)) { e.preventDefault(); } }} /> {mapping.count === 0 ? ( No matching page ) : ( )} ); }; const VirtualizedPageList = ({ mapping, currentPage, listHeight, onSelect, }: { mapping: PageMapping; currentPage: number; listHeight: number; onSelect: (page: number) => void; }) => { const parentRef = React.useRef(null); const currentIndex = mapping.indexOfPage(currentPage); const virtualizer = useVirtualizer({ count: mapping.count, getScrollElement: () => parentRef.current, estimateSize: () => PAGE_ITEM_HEIGHT, overscan: 10, initialOffset: currentIndex > 0 ? Math.max(0, currentIndex * PAGE_ITEM_HEIGHT - listHeight / 2) : 0, }); // Scroll to top when filtered results change (user is searching) const prevCount = React.useRef(mapping.count); React.useEffect(() => { if (mapping.count !== prevCount.current) { virtualizer.scrollToIndex(0); prevCount.current = mapping.count; } }, [mapping.count, virtualizer]); return (
{virtualizer.getVirtualItems().map((virtualItem) => { const page = mapping.pageAtIndex(virtualItem.index); return ( onSelect(page)} onMouseDown={Events.preventFocus} > {page} ); })}
); }; export function prettifyRowCount(rowCount: number, locale: string): string { return `${prettyNumber(rowCount, locale)} ${new PluralWord("row").pluralize(rowCount)}`; } export const prettifyRowColumnCount = ({ numRows, totalColumns, locale, }: { numRows: number | "too_many"; totalColumns: number; locale: string; }): string => { const rowsLabel = numRows === "too_many" ? "Unknown" : prettifyRowCount(numRows, locale); const columnsLabel = `${prettyNumber(totalColumns, locale)} ${new PluralWord("column").pluralize(totalColumns)}`; return [rowsLabel, columnsLabel].join(", "); };