AG Grid Cell Editors from Inputs
This page describes how to turn a standard Arda input component (text,
typeahead, select, etc.) into an AG Grid cell editor without forking the
component. It generalizes the patterns established by TypeaheadInput and
MultiSelectTypeaheadInput in ux-prototype.
The guiding principle, from the
React Component Design page, is that the input is the
editor. AG Grid’s cell editor contract (value + onValueChange) already
matches a controlled React input. So we don’t build a separate editor — we add
a thin adapter and a cellEditorMode styling branch.
Two pieces
Section titled “Two pieces”1. The cell-editor adapter (factory)
Section titled “1. The cell-editor adapter (factory)”A small factory wraps the input, maps AG Grid’s lifecycle onto the input’s
controlled API, and registers the editor lifecycle hooks via useGridCellEditor.
import { useState, useRef, useCallback } from 'react';import { useGridCellEditor } from 'ag-grid-react';import { MyInput } from './my-input';
export interface MyCellEditorConfig { // Whatever the input needs, fixed per column. lookup?: (search: string) => Promise<Option[]>; placeholder?: string;}
export interface MyCellEditorProps { value: TValue | null; onValueChange: (value: TValue | null) => void; stopEditing: (cancel?: boolean) => void;}
function MyCellEditorInner({ value, onValueChange, stopEditing, config,}: MyCellEditorProps & { config: MyCellEditorConfig }) { const [current, setCurrent] = useState(value ?? defaultValue); const cancelledRef = useRef(false);
useGridCellEditor({ isCancelAfterEnd: () => cancelledRef.current, });
const handleChange = useCallback( (val: TValue) => { setCurrent(val); onValueChange(val); }, [onValueChange], );
// Commit-and-exit: the input calls this when the user is "done" // (Enter / Tab / single-select pick). It maps to AG Grid's stopEditing. const handleCommit = useCallback(() => stopEditing(), [stopEditing]);
return ( <MyInput value={current} onValueChange={handleChange} onCommit={handleCommit} cellEditorMode className="w-full" {...config} /> );}
export function createMyCellEditor(config: MyCellEditorConfig) { function CellEditor(props: MyCellEditorProps) { return <MyCellEditorInner {...props} config={config} />; } CellEditor.displayName = `MyCellEditor(${config.placeholder ?? ''})`; return CellEditor;}Usage in a column definition:
const RoleCellEditor = useMemo( () => createMyCellEditor({ lookup: lookupRoles, placeholder: 'Select roles…' }), [],);
const columnDefs = [ { field: 'roles', headerName: 'Roles', editable: true, cellEditor: RoleCellEditor },];2. The cellEditorMode prop on the input
Section titled “2. The cellEditorMode prop on the input”The input keeps its normal form styling by default and switches to a
grid-friendly presentation when cellEditorMode is set. This is the part with
the non-obvious gotchas — see the contract below.
The cellEditorMode styling contract
Section titled “The cellEditorMode styling contract”When cellEditorMode is true, the input must fit inside an AG Grid cell. AG
Grid already draws the editing affordance (a border around the cell, themed with
--ag-accent-color) and positions the editor over the cell. The input therefore
has to defer to the cell rather than draw competing chrome.
| Concern | Form mode | cellEditorMode | Why |
|---|---|---|---|
| Background | bg-background | bg-background (opaque) | A transparent background lets the read-mode cell renderer bleed through behind the editor. |
| Border / radius | border border-input rounded-md | border-0 rounded-none | The AG Grid cell supplies the edit border. A second inner border reads as a stray ring inside the cell. |
| Focus ring | focus-visible:ring-2 | focus-visible:ring-0 (and focus-within:ring-0 for token inputs) | The cell border is the focus affordance; our ring would double it. |
| Height | natural (h-9) | h-full | Fill the cell exactly so the hit area matches. |
| Vertical padding | py-1 | py-0 | Combined with h-full, this centers content at the same vertical offset as the read renderer, avoiding a 1–2px jump on entering edit mode. |
| Dropdown / popup | inline | portaled (Radix Popover) | Cell containers clip overflow; a portal lets the dropdown escape. |
Auto-focus on mount
Section titled “Auto-focus on mount”A cell editor should be ready to type immediately. In cellEditorMode, focus
the input on mount so the dropdown opens without an extra click:
React.useEffect(() => { if (cellEditorMode) inputRef.current?.focus();}, [cellEditorMode]);Commit and exit semantics
Section titled “Commit and exit semantics”onCommit— the input fires this when the user finishes (Enter, Tab, or a single-select pick). The adapter maps it tostopEditing().- Blur — accept the typed/selected value rather than reverting (a cell edit is a deliberate action). Reverting is the right default only in form mode.
- Escape — handled by AG Grid to cancel; expose
isCancelAfterEndviauseGridCellEditorif the input needs to veto a commit.
Inline editing vs cellEditorPopup
Section titled “Inline editing vs cellEditorPopup”Prefer inline editing. An inline editor fills the cell and keeps the read and edit states visually identical (same height, same content offset).
Reach for cellEditorPopup: true only when the editor must grow beyond the
cell bounds while editing — e.g. a multi-line memo, or a token input that
expands to show every selected token. A popup floats with its own rounded
corners and shadow, so it deliberately looks detached from the cell. Using it
for a simple single-line input makes the editor look like a misaligned floating
box.
Note that the dropdown of a typeahead does not require popup mode — the dropdown is already portaled via Radix Popover, so it escapes cell clipping on its own.
Where the boundary sits — input vs grid
Section titled “Where the boundary sits — input vs grid”A recurring question is whether a visual issue belongs to the input or the grid. The rule of thumb:
- The grid owns the cell box: the edit border, the cell background, vertical
centering of read content, row height. Theme-level concerns
(
--ag-accent-color, row height) are configured once on the grid. - The input owns what’s inside the box: its own borders/rings (which it must
drop in
cellEditorMode), background opacity, content padding, and the dropdown.
If every cell editor shows the same treatment (e.g. the orange edit border), it
is a grid theme concern. If only one editor misbehaves (bleed-through, an inner
ring, a vertical shift), it is that input’s cellEditorMode styling.
Checklist
Section titled “Checklist”When adding a cell editor to an input:
-
cellEditorModeprop added; default styling unchanged. - Opaque background; no inner border, radius, or focus ring in cell mode.
-
h-full py-0so edit content aligns with the read renderer (no vertical jump). - Auto-focus on mount.
- Dropdown/overlay portaled to escape cell overflow.
-
createXCellEditorfactory withuseGridCellEditorandonCommit → stopEditing. - Inline editing unless the editor must grow beyond the cell (
cellEditorPopup). - Storybook
InGridstory renders the canaryDataGrid(not a rawAgGridReact). - Unit tests cover keyboard commit, blur-accept, and cancel; layout-dependent sizing is verified in the story / VRT (jsdom has no layout).
Related
Section titled “Related”- React Component Design — the base component conventions.
- Frontend Development — broader frontend workflow.
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved