@talberos/custom-table
v1.0.17
Published
Advanced Excel-style table component for React with inline editing, cell selection, filters and more
Maintainers
Readme
@talberos/custom-table
Advanced Excel-style table component for React with inline editing, cell selection, filters, sorting and more.
Table of Contents
- Features
- Installation
- Quick Start
- Component Props
- Column Definition
- Row Identifiers
- Column Types
- Edit Types
- Supported Countries
- Practical Examples
- Editing and Persistence
- Dynamic Dropdowns
- Context Menu
- Data Export
- Customization
- Keyboard Shortcuts
- API Reference
Features
| Feature | Description | |---------|-------------| | Excel-like Selection | Click, drag, Shift+Click for ranges | | Inline Editing | Double click to edit any cell | | 15 Column Types | text, numeric, badge, country, date, datetime, link, email, phone, boolean, rating, progress, heatmap, sparkline, avatar | | 5 Edit Types | text, numeric, select, date, boolean | | Smart Dropdowns | With search, flags and dynamic creation | | 67 Countries with Flags | Americas, Europe, Asia, Africa and Oceania | | Auto-scroll | Automatic scroll when selecting near edges | | Resizable Columns | Drag column borders to resize | | Sorting | Click headers to sort ASC/DESC | | Global Filter | Search across all columns | | Pagination | 50, 100, 200, 500 or all rows | | Context Menu | Right-click to copy, hide columns/rows | | Copy Cells | Ctrl/Cmd + C to copy selection | | Export | CSV and Excel | | Themes | Automatic dark/light mode | | Mobile-first | Optimized for touch | | TypeScript | Full typing |
Installation
npm install @talberos/custom-tablePeer Dependencies
npm install react react-dom @mui/material @emotion/react @emotion/styled @tanstack/react-tableQuick Start
'use client'
import CustomTable from '@talberos/custom-table'
import type { ColumnDef } from '@talberos/custom-table'
const columns: ColumnDef[] = [
{ accessorKey: 'id', header: 'ID', width: 60 },
{ accessorKey: 'name', header: 'Name', editable: true },
{ accessorKey: 'email', header: 'Email', type: 'email' },
{ accessorKey: 'status', header: 'Status', type: 'badge' },
]
const data = [
{ id: 1, name: 'John Doe', email: '[email protected]', status: 'Active' },
{ id: 2, name: 'Jane Smith', email: '[email protected]', status: 'Pending' },
]
export default function MyTable() {
return (
<CustomTable
data={data}
columnsDef={columns}
containerHeight="500px"
/>
)
}Component Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| data | any[] | required | Array of objects with data |
| columnsDef | ColumnDef[] | required | Column definitions |
| onCellUpdate | (rowId, colId, value) => Promise<void> | - | Callback when editing cell |
| onExportCSV | (data) => void | - | CSV export handler |
| onExportExcel | (data) => void | - | Excel export handler |
| loadingText | string | 'Loading data...' | Loading text |
| noResultsText | string | 'No results found' | No data text |
| enableFilters | boolean | true | Enable global filter |
| enableColumnFilters | boolean | true | Enable column filters |
| enableSorting | boolean | true | Enable sorting |
| enablePagination | boolean | true | Enable pagination |
| enableCellSelection | boolean | true | Enable selection |
| enableCellEditing | boolean | true | Enable editing |
| enableExport | boolean | true | Show export button |
| defaultPageSize | number | 100 | Rows per page |
| rowHeight | number | 36 | Row height (px) |
| containerHeight | string \| number | '80vh' | Container height |
| defaultTheme | 'light' \| 'dark' \| 'system' | 'light' | Default theme |
| className | string | - | Custom CSS class |
| style | CSSProperties | - | Inline styles |
Column Definition
interface ColumnDef {
accessorKey: string // Field key in data
header: string // Header text
type?: ColumnType // Render type
width?: number // Initial width (default: 150)
minWidth?: number // Minimum width (default: 50)
maxWidth?: number // Maximum width (default: 800)
editable?: boolean // Allow editing
editType?: EditType // 'text' | 'numeric' | 'select' | 'date' | 'boolean'
options?: SelectOption[] // Options for select
allowCreate?: boolean // Create new options in select
onCreateOption?: (value: string) => Promise<void>
isNumeric?: boolean // Indicates numeric (auto-detected if type: 'numeric')
isRowId?: boolean // Mark this column as unique row identifier
precision?: number // Decimals for numeric
min?: number // Minimum value for numeric
max?: number // Maximum value for numeric
textAlign?: 'left' | 'center' | 'right'
badgeColors?: Record<string, { bg: string; text: string }>
sortable?: boolean // Allow sorting (default: true)
filterable?: boolean // Allow filtering (default: true)
hidden?: boolean // Hide column
render?: (value: any, row: any) => React.ReactNode
}Row Identifiers (Row IDs)
CustomTable automatically handles row identifiers to optimize performance and avoid conflicts with your business id field.
Automatic Detection
The table automatically detects which field to use as unique identifier following this priority order:
- Explicitly marked column with
isRowId: true - Column with
accessorKey: 'id'(automatic detection) - Auto-generated IDs based on index (fallback)
Use Cases
✅ Case 1: Using 'id' field from your database (RECOMMENDED)
If your data already has a unique id field, simply include it in the columns:
const columns: ColumnDef[] = [
{ accessorKey: 'id', header: 'ID', width: 80 }, // ← Automatically detected
{ accessorKey: 'name', header: 'Name', editable: true },
{ accessorKey: 'email', header: 'Email', type: 'email' },
]
const data = [
{ id: 123, name: 'John Doe', email: '[email protected]' },
{ id: 456, name: 'Jane Smith', email: '[email protected]' },
]
// ✅ Table automatically uses 'id' field as internal identifier
// ✅ You can edit, filter and sort by 'id' without issues
// ✅ onCellUpdate will receive rowId='123' when editing first row✅ Case 2: ID field with different name
If your identifier is called userId, productId, etc.:
const columns: ColumnDef[] = [
{ accessorKey: 'userId', header: 'User ID', width: 80, isRowId: true }, // ← Mark explicitly
{ accessorKey: 'name', header: 'Name', editable: true },
]
const data = [
{ userId: 'usr_123', name: 'John Doe' },
{ userId: 'usr_456', name: 'Jane Smith' },
]
// ✅ Table uses 'userId' as internal identifier
// ✅ onCellUpdate will receive rowId='usr_123' when editing first row✅ Case 3: Data without unique identifier
If your data doesn't have an ID field:
const columns: ColumnDef[] = [
{ accessorKey: 'name', header: 'Name', editable: true },
{ accessorKey: 'email', header: 'Email', type: 'email' },
]
const data = [
{ name: 'John Doe', email: '[email protected]' },
{ name: 'Jane Smith', email: '[email protected]' },
]
// ✅ Table generates IDs automatically: '0', '1', '2', etc.
// ⚠️ onCellUpdate will receive rowId='0' for first row
// ⚠️ NOT recommended if you need to sync with backendBackend Persistence
When you edit a cell, onCellUpdate receives the row ID:
const handleCellUpdate = async (rowId: string, colId: string, value: string) => {
// rowId is the value of the field marked as ID
// If using 'id' → rowId will be '123', '456', etc.
// If using 'userId' with isRowId: true → rowId will be 'usr_123', 'usr_456', etc.
// If no ID → rowId will be '0', '1', '2', etc. (index)
await fetch(`/api/items/${rowId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [colId]: value })
})
// Update local state
setData(prev => prev.map(item =>
item.id === Number(rowId) ? { ...item, [colId]: value } : item
))
}Best Practices
| Scenario | Recommendation |
|----------|----------------|
| API/Database data | ✅ Include id field in data and columns |
| Different identifier | ✅ Use isRowId: true on corresponding column |
| Static data without ID | ⚠️ Add sequential id before passing to table |
| Multiple tables | ✅ Each table can have its own ID field |
Column Types
| Type | Description | Example |
|------|-------------|---------|
| text | Plain text (default) | "Hello world" |
| numeric | Formatted number | 1234.56 |
| badge | Colored label | "Active" |
| country | Flag + name | "Argentina" |
| date | Date | "2024-01-15" |
| datetime | Date and time | "2024-01-15T10:30" |
| link | Clickable URL | "https://..." |
| email | Email with mailto | "[email protected]" |
| phone | Phone with tel: | "+1 234 5678" |
| boolean | Visual indicator | true / false |
| rating | Stars (1-5) | 4 |
| progress | Progress bar | 75 |
| heatmap | Color by value | 50 |
| sparkline | Mini chart | [10, 20, 15] |
| avatar | Circular image | "https://...jpg" |
Edit Types
| EditType | Description |
|----------|-------------|
| text | Free text (textarea) |
| numeric | Numbers only with min/max |
| select | Dropdown with search |
| date | Date picker |
| boolean | Visual toggle |
Supported Countries
67 countries with flags organized by region:
Americas (22)
Argentina, Bolivia, Brazil, Canada, Chile, Colombia, Costa Rica, Cuba, Ecuador, El Salvador, United States, Guatemala, Honduras, Mexico, Nicaragua, Panama, Paraguay, Peru, Puerto Rico, Dominican Republic, Uruguay, Venezuela
Europe (22)
Germany, Austria, Belgium, Denmark, Spain, Finland, France, Greece, Hungary, Ireland, Italy, Norway, Netherlands, Poland, Portugal, United Kingdom, Czech Republic, Romania, Russia, Sweden, Switzerland, Ukraine
Asia (17)
Saudi Arabia, China, South Korea, United Arab Emirates, Philippines, Hong Kong, India, Indonesia, Israel, Japan, Malaysia, Pakistan, Singapore, Thailand, Taiwan, Turkey, Vietnam
Africa and Oceania (6)
Australia, Egypt, Morocco, Nigeria, New Zealand, South Africa
{
accessorKey: 'country',
header: 'Country',
type: 'country',
editable: true,
editType: 'select',
options: [
{ value: 'Argentina', label: 'Argentina' },
{ value: 'Mexico', label: 'Mexico' },
{ value: 'Spain', label: 'Spain' },
// ... use exactly these names
]
}Practical Examples
Static Data (JSON)
'use client'
import CustomTable from '@talberos/custom-table'
import type { ColumnDef } from '@talberos/custom-table'
const products = [
{ id: 1, name: 'Laptop', price: 999.99, stock: 15, status: 'Available' },
{ id: 2, name: 'Mouse', price: 29.99, stock: 150, status: 'Available' },
{ id: 3, name: 'Keyboard', price: 79.99, stock: 0, status: 'Out of Stock' },
]
const columns: ColumnDef[] = [
{ accessorKey: 'id', header: 'ID', width: 60 },
{ accessorKey: 'name', header: 'Product', editable: true },
{ accessorKey: 'price', header: 'Price', type: 'numeric', precision: 2 },
{ accessorKey: 'stock', header: 'Stock', type: 'numeric' },
{
accessorKey: 'status',
header: 'Status',
type: 'badge',
badgeColors: {
'Available': { bg: '#D1FAE5', text: '#059669' },
'Out of Stock': { bg: '#FEE2E2', text: '#DC2626' },
}
},
]
export default function ProductsTable() {
return (
<CustomTable
data={products}
columnsDef={columns}
containerHeight="600px"
rowHeight={32}
/>
)
}Fetch from REST API
'use client'
import { useState, useEffect } from 'react'
import CustomTable from '@talberos/custom-table'
import type { ColumnDef } from '@talberos/custom-table'
const columns: ColumnDef[] = [
{ accessorKey: 'id', header: 'ID', width: 60 },
{ accessorKey: 'name', header: 'Name', editable: true },
{ accessorKey: 'email', header: 'Email', type: 'email' },
{ accessorKey: 'phone', header: 'Phone', type: 'phone' },
]
export default function UsersTable() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/users')
.then(res => res.json())
.then(data => {
setUsers(data)
setLoading(false)
})
}, [])
const handleCellUpdate = async (rowId: string, colId: string, value: string) => {
await fetch(`/api/users/${rowId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [colId]: value })
})
setUsers(prev =>
prev.map(u => u.id === Number(rowId) ? { ...u, [colId]: value } : u)
)
}
if (loading) return <div>Loading...</div>
return (
<CustomTable
data={users}
columnsDef={columns}
onCellUpdate={handleCellUpdate}
containerHeight="500px"
/>
)
}Editing and Persistence
const handleCellUpdate = async (rowId: string, colId: string, value: string) => {
try {
// 1. Save to backend
await fetch(`/api/items/${rowId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [colId]: value })
})
// 2. Update local state
setData(prev => prev.map(item =>
item.id === Number(rowId) ? { ...item, [colId]: value } : item
))
} catch (error) {
console.error('Error:', error)
}
}Dynamic Dropdowns
Basic Select
{
accessorKey: 'status',
header: 'Status',
type: 'badge',
editable: true,
editType: 'select',
options: [
{ value: 'Active', label: 'Active' },
{ value: 'Inactive', label: 'Inactive' },
]
}Select with Dynamic Creation
{
accessorKey: 'category',
header: 'Category',
type: 'badge',
editable: true,
editType: 'select',
options: categories,
allowCreate: true,
onCreateOption: async (newValue) => {
await fetch('/api/categories', { method: 'POST', body: JSON.stringify({ name: newValue }) })
setCategories(prev => [...prev, { value: newValue, label: newValue }])
}
}Context Menu
Right-click on table:
| Option | Description | |--------|-------------| | Copy | Copy selected cells | | Hide column | Hide the column | | Hide row | Hide the row |
Data Export
Default
Table includes automatic CSV export button.
Custom
const handleExportCSV = (data: any[]) => {
// Your custom logic
}
<CustomTable
data={data}
columnsDef={columns}
onExportCSV={handleExportCSV}
/>Customization
Badge Colors
{
accessorKey: 'priority',
header: 'Priority',
type: 'badge',
badgeColors: {
'High': { bg: '#FEE2E2', text: '#DC2626' },
'Medium': { bg: '#FEF3C7', text: '#D97706' },
'Low': { bg: '#D1FAE5', text: '#059669' },
}
}Custom Rendering
{
accessorKey: 'actions',
header: 'Actions',
render: (value, row) => (
<button onClick={() => handleEdit(row.id)}>Edit</button>
)
}Compact Rows
<CustomTable
data={data}
columnsDef={columns}
rowHeight={28}
defaultPageSize={100}
/>Keyboard Shortcuts
| Key | Action |
|-----|--------|
| ↑ ↓ ← → | Navigate between cells |
| Shift + Arrows | Extend selection |
| Ctrl/Cmd + C | Copy cells |
| Enter | Confirm edit |
| Tab | Next cell |
| Escape | Cancel edit |
| Double click | Start editing |
API Reference
import CustomTable, {
type ColumnDef,
type CustomTableProps,
type ColumnType,
type EditType,
type SelectOption,
buildColumns,
THEME_COLORS,
COLUMN_CONFIG,
STYLES_CONFIG,
TableEditContext,
useTableEdit,
} from '@talberos/custom-table'Requirements
- React 18+
- @mui/material 5+
- @tanstack/react-table 8+
Column Filters
Filters are automatically shown below each header according to column type:
| Column Type | Filter Type |
|-------------|-------------|
| text, email, phone, link, country | Text input |
| numeric, rating, progress, heatmap | Min-Max range |
| badge (with options) | Options dropdown |
| boolean | Yes/No dropdown |
| date, datetime | Date picker |
Project Structure
src/
├── index.tsx # Main CustomTable component
├── types.ts # TypeScript types
├── config.ts # Configuration (sizes, styles)
├── CustomTableColumnsConfig.tsx # Column rendering and flags
├── theme/
│ └── colors.ts # Theme colors
├── hooks/
│ ├── useThemeMode.ts # Light/dark theme handling
│ ├── useCustomTableLogic.ts # Export logic
│ └── useCellEditingOrchestration.ts # Edit orchestration
└── TableView/
├── index.tsx # Main table view
├── hooks/
│ ├── useCellSelection.ts # Cell selection and auto-scroll
│ ├── useColumnResize.ts # Resize columns
│ ├── useInlineCellEdit.ts # Inline editing
│ ├── useClipboardCopy.ts # Copy to clipboard
│ └── useTableViewContextMenu.ts # Context menu
├── logic/
│ ├── domUtils.ts # DOM utilities
│ ├── selectionLogic.ts # Selection logic
│ └── dragLogic.ts # Drag logic
└── subcomponents/
├── TableHeader.tsx # Headers and filters
├── TableBody.tsx # Table body
├── Pagination.tsx # Pagination
├── ContextualMenu.tsx # Context menu
├── CustomSelectDropdown.tsx # Custom dropdown
├── LoadingOverlay.tsx # Loading overlay
└── NoResultsOverlay.tsx # No results overlayLicense
MIT
Author
Gabriel Hércules Miguel
- LinkedIn: gabrielherculesmiguel
- GitHub: @gabrielmiguelok
