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