CellPopover.tsx3.3 KBView on GitHub
'use client';

/**
 * The panel a CLIPPED cell opens into — layered over the grid rather than confined to the cell.
 *
 * A clipping column shows one line and hides the rest, on purpose (see `CELL_CLIP_CLASS`). So
 * the moment you open such a cell, the one thing you need is the part that did not fit: editing
 * inside the cell's own 200px box would show exactly as little as the display did. The panel
 * therefore grows past the cell in both axes, up to `CELL_POPOVER_MAX_WIDTH` /
 * `CELL_POPOVER_MAX_HEIGHT`, and scrolls only beyond that.
 *
 * A WRAPPING column gets none of this: its value is already fully visible, so it edits in
 * place and a panel would be a jump for nothing.
 *
 * ── Why not Radix `Popover` ──
 * Radix portals its content to the body, which would take the editor out of the grid's DOM —
 * and the grid's keyboard model, its `closest('[role="grid"]')` lookups and the row's stacking
 * context all key off the editor being INSIDE the cell. This is a positioned child instead:
 * ~40 lines, and the editor stays where every other part of the grid expects to find it.
 */

import { useLayoutEffect, useRef, useState } from 'react';

import { cn } from '@/lib/utils';
import { CELL_POPOVER_CLASS, CELL_POPOVER_MAX_HEIGHT, CELL_POPOVER_MAX_WIDTH } from './constants';

export interface CellPopoverProps {
  /** The cell's width. The panel is never narrower than the cell it grew out of. */
  width: number;
  children: React.ReactNode;
}

/** Which corner of the cell the panel is pinned to, once it knows where the viewport edges are. */
interface Placement {
  fromBottom: boolean;
  fromRight: boolean;
}

/**
 * The 2px offset that lands the panel's border exactly on top of the cell's, so opening a cell
 * looks like the cell growing rather than a second box appearing beside it.
 */
const BORDER_OVERLAY_PX = 2;

export function CellPopover({ width, children }: CellPopoverProps) {
  const panelRef = useRef<HTMLDivElement>(null);
  const [placement, setPlacement] = useState<Placement>({ fromBottom: false, fromRight: false });

  // Measured once, after the panel exists at its default (top-left) placement: it flips only
  // when it would otherwise be clipped by the scroll container. Doing this on every render
  // would fight the user's typing as the panel grows line by line.
  useLayoutEffect(() => {
    const panel = panelRef.current;
    const container = panel?.closest<HTMLElement>('[role="grid"]');
    if (!panel || !container) return;
    const panelRect = panel.getBoundingClientRect();
    const containerRect = container.getBoundingClientRect();
    setPlacement({
      fromBottom: panelRect.bottom > containerRect.bottom && panelRect.height < containerRect.height,
      fromRight: panelRect.right > containerRect.right && panelRect.width < containerRect.width,
    });
  }, []);

  return (
    <div
      ref={panelRef}
      data-cell-popover=""
      className={cn(CELL_POPOVER_CLASS, 'overflow-y-auto')}
      style={{
        minWidth: width + BORDER_OVERLAY_PX * 2,
        width: 'max-content',
        maxWidth: CELL_POPOVER_MAX_WIDTH,
        maxHeight: CELL_POPOVER_MAX_HEIGHT,
        [placement.fromBottom ? 'bottom' : 'top']: -BORDER_OVERLAY_PX,
        [placement.fromRight ? 'right' : 'left']: -BORDER_OVERLAY_PX,
      }}
    >
      {children}
    </div>
  );
}