BaseTable
Preview
Basic
Loading…
Preview
Code
ts
import BaseTable from '@/components/table/BaseTable';src/components/table/BaseTable.tsx
AI prompt
text
Build a saved-views data table component in React + TypeScript + Tailwind CSS: a bar of view tabs above an Airtable-style grid. Each tab is a saved view, shown as either a grid or a board.
## Structure
- A view tab bar with `mb-3`, then the grid showing the active view. Build these two as separate components:
- **View tabs**: one tab per view with its mode icon (Table2 for a grid, SquareKanban for a board). Active tab: white background, indigo text and an indigo underline. It has a caret menu (Rename, Duplicate, Delete view), double-click to rename in place, drag to reorder, and "+" to add a grid or a board.
- **Grid**: generic over the row type. A view bar (search, Fields, Filter, Group, Sort, conditional Colour) over a sticky-header table. The table has frozen columns, pinned rows, collapsible group bands, resizable and draggable headers, in-place cell editing, a record drawer with History and Log tabs, and a board mode with lanes. Its whole configuration is one controlled object, `GridView`: `{ mode?: 'grid'|'board', boardBy?, conditions, match: 'all'|'any', groups, sorts, colors, fields?: {order, hidden, labels}, frozen?, frozenEnd?, pinnedRows?, widths? }`.
## Behaviour
- `SavedView = { id, name, view: GridView }`. The default is a single view named "Grid" with an empty view.
- The active view's `GridView` is passed to the grid as controlled state. Every grid change (a filter, a column width, a pinned row) is written back into that view only.
- Create: a new id, named "Grid" or "Board" after the mode. If the name is taken, it gets a number: "Grid 2", "Grid 3". The new tab becomes active and opens straight into rename.
- Duplicate: a deep clone of the view, named "<name> copy" (numbered the same way), inserted right after the source and made active.
- Delete: never the last view. Deleting the active view activates the one before it.
- Rename and reorder come back from the tab bar.
- Controlled when `views` and `onViewsChange` are both passed, so the caller can store views on a server, per user or shared. Otherwise the views are kept internally and, with `storageKey`, saved to localStorage as `{ views, active }` (including which tab is active). Read storage once after mount, never during render, to avoid a hydration mismatch. Wrap reads and writes in try/catch: if storage is blocked or corrupt, the defaults are used.
## API
- `columns`, `rows`, `getRowId`, `onRowChange(next, prev)`, `editOn?: 'click'|'doubleClick'`
- `onFieldAdd`, `onFieldChange`, `onFieldDelete`
- `views`, `onViewsChange`, `defaultViews`, `storageKey`
- `history`, `onHistoryAdd`, `actor`, `noun`, `toolbarEnd`, `maxHeight`, `className`
- Everything except the views is passed straight to the grid.
## Demo
A task tracker (Task, Status, Priority, Team, Owner, Tags, Estimate, Due, Billable) with three starting views:
- "All tasks": empty view.
- "Open by team": Status is not Done, grouped by Team, sorted by Due ascending, Urgent rows tinted rose, two frozen columns.
- "Status board": a board stacked by Status, sorted by Priority descending.
Rows and history are kept in state, and views persist under one storage key.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { FieldDef } from '@/lib/fields';
import BaseGrid, { EMPTY_VIEW, type GridColumn, type GridView, type HistoryEntry } from './BaseGrid';
import ViewTabs, { type ViewMode } from './ViewTabs';
/** A saved view: a name and everything the grid needs to show it. */
export type SavedView = { id: string; name: string; view: GridView };
const newId = () => `v${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
export const DEFAULT_VIEWS: SavedView[] = [{ id: 'grid', name: 'Grid', view: EMPTY_VIEW }];
/**
* THE WHOLE TABLE — saved views over a `BaseGrid`, as one component. This is
* the unit to lift into another project: pass your columns and rows, answer
* the edit callbacks, and every Lark Base feature comes with it —
*
* views tabs of saved views, each a grid or a board; add, rename,
* duplicate, delete, drag to reorder
* per view search, fields (show, hide, reorder, add), filter, group,
* sort, conditional colour, frozen columns at either edge,
* pinned rows, column widths, board lane field
* editing in-place cells, a record panel with Details, History and
* Log, drag a card between board lanes
* fields add a column of any of eleven types, edit or delete one
*
* ── Where the views live ────────────────────────────────────────────────────
*
* Controlled when `views` and `onViewsChange` are passed — the caller saves
* them on its server, per user or shared. Otherwise they are kept here, and
* with a `storageKey` also in `localStorage`, so a reader's views survive a
* reload without any backend. Read after mount, never during render: the
* server has no `localStorage`, and seeding from it during render would be a
* hydration mismatch.
*/
export default function BaseTable<T>({
columns,
rows,
getRowId,
onRowChange,
editOn,
onFieldAdd,
onFieldChange,
onFieldDelete,
views: controlledViews,
onViewsChange,
defaultViews = DEFAULT_VIEWS,
storageKey,
history,
onHistoryAdd,
actor,
noun,
toolbarEnd,
maxHeight,
className,
}: {
columns: GridColumn<T>[];
rows: T[];
getRowId: (row: T) => string | number;
onRowChange?: (next: T, prev: T) => void;
/** What opens a cell's editor — see BaseGrid. Default `click`. */
editOn?: 'click' | 'doubleClick';
onFieldAdd?: (field: FieldDef) => void;
onFieldChange?: (key: string, field: FieldDef) => void;
onFieldDelete?: (key: string) => void;
/** Controlled views. Pass with `onViewsChange`, or leave both out. */
views?: SavedView[];
onViewsChange?: (views: SavedView[]) => void;
/** The views a first visit starts with, when uncontrolled. */
defaultViews?: SavedView[];
/** Keep uncontrolled views in `localStorage` under this key. */
storageKey?: string;
history?: HistoryEntry[];
onHistoryAdd?: (entry: HistoryEntry) => void;
actor?: string;
noun?: string;
toolbarEnd?: ReactNode;
maxHeight?: number | string;
className?: string;
}) {
const [innerViews, setInnerViews] = useState<SavedView[]>(defaultViews);
const views = controlledViews ?? innerViews;
const [activeId, setActiveId] = useState(views[0]?.id ?? '');
const loaded = useRef(false);
// Load persisted views once, after mount.
useEffect(() => {
if (controlledViews || !storageKey || loaded.current) return;
loaded.current = true;
try {
const raw = window.localStorage.getItem(storageKey);
const saved = raw ? (JSON.parse(raw) as { views?: SavedView[]; active?: string }) : null;
if (saved?.views?.length) {
setInnerViews(saved.views);
setActiveId(saved.views.some((v) => v.id === saved.active) ? saved.active! : saved.views[0].id);
}
} catch {
// Blocked or corrupt storage: the defaults stand.
}
}, [controlledViews, storageKey]);
const setViews = (next: SavedView[], active = activeId) => {
if (onViewsChange) onViewsChange(next);
if (!controlledViews) {
setInnerViews(next);
if (storageKey) {
try {
window.localStorage.setItem(storageKey, JSON.stringify({ views: next, active }));
} catch {
// Storage is a convenience here; failing to write it loses nothing live.
}
}
}
};
const select = (id: string) => {
setActiveId(id);
if (storageKey && !controlledViews) {
try {
window.localStorage.setItem(storageKey, JSON.stringify({ views, active: id }));
} catch {}
}
};
const active = views.find((v) => v.id === activeId) ?? views[0];
const uniqueName = (base: string) => {
let n = 1;
let name = base;
while (views.some((v) => v.name === name)) name = `${base} ${++n}`;
return name;
};
return (
<div className={className}>
<ViewTabs
views={views.map((v) => ({ id: v.id, name: v.name, mode: v.view.mode ?? 'grid' }))}
activeId={active?.id ?? ''}
onSelect={select}
onCreate={(mode: ViewMode) => {
const id = newId();
const next = [...views, { id, name: uniqueName(mode === 'board' ? 'Board' : 'Grid'), view: { ...EMPTY_VIEW, mode } }];
setViews(next, id);
setActiveId(id);
return id;
}}
onRename={(id, name) => setViews(views.map((v) => (v.id === id ? { ...v, name } : v)))}
onDuplicate={(id) => {
const src = views.find((v) => v.id === id);
if (!src) return;
const copy = { id: newId(), name: uniqueName(`${src.name} copy`), view: structuredClone(src.view) };
const at = views.indexOf(src) + 1;
setViews([...views.slice(0, at), copy, ...views.slice(at)], copy.id);
setActiveId(copy.id);
}}
onDelete={(id) => {
if (views.length <= 1) return;
const at = views.findIndex((v) => v.id === id);
const next = views.filter((v) => v.id !== id);
const nextActive = id === activeId ? next[Math.max(0, at - 1)].id : activeId;
setViews(next, nextActive);
setActiveId(nextActive);
}}
onReorder={(ids) => setViews(ids.map((id) => views.find((v) => v.id === id)!).filter(Boolean))}
className="mb-3"
/>
{active && (
<BaseGrid
columns={columns}
rows={rows}
getRowId={getRowId}
view={active.view}
onViewChange={(view) => setViews(views.map((v) => (v.id === active.id ? { ...v, view } : v)))}
onRowChange={onRowChange}
editOn={editOn}
onFieldAdd={onFieldAdd}
onFieldChange={onFieldChange}
onFieldDelete={onFieldDelete}
history={history}
onHistoryAdd={onHistoryAdd}
actor={actor}
noun={noun}
toolbarEnd={toolbarEnd}
maxHeight={maxHeight}
/>
)}
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
columns* | GridColumn<T>[] | — | |
rows* | T[] | — | |
getRowId* | (row: T) => string | number | — | |
onRowChange | (next: T, prev: T) => void | — | |
editOn | 'click' | 'doubleClick' | — | What opens a cell's editor — see BaseGrid. Default `click`. |
onFieldAdd | (field: FieldDef) => void | — | |
onFieldChange | (key: string, field: FieldDef) => void | — | |
onFieldDelete | (key: string) => void | — | |
views | SavedView[] | — | Controlled views. Pass with `onViewsChange`, or leave both out. |
onViewsChange | (views: SavedView[]) => void | — | |
defaultViews | SavedView[] | — | The views a first visit starts with, when uncontrolled. |
storageKey | string | — | Keep uncontrolled views in `localStorage` under this key. |
history | HistoryEntry[] | — | |
onHistoryAdd | (entry: HistoryEntry) => void | — | |
actor | string | — | |
noun | string | — | |
toolbarEnd | ReactNode | — | |
maxHeight | number | string | — | |
className | string | — |