SmartTable component`
Source Code
tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
ChevronUp,
ChevronDown,
ChevronsUpDown,
ChevronLeft,
ChevronRight,
Settings,
Eye,
EyeOff,
} from "lucide-react";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getPaginationRowModel,
flexRender,
ColumnDef,
SortingState,
VisibilityState,
} from "@tanstack/react-table";
// Exported interfaces for users
export interface ColumnsVisibility {
sm?: string[];
md?: string[];
lg?: string[];
}
export interface ColumnsConfig {
columnsVisibility?: ColumnsVisibility;
freezeHeader?: boolean;
enableColumnToggle?: boolean;
}
export interface SmartTableProps<TData> {
data: TData[];
columns: ColumnDef<TData, any>[];
columnsConfig?: ColumnsConfig;
className?: string;
headerStyle?: React.CSSProperties;
pageSize?: number;
}
// Utility hook to handle responsive column visibility
function useResponsiveColumns<TData>(
columns: ColumnDef<TData, any>[],
columnsVisibility?: ColumnsVisibility,
enableColumnToggle?: boolean
) {
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [userToggledColumns, setUserToggledColumns] =
React.useState<VisibilityState>({});
React.useEffect(() => {
if (!columnsVisibility) {
// Apply user toggles if no responsive config
setColumnVisibility(userToggledColumns);
return;
}
const updateVisibility = () => {
const width = window.innerWidth;
let visibleColumnIds: string[] | undefined;
// Tailwind breakpoints: sm: 640px, md: 768px, lg: 1024px
if (width < 640 && columnsVisibility.sm) {
visibleColumnIds = columnsVisibility.sm;
} else if (width >= 640 && width < 768 && columnsVisibility.md) {
visibleColumnIds = columnsVisibility.md;
} else if (width >= 768 && width < 1024 && columnsVisibility.lg) {
visibleColumnIds = columnsVisibility.lg;
}
if (visibleColumnIds) {
const visibility: VisibilityState = {};
columns.forEach((col) => {
const colId = (col as any).accessorKey || (col as any).id;
if (colId) {
// Merge responsive visibility with user toggles
const isVisibleByBreakpoint = visibleColumnIds!.includes(colId);
const userToggled = userToggledColumns[colId];
visibility[colId] =
userToggled !== undefined ? userToggled : isVisibleByBreakpoint;
}
});
setColumnVisibility(visibility);
} else {
// All columns visible by default, but respect user toggles
setColumnVisibility(userToggledColumns);
}
};
updateVisibility();
window.addEventListener("resize", updateVisibility);
return () => window.removeEventListener("resize", updateVisibility);
}, [columns, columnsVisibility, userToggledColumns]);
const toggleColumn = React.useCallback(
(columnId: string) => {
setUserToggledColumns((prev) => {
const current =
prev[columnId] !== undefined
? prev[columnId]
: (columnVisibility[columnId] ?? true);
return {
...prev,
[columnId]: !current,
};
});
},
[columnVisibility]
);
return {
columnVisibility,
setColumnVisibility,
toggleColumn,
userToggledColumns,
};
}
function SmartTable<TData>({
data,
columns,
columnsConfig,
className,
headerStyle,
pageSize = 50,
}: SmartTableProps<TData>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const { columnVisibility, setColumnVisibility, toggleColumn } =
useResponsiveColumns(
columns,
columnsConfig?.columnsVisibility,
columnsConfig?.enableColumnToggle
);
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
},
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: {
pageSize,
},
},
});
const freezeHeader = columnsConfig?.freezeHeader ?? false;
const enableColumnToggle = columnsConfig?.enableColumnToggle ?? false;
return (
<div className="w-full space-y-4">
{enableColumnToggle && (
<ColumnToggleMenu
columns={columns}
columnVisibility={columnVisibility}
toggleColumn={toggleColumn}
/>
)}
<div
data-slot="smart-table-container"
className={cn(
"relative w-full overflow-x-auto",
freezeHeader && "max-h-[600px] overflow-y-auto"
)}
>
<table
data-slot="smart-table"
className={cn("w-full caption-bottom text-sm", className)}
>
<SmartTableHeader
headerGroups={table.getHeaderGroups()}
style={headerStyle}
freezeHeader={freezeHeader}
/>
<SmartTableBody rows={table.getRowModel().rows} />
</table>
</div>
<SmartTablePagination table={table} />
</div>
);
}
interface ColumnToggleMenuProps<TData> {
columns: ColumnDef<TData, any>[];
columnVisibility: VisibilityState;
toggleColumn: (columnId: string) => void;
}
function ColumnToggleMenu<TData>({
columns,
columnVisibility,
toggleColumn,
}: ColumnToggleMenuProps<TData>) {
const [showMenu, setShowMenu] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!showMenu) return;
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setShowMenu(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showMenu]);
return (
<div className="flex justify-end" ref={menuRef}>
<button
onClick={() => setShowMenu(!showMenu)}
className="flex items-center gap-2 px-3 py-2 text-sm border rounded-md hover:bg-muted transition-colors"
>
<Settings className="w-4 h-4" />
Columns
</button>
{showMenu && (
<div className="absolute z-50 mt-12 min-w-[200px] border rounded-md bg-background shadow-lg">
<div className="p-2 border-b font-medium text-sm">Toggle Columns</div>
<div className="p-2 max-h-[300px] overflow-y-auto">
{columns.map((col) => {
const colId = (col as any).accessorKey || (col as any).id;
if (!colId) return null;
const header =
typeof col.header === "string" ? col.header : colId;
const isVisible = columnVisibility[colId] ?? true;
return (
<label
key={colId}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer text-sm"
>
<input
type="checkbox"
checked={isVisible}
onChange={() => toggleColumn(colId)}
className="w-4 h-4"
/>
{isVisible ? (
<Eye className="w-4 h-4 text-green-600" />
) : (
<EyeOff className="w-4 h-4 text-gray-400" />
)}
<span>{header}</span>
</label>
);
})}
</div>
</div>
)}
</div>
);
}
interface SmartTableHeaderProps {
headerGroups: Array<{
id: string;
headers: Array<{
id: string;
isPlaceholder: boolean;
column: {
getCanSort: () => boolean;
getIsSorted: () => false | "asc" | "desc";
getToggleSortingHandler: () => ((event: unknown) => void) | undefined;
columnDef: ColumnDef<any, any>;
};
getContext: () => any;
}>;
}>;
style?: React.CSSProperties;
freezeHeader?: boolean;
}
function SmartTableHeader({
headerGroups,
style,
freezeHeader,
}: SmartTableHeaderProps) {
const [isFrozen, setIsFrozen] = React.useState(false);
const headerRef = React.useRef<HTMLTableSectionElement>(null);
React.useEffect(() => {
if (!freezeHeader) return;
const container = headerRef.current?.closest(
'[data-slot="smart-table-container"]'
);
if (!container) return;
const checkScroll = () => {
setIsFrozen(container.scrollTop > 0);
};
container.addEventListener("scroll", checkScroll);
return () => container.removeEventListener("scroll", checkScroll);
}, [freezeHeader]);
return (
<thead
ref={headerRef}
data-slot="smart-table-header"
className={cn(
"[&_tr]:border-b",
freezeHeader && "sticky top-0 z-10 bg-background",
isFrozen && "shadow-sm"
)}
style={style}
>
{headerGroups.map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<SmartTableHead key={header.id} header={header} />
))}
</tr>
))}
</thead>
);
}
interface SmartTableHeadProps {
header: {
id: string;
isPlaceholder: boolean;
column: {
getCanSort: () => boolean;
getIsSorted: () => false | "asc" | "desc";
getToggleSortingHandler: () => ((event: unknown) => void) | undefined;
columnDef: ColumnDef<any, any>;
};
getContext: () => any;
};
className?: string;
}
function SmartTableHead({ header, className }: SmartTableHeadProps) {
const canSort = header.column.getCanSort();
const isSorted = header.column.getIsSorted();
return (
<th
data-slot="smart-table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap",
canSort && "cursor-pointer select-none hover:bg-muted/50",
className
)}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
>
<div className="flex items-center gap-2">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
{canSort && (
<span className="flex items-center">
{isSorted === "asc" ? (
<ChevronUp className="h-4 w-4" />
) : isSorted === "desc" ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronsUpDown className="h-4 w-4 opacity-50" />
)}
</span>
)}
</div>
</th>
);
}
interface SmartTableBodyProps {
rows: Array<{
id: string;
getVisibleCells: () => Array<{
id: string;
column: {
columnDef: ColumnDef<any, any>;
};
getContext: () => any;
}>;
}>;
}
function SmartTableBody({ rows }: SmartTableBodyProps) {
return (
<tbody data-slot="smart-table-body" className="[&_tr:last-child]:border-0">
{rows.length ? (
rows.map((row) => <SmartTableRow key={row.id} row={row} />)
) : (
<tr>
<td colSpan={100} className="h-24 text-center text-muted-foreground">
No results found.
</td>
</tr>
)}
</tbody>
);
}
interface SmartTableRowProps {
row: {
id: string;
getVisibleCells: () => Array<{
id: string;
column: {
columnDef: ColumnDef<any, any>;
};
getContext: () => any;
}>;
};
className?: string;
}
function SmartTableRow({ row, className }: SmartTableRowProps) {
return (
<tr
data-slot="smart-table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
>
{row.getVisibleCells().map((cell) => (
<SmartTableCell key={cell.id} cell={cell} />
))}
</tr>
);
}
interface SmartTableCellProps {
cell: {
id: string;
column: {
columnDef: ColumnDef<any, any>;
};
getContext: () => any;
};
className?: string;
}
function SmartTableCell({ cell, className }: SmartTableCellProps) {
return (
<td
data-slot="smart-table-cell"
className={cn("p-2 align-middle whitespace-nowrap", className)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
}
interface SmartTablePaginationProps {
table: {
getState: () => {
pagination: {
pageIndex: number;
pageSize: number;
};
};
getFilteredRowModel: () => {
rows: any[];
};
getCanPreviousPage: () => boolean;
getCanNextPage: () => boolean;
previousPage: () => void;
nextPage: () => void;
getPageCount: () => number;
};
}
function SmartTablePagination({ table }: SmartTablePaginationProps) {
return (
<div className="flex items-center justify-between px-2">
<div className="text-sm text-muted-foreground">
Showing{" "}
{table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1}{" "}
to{" "}
{Math.min(
(table.getState().pagination.pageIndex + 1) *
table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length
)}{" "}
of {table.getFilteredRowModel().rows.length} results
</div>
<div className="flex items-center gap-2">
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="px-3 py-2 border rounded-md hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="h-4 w-4" />
</button>
<div className="text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="px-3 py-2 border rounded-md hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
);
}
// Demo Component
interface Employee {
id: number;
name: string;
email: string;
role: string;
status: string;
department: string;
salary: number;
}
function Demo() {
const data: Employee[] = Array.from({ length: 150 }, (_, i) => ({
id: i + 1,
name: `Employee ${i + 1}`,
email: `employee${i + 1}@example.com`,
role: ["Developer", "Designer", "Manager", "Analyst"][i % 4],
status: i % 5 === 0 ? "Inactive" : "Active",
department: ["Engineering", "Design", "HR", "Sales"][i % 4],
salary: 50000 + i * 1000,
}));
const columns: ColumnDef<Employee>[] = [
{
accessorKey: "id",
header: "ID",
enableSorting: true,
},
{
accessorKey: "name",
header: "Name",
enableSorting: true,
},
{
accessorKey: "email",
header: "Email",
enableSorting: true,
},
{
accessorKey: "role",
header: "Role",
enableSorting: true,
},
{
accessorKey: "department",
header: "Department",
enableSorting: true,
},
{
accessorKey: "salary",
header: "Salary",
enableSorting: true,
cell: ({ getValue }) => {
const value = getValue() as number;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(value);
},
},
{
accessorKey: "status",
header: "Status",
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue() as string;
return (
<span
className={cn(
"px-2 py-1 rounded-full text-xs",
status === "Active"
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
)}
>
{status}
</span>
);
},
},
];
const columnsConfig: ColumnsConfig = {
columnsVisibility: {
sm: ["name", "status"],
md: ["name", "email", "role", "status"],
lg: ["name", "email", "role", "department", "status"],
},
freezeHeader: true,
enableColumnToggle: true,
};
return (
<div className="p-8 max-w-7xl mx-auto">
<h1 className="text-2xl font-bold mb-2">SmartTable Component Demo</h1>
<p className="text-muted-foreground mb-6">
Featuring sorting, pagination (50 rows/page), frozen headers, responsive
column visibility, and manual column toggle
</p>
<SmartTable
data={data}
columns={columns}
columnsConfig={columnsConfig}
headerStyle={{ backgroundColor: "#f8fafc", fontWeight: 600 }}
pageSize={50}
/>
</div>
);
}
export default Demo;
export {
SmartTable,
SmartTableHeader,
SmartTableHead,
SmartTableBody,
SmartTableRow,
SmartTableCell,
SmartTablePagination,
};1. Extended Breakpoints
typescript
interface ColumnsVisibility {
sm?: string[]; // <640px
md?: string[]; // 640px-768px
lg?: string[]; // 768px-1024px (NEW!)
}2. Manual Column Toggle
- Added
enableColumnToggleoption inColumnsConfig - Click the "Columns" button to show/hide individual columns
- Visual indicators (eye icons) show visibility status
- Works alongside responsive visibility
3. Smart Visibility Logic
The component now intelligently merges:
- Responsive visibility (based on screen size)
- User manual toggles (via the column menu)
- Default behavior (all visible if not specified)
User toggles take precedence over responsive config, so users can override the default responsive behavior.
📝 Updated Usage
typescript
const columnsConfig: ColumnsConfig = {
columnsVisibility: {
sm: ["name", "status"], // Mobile
md: ["name", "email", "status"], // Tablet
lg: ["name", "email", "role", "department", "status"], // Desktop
},
freezeHeader: true,
enableColumnToggle: true, // NEW! Shows the column toggle menu
}
<SmartTable
data={data}
columns={columns}
columnsConfig={columnsConfig}
pageSize={50}
/>How It Works
- Resize the window to see responsive column visibility in action
- Use the "Columns" button to manually show/hide columns
- Manual toggles persist even when resizing the window
- Click column headers to sort
- Use pagination controls at the bottom
All exported interfaces are ready for TypeScript users to import!