v1.0

EditableCell

Preview

Basic

Loading…

Preview

Code

ts
import EditableCell from '@/components/table/EditableCell';

src/components/table/EditableCell.tsx

AI prompt

text
Build a click-to-edit table cell component in React + TypeScript + Tailwind CSS: it reads as plain text, hints on hover that it is editable, opens an input over itself without moving anything, and marks unsaved values.

## Look
- Wrapper (`td` or `div`, via `as`): `group relative`, text slate-600 / dark slate-300; when dirty `text-amber-600 dark:text-amber-400 font-medium`. The wrapper owns the text colour, so callers pass only width / padding.
- Inner box: `relative box-border w-full flex items-center gap-2.5 px-2 py-1 rounded text-xs` (`items-start` when `wrap`), dirty adds `bg-slate-100 dark:bg-slate-700/50`.
- Editable affordance: the box always has a 1px bottom border, transparent at rest, `border-dashed border-slate-400 dark:border-slate-500` on hover and while editing; `cursor-pointer`. A 12px Pencil (slate-400 / dark slate-500) sits at the right, `shrink-0`, opacity 0 → 100 on hover.
- Display text: `flex-1 min-w-0 truncate` (no truncation with `wrap`, for chip lists).
- Editing: the input overlays the box (`absolute inset-0 w-full h-full px-2 py-1 rounded text-xs bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 outline-none`), autofocused with its text selected. Number inputs hide the spin buttons (`[appearance:textfield]` + webkit spin-button `appearance-none`).

## No-jump rules (the point of the component)
- Read and edit share one reserved box — same width, padding and bottom border — so opening a cell never resizes its row or column.
- `min-w-0` stops an input's intrinsic width from widening a narrow column.
- The pencil is always rendered, even under the open input; only its opacity changes.
- While editing, the display text stays in layout with `invisible` (not hidden), so the box keeps its size.
- `autoWidth`: the input is in flow instead (`relative -mx-2 -my-1 min-w-0 max-w-full`), sized to `clamp(value.length + 1, 8, 60)ch`, and the display text is hidden. Off by default.

## Behaviour
- Presentational and fully controlled: the parent decides which cell is open, holds the pending value and whether the user may edit. Edits are meant to be batched and saved together, not written per keystroke.
- Clicking the cell calls `onStartEdit` (when not already editing). Enter or blur → `onCommit`; Escape → `onCancel`.
- `editor` replaces the built-in input with a custom control (e.g. a select) in an `absolute inset-0 flex items-center` overlay whose clicks don't bubble back to the cell.
- Tooltip (`title`) "Click to edit", or "Click to edit — unsaved" when dirty; none while editing or when `canEdit` is false (then no pencil or dashed line either).

## API
`editing`, `dirty`, `canEdit`, `display: ReactNode`, `onStartEdit`; built-in input: `value`, `onChange(v)`, `onCommit`, `onCancel`, `inputType` ('text' | 'number' | 'date', default 'text'), `step` (e.g. '0.01'); layout: `alignRight` (right-aligns both modes), `wrap`, `autoWidth`, `className`, `inputClassName`, `as` ('div' | 'td', default 'div'); `editor?: ReactNode`.

## Demo
A one-row table (Person / Amount / Note): "Ada Lovelace", an Amount cell (`as="td"`, right-aligned, number, step 0.01) showing 1200, and a Note cell "Q3 bonus". Only one cell open at a time; commit saves the draft, Escape restores the saved value; an edited-but-uncommitted cell shows amber on grey.

## 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 { Pencil } from 'lucide-react';
import { cn } from '@/lib/cn';

/**
 * The click-to-edit cell.
 *
 * MERGED from two copies. bonus-adjustment's is the base — it added the `as`
 * wrapper, `autoWidth`, `inputClassName` and the slate palette. Four props came
 * back across from marketing-stats, because without them a numeric or wrapping
 * column cannot be expressed at all: `inputType`, `step`, `alignRight` and
 * `wrap`. The number-input path also suppresses the spin buttons, which is
 * marketing-stats' behaviour — a stepper inside a table cell is a mis-click
 * waiting to happen.
 *
 * At rest it looks like plain text. On hover a DASHED bottom line appears under
 * the cell and a pencil fades in at the right, spaced away from the text — a
 * quiet "this is editable" hint that costs nothing when you aren't looking for
 * it. Click anywhere in the cell to edit; Enter or blur commits, Escape cancels.
 *
 * Presentational only — the parent owns which cell is open, the pending-changes
 * map, and whether the user may edit. Edits are meant to be BATCHED and
 * committed through SaveAllBar, never written per keystroke.
 *
 * The behaviour worth preserving, because each part fixes a specific jump:
 *
 * - Read and edit modes share ONE reserved box — same width, padding and 1px
 *   bottom border (transparent at rest). Entering or leaving edit never resizes
 *   the cell, so no row or column shifts.
 * - `min-w-0` on that box is what stops a narrow column widening when its cell
 *   becomes an <input>: an input's intrinsic preferred width would otherwise
 *   push the column out. With no minimum, the read cells alone dictate width in
 *   both modes.
 * - The pencil is always rendered, even mid-edit under the overlay, so its
 *   reserved width never changes.
 * - While editing, the display text stays in layout but hidden — the box keeps
 *   its size, and the dashed line stays put between hover and edit.
 * - A dirty cell gets a tinted background and amber text, so unsaved changes are
 *   visible before Save All.
 */
export default function EditableCell({
  editing,
  dirty,
  canEdit,
  value = '',
  display,
  onStartEdit,
  onChange,
  onCommit,
  onCancel,
  inputType = 'text',
  step,
  alignRight = false,
  editor,
  wrap = false,
  className = '',
  autoWidth = false,
  inputClassName = '',
  as: Wrapper = 'div',
}: {
  editing: boolean;
  dirty: boolean;
  canEdit: boolean;
  /** Read-mode content. Also the at-rest content when a custom `editor` is used. */
  display: React.ReactNode;
  onStartEdit: () => void;
  /** Built-in text input path — required unless `editor` is supplied. */
  value?: string;
  onChange?: (v: string) => void;
  onCommit?: () => void;
  onCancel?: () => void;
  /** `text` | `number` | `date` — the built-in input's type. */
  inputType?: string;
  /** Step for `inputType="number"`, e.g. '0.01' for a currency column. */
  step?: string;
  /** Right-align both modes. Numeric columns need this to stay readable. */
  alignRight?: boolean;
  /**
   * Custom control (a select, say). Rendered while editing INSTEAD of the
   * built-in input, overlaying the read box. The parent wires its value, change
   * and close behaviour; this component only supplies the affordance and the
   * no-jump overlay.
   */
  editor?: React.ReactNode;
  /**
   * Let the read display WRAP instead of truncating to one line — for a cell
   * holding a chip list rather than a value. Top-aligns the box so multi-line
   * content and the pencil still line up.
   */
  wrap?: boolean;
  /** Per-cell width/padding. Do NOT set a text colour — this owns it, so the
   *  dirty state can override cleanly. */
  className?: string;
  /**
   * Size the input to what is typed instead of filling the cell.
   *
   * OFF by default: the overlay input is what stops a fixed-width column
   * shifting when a cell opens, and in a grid of aligned columns that matters
   * more than a snug box. Worth turning on where the cell is the widest thing
   * in its column and a full-width input reads as a text area.
   */
  autoWidth?: boolean;
  /** Extra classes for the built-in input — usually to match `display`'s font. */
  inputClassName?: string;
  as?: 'td' | 'div';
}) {
  return (
    <Wrapper
      className={cn(
        'group/edit relative',
        className,
        alignRight && 'text-right',
        dirty
          ? 'text-amber-600 dark:text-amber-400 font-medium'
          : 'text-slate-600 dark:text-slate-300',
      )}
      onClick={() => {
        if (!editing) onStartEdit();
      }}
      title={canEdit && !editing ? (dirty ? 'Click to edit — unsaved' : 'Click to edit') : undefined}
    >
      <div
        className={cn(
          'box-border relative w-full flex gap-2.5 px-2 py-1 rounded text-xs align-middle',
          wrap ? 'items-start' : 'items-center',
          alignRight && 'text-right',
          dirty && 'bg-slate-100 dark:bg-slate-700/50',
          canEdit &&
            (editing
              ? 'cursor-pointer border-b border-dashed border-slate-400 dark:border-slate-500'
              : 'cursor-pointer border-b border-transparent group-hover/edit:border-dashed group-hover/edit:border-slate-400 dark:group-hover/edit:border-slate-500'),
        )}
      >
        {/* `invisible` not `hidden`: the read text keeps reserving its width so
            the column cannot shift when the overlay opens. With `autoWidth`
            there is no overlay and the input owns the width, so it goes. */}
        <span
          className={cn(
            'flex-1 min-w-0',
            wrap ? '' : 'truncate',
            editing && (autoWidth ? 'hidden' : 'invisible'),
          )}
        >
          {display}
        </span>

        {canEdit && (
          // Always rendered — even mid-edit, under the overlay — so the width it
          // reserves never changes and the column can't shift when a cell opens.
          // Only its opacity animates.
          <Pencil
            size={12}
            aria-hidden
            className="shrink-0 text-slate-400 dark:text-slate-500 opacity-0 group-hover/edit:opacity-100 transition-opacity"
          />
        )}

        {editing && editor && (
          <div
            className="absolute inset-0 flex items-center"
            onClick={(e) => e.stopPropagation()}
          >
            {editor}
          </div>
        )}

        {editing && !editor && (
          <input
            type={inputType}
            step={step}
            autoFocus
            value={value}
            onChange={(e) => onChange?.(e.target.value)}
            onFocus={(e) => e.currentTarget.select()}
            onBlur={() => onCommit?.()}
            onKeyDown={(e) => {
              if (e.key === 'Enter') {
                e.preventDefault();
                onCommit?.();
              }
              if (e.key === 'Escape') {
                e.preventDefault();
                onCancel?.();
              }
            }}
            // `ch` is relative to the INPUT's own font, so the width tracks
            // whatever `inputClassName` sets. +1 leaves room for the caret at
            // the end; the floor stops an emptied field collapsing to nothing
            // and the ceiling stops a long paste escaping the cell.
            style={
              autoWidth
                ? { width: `${Math.min(Math.max(value.length + 1, 8), 60)}ch` }
                : undefined
            }
            className={cn(
              'box-border px-2 py-1 rounded text-xs bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 outline-none focus:outline-none',
              alignRight && 'text-right',
              // No stepper: a spin button inside a table cell is a mis-click
              // waiting to happen, and it eats the space the value needs.
              inputType === 'number' &&
                '[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
              autoWidth
                ? 'relative -mx-2 -my-1 min-w-0 max-w-full'
                : 'absolute inset-0 w-full h-full',
              inputClassName,
            )}
          />
        )}
      </div>
    </Wrapper>
  );
}

Props

PropTypeDefaultDescription
editing*boolean—
dirty*boolean—
canEdit*boolean—
display*React.ReactNode—Read-mode content. Also the at-rest content when a custom `editor` is used.
onStartEdit*() => void—
valuestring''Built-in text input path — required unless `editor` is supplied.
onChange(v: string) => void—
onCommit() => void—
onCancel() => void—
inputTypestring'text'`text` | `number` | `date` — the built-in input's type.
stepstring—Step for `inputType="number"`, e.g. '0.01' for a currency column.
alignRightbooleanfalseRight-align both modes. Numeric columns need this to stay readable.
editorReact.ReactNode—Custom control (a select, say). Rendered while editing INSTEAD of the built-in input, overlaying the read box. The parent wires its value, change and close behaviour; this component only supplies the affordance and the no-jump overlay.
wrapbooleanfalseLet the read display WRAP instead of truncating to one line — for a cell holding a chip list rather than a value. Top-aligns the box so multi-line content and the pencil still line up.
classNamestring''Per-cell width/padding. Do NOT set a text colour — this owns it, so the dirty state can override cleanly.
autoWidthbooleanfalseSize the input to what is typed instead of filling the cell. OFF by default: the overlay input is what stops a fixed-width column shifting when a cell opens, and in a grid of aligned columns that matters more than a snug box. Worth turning on where the cell is the widest thing in its column and a full-width input reads as a text area.
inputClassNamestring''Extra classes for the built-in input — usually to match `display`'s font.
as'td' | 'div''div'