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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user