- A press that begins while a field in the row is edited only ends the editing (rowOpen latches it at pointerdown); the card layout's row press is now tested. - A related video reset in the player forgets the session's stored position. - Removing the playing video passes over known-dead items and steps the way it went. - Prev/Next off only because of dead items stay focusable and say why (HU/EN). - The docked bar's scroller leaves room for focus rings. - A focus-opened About card is not closed by its own scroll into view. - EN "Clear watch progress" for the video reset, distinct from a page's "Reset view". - The signed-out title read goes to the page yt-dlp read; the retitle writes are conditional. - Tests: the cap of three errors, the bar's own Fullscreen, the group glyph, the backfill's reads.
74 lines
3.6 KiB
TypeScript
74 lines
3.6 KiB
TypeScript
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<Element, { x: number; y: number; editing: boolean }>();
|
|
|
|
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>) => {
|
|
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<HTMLElement>) => {
|
|
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<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;
|
|
const pressed = pressedAt.get(row) ?? null;
|
|
return !pressed?.editing && !endsTextSelection(e) && !movedPastSlop(pressed, e);
|
|
}
|