@talberos/custom-table
Version:
Advanced Excel-style table component for React with inline editing, cell selection, filters and more
688 lines (545 loc) • 19.1 kB
Markdown
# @talberos/custom-table
Advanced Excel-style table component for React with inline editing, cell selection, filters, sorting and more.
[](https://www.npmjs.com/package/@talberos/custom-table)
[](https://opensource.org/licenses/MIT)
## Table of Contents
- [Features](#features)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Component Props](#component-props)
- [Column Definition](#column-definition)
- [Row Identifiers](#row-identifiers)
- [Column Types](#column-types)
- [Edit Types](#edit-types)
- [Supported Countries](#supported-countries)
- [Practical Examples](#practical-examples)
- [Editing and Persistence](#editing-and-persistence)
- [Dynamic Dropdowns](#dynamic-dropdowns)
- [Context Menu](#context-menu)
- [Data Export](#data-export)
- [Customization](#customization)
- [Keyboard Shortcuts](#keyboard-shortcuts)
- [API Reference](#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
```bash
npm install @talberos/custom-table
```
### Peer Dependencies
```bash
npm install react react-dom @mui/material @emotion/react @emotion/styled @tanstack/react-table
```
## Quick Start
```tsx
'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: 'john@mail.com', status: 'Active' },
{ id: 2, name: 'Jane Smith', email: 'jane@mail.com', 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
```typescript
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:
1. **Explicitly marked column** with `isRowId: true`
2. **Column with `accessorKey: 'id'`** (automatic detection)
3. **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:
```tsx
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: 'john@mail.com' },
{ id: 456, name: 'Jane Smith', email: 'jane@mail.com' },
]
// ✅ 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.:
```tsx
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:
```tsx
const columns: ColumnDef[] = [
{ accessorKey: 'name', header: 'Name', editable: true },
{ accessorKey: 'email', header: 'Email', type: 'email' },
]
const data = [
{ name: 'John Doe', email: 'john@mail.com' },
{ name: 'Jane Smith', email: 'jane@mail.com' },
]
// ✅ Table generates IDs automatically: '0', '1', '2', etc.
// ⚠️ onCellUpdate will receive rowId='0' for first row
// ⚠️ NOT recommended if you need to sync with backend
```
### Backend Persistence
When you edit a cell, `onCellUpdate` receives the row ID:
```tsx
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@mail.com"` |
| `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
```tsx
{
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)
```tsx
'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
```tsx
'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
```tsx
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
```tsx
{
accessorKey: 'status',
header: 'Status',
type: 'badge',
editable: true,
editType: 'select',
options: [
{ value: 'Active', label: 'Active' },
{ value: 'Inactive', label: 'Inactive' },
]
}
```
### Select with Dynamic Creation
```tsx
{
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
```tsx
const handleExportCSV = (data: any[]) => {
// Your custom logic
}
<CustomTable
data={data}
columnsDef={columns}
onExportCSV={handleExportCSV}
/>
```
## Customization
### Badge Colors
```tsx
{
accessorKey: 'priority',
header: 'Priority',
type: 'badge',
badgeColors: {
'High': { bg: '#FEE2E2', text: '#DC2626' },
'Medium': { bg: '#FEF3C7', text: '#D97706' },
'Low': { bg: '#D1FAE5', text: '#059669' },
}
}
```
### Custom Rendering
```tsx
{
accessorKey: 'actions',
header: 'Actions',
render: (value, row) => (
<button onClick={() => handleEdit(row.id)}>Edit</button>
)
}
```
### Compact Rows
```tsx
<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
```tsx
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 overlay
```
## License
MIT
## Author
**Gabriel Hércules Miguel**
- LinkedIn: [gabrielherculesmiguel](https://linkedin.com/in/gabrielherculesmiguel)
- GitHub: [@gabrielmiguelok](https://github.com/gabrielmiguelok)
## Repository
[https://github.com/gabrielmiguelok/customtable](https://github.com/gabrielmiguelok/customtable)