import type { MouseEvent, PointerEvent } from "react"; import { endsTextSelection, movedPastSlop } from "../../lib/textSelection"; // A table row (or its narrow card twin) as the click and tap surface of its object's default action, // the way a card is: a press anywhere on the row opens it, not only on the title inside it. // // What the row does NOT claim: // - a press on a control inside it (its quick icons, its "…", a link, a field): that is the control's; // - a click that ends a text selection, or a press that travelled (a drag across the row's padding): // DESIGN.md "Text stays selectable" — no play or open fires; // - an event from a portal (a popover a cell opened): React bubbles it through the row, the DOM does // not, so only a target the row really contains counts; // - a press that began while a field inside the row held the focus (its rename): that press ends the // editing — its mousedown blurs the field, which saves — and does nothing more. Read at pointerdown, // because by the click the blur has already put the row back as it is when nothing is edited. // The long-press menu's trailing click is swallowed by its own trigger (`useContextMenu`), before it // reaches the row. // A field the focus can be editing in. const FIELD = 'input, textarea, select, [contenteditable="true"]'; // What counts as a control of its own. `[tabindex]` covers a focusable cell (a hover card's trigger); // the row's own roving tab stop is the row itself, which the containment check below leaves out. const CONTROL = 'a, button, input, select, textarea, label, summary, [role="button"], [role="menuitem"], [role="checkbox"], [role="switch"], [contenteditable="true"], [tabindex]'; // Where each row's press began, for the drag slop, and whether it began while a field inside the row // was being edited. Per element: the table row and its card twin are two elements, and a press on one // must not be measured against the other. const pressedAt = new WeakMap(); type Handlers = { onPointerDown?: (e: PointerEvent) => void; onClick?: (e: MouseEvent) => void; }; /** The row's props with its default action added on top of the caller's own (a context menu's * trigger props, which keep their handlers). `open` null: the row has no default action. */ export function withRowOpen( props: Record | undefined, open: (() => void) | null, ): Record { const base = props ?? {}; if (!open) return base; const { onPointerDown, onClick } = base as Handlers; return { ...base, onPointerDown: (e: PointerEvent) => { const row = e.currentTarget; const focused = document.activeElement; const editing = !!focused && row.contains(focused) && focused.matches(FIELD); pressedAt.set(row, { x: e.clientX, y: e.clientY, editing }); onPointerDown?.(e); }, onClick: (e: MouseEvent) => { onClick?.(e); const claimed = !e.defaultPrevented && claims(e); // One press, one click: a later keyboard click (no press) is not judged by this one. pressedAt.delete(e.currentTarget); if (claimed) open(); }, }; } function claims(e: MouseEvent): boolean { const row = e.currentTarget; const target = e.target; if (!(target instanceof Element) || !row.contains(target)) return false; const control = target.closest(CONTROL); if (control && control !== row && row.contains(control)) return false; const pressed = pressedAt.get(row) ?? null; return !pressed?.editing && !endsTextSelection(e) && !movedPastSlop(pressed, e); }