feat(files): a press anywhere on a file's row plays it

A table row (and its narrow card) can carry its object's default action:
a click or tap on the row runs it, a press on a control inside the row,
the click that ends a text selection and a drag do not.
This commit is contained in:
2026-11-01 03:30:49 +01:00
parent 85fd9b6704
commit 41052eb4ea
5 changed files with 135 additions and 5 deletions
+60
View File
@@ -0,0 +1,60 @@
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.
// The long-press menu's trailing click is swallowed by its own trigger (`useContextMenu`), before it
// reaches the row.
// 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. 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<Element, { x: number; y: number }>();
type Handlers = {
onPointerDown?: (e: PointerEvent<HTMLElement>) => void;
onClick?: (e: MouseEvent<HTMLElement>) => 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<string, unknown> | undefined,
open: (() => void) | null,
): Record<string, unknown> {
const base = props ?? {};
if (!open) return base;
const { onPointerDown, onClick } = base as Handlers;
return {
...base,
onPointerDown: (e: PointerEvent<HTMLElement>) => {
pressedAt.set(e.currentTarget, { x: e.clientX, y: e.clientY });
onPointerDown?.(e);
},
onClick: (e: MouseEvent<HTMLElement>) => {
onClick?.(e);
if (e.defaultPrevented || !claims(e)) return;
open();
},
};
}
function claims(e: MouseEvent<HTMLElement>): 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;
return !endsTextSelection(e) && !movedPastSlop(pressedAt.get(row) ?? null, e);
}