Trove

Installation

How to install Trove and its dependencies.

Installation

pnpm dlx shadcn@latest add http://trove.pragyan.fyi/r/trove.json

Under the Hood

Trove uses @tanstack/react-table for its core table logic and is styled with shadcn/ui components.

Install Tanstak table

pnpm add @tanstack/react-table

Initialize ShadCN

Add Button and Table from shadcn/ui.

Copy and paste the following code into your project.

@/components/ui/trove.tsx
"use client";

import React, { useState, createContext, useContext, ReactNode } from "react";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { ChevronDown, ChevronRight } from "lucide-react";
import { flexRender, Table as TanstackTable, Row } from "@tanstack/react-table";
import { cn } from "@/lib/utils";

type TroveContextType = {
  expanded: Set<string>;
  toggle: (id: string) => void;
  expandAll: () => void;
  collapseAll: () => void;
  animationDuration?: number;
  clickableRows: boolean;
};

const TroveContext = createContext<TroveContextType | null>(null);

const useTrove = () => {
  const context = useContext(TroveContext);
  if (!context) throw new Error("Trove components must be used within <Trove>");
  return context;
};

type TroveProps<TData> = {
  table: TanstackTable<TData>;
  children: ReactNode;
  getRowId: (row: TData) => string;
  renderExpanded: (row: Row<TData>) => ReactNode;
  defaultExpanded?: string[];
  animationDuration?: number;
  className?: string;
  onExpandedChange?: (expanded: Set<string>) => void;
  clickableRows?: boolean;
};

export function Trove<TData>({
  table,
  children,
  getRowId,
  renderExpanded,
  defaultExpanded = [],
  animationDuration = 200,
  className,
  onExpandedChange,
  clickableRows = false,
}: TroveProps<TData>) {
  const [expandedRows, setExpandedRows] = useState<Set<string>>(
    new Set(defaultExpanded)
  );

  const toggle = (id: string) => {
    const next = new Set(expandedRows);
    next.has(id) ? next.delete(id) : next.add(id);
    setExpandedRows(next);
    onExpandedChange?.(next);
  };

  const expandAll = () => {
    const allIds = new Set(
      table.getRowModel().rows.map((row) => getRowId(row.original))
    );
    setExpandedRows(allIds);
    onExpandedChange?.(allIds);
  };

  const collapseAll = () => {
    setExpandedRows(new Set());
    onExpandedChange?.(new Set());
  };

  const contextValue: TroveContextType = {
    expanded: expandedRows,
    toggle,
    expandAll,
    collapseAll,
    animationDuration,
    clickableRows,
  };

  return (
    <TroveContext.Provider value={contextValue}>
      <div className={cn("space-y-4", className)}>
        {children}
        <div className="border rounded-lg overflow-hidden">
          <Table>
            <TableHeader>
              {table.getHeaderGroups().map((headerGroup) => (
                <TableRow key={headerGroup.id}>
                  <TableHead className="w-12" />
                  {headerGroup.headers.map((header) => (
                    <TableHead key={header.id}>
                      {header.isPlaceholder
                        ? null
                        : flexRender(
                            header.column.columnDef.header,
                            header.getContext()
                          )}
                    </TableHead>
                  ))}
                </TableRow>
              ))}
            </TableHeader>
            <TableBody>
              {table.getRowModel().rows?.length ? (
                table
                  .getRowModel()
                  .rows.map((row) => (
                    <TroveRow
                      key={row.id}
                      row={row}
                      getRowId={getRowId}
                      renderExpanded={renderExpanded}
                    />
                  ))
              ) : (
                <TableRow>
                  <TableCell
                    colSpan={
                      table.getHeaderGroups()[0]?.headers.length + 1 || 1
                    }
                    className="h-24 text-center text-muted-foreground"
                  >
                    No results found.
                  </TableCell>
                </TableRow>
              )}
            </TableBody>
          </Table>
        </div>
      </div>
    </TroveContext.Provider>
  );
}

export function TroveHeader({
  children,
  className,
}: {
  children: ReactNode;
  className?: string;
}) {
  return <div className={cn("space-y-1", className)}>{children}</div>;
}

export function TroveTitle({
  children,
  className,
}: {
  children: ReactNode;
  className?: string;
}) {
  return <h1 className={cn("text-3xl font-bold", className)}>{children}</h1>;
}

export function TroveDescription({
  children,
  className,
}: {
  children: ReactNode;
  className?: string;
}) {
  return <p className={cn("text-muted-foreground", className)}>{children}</p>;
}

export function TroveActions({
  children,
  className,
}: {
  children: ReactNode;
  className?: string;
}) {
  return (
    <div className={cn("flex items-center gap-2", className)}>{children}</div>
  );
}

export function TroveExpandAllButton({
  children = "Expand All",
  className,
  ...props
}: React.ComponentProps<typeof Button> & {
  children?: ReactNode;
}) {
  const { expandAll } = useTrove();
  return (
    <Button
      variant="outline"
      size="sm"
      onClick={expandAll}
      className={className}
      {...props}
    >
      {children}
    </Button>
  );
}

export function TroveCollapseAllButton({
  children = "Collapse All",
  className,
  ...props
}: React.ComponentProps<typeof Button> & {
  children?: ReactNode;
}) {
  const { collapseAll } = useTrove();
  return (
    <Button
      variant="outline"
      size="sm"
      onClick={collapseAll}
      className={className}
      {...props}
    >
      {children}
    </Button>
  );
}

export function TroveContent({
  children,
  className,
}: {
  children: ReactNode;
  className?: string;
}) {
  return (
    <div className={cn("p-4 pl-12 bg-muted/50", className)}>{children}</div>
  );
}

function TroveRow<TData>({
  row,
  getRowId,
  renderExpanded,
}: {
  row: Row<TData>;
  getRowId: (row: TData) => string;
  renderExpanded: (row: Row<TData>) => ReactNode;
}) {
  const { expanded, toggle, animationDuration, clickableRows } = useTrove();
  const rowId = getRowId(row.original);
  const isOpen = expanded.has(rowId);

  const handleToggle = () => {
    toggle(rowId);
  };

  const handleRowClick = (event: React.MouseEvent) => {
    if (!clickableRows) return;

    if ((event.target as Element).closest("button")) return;

    handleToggle();
  };

  const handleKeyDown = (event: React.KeyboardEvent) => {
    if (event.key === "Enter" || event.key === " ") {
      event.preventDefault();
      handleToggle();
    }
  };

  return (
    <>
      <TableRow
        className={cn(
          "group",
          clickableRows && "cursor-pointer hover:bg-muted/50"
        )}
        onClick={handleRowClick}
      >
        <TableCell className="w-12">
          <Button
            variant="ghost"
            size="sm"
            onClick={handleToggle}
            onKeyDown={handleKeyDown}
            className="p-0 h-8 w-8 hover:bg-muted transition-colors"
            aria-expanded={isOpen}
            aria-label={isOpen ? "Collapse row" : "Expand row"}
          >
            {isOpen ? (
              <ChevronDown className="h-4 w-4 transition-transform duration-200" />
            ) : (
              <ChevronRight className="h-4 w-4 transition-transform duration-200" />
            )}
          </Button>
        </TableCell>
        {row.getVisibleCells().map((cell) => (
          <TableCell key={cell.id}>
            {flexRender(cell.column.columnDef.cell, cell.getContext())}
          </TableCell>
        ))}
      </TableRow>
      <TableRow>
        <TableCell
          colSpan={row.getVisibleCells().length + 1}
          className="p-0 border-0"
        >
          <div
            style={{ transitionDuration: `${animationDuration}ms` }}
            className={cn(
              "overflow-hidden transition-all ease-in-out",
              isOpen ? "max-h-[1000px] opacity-100" : "max-h-0 opacity-0"
            )}
          >
            {renderExpanded(row)}
          </div>
        </TableCell>
      </TableRow>
    </>
  );
}

Anatomy

Here is a basic example of how to use the Trove component.

import {
  Trove,
  TroveContent,
  TroveHeader,
  TroveTitle,
  TroveActions,
  TroveExpandAllButton,
  TroveCollapseAllButton,
} from "@/components/ui/trove";
function MyTable() {
  return (
    <Trove
      table={table}
      getRowId={(row) => row.id}
      renderExpanded={(row) => (
        <TroveContent>
          <p>Expanded content for {row.original.name}</p>
        </TroveContent>
      )}
      clickableRows
    >
      <TroveHeader>
        <TroveTitle>My Data Table</TroveTitle>
        <TroveDescription>Click to expand the rows</TroveDescription>
        <TroveActions>
          <TroveExpandAllButton />
          <TroveCollapseAllButton />
        </TroveActions>
      </TroveHeader>
    </Trove>
  );
}

Types

PropTypeDefault
table?
TanstackTable<TData>
required
children?
ReactNode
required
getRowId?
(row: TData) => string
required
renderExpanded?
(row: Row<TData>) => ReactNode
required
defaultExpanded?
string[]
[]
animationDuration?
number
200
className?
string
undefined
onExpandedChange?
(expanded: Set<string>) => void
undefined
clickableRows?
boolean
false