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>
  );
}