TableGrid.tsx29.6 KBView on GitHub
'use client';

/**
 * The grid. Hand-rolled over `@tanstack/react-virtual`, deliberately NOT
 * `@tanstack/react-table`.
 *
 * The design doc reached for react-table because it is already a dependency, but its
 * headless model is built around a `data` array it derives a row model from — and this
 * grid has no such array. Cell values live in per-row Y.Maps that each row subscribes to
 * itself, which is the whole granularity story (see `useYTable`); handing react-table a
 * materialized `data` array would rebuild the row model, and re-render every row, on every
 * keystroke. Nothing else it offers is wanted here either: filter/group/paginate are absent,
 * the ORDER is `schema.sort` applied to the handle list by `useYTable` (a view over document
 * order, never a rewrite of it), and column order and sizing are persisted in the Y schema,
 * which is the source of truth rather than table state. So this follows the in-repo precedent —
 * `modules/crm/components/crm-table.tsx` is also hand-rolled over the same virtualizer.
 *
 * ── Selection, and where the keyboard lives ──
 *
 * Focus lives on the scroll container and never moves into a cell; the selection is a
 * `CellSelectionStore` that cells subscribe to individually, so a range drag never re-renders
 * the grid (see `cell-selection.ts`). What each key and each paste MEANS is
 * `use-grid-commands.ts` — a different job from laying the grid out, meeting this file at
 * exactly the store and the row list.
 *
 * ── Row heights are measured, not declared ──
 *
 * A wrapping column makes a row as tall as its tallest cell, so the virtualizer estimates
 * `ROW_HEIGHT` and then measures what it actually rendered. `MAX_ROW_HEIGHT` bounds that
 * from the cell side, so no measurement can be pathological.
 */

import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { AlertTriangle, ChevronDown, GripVertical, Link2, Loader2, Plus } from 'lucide-react';
import type { TableColumn, TableSortDirection } from '@zero/server/table';

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { cn } from '@/lib/utils';
import { resolveBoundWrite } from './bound-writes';
import { useBoundCellWrite } from './use-bound-cell-write';
import {
  CellSelectionProvider,
  CellSelectionStore,
  useActiveDescendant,
  type CellPosition,
} from './cell-selection';
import {
  ADD_COLUMN_WIDTH,
  DEFAULT_COLUMN_WIDTH,
  EXTEND_BLANK_RUN_PX,
  GUTTER_WIDTH,
  MIN_COLUMN_WIDTH,
  ROW_HEIGHT,
  SORT_DIRECTION_ICONS,
  sortStateLabel,
  TRAILING_BLANK_ROWS,
} from './constants';
import {
  dropColumn,
  insertColumn,
  moveColumn,
  patchColumn,
  setSort,
  unbindColumn,
  withoutSortOn,
} from './schema-edits';
import { TableColumnMenu } from './TableColumnMenu';
import { BlankRowView, TableRowView, type CreateTaskForRowInput } from './TableRow';
import { useGridCommands } from './use-grid-commands';
import type { UseYTableResult } from './useYTable';

export interface TableGridProps {
  table: UseYTableResult;
  /**
   * Per-row "Create task for this row". Owned by the caller because it is a mutation (and a
   * table opened read-only, e.g. on the share page, simply omits it).
   */
  onCreateTaskForRow?: (input: CreateTaskForRowInput) => void;
  className?: string;
}

interface ResizeDraft {
  key=[redacted];
  width: number;
}

export function TableGrid({ table, onCreateTaskForRow, className }: TableGridProps) {
  // Destructured because every callback below must depend on the individual (stable)
  // mutators rather than on `table`, which is a new object each render — depending on the
  // object would hand the memoized rows a fresh callback identity on every render.
  const {
    schema,
    schemaError,
    rows,
    isLoading,
    setCell,
    setCells,
    addRows,
    deleteRow,
    moveRow,
    setSchema,
    removeColumn,
    insertSection,
    undo,
    redo,
  } = table;
  const scrollRef = useRef<HTMLDivElement>(null);
  const gridId = useId();
  const [blankRun, setBlankRun] = useState(TRAILING_BLANK_ROWS);
  const [resize, setResize] = useState<ResizeDraft | null>(null);
  const [draggedColumn, setDraggedColumn] = useState<string | null>(null);
  /**
   * Header the pointer is currently over mid-drag — the only feedback that a drop will land.
   * NOT `dropColumn`: that is the schema-edit helper this file imports, and shadowing it made
   * `handleDeleteColumn` call a string.
   */
  const [dropTargetColumn, setDropTargetColumn] = useState<string | null>(null);
  /** The column whose link the user is being asked to confirm severing. */
  const [severColumn, setSeverColumn] = useState<string | null>(null);
  const draggedRowRef = useRef<number | null>(null);
  /**
   * Whether the view is sorted, readable from a callback that must not change identity.
   *
   * `handleBlankInput` is handed to every rendered blank row, and taking `schema.sort` as a
   * dependency would give all of them a fresh prop on any schema write — which is the row
   * memoization the whole grid rests on, defeated to answer a question asked once per keystroke
   * in a trailing blank.
   */
  const sortedRef = useRef(false);
  /**
   * The current row list, for a callback that runs AFTER the Y observer has re-synced it.
   * `handleInsertSection` needs the new row's index, which does not exist at the moment it
   * asks for the insert.
   */
  const orderedRowsRef = useRef(rows);
  orderedRowsRef.current = rows;

  const writeBoundCell = useBoundCellWrite();
  /**
   * Where a bound cell's edit goes, for one row. Depends on the schema — which is what knows
   * the ref columns a binding reads through — and is therefore as stable as the schema is:
   * it changes when a column does, which is the same moment `columns` changes anyway.
   */
  const boundWriteFor = useCallback(
    (column: TableColumn, cells: Record<string, string>) =>
      resolveBoundWrite(column, schema, cells),
    [schema],
  );

  // One store per mounted grid. Not React state — see the module comment.
  const selection = useMemo(() => new CellSelectionStore(), []);
  const activeDescendant = useActiveDescendant(gridId, selection);

  const columns = useMemo(() => {
    const visible = (schema?.columns ?? []).filter((column) => !column.hidden);
    if (!resize) return visible;
    // Only allocates a new array mid-drag, so the row memoization holds the rest of the
    // time — a fresh `columns` array on every render would defeat it permanently.
    return visible.map((column) =>
      column.key === resize.key ? { ...column, width: resize.width } : column,
    );
  }, [schema?.columns, resize]);

  const bodyWidth =
    GUTTER_WIDTH + columns.reduce((sum, c) => sum + (c.width ?? DEFAULT_COLUMN_WIDTH), 0);

  const rowCount = rows.length + blankRun;
  const virtualizer = useVirtualizer({
    count: rowCount,
    getScrollElement: () => scrollRef.current,
    estimateSize: () => ROW_HEIGHT,
    // A wrapping cell makes its row taller, so the row is measured rather than told. The
    // `max` is not defensive padding: jsdom reports every rect as zero-height, and without
    // it every row in a test would collapse to nothing and the virtual window would be the
    // whole table.
    measureElement: (element) => Math.max(element.getBoundingClientRect().height, ROW_HEIGHT),
    overscan: 8,
  });

  /**
   * Which rendered rows are headings, and the number each RECORD shows in the gutter.
   *
   * Both come off the same walk because both are answers about the rendered ORDER rather than
   * about a row: 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 toolbar's count disagree with the last number
   * in the gutter. Recomputed only when the row list changes identity, which is the same moment
   * the order itself can have changed.
   */
  const rowShape = useMemo(() => {
    const isSection: boolean[] = [];
    const numbers: number[] = [];
    let records = 0;
    for (const handle of rows) {
      isSection.push(handle.isSection);
      numbers.push(handle.isSection ? 0 : (records += 1));
    }
    return { isSection, numbers, recordCount: records };
  }, [rows]);

  useEffect(() => {
    selection.setBounds({
      rowCount,
      colCount: columns.length,
      // A trailing blank is never a heading, so anything past the materialized rows is false.
      isSpanRow: (row) => rowShape.isSection[row] ?? false,
    });
  }, [selection, rowCount, columns.length, rowShape]);

  // A drag-select that ends anywhere — over the header, outside the window — still ends.
  useEffect(() => {
    const stop = () => selection.endDrag();
    window.addEventListener('mouseup', stop);
    return () => window.removeEventListener('mouseup', stop);
  }, [selection]);

  // ── Row writes ──

  const handleCommitCell = useCallback(
    (rowId: string, columnKey=[redacted], value: string) => setCell(rowId, columnKey, value),
    [setCell],
  );

  const handleDeleteRow = useCallback((rowId: string) => deleteRow(rowId), [deleteRow]);

  /**
   * Insert a heading, then open it for editing — a section with the placeholder name still on
   * it is a section nobody has named, and making the user find it and click again is the step
   * that gets skipped.
   */
  const handleInsertSection = useCallback(
    (anchorRowId: string, where: 'above' | 'below') => {
      const created = insertSection(anchorRowId, where);
      if (!created) return;
      // The row list re-syncs from the Y observer, so the new index is only knowable after it.
      queueMicrotask(() => {
        const at = orderedRowsRef.current.findIndex((handle) => handle.rowId === created);
        if (at >= 0) selection.beginEdit({ row: at, col: 0 });
      });
    },
    [insertSection, selection],
  );

  // Stable even when the caller supplies nothing, so the memoized rows never see a changing
  // prop identity — the whole granularity story depends on that.
  const handleCreateTaskForRow = useCallback(
    (input: CreateTaskForRowInput) => onCreateTaskForRow?.(input),
    [onCreateTaskForRow],
  );

  const handleDragStartRow = useCallback((index: number) => {
    draggedRowRef.current = index;
  }, []);

  const handleDropRow = useCallback(
    (index: number) => {
      const from = draggedRowRef.current;
      draggedRowRef.current = null;
      if (from === null) return;
      moveRow(from, index);
    },
    [moveRow],
  );

  /**
   * A trailing blank committed a value. Materializes the blanks above it as empty rows so the
   * typed one keeps the position it was typed in — the Sheets behaviour of typing into a row a
   * few below the data. The selection needs no repair: the materialized row lands at exactly
   * the index the blank occupied.
   */
  const handleBlankInput = useCallback(
    (offset: number, columnKey=[redacted], value: string) => {
      // Under a sort the gap is not a position: the typed row lands wherever the comparator
      // puts it, and the empties meant to hold its place would all sort to the bottom as N
      // blank rows nobody asked for. So a sorted table materializes exactly the row typed.
      const empties = sortedRef.current ? [] : Array.from({ length: offset }, () => ({}));
      addRows([...empties, { [columnKey]: value }]);
    },
    [addRows],
  );

  /** Bring the active cell into view. Vertical is the virtualizer's job; horizontal is ours. */
  const revealActive = useCallback(() => {
    const active = selection.getActive();
    const element = scrollRef.current;
    if (!active || !element) return;
    virtualizer.scrollToIndex(active.row, { align: 'auto' });
    let left = GUTTER_WIDTH;
    for (let col = 0; col < active.col; col++) left += columns[col]?.width ?? DEFAULT_COLUMN_WIDTH;
    const width = columns[active.col]?.width ?? DEFAULT_COLUMN_WIDTH;
    if (left < element.scrollLeft + GUTTER_WIDTH) element.scrollLeft = left - GUTTER_WIDTH;
    else if (left + width > element.scrollLeft + element.clientWidth) {
      element.scrollLeft = left + width - element.clientWidth;
    }
  }, [selection, virtualizer, columns]);

  const { onKeyDown, onCopy, onCut, onPaste } = useGridCommands({
    selection,
    columns,
    rows,
    setCells,
    addRows,
    undo,
    redo,
    revealActive,
    resolveBoundWrite: boundWriteFor,
    onWriteBound: writeBoundCell,
  });

  // ── Column writes ──

  const patch = useCallback(
    (key=[redacted], columnPatch: Partial<Omit<TableColumn, 'key'>>) => {
      if (!schema) return;
      const patched = patchColumn(schema, key, columnPatch);
      // Hiding the column the table is ordered by leaves the rows in an order with nothing on
      // screen to explain it — the same reason deleting one clears the sort (see `dropColumn`).
      setSchema(columnPatch.hidden === true ? withoutSortOn(patched, key) : patched);
    },
    [schema, setSchema],
  );

  /**
   * Order the table by a column, or clear the order.
   *
   * A schema write and nothing else: `tableRows` keeps document order, so `_id` addressing and
   * every row's CRDT identity survive a sort — `useYTable` reorders the HANDLES it hands back.
   */
  const handleSort = useCallback(
    (key=[redacted], direction: TableSortDirection | null) => {
      if (!schema) return;
      setSchema(setSort(schema, key, direction));
    },
    [schema, setSchema],
  );

  /**
   * The rows on screen are not in document order, so a row drag cannot mean what it looks like.
   *
   * `moveRow` writes an INDEX into the Y.Array, and under a sort the index the user dropped on
   * is a position in the sorted view — so the row would land somewhere else entirely and then
   * snap back to wherever the comparator puts it, which reads as the drag being ignored. The
   * handle is therefore withdrawn while a sort is on, rather than left there to lie.
   */
  const sorted = schema?.sort ?? null;
  sortedRef.current = !!sorted;
  const sortDirectionFor = useCallback(
    (key=[redacted] TableSortDirection | null => (sorted?.columnKey === key ? sorted.direction : null),
    [sorted],
  );

  const handleInsertColumn = useCallback(
    (atIndex: number) => {
      if (!schema) return;
      setSchema(insertColumn(schema, atIndex));
    },
    [schema, setSchema],
  );

  const handleDeleteColumn = useCallback(
    (key=[redacted] => {
      if (!schema) return;
      removeColumn(key, dropColumn(schema, key));
    },
    [schema, removeColumn],
  );

  /**
   * Sever a column's link — a schema write only. The cells are deliberately untouched, so the
   * last values Cedar computed stay put and become ordinary editable ones: the column stops
   * recomputing, and editing it stops reaching the deal.
   *
   * Confirmed rather than immediate, because it is the one column action with no way back
   * from the grid — re-binding takes an agent, and the header's link glyph is a small target
   * beside the column's own name.
   */
  const handleUnbindColumn = useCallback(
    (key=[redacted] => {
      if (!schema) return;
      setSchema(unbindColumn(schema, key));
    },
    [schema, setSchema],
  );

  /** The column the sever dialog is about, so it can name what it is about to change. */
  const severTarget = severColumn
    ? (schema?.columns.find((column) => column.key === severColumn) ?? null)
    : null;

  const handleDropColumn = useCallback(
    (targetKey=[redacted] => {
      setDropTargetColumn(null);
      setDraggedColumn(null);
      if (!schema || !draggedColumn || draggedColumn === targetKey) return;
      setSchema(moveColumn(schema, draggedColumn, targetKey));
    },
    [schema, draggedColumn, setSchema],
  );

  /** A drag that ends anywhere — a drop, an Escape, a release off-target — clears the state. */
  const handleDragEndColumn = useCallback(() => {
    setDraggedColumn(null);
    setDropTargetColumn(null);
  }, []);

  const startResize = useCallback(
    (column: TableColumn, event: React.PointerEvent<HTMLDivElement>) => {
      event.preventDefault();
      event.stopPropagation();
      const startX = event.clientX;
      const startWidth = column.width ?? DEFAULT_COLUMN_WIDTH;
      let width = startWidth;
      const onMove = (moveEvent: PointerEvent) => {
        width = Math.max(MIN_COLUMN_WIDTH, startWidth + moveEvent.clientX - startX);
        setResize({ key=[redacted], width });
      };
      const onUp = () => {
        window.removeEventListener('pointermove', onMove);
        window.removeEventListener('pointerup', onUp);
        setResize(null);
        // The draft only ever lived in React state; the committed width goes into the
        // schema so it survives a reload and reaches every other client.
        if (width !== startWidth) patch(column.key, { width });
      };
      window.addEventListener('pointermove', onMove);
      window.addEventListener('pointerup', onUp);
    },
    [patch],
  );

  /** Select a whole column by clicking its header — the other half of Cmd+A. */
  const selectColumn = useCallback(
    (colIndex: number) => {
      const top: CellPosition = { row: 0, col: colIndex };
      selection.select(top);
      selection.select({ row: rowCount - 1, col: colIndex }, { extend: true });
    },
    [selection, rowCount],
  );

  // ── Scroll: extend the blank run rather than preallocating rows ──

  const handleScroll = useCallback(() => {
    const element = scrollRef.current;
    if (!element) return;
    const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
    if (distanceFromBottom < EXTEND_BLANK_RUN_PX) {
      setBlankRun((run) => run + TRAILING_BLANK_ROWS);
    }
  }, []);

  if (schemaError) {
    return (
      <div className={cn('p-6', className)}>
        <div className="rounded-lg border border-border bg-background p-4 text-xs text-destructive">
          <p className="flex items-center gap-2 font-semibold">
            <AlertTriangle className="size-4 shrink-0" />
            This table could not be rendered
          </p>
          <p className="mt-1 text-muted-foreground">{schemaError}</p>
          <p className="mt-2 text-muted-foreground">
            The rows are untouched — the markdown mirror still holds them, so nothing has
            been lost.
          </p>
        </div>
      </div>
    );
  }

  if (isLoading && rows.length === 0) {
    return (
      <div className={cn('flex h-40 items-center justify-center text-muted-foreground', className)}>
        <Loader2 className="size-4 animate-spin" />
      </div>
    );
  }

  const virtualItems = virtualizer.getVirtualItems();

  return (
    <CellSelectionProvider value={selection}>
      <div
        ref={scrollRef}
        tabIndex={0}
        role="grid"
        aria-label="Table"
        // Focus stays here and never roves into a cell — that is what lets a cell be a plain
        // element a click can SELECT rather than a tab stop a click can only focus.
        aria-activedescendant={activeDescendant}
        // The TRUE totals, not the virtualized window's. Only a handful of rows are in the DOM at
        // any time, so without these a screen reader reports whatever happens to be rendered as
        // the whole table. Both are 1-based and count the header row / gutter column.
        aria-rowcount={rows.length + 1}
        aria-colcount={columns.length + 1}
        onScroll={handleScroll}
        onKeyDown={onKeyDown}
        onCopy={onCopy}
        onCut={onCut}
        onPaste={onPaste}
        className={cn('min-h-0 flex-1 overflow-auto outline-none', className)}
      >
        <div style={{ width: bodyWidth + ADD_COLUMN_WIDTH }}>
          <div role="row" aria-rowindex={1} className="sticky top-0 z-10 flex border-b border-border bg-background">
            <div
              role="columnheader"
              aria-colindex={1}
              className="shrink-0 border-r border-border"
              style={{ width: GUTTER_WIDTH }}
            />
            {columns.map((column, index) => (
              <div
                key=[redacted]
                role="columnheader"
                aria-colindex={index + 2}
                onDragOver={(event) => {
                  event.preventDefault();
                  if (draggedColumn && draggedColumn !== column.key) setDropTargetColumn(column.key);
                }}
                onDragLeave={() => setDropTargetColumn((key) => (key === column.key ? null : key))}
                onDrop={() => handleDropColumn(column.key)}
                className={cn(
                  'group/header relative flex shrink-0 items-center border-r border-border',
                  draggedColumn === column.key && 'opacity-50',
                  // Where the dragged column will land, drawn on the target's leading edge.
                  dropTargetColumn === column.key &&
                    'before:absolute before:inset-y-0 before:left-0 before:z-20 before:w-0.5 before:bg-action',
                )}
                style={{ width: column.width ?? DEFAULT_COLUMN_WIDTH, height: ROW_HEIGHT }}
                title={column.description ?? column.label}
              >
                {/*
                  The reorder handle, and why it is a separate element rather than the whole header.
                  The header IS a Radix menu trigger, and Radix opens on `pointerdown` and calls
                  `preventDefault()` — which is exactly what suppresses the native `dragstart`. With
                  `draggable` on the header itself the gesture was therefore dead everywhere the
                  trigger covered, which was all of it. A handle outside the trigger is the one
                  surface where the pointer is still free.
                */}
                <div
                  draggable
                  role="button"
                  aria-label={`Reorder ${column.label}`}
                  onDragStart={() => setDraggedColumn(column.key)}
                  onDragEnd={handleDragEndColumn}
                  className="flex h-full w-3 shrink-0 cursor-grab items-center justify-center text-muted-foreground opacity-0 transition-opacity group-hover/header:opacity-70"
                >
                  <GripVertical className="size-3" />
                </div>
                {column.binding && (
                  <button
                    type="button"
                    aria-label={`Sever the link on ${column.label}`}
                    title={
                      `Linked to ${column.binding}. Edits in this column update the deal, ` +
                      'everywhere. Click to sever the link and make it this table only.'
                    }
                    onClick={() => setSeverColumn(column.key)}
                    className="flex shrink-0 cursor-pointer items-center rounded p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
                  >
                    <Link2 className="size-3" />
                  </button>
                )}
                <TableColumnMenu
                  column={column}
                  // The whole set, for the fill-instruction panel: one column's instruction to
                  // its agent is written against the ones beside it.
                  columns={columns}
                  onPatch={(columnPatch) => patch(column.key, columnPatch)}
                  onPatchColumn={patch}
                  onInsertBefore={() => handleInsertColumn(index)}
                  onInsertAfter={() => handleInsertColumn(index + 1)}
                  onDelete={() => handleDeleteColumn(column.key)}
                  onUnbind={() => setSeverColumn(column.key)}
                  onSelectColumn={() => selectColumn(index)}
                  onSort={(direction) => handleSort(column.key, direction)}
                  sortDirection={sortDirectionFor(column.key)}
                >
                  <button
                    type="button"
                    className="flex h-full min-w-0 flex-1 cursor-pointer items-center gap-1 pl-0.5 pr-2 text-left text-sm font-medium hover:bg-muted/60"
                  >
                    <span className="truncate">{column.label}</span>
                    {/*
                      The arrow is the only thing on screen that explains the row order, so it
                      is NOT hover-revealed and it sits beside the label rather than at the far
                      edge of a 400px column, where it would be nowhere near the name it
                      qualifies. `title` says which way, in the column's own words.
                    */}
                    <SortGlyph column={column} direction={sortDirectionFor(column.key)} />
                    <ChevronDown className="size-3 shrink-0 text-muted-foreground" />
                  </button>
                </TableColumnMenu>
                <div
                  role="separator"
                  aria-label={`Resize ${column.label}`}
                  onPointerDown={(event) => startResize(column, event)}
                  className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary"
                />
              </div>
            ))}
            <button
              type="button"
              aria-label="Add column"
              onClick={() => handleInsertColumn(columns.length)}
              className="flex shrink-0 cursor-pointer items-center justify-center text-muted-foreground hover:bg-muted/60 hover:text-foreground"
              style={{ width: ADD_COLUMN_WIDTH, height: ROW_HEIGHT }}
            >
              <Plus className="size-4" />
            </button>
          </div>

          <div
            className="relative"
            style={{ height: virtualizer.getTotalSize(), width: bodyWidth }}
          >
            {virtualItems.map((item) => {
              const handle = rows[item.index];
              if (handle) {
                return (
                  <TableRowView
                    key=[redacted]
                    rowId={handle.rowId}
                    rowMap={handle.map}
                    columns={columns}
                    index={item.index}
                    rowNumber={rowShape.numbers[item.index] ?? item.index + 1}
                    top={item.start}
                    gridId={gridId}
                    measureRef={virtualizer.measureElement}
                    titleColumn={schema?.titleColumn ?? ''}
                    onCommitCell={handleCommitCell}
                    onDeleteRow={handleDeleteRow}
                    onDragStartRow={handleDragStartRow}
                    onDropRow={handleDropRow}
                    reorderable={!sorted}
                    // The STABLE callback, not a per-row closure over `handle.rowId` — a fresh
                    // function identity on every render would break the row memoization the
                    // whole grid rests on, which is exactly what TableGrid.test.tsx caught.
                    onInsertSection={sorted ? undefined : handleInsertSection}
                    onCreateTaskForRow={handleCreateTaskForRow}
                    resolveBoundWrite={boundWriteFor}
                    onWriteBound={writeBoundCell}
                  />
                );
              }
              return (
                <BlankRowView
                  key=[redacted]
                  offset={item.index - rows.length}
                  index={item.index}
                  columns={columns}
                  top={item.start}
                  gridId={gridId}
                  measureRef={virtualizer.measureElement}
                  onFirstInput={handleBlankInput}
                />
              );
            })}
          </div>
        </div>
      </div>
      <AlertDialog
        open={!!severColumn}
        onOpenChange={(next) => !next && setSeverColumn(null)}
      >
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>
              Sever the link on {severTarget?.label ?? 'this column'}?
            </AlertDialogTitle>
            <AlertDialogDescription>
              {severTarget?.binding ? (
                <>
                  The column stops reading{' '}
                  <span className="font-mono">{severTarget.binding}</span>. Every value Cedar has
                  computed stays exactly as it is and becomes this table&rsquo;s own &mdash; it
                  will no longer update when the deal changes, and editing it will no longer
                  update the deal.
                </>
              ) : null}
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
            <AlertDialogAction
              className="cursor-pointer"
              onClick={() => {
                if (severColumn) handleUnbindColumn(severColumn);
                setSeverColumn(null);
              }}
            >
              Sever link
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </CellSelectionProvider>
  );
}

/**
 * The arrow on a sorted column's header, and nothing at all on every other one.
 *
 * Same glyph the menu row that set it uses, so the two read as one control rather than as an
 * action and an unrelated indicator. The tooltip lives on this span and not on the header,
 * whose own `title` is the column DESCRIPTION — overwriting that would trade "what this column
 * holds" for "which way it points".
 */
function SortGlyph({
  column,
  direction,
}: {
  column: TableColumn;
  direction: TableSortDirection | null;
}) {
  if (!direction) return null;
  const Icon = SORT_DIRECTION_ICONS[direction];
  const label = sortStateLabel(column, direction);
  return (
    <span title={label} className="flex shrink-0 items-center">
      <Icon aria-label={label} role="img" className="size-3 text-foreground" />
    </span>
  );
}