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
| Prop | Type | Default | Description |
|---|---|---|---|
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 | — | |
value | string | '' | Built-in text input path — required unless `editor` is supplied. |
onChange | (v: string) => void | — | |
onCommit | () => void | — | |
onCancel | () => void | — | |
inputType | string | 'text' | `text` | `number` | `date` — the built-in input's type. |
step | string | — | Step for `inputType="number"`, e.g. '0.01' for a currency column. |
alignRight | boolean | false | Right-align both modes. Numeric columns need this to stay readable. |
editor | React.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. |
wrap | boolean | false | 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. |
className | string | '' | Per-cell width/padding. Do NOT set a text colour — this owns it, so the dirty state can override cleanly. |
autoWidth | boolean | false | 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. |
inputClassName | string | '' | Extra classes for the built-in input — usually to match `display`'s font. |
as | 'td' | 'div' | 'div' |