TableRow.tsx27.6 KBView on GitHub 'use client';
/**
* One grid row, and one grid cell.
*
* `TableRowView` is memoized and every prop it takes is stable across cell writes — the
* row's Y.Map, the column array, its index and its virtualized offset. The cell VALUES
* arrive through `useYRowCells`, a subscription to this row's own Y.Map, so an agent
* filling row 400 re-renders row 400 and nothing else. Breaking either half of that
* (an unstable prop, or lifting cell values into the parent) silently turns every
* keystroke into a full-grid render. Selection is held to the same bar: it lives in
* `cell-selection.ts`, and each CELL subscribes to its own bitmask, so moving the cursor
* re-renders two cells rather than a row — let alone a grid.
*
* `BlankRowView` is the trailing typing affordance: it holds no rowId, exists nowhere in
* the Y.Doc or the markdown mirror, and materializes a real row when a cell in it commits.
*
* ── Select, then edit ──
* A cell is a DISPLAY until the selection opens it (`CellDisplay`), and only then is that
* column type's editor mounted in its place. This is what makes the ordinary spreadsheet
* gestures possible at all: a single click selects, shift-click and drag extend, and a
* second click / Enter / typing opens the cell. When the display was itself an `<input>`
* — as it was before — a click could only ever mean "put a caret here", so there was no
* gesture left over for selecting.
*
* Where the editor appears depends on the column's WRAP mode, and only on that:
* - wrap → in place. The value was fully visible already; a panel would be a jump
* for nothing.
* - overflow → in a `CellPopover` layered over the grid, because the whole reason to open
* a clipped cell is to see the part that did not fit.
*
* ── A bound cell edits its SOURCE ──
* A derived cell cannot be typed over — the next recompute would discard it silently — so it
* is either read-only or it writes THROUGH the binding to the deal it came from. Which one it
* is comes from `resolveBoundWrite` (see `bound-writes.ts`): a conversation field Cedar can
* write gives a target, and then the cell opens the editor that field deserves (a stage picker,
* a date picker — `BoundCellEditors`), commits into the Y.Doc for the paint, and sends the same
* mutation the deal header would. Everything else — an enrichment field, a row that references
* no deal — stays muted and inert, and says why on hover.
*/
import { memo, useCallback, useRef, useState } from 'react';
import { GripVertical, ListPlus, MoreVertical, Rows3, Trash2 } from 'lucide-react';
import {
columnWrap,
isSectionCells,
RESERVED_COLUMN,
TABLE_CELL_WRAP,
TABLE_COLUMN_TYPE,
type TableColumn,
} from '@zero/server/table';
import type { TableRowMap } from '@zero/server/table/ydoc';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { boundCellTitle, type BoundWriteTarget } from './bound-writes';
import { boundEditorFor } from './BoundCellEditors';
import { CellDisplay } from './CellDisplay';
import { CellEditor } from './CellEditor';
import { CellPopover } from './CellPopover';
import type { BoundCellWriter } from './use-bound-cell-write';
import {
CELL_SHELL_CLASS,
CELL_CONTENT_CLASS,
CELL_WRAP_CLASS,
DEFAULT_COLUMN_WIDTH,
GUTTER_WIDTH,
ROW_HEIGHT,
} from './constants';
import {
CELL_STATE,
cellDomId,
selectionBoxShadow,
useCellSelectionStore,
useCellState,
useRowHasEditingCell,
type EditExit,
} from './cell-selection';
import { RowStatusChip } from './RowStatusChip';
import { typedCellFor } from './TypedCells';
import { useYRowCells } from './useYTable';
/** What "Create task for this row" needs, gathered from the row that has the cells. */
export interface CreateTaskForRowInput {
rowId: string;
/** The row's `titleColumn` value — the task's description. */
title: string;
/** First `task`-typed column, where the `[[task: id]]` token lands. Null if none exists. */
taskColumnKey=[redacted] | null;
}
export interface TableRowViewProps {
rowId: string;
/** This row's Y.Map. Stable for the row's lifetime, which is what memoization needs. */
rowMap: TableRowMap;
columns: TableColumn[];
/** Zero-based position in the RENDERED list — the selection's coordinate, and the drag payload. */
index: number;
/**
* The number shown in the gutter, counting RECORDS only.
*
* Not `index + 1`: a heading occupies an index but is not a row of data, so numbering by
* index would make the third record "row 4" and the count in the toolbar disagree with the
* last number in the gutter. Computed by the grid, which is the only thing holding the whole
* list. Ignored for a heading, which shows no number at all.
*/
rowNumber: number;
/** Absolute offset handed down by the virtualizer. */
top: number;
/** `schema.titleColumn` — a string, so it costs the memoization nothing. */
titleColumn: string;
/** Prefix for cell DOM ids, so two grids on one page cannot collide. */
gridId: string;
/**
* `virtualizer.measureElement`. A wrapping row's height is its CONTENT's, so the row is
* measured rather than told — see `TableGrid`. Stable for the virtualizer's lifetime, which
* is what keeps it out of the memoization's way.
*/
measureRef: (element: HTMLElement | null) => void;
onCommitCell: (rowId: string, columnKey=[redacted], value: string) => void;
onDeleteRow: (rowId: string) => void;
onDragStartRow: (index: number) => void;
onDropRow: (index: number) => void;
/**
* False while the grid is showing a SORTED view.
*
* A drag writes a position in the Y.Array, and under a sort the position dropped on is a
* position in the view — so the row would move somewhere the user did not point at and then
* snap back to wherever the comparator puts it. A primitive, so the row memoization is
* unaffected. See `TableGrid`'s `sorted`.
*/
reorderable: boolean;
/**
* Insert a heading above or below this row. Absent while the grid is SORTED, for the same
* reason `reorderable` is false there: a section is document-order structure and the rendered
* order is not document order, so "above this row" would place it somewhere the user did not
* point at.
*/
onInsertSection?: (rowId: string, where: 'above' | 'below') => void;
onCreateTaskForRow: (input: CreateTaskForRowInput) => void;
/**
* Where a bound column's edit goes for THIS row, or null if nowhere.
*
* Resolved per row rather than per column because the ref a binding reads through lives in
* a different cell of the same row — so the answer differs between two rows of one column,
* and the cell alone cannot work it out. Stable identity, from the grid.
*/
resolveBoundWrite: (column: TableColumn, cells: Record<string, string>) => BoundWriteTarget | null;
onWriteBound: BoundCellWriter;
}
function TableRowBody({
rowId,
rowMap,
columns,
index,
rowNumber,
top,
titleColumn,
gridId,
measureRef,
onCommitCell,
onDeleteRow,
onDragStartRow,
onDropRow,
reorderable,
onInsertSection,
onCreateTaskForRow,
resolveBoundWrite,
onWriteBound,
}: TableRowViewProps) {
const cells = useYRowCells(rowMap);
const [isDropTarget, setIsDropTarget] = useState(false);
// Rows are absolutely-positioned siblings, so a later row paints over an earlier one's
// popover editor. Lifting the editing row is the whole fix, and it costs one boolean
// subscription per visible row.
const hasEditingCell = useRowHasEditingCell(index);
const status = cells[RESERVED_COLUMN.STATUS] ?? '';
/**
* A heading rather than a record — see `RESERVED_COLUMN.SECTION`.
*
* A heading is rendered defensively: a hand-edited markdown mirror can produce a row that has
* BOTH a `_section` and column values (the mirror path is the whole-table hammer and runs no
* write guard). The heading wins, because that is what `isSectionRow` tells every other reader
* — the sort's segmenter, the fan-out selector — and a grid that disagreed with them about
* what a row is would be the worst of the three to debug.
*/
const isSection = isSectionCells(cells);
// Adapts the cell's `(columnKey, value, rowId)` order to the row-level callback's. Stable
// because the row itself is memoized and this must not be the prop that breaks it.
const commitCell = useCallback(
(columnKey=[redacted], value: string, cellRowId: string) =>
onCommitCell(cellRowId, columnKey, value),
[onCommitCell],
);
return (
<div
ref={measureRef}
data-index={index}
data-row-id={rowId}
// `role="row"` because the scroll container declares `role="grid"`. Without it a screen
// reader announced a grid owning zero rows and read the cells as unrelated loose
// textboxes. `aria-rowindex` is 1-based and counts the header, so it is index + 2 — that
// is what lets a virtualized window be distinguishable from the whole table.
role="row"
aria-rowindex={index + 2}
className={cn(
'group absolute left-0 flex border-b border-border',
isDropTarget && 'border-b-2 border-b-primary',
)}
style={{ top, minHeight: ROW_HEIGHT, zIndex: hasEditingCell ? 20 : undefined }}
onDragOver={(event) => {
if (!reorderable) return;
event.preventDefault();
setIsDropTarget(true);
}}
onDragLeave={() => setIsDropTarget(false)}
onDrop={(event) => {
if (!reorderable) return;
event.preventDefault();
setIsDropTarget(false);
onDropRow(index);
}}
>
<div
draggable={reorderable}
// The gutter IS column 1 — `aria-colcount` counts it, and the data cells start at index
// 2 — so it needs the role. Without it the row declared a cell at index 1 that did not
// exist, and a `role="row"` had a non-cell child, which assistive tech skips or reports
// inconsistently.
role="gridcell"
aria-colindex={1}
onDragStart={() => onDragStartRow(index)}
className={cn(
'flex shrink-0 items-start justify-between gap-1 border-r border-border py-[7px] pl-2 pr-1 text-xs leading-5 text-muted-foreground',
reorderable && 'cursor-grab',
)}
style={{ width: GUTTER_WIDTH }}
title={reorderable ? 'Drag to reorder' : 'Clear the sort to reorder rows by hand'}
>
{isSection ? (
// No row NUMBER on a heading: the numbers count records, and a label sitting in the
// sequence would make row 4 the third record. No status chip either — a heading has
// no fan-out state, because it is never a fan-out target.
<Rows3 className="size-3 shrink-0 opacity-60" aria-hidden />
) : status ? (
<RowStatusChip
status={status}
error={cells[RESERVED_COLUMN.ERROR]}
className={cn(reorderable && 'group-hover:hidden')}
/>
) : (
<span className={cn(reorderable && 'group-hover:hidden')}>{rowNumber}</span>
)}
{reorderable && !isSection && (
<GripVertical className="hidden size-3 shrink-0 group-hover:block" />
)}
<RowMenu
rowNumber={rowNumber}
onInsertSection={onInsertSection && ((where) => onInsertSection(rowId, where))}
onDelete={() => onDeleteRow(rowId)}
// A heading produces nothing, so there is nothing to make a task out of.
onCreateTask={
isSection
? undefined
: () =>
onCreateTaskForRow({
rowId,
title: cells[titleColumn] ?? '',
taskColumnKey=[redacted] => column.type === 'task')?.key ?? null,
})
}
/>
</div>
{isSection ? (
<SectionCell
gridId={gridId}
rowId={rowId}
rowIndex={index}
value={cells[RESERVED_COLUMN.SECTION] ?? ''}
width={columns.reduce((sum, c) => sum + (c.width ?? DEFAULT_COLUMN_WIDTH), 0)}
onCommit={commitCell}
/>
) : (
columns.map((column, colIndex) => (
<TableCell
key=[redacted]
gridId={gridId}
rowId={rowId}
rowIndex={index}
colIndex={colIndex}
column={column}
value={cells[column.key] ?? ''}
onCommit={commitCell}
boundTarget={column.binding ? resolveBoundWrite(column, cells) : null}
onWriteBound={onWriteBound}
/>
))
)}
</div>
);
}
/**
* A section row's single cell — the merged band a spreadsheet gives you for a heading.
*
* ── Why it is still a `gridcell`, at column 0 ──
*
* The selection is geometry: `{ row, col }` indices into a rectangle (see `cell-selection.ts`).
* Taking the heading out of that rectangle entirely would mean ArrowDown from the row above it
* lands nowhere, which is worse than the thing it avoids. So a heading IS one cell, it lives at
* column 0, and `CellSelectionStore.clamp` pins the column there for rows the grid reports as
* spanning — so ArrowRight along a heading stays put rather than walking into columns that have
* no DOM node and leaving the selection ring nowhere.
*
* It spans the full width of the data columns rather than one column's width, which is the
* whole point: a heading that stopped at the first column boundary would read as a value in
* the first column, which is exactly the "section column" workaround this replaces.
*/
function SectionCell({
gridId,
rowId,
rowIndex,
value,
width,
onCommit,
}: {
gridId: string;
rowId: string;
rowIndex: number;
value: string;
/** The summed width of the data columns — the band covers all of them. */
width: number;
onCommit: (columnKey=[redacted], value: string, rowId: string) => void;
}) {
const store = useCellSelectionStore();
const state = useCellState(rowIndex, 0);
const shellRef = useRef<HTMLDivElement>(null);
const editing = (state & CELL_STATE.EDITING) !== 0;
const finish = (next: string) => {
// An empty heading would be a row that is no longer a section and no longer anything else,
// so a cleared band commits the empty string and the row becomes an ordinary empty record —
// the same "way back" the write guard documents for `clear r_h1._section`.
if (next !== value) onCommit(RESERVED_COLUMN.SECTION, next, rowId);
store.endEdit(null);
shellRef.current?.closest<HTMLElement>('[role="grid"]')?.focus();
};
return (
<div
ref={shellRef}
id={cellDomId(gridId, rowIndex, 0)}
role="gridcell"
aria-colindex={2}
aria-selected={(state & CELL_STATE.SELECTED) !== 0}
data-cell={`${rowId}:${RESERVED_COLUMN.SECTION}`}
data-cell-section="true"
style={{ width, boxShadow: selectionBoxShadow(state) }}
onMouseDown={(event) => {
if (editing) return;
const wasActive = (state & CELL_STATE.ACTIVE) !== 0;
if (store.getEditing()) (document.activeElement as HTMLElement | null)?.blur?.();
event.preventDefault();
event.currentTarget.closest<HTMLElement>('[role="grid"]')?.focus();
store.select({ row: rowIndex, col: 0 });
// Same "click the selected thing to edit it" gesture every other cell has.
if (wasActive) store.beginEdit({ row: rowIndex, col: 0 });
}}
onDoubleClick={() => store.beginEdit({ row: rowIndex, col: 0 })}
className={cn(
// `cursor-cell` to match every sibling cell: the band is click-to-select and
// double-click-to-edit, and a default arrow is the only thing that says otherwise.
'flex shrink-0 cursor-cell items-center border-r border-border bg-muted/40 px-3',
'text-sm font-semibold text-foreground',
)}
>
{editing ? (
<input
autoFocus
aria-label="Section heading"
defaultValue={value}
onBlur={(event) => finish(event.target.value.trim())}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
finish(event.currentTarget.value.trim());
return;
}
if (event.key === 'Escape') {
event.preventDefault();
store.endEdit(null);
shellRef.current?.closest<HTMLElement>('[role="grid"]')?.focus();
}
}}
className="w-full bg-transparent text-sm font-semibold outline-none"
/>
) : (
<span className="truncate">{value}</span>
)}
</div>
);
}
/** Exported unmemoized so tests can count renders of the real body. */
export { TableRowBody };
export const TableRowView = memo(TableRowBody);
function RowMenu({
rowNumber,
onDelete,
onInsertSection,
onCreateTask,
}: {
rowNumber: number;
onDelete: () => void;
/** Absent while the grid is sorted — see `TableRowViewProps.onInsertSection`. */
onInsertSection?: (where: 'above' | 'below') => void;
/** Absent on a heading, which produces nothing there is a task to be made of. */
onCreateTask?: () => void;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={`Row ${rowNumber} actions`}
className="hidden cursor-pointer rounded p-0.5 hover:bg-muted group-hover:block data-[state=open]:block"
>
<MoreVertical className="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{onCreateTask && (
<DropdownMenuItem className="cursor-pointer" onSelect={onCreateTask}>
<ListPlus className="size-4 shrink-0" />
<span className="truncate">Create task for this row</span>
</DropdownMenuItem>
)}
{onInsertSection && (
<>
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => onInsertSection('above')}
>
<Rows3 className="size-4 shrink-0" />
<span className="truncate">Insert section above</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => onInsertSection('below')}
>
<Rows3 className="size-4 shrink-0" />
<span className="truncate">Insert section below</span>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem
className="cursor-pointer text-destructive focus:text-destructive"
onSelect={onDelete}
>
<Trash2 className="size-4 shrink-0" />
<span className="truncate">Delete row</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export interface BlankRowViewProps {
/** Distance below the last materialized row: 0 is the first blank. */
offset: number;
columns: TableColumn[];
/** Row index in the grid's coordinate space — `rows.length + offset`. */
index: number;
top: number;
gridId: string;
measureRef: (element: HTMLElement | null) => void;
/** A blank cell committed a value. Materializes it (and any blanks above it). */
onFirstInput: (offset: number, columnKey=[redacted], value: string) => void;
}
export const BlankRowView = memo(function BlankRowView({
offset,
columns,
index,
top,
gridId,
measureRef,
onFirstInput,
}: BlankRowViewProps) {
const hasEditingCell = useRowHasEditingCell(index);
const commit = useCallback(
(columnKey=[redacted], value: string) => onFirstInput(offset, columnKey, value),
[onFirstInput, offset],
);
return (
<div
ref={measureRef}
data-index={index}
data-blank-row={offset}
role="row"
aria-rowindex={index + 2}
className="absolute left-0 flex border-b border-border"
style={{ top, minHeight: ROW_HEIGHT, zIndex: hasEditingCell ? 20 : undefined }}
>
<div
role="gridcell"
aria-colindex={1}
className="flex shrink-0 items-start border-r border-border py-[7px] pl-2 text-xs leading-5 text-muted-foreground/50"
style={{ width: GUTTER_WIDTH }}
>
{index + 1}
</div>
{columns.map((column, colIndex) => (
<TableCell
key=[redacted]
gridId={gridId}
rowId=""
rowIndex={index}
colIndex={colIndex}
column={column}
value=""
onCommit={commit}
/>
))}
</div>
);
});
interface TableCellProps {
gridId: string;
/** Empty for a blank row's cell. */
rowId: string;
/** Position in the grid's coordinate space — what the selection addresses. */
rowIndex: number;
colIndex: number;
column: TableColumn;
value: string;
/**
* `columnKey` first, `rowId` last, because a blank row's cell has no rowId yet and its
* handler would otherwise have to name a parameter it never uses.
*/
onCommit: (columnKey=[redacted], value: string, rowId: string) => void;
/**
* Where this bound cell's edit goes, or null when the column is not bound, the field is not
* writable, or the row references no deal. Absent on a blank row, which references nothing
* by construction.
*/
boundTarget?: BoundWriteTarget | null;
onWriteBound?: BoundCellWriter;
}
function TableCell({
gridId,
rowId,
rowIndex,
colIndex,
column,
value,
onCommit,
boundTarget = null,
onWriteBound,
}: TableCellProps) {
const store = useCellSelectionStore();
const state = useCellState(rowIndex, colIndex);
const shellRef = useRef<HTMLDivElement>(null);
const width = column.width ?? DEFAULT_COLUMN_WIDTH;
const wrap = columnWrap(column);
const editing = (state & CELL_STATE.EDITING) !== 0;
/** Bound with nowhere to write: the one cell that is still genuinely read-only. */
const derivedReadOnly = !!column.binding && !boundTarget;
const commitValue = useCallback(
(next: string) => {
// The local write goes in FIRST, before the mutation — it is the paint the user is
// waiting on, and the recompute the CRM write triggers lands on the same value. A
// rejected write puts the old one back; see `use-bound-cell-write.ts`.
const previous = value;
onCommit(column.key, next, rowId);
if (boundTarget) {
onWriteBound?.(boundTarget, next, () => onCommit(column.key, previous, rowId));
}
},
[onCommit, rowId, column.key, value, boundTarget, onWriteBound],
);
const beginEdit = () => {
if (derivedReadOnly || column.type === TABLE_COLUMN_TYPE.CHECKBOX) return;
store.beginEdit({ row: rowIndex, col: colIndex });
};
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
// Inside the open editor the pointer belongs to the editor — a caret placement, a
// selection drag, a click on a chip. Nothing here may take that over.
if (editing) return;
const wasActive = (state & CELL_STATE.ACTIVE) !== 0;
// Committing the OTHER open cell has to happen before this cell takes the selection, and
// `preventDefault` below would otherwise stop the blur that does it. Explicit, and in the
// right order: blur commits, then the selection moves.
if (store.getEditing()) (document.activeElement as HTMLElement | null)?.blur?.();
// Suppresses the browser's text selection across cells, which otherwise fights the grid's
// own range selection and leaves a half-highlighted table behind.
event.preventDefault();
event.currentTarget.closest<HTMLElement>('[role="grid"]')?.focus();
if (event.shiftKey) {
store.select({ row: rowIndex, col: colIndex }, { extend: true });
return;
}
store.startDrag({ row: rowIndex, col: colIndex });
// A second click on the cell that already holds the selection opens it — the same
// "click the selected thing to edit it" gesture as renaming a file.
if (wasActive) beginEdit();
};
const handleDone = useCallback(
(exit: EditExit) => {
store.endEdit(exit);
// The editor was holding focus and is about to unmount, so focus has to be PUT
// somewhere — otherwise it falls to `<body>` and the next arrow key scrolls the page
// instead of moving the selection. It goes back to the grid root, which is where the
// grid's keyboard model lives.
shellRef.current?.closest<HTMLElement>('[role="grid"]')?.focus();
},
[store],
);
// A bound cell's editor is chosen by the FIELD (a stage picker, a date picker) and only
// falls through to the column's own when the field has nothing better — see
// `BoundCellEditors`.
const BoundEditor = editing && boundTarget ? boundEditorFor(boundTarget.kind) : undefined;
const Typed = editing && !BoundEditor && !derivedReadOnly ? typedCellFor(column.type) : undefined;
// The rich editor's two homes. A typed column has its own editor and never reaches either.
const richEditing =
editing &&
!Typed &&
!BoundEditor &&
!derivedReadOnly &&
column.type !== TABLE_COLUMN_TYPE.CHECKBOX;
const inlineEdit = richEditing && wrap === TABLE_CELL_WRAP.WRAP;
const popoverEdit = richEditing && wrap === TABLE_CELL_WRAP.OVERFLOW;
const editor = richEditing ? (
<CellEditor
ariaLabel={column.label}
value={value}
seed={store.getEditSeed()}
className={cn(CELL_CONTENT_CLASS, CELL_WRAP_CLASS)}
onCommit={(next, move, dirty) => {
// `dirty` comes from the editor's own seed, not from a comparison against `value` —
// see CellEditorProps.onCommit. An untouched cell writes nothing, which is what lets a
// fan-out fill a cell the user is merely looking at.
if (dirty) commitValue(next);
handleDone(move);
}}
onCancel={() => handleDone(null)}
/>
) : null;
return (
<div
ref={shellRef}
id={cellDomId(gridId, rowIndex, colIndex)}
role="gridcell"
aria-colindex={colIndex + 2}
aria-selected={(state & CELL_STATE.SELECTED) !== 0}
aria-readonly={derivedReadOnly ? true : undefined}
data-cell={`${rowId}:${column.key}`}
data-cell-value={value}
// The binding path, so a test — and a debugging human reading the DOM — can tell a
// derived cell from an empty editable one without inferring it from styling.
data-cell-bound={column.binding}
// Which of the two a bound cell is, without having to reproduce the resolution to find
// out: a linked cell writes back, a derived one does not.
data-cell-linked={boundTarget ? 'true' : undefined}
title={column.binding ? boundCellTitle(column.binding, !!boundTarget) : undefined}
className={cn(
CELL_SHELL_CLASS,
(state & CELL_STATE.SELECTED) && !(state & CELL_STATE.ACTIVE) && 'bg-action-muted/40',
!editing && 'cursor-cell',
)}
style={{ width, boxShadow: selectionBoxShadow(state) }}
onMouseDown={handleMouseDown}
onMouseEnter={() => store.dragTo({ row: rowIndex, col: colIndex })}
onDoubleClick={beginEdit}
>
{BoundEditor && boundTarget ? (
<BoundEditor
column={column}
value={value}
wrap={wrap}
target={boundTarget}
onCommit={commitValue}
onDone={handleDone}
/>
) : Typed ? (
<Typed
column={column}
value={value}
wrap={wrap}
seed={store.getEditSeed()}
onCommit={commitValue}
onDone={handleDone}
/>
) : inlineEdit ? (
editor
) : (
<CellDisplay
column={column}
value={value}
wrap={wrap}
readOnly={derivedReadOnly}
onToggle={derivedReadOnly ? undefined : commitValue}
/>
)}
{popoverEdit && <CellPopover width={width}>{editor}</CellPopover>}
</div>
);
}