v1.0

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

PropTypeDefaultDescription
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—
viewsSavedView[]—Controlled views. Pass with `onViewsChange`, or leave both out.
onViewsChange(views: SavedView[]) => void—
defaultViewsSavedView[]—The views a first visit starts with, when uncontrolled.
storageKeystring—Keep uncontrolled views in `localStorage` under this key.
historyHistoryEntry[]—
onHistoryAdd(entry: HistoryEntry) => void—
actorstring—
nounstring—
toolbarEndReactNode—
maxHeightnumber | string—
classNamestring—