Auto Table

Paginated table that auto-adjusts visible rows to container height.

Afhængigheder:motion

Simpel tabel

Programming languages
LanguageReleasedParadigm
TypeScript2012Multi-paradigm
Rust2015Multi-paradigm
Go2009Concurrent
Python1991Multi-paradigm
Swift2014Multi-paradigm
1 / 3

Users tabel

CRM Leads
Lead nameStatusDeal ValueAssigned ToInteractedPayment StatusDate Added
Sarah Parker3Won$2,500.00Sarah P.[C]2 days agoFull Payment1 month ago
Mike BrownCall BookedPending...Alex W.[C]3 hours agoN/A5 days ago
Linda ChenUnqualified - No MoneyN/AJane D.[S]1 week agoN/A2 weeks ago
David LeeWon$5,000.00John S.[C]4 days ago$1K Deposit6 days ago
Emily White5No ShowPending...Ali M.[S]15 mins agoN/A1 day ago
1 / 3

Installer

npx shadcn@latest add https://byhartvig.dk/r/auto-table.json

Brug

import { AutoTable } from "@/components/ui/auto-table"

const columns = [
  { key: "name", header: "Name" },
  { key: "role", header: "Role" },
]

const data = [
  { name: "Alice", role: "Engineer" },
  { name: "Bob", role: "Designer" },
]

<AutoTable columns={columns} data={data} rowKey="name" />

Kildekode

"use client";

import {
  useState,
  useLayoutEffect,
  useRef,
  useCallback,
  type ReactNode,
} from "react";
import { motion, AnimatePresence } from "motion/react";

interface Column<T> {
  key: string;
  header: string;
  render?: (value: unknown, row: T, rowIndex: number) => ReactNode;
  width?: string;
  align?: "left" | "center" | "right";
}

interface AutoTableProps<T extends Record<string, unknown>> {
  columns: Column<T>[];
  data: T[];
  rowHeight?: number;
  itemsPerPage?: number;
  className?: string;
  caption?: string;
  rowKey?: keyof T;
}

const PAGINATION_HEIGHT = 38;

function useAutoPageSize(
  containerRef: React.RefObject<HTMLDivElement | null>,
  theadRef: React.RefObject<HTMLTableSectionElement | null>,
  rowHeight: number,
  totalRows: number,
  manualOverride?: number
) {
  const [pageSize, setPageSize] = useState(manualOverride ?? 5);

  useLayoutEffect(() => {
    if (manualOverride != null) {
      setPageSize(manualOverride);
      return;
    }

    const container = containerRef.current;
    if (!container) return;

    let rafId = 0;

    const measure = () => {
      const containerHeight = container.clientHeight;
      const theadHeight = theadRef.current?.offsetHeight ?? 0;
      const available = containerHeight - theadHeight;
      const rawFit = Math.max(1, Math.floor(available / rowHeight));

      if (rawFit >= totalRows) {
        // All rows fit without pagination — no pagination needed
        setPageSize(rawFit);
      } else {
        // Need pagination — reserve space for the pagination bar
        const withPagination = Math.max(1, Math.floor((available - PAGINATION_HEIGHT) / rowHeight));
        // Guard: if reserving pagination space would fit all rows,
        // still keep pagination to avoid oscillation at the boundary
        setPageSize(Math.min(withPagination, totalRows - 1));
      }
    };

    measure();

    const observer = new ResizeObserver(() => {
      cancelAnimationFrame(rafId);
      rafId = requestAnimationFrame(measure);
    });

    observer.observe(container);

    return () => {
      cancelAnimationFrame(rafId);
      observer.disconnect();
    };
  }, [containerRef, theadRef, rowHeight, totalRows, manualOverride]);

  return pageSize;
}

const spring = { type: "spring" as const, stiffness: 500, damping: 35, mass: 0.8 };

export function AutoTable<T extends Record<string, unknown>>({
  columns,
  data,
  rowHeight = 44,
  itemsPerPage,
  className,
  caption,
  rowKey,
}: AutoTableProps<T>) {
  const containerRef = useRef<HTMLDivElement>(null);
  const theadRef = useRef<HTMLTableSectionElement>(null);
  const [page, setPage] = useState(0);
  const navKeyRef = useRef(0);

  const pageSize = useAutoPageSize(
    containerRef,
    theadRef,
    rowHeight,
    data.length,
    itemsPerPage
  );

  const totalPages = Math.max(1, Math.ceil(data.length / pageSize));
  const currentPage = Math.min(page, totalPages - 1);
  const start = currentPage * pageSize;
  const visibleRows = data.slice(start, start + pageSize);

  const goToPrev = useCallback(() => {
    navKeyRef.current++;
    setPage((p) => Math.max(0, p - 1));
  }, []);

  const goToNext = useCallback(() => {
    navKeyRef.current++;
    setPage((p) => p + 1);
  }, []);

  const alignClass = (align?: "left" | "center" | "right") =>
    align === "center"
      ? "text-center"
      : align === "right"
        ? "text-right"
        : "text-left";

  return (
    <div
      ref={containerRef}
      className={`h-full flex flex-col rounded-2xl border border-current/5 bg-current/[0.02] overflow-hidden ${className ?? ""}`}
    >
      <div className="flex-1 overflow-hidden overflow-x-auto">
        <table className="w-full border-collapse min-w-[400px]">
          {caption && (
            <caption className="sr-only">{caption}</caption>
          )}
          <thead ref={theadRef}>
            <tr>
              {columns.map((col) => (
                <th
                  key={col.key}
                  className={`px-4 pt-4 pb-3 text-xs font-normal text-current/40 tracking-wide ${alignClass(col.align)}`}
                  style={col.width ? { width: col.width } : undefined}
                >
                  {col.header}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            <AnimatePresence key={navKeyRef.current} initial={false}>
              {visibleRows.map((row, i) => {
                const key = rowKey ? String(row[rowKey]) : `${start + i}`;
                const isLast = i === visibleRows.length - 1;
                return (
                  <motion.tr
                    key={key}
                    initial={{ opacity: 0, height: 0 }}
                    animate={{ opacity: 1, height: rowHeight }}
                    exit={{ opacity: 0, height: 0 }}
                    transition={spring}
                    className={isLast ? "" : "border-b border-current/5"}
                  >
                    {columns.map((col, colIndex) => (
                      <td
                        key={col.key}
                        className={`px-4 text-sm whitespace-nowrap ${colIndex === 0 ? "font-medium" : "text-current/70"} ${alignClass(col.align)}`}
                      >
                        {col.render
                          ? col.render(row[col.key], row, start + i)
                          : (row[col.key] as ReactNode)}
                      </td>
                    ))}
                  </motion.tr>
                );
              })}
            </AnimatePresence>
          </tbody>
        </table>
      </div>
      <AnimatePresence initial={false}>
        {totalPages > 1 && (
          <motion.div
            initial={{ opacity: 0, height: 0 }}
            animate={{ opacity: 1, height: PAGINATION_HEIGHT }}
            exit={{ opacity: 0, height: 0 }}
            transition={spring}
            className="flex items-center justify-center gap-4 px-4 overflow-hidden shrink-0"
          >
            <button
              onClick={goToPrev}
              disabled={currentPage === 0}
              className="rounded-full border border-current/10 p-1.5 text-current/40 transition-colors duration-200 enabled:hover:text-current disabled:opacity-30"
              aria-label="Previous page"
            >
              <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
                <path d="M10 4L6 8L10 12" />
              </svg>
            </button>
            <span className="text-xs text-current/40 tabular-nums">
              {currentPage + 1} / {totalPages}
            </span>
            <button
              onClick={goToNext}
              disabled={currentPage === totalPages - 1}
              className="rounded-full border border-current/10 p-1.5 text-current/40 transition-colors duration-200 enabled:hover:text-current disabled:opacity-30"
              aria-label="Next page"
            >
              <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
                <path d="M6 4L10 8L6 12" />
              </svg>
            </button>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}