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
+14 -4
View File
@@ -9,6 +9,7 @@ import { alignClass } from "./tableAlign";
import { columnHideClass, filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
import { navItemAttrs, useListNav } from "../../lib/listNav";
import { useMediaQuery } from "../../lib/useMediaQuery";
import { withRowOpen } from "./rowOpen";
import { SURFACE } from "./surfaces";
import { HiddenFilterCue } from "./HiddenFilterCue";
import { useProgressiveRows } from "./progressiveRows";
@@ -57,6 +58,7 @@ interface TableRowProps<T> {
index: number;
columns: Column<T>[];
rowProps?: (row: T) => Record<string, unknown>;
rowOpen?: (row: T) => (() => void) | null;
rowClassName?: (row: T) => string;
keyboardNav: boolean;
}
@@ -69,14 +71,16 @@ function TableRow<T>({
index,
columns,
rowProps,
rowOpen,
rowClassName,
keyboardNav,
}: TableRowProps<T>) {
const open = rowOpen?.(row) ?? null;
return (
<tr
{...rowProps?.(row)}
{...withRowOpen(rowProps?.(row), open)}
{...(keyboardNav && navItemAttrs(index))}
className={`border-b border-border/50 state-layer transition ${rowClassName?.(row) ?? ""}`}
className={`border-b border-border/50 state-layer transition ${open ? "cursor-pointer " : ""}${rowClassName?.(row) ?? ""}`}
>
{columns.map((col) => (
<td
@@ -102,6 +106,7 @@ export default function DataTable<T>({
emptyText,
rowClassName,
rowProps,
rowOpen,
controlsPosition = "bottom",
controlsLeading,
controlsInBand = false,
@@ -137,6 +142,10 @@ export default function DataTable<T>({
// answer the same gesture. Deliberately not a bag of named callbacks: the table stays domain-free
// and never learns what a menu is.
rowProps?: (row: T) => Record<string, unknown>;
// The row's default action (play, open), or null where a row has none: a press anywhere on the row
// or its card twin runs it, except on a control inside it or at the end of a text selection
// (`rowOpen.ts`). The keyboard's Enter keeps reaching it through the row's `data-nav-open` element.
rowOpen?: (row: T) => (() => void) | null;
controlsPosition?: "top" | "bottom" | "both";
controlsLeading?: ReactNode;
// Render the controls row (controlsLeading + pager) into the shell's fixed toolbar band instead
@@ -414,6 +423,7 @@ export default function DataTable<T>({
index={i}
columns={columns}
rowProps={rowProps}
rowOpen={rowOpen}
rowClassName={rowClassName}
keyboardNav={keyboardNav}
/>
@@ -430,9 +440,9 @@ export default function DataTable<T>({
{shown.map((row, i) => (
<div
key={rowKey(row)}
{...rowProps?.(row)}
{...withRowOpen(rowProps?.(row), rowOpen?.(row) ?? null)}
{...(keyboardNav && navItemAttrs(i))}
className={`${SURFACE.card} p-3 ${rowClassName?.(row) ?? ""}`}
className={`${SURFACE.card} p-3 ${rowOpen?.(row) ? "cursor-pointer " : ""}${rowClassName?.(row) ?? ""}`}
>
{columns
.filter((c) => c.cardPrimary)
+1 -1
View File
@@ -39,7 +39,7 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
+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);
}
@@ -1390,3 +1390,61 @@ describe("Files · a file watched part way", () => {
expect(await screen.findByText("playing 3")).toBeTruthy();
});
});
// The row is the click (and tap) surface for its file's default action, as the name always was: a
// press anywhere on it plays. A press on one of its own controls is that control's, and a click that
// ends a selection or a drag across the row opens nothing (DESIGN "Text stays selectable").
describe("Files · a press on the row plays it", () => {
const sizeCellOf = (text: string) =>
[...rowOf(text).querySelectorAll<HTMLElement>("td")].find((td) => !td.querySelector("button"))!;
it("plays the file from a click on the row, not only on its name", async () => {
mocks.files = [file(1)];
mocks.folders = [YT];
mount();
fireEvent.click(sizeCellOf("Clip 1"));
expect(await screen.findByText("playing 1")).toBeTruthy();
});
it("leaves a press on the row's own control to that control", async () => {
mocks.files = [file(1)];
mocks.folders = [YT];
mount();
// Share opens its own dialog; the row must not start the player under it.
const share = rowOf("Clip 1").querySelector<HTMLElement>(
'button[aria-label="files.actions.share"]',
)!;
fireEvent.click(share);
expect(await screen.findByRole("dialog")).toBeTruthy();
expect(screen.queryByText("playing 1")).toBeNull();
});
it("opens nothing for a press that travelled across the row", async () => {
mocks.files = [file(1)];
mocks.folders = [YT];
mount();
const cell = sizeCellOf("Clip 1");
// jsdom has no PointerEvent: the press's coordinates are set on the event by hand.
const down = createEvent.pointerDown(cell);
Object.defineProperties(down, { clientX: { value: 10 }, clientY: { value: 10 } });
fireEvent(cell, down);
fireEvent.click(cell, { clientX: 60, clientY: 10, detail: 1 });
await act(async () => {});
expect(screen.queryByText("playing 1")).toBeNull();
});
it("opens nothing for the click that ends selecting the row's words", async () => {
mocks.files = [file(1)];
mocks.folders = [YT];
mount();
const cell = sizeCellOf("Clip 1");
const range = document.createRange();
range.selectNodeContents(rowOf("Clip 1"));
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
fireEvent.click(cell, { detail: 1 });
await act(async () => {});
window.getSelection()!.removeAllRanges();
expect(screen.queryByText("playing 1")).toBeNull();
});
});
+2
View File
@@ -690,6 +690,8 @@ export default function Files() {
narrowedBy={q}
pageScope={pageScope}
rowProps={(f) => ({ ...rowMenu.triggerProps(f), "data-file-id": f.id })}
// A press anywhere on the row does what its name does: plays, or opens the viewer.
rowOpen={openOf}
// `group`: the row's quick icons show while it is hovered or holds the focus.
rowClassName={(f) =>
ringed === f.id ? "group bg-accent/8 ring-2 ring-inset ring-accent/60" : "group"