fix(e10): review round 1 — the boundary that did not bind, and 14 more
The epic-end review found 32 verified issues. This is the first fix round: the three highs, the user-facing mediums, and the tests that were passing for the wrong reason. Docs and the remaining low-severity tail are next. The boundary rule was the worst of them. Both `no-restricted-imports` regexes were written in plain "..." literals, where `\.` is an unknown escape that collapses to the any-char `.` — so the rule read like a boundary and matched far less than one. `../../lib/api` (the barrel every feature component imports) and `../../components/Feed` (the same file `../Feed` names) both sailed through a green gate. String.raw, a third pattern for the second spelling, and a vitest over ESLint.lintText so a rule that silently matches nothing can never look like a clean codebase again. closeThenNavigate waited for exactly one reclaim popstate. With a second Back-closable layer still mounted — a palette opened over the player — the navigation unmounted that layer too, and its reconcile backed over the entry the navigation had just pushed: "go to Channels" closed the player and landed on the feed. It now waits for the LAST programmatic back, then adopts the entries the navigation buried, and falls back after 250ms rather than hanging if no popstate is ever coming. The rail's inline rename died on arrival. With no domReference, floating- ui's returnFocus falls through to a module-global stack and restores some unrelated element from an earlier popover — blurring the field into its own commit-and-close, from the second popover of a session onward. The context menu now owns the restore: back to the row, but only if the row is still there and nothing else has claimed focus. Also: - Backspace under the cheat sheet stopped Plex playback and dropped to the grid; `back` now defers to any layer above, like `escape` beside it. - A move invalidated only the source list, so the target kept serving pre-move contents for 30s while the rail already counted N+1. - Every move error a user can reach is a 404, which `req` leaves to the caller — so the row snapped back in silence and the shipped moveErrors strings were unreachable. - swallowClick armed on long-press but was disarmed only by the click it was armed for; any press that produced no click ate the next tap on the whole list. - The context menu stayed parked over whatever row scrolled under it. - overflow-clip on the chip lanes ate the focus ring's top and bottom. - "Why is this off" rode on disabled rows, which show no tooltip and take no focus: a new `inert` item keeps the reason readable. - Shift+F10 was advertised app-wide but wired only on Playlists; it has its own scope now, and useContextMenu matches through the shared parser instead of its own literals. - Mod+K could not close the palette, `h` machine-gunned on auto-repeat, and both were bound above the branch that mounts their overlays. - Palette options were in the tab order under aria-activedescendant, and a scroll-synthesised mousemove stole the keyboard selection. - The OSD live region was created together with its own text. - Ending a rename stranded focus on <body>. - The Button variants' hover halves are `enabled:` — the one migrated site that had guarded its own lost the guard on the way in. - HU builtinLocked named the list "Watch later" in a Hungarian sentence. Tests: the chip-lane contract counted per FILE, so a second hand-rolled lane in an already-compliant file was invisible; the closeThenNavigate ordering test only passed on a popstate left behind by its neighbours and failed alone, so it moved to its own file.
This commit is contained in:
@@ -22,7 +22,15 @@ interface MenuItemBase {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending)
|
||||
/** Off because it is BUSY (a mutation in flight): a native `disabled` button, skipped by roving
|
||||
* focus. Use it when there is nothing to explain — the row will be back in a moment. */
|
||||
disabled?: boolean;
|
||||
/** Off because it does not APPLY here (no other list to move to, a built-in list you cannot
|
||||
* rename): reachable but inert — `aria-disabled`, still focusable, still tooltip-capable, and it
|
||||
* no-ops on activation. `disabled` would hide the very explanation the row exists to give, since
|
||||
* a disabled button fires no pointer events (no `title`) and roving focus skips it, so neither a
|
||||
* mouse nor a keyboard could ever read the `title` (E10 review round 1). */
|
||||
inert?: boolean;
|
||||
title?: string; // optional native tooltip (carries a longer hint the label alone can't)
|
||||
/** Draw a divider above this item. Ignored on the first item — a menu never opens with a rule. */
|
||||
separatorBefore?: boolean;
|
||||
@@ -71,13 +79,16 @@ export function MenuItemList({
|
||||
<PopoverItem
|
||||
role="menuitem"
|
||||
disabled={it.disabled}
|
||||
aria-disabled={it.inert || undefined}
|
||||
label={it.label}
|
||||
title={it.title}
|
||||
onSelect={() => {
|
||||
// An inert row is reachable on purpose (so its `title` can be read) but must not act.
|
||||
if (it.inert) return;
|
||||
it.onSelect();
|
||||
onClose();
|
||||
}}
|
||||
className={`${ROW}${it.danger ? " text-danger" : ""}`}
|
||||
className={`${ROW}${it.inert ? " opacity-50" : ""}${it.danger ? " text-danger" : ""}`}
|
||||
>
|
||||
{it.icon && (
|
||||
<span className={`shrink-0 ${it.danger ? "text-danger" : "text-muted"}`}>
|
||||
|
||||
@@ -112,6 +112,19 @@ export interface AnchoredPopoverOptions {
|
||||
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
|
||||
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
|
||||
selectedIndex?: number | null;
|
||||
/** Close when a scroll container under the panel scrolls. Off by default, because a panel anchored
|
||||
* to a real trigger element simply MOVES with it. Turn it on when the anchor is a fixed point
|
||||
* rather than an element — a right-click menu, which otherwise stays parked over whatever row
|
||||
* scrolls under it while its actions still target the row you opened it on (E10 review round 1).
|
||||
* This is also what a native context menu does. */
|
||||
dismissOnScroll?: boolean;
|
||||
/** Hand the focus back to the trigger on close. On by default, and right whenever there IS a
|
||||
* trigger element. Turn it off when the anchor is a virtual point: floating-ui then has no
|
||||
* `domReference` to return to and falls back to a module-global "previously focused" stack, so it
|
||||
* restores some unrelated element from an earlier popover — which, in the rail's inline rename,
|
||||
* stole focus back from the field that had just opened and tore it down again (E10 review round
|
||||
* 1). A caller that turns this off owns the restore. */
|
||||
returnFocus?: boolean;
|
||||
}
|
||||
|
||||
// Internal: call sites use the inferred hook return, they don't name the type.
|
||||
@@ -215,7 +228,10 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
// off entirely. (Leaving it on for hover made an open tooltip's document-level handler either
|
||||
// stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside-
|
||||
// press stays with floating-ui (tree-aware).
|
||||
const dismiss = useDismiss(context, { escapeKey: false });
|
||||
const dismiss = useDismiss(context, {
|
||||
escapeKey: false,
|
||||
ancestorScroll: opts.dismissOnScroll ?? false,
|
||||
});
|
||||
// `role:"combobox"` makes Floating UI wire the whole typeable-combobox ARIA tree: the reference input
|
||||
// → role=combobox + aria-expanded/controls/autocomplete + aria-activedescendant (from the virtual nav),
|
||||
// the surface → role=listbox, the items → role=option + their option ids. (`combobox` maps to the
|
||||
@@ -285,6 +301,7 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
roves,
|
||||
open: opts.open,
|
||||
selectedIndex: opts.selectedIndex ?? null,
|
||||
returnFocus: opts.returnFocus ?? true,
|
||||
refs,
|
||||
floatingStyles,
|
||||
context,
|
||||
@@ -509,6 +526,7 @@ function PopoverSurfaceInner({
|
||||
isNav,
|
||||
isCombobox,
|
||||
roves,
|
||||
returnFocus,
|
||||
selectedIndex,
|
||||
activeIndex,
|
||||
highlightIndex,
|
||||
@@ -606,7 +624,7 @@ function PopoverSurfaceInner({
|
||||
// — in an item-less menu (e.g. a first-run tag editor with only a create input) — the
|
||||
// input, so such a menu still has a keyboard entry point instead of stranding focus.
|
||||
initialFocus={initialFocus ?? (isNav && selectedIndex != null ? -1 : undefined)}
|
||||
returnFocus
|
||||
returnFocus={returnFocus}
|
||||
>
|
||||
{positioned}
|
||||
</FloatingFocusManager>
|
||||
|
||||
@@ -43,23 +43,30 @@ const VARIANTS: Record<ButtonVariant, string> = {
|
||||
// (ghost), while this primitive hovered to `fg` and filled with `surface` — so the 37 migrated sites
|
||||
// and the ~100 hand-rolled ones had drifted into two different looks. That split IS the
|
||||
// inconsistency this epic exists to remove; the primitive follows the majority, not the reverse.
|
||||
outline: "font-medium border border-border text-muted hover:text-accent hover:border-accent",
|
||||
"outline-accent": "font-medium border border-accent/40 text-accent hover:bg-accent/10",
|
||||
outline:
|
||||
"font-medium border border-border text-muted enabled:hover:text-accent enabled:hover:border-accent",
|
||||
"outline-accent": "font-medium border border-accent/40 text-accent enabled:hover:bg-accent/10",
|
||||
// Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference
|
||||
// is whether the button should advertise the destruction before the pointer arrives.
|
||||
"outline-danger":
|
||||
"font-medium border border-border text-muted hover:text-danger hover:border-danger/40",
|
||||
"outline-warn": "font-medium border border-warn/40 text-warn hover:bg-warn/10",
|
||||
ghost: "font-medium text-muted hover:text-fg hover:bg-card",
|
||||
glass: "font-medium glass-card glass-hover hover:text-accent",
|
||||
"font-medium border border-border text-muted enabled:hover:text-danger enabled:hover:border-danger/40",
|
||||
"outline-warn": "font-medium border border-warn/40 text-warn enabled:hover:bg-warn/10",
|
||||
ghost: "font-medium text-muted enabled:hover:text-fg enabled:hover:bg-card",
|
||||
glass: "font-medium glass-card glass-hover enabled:hover:text-accent",
|
||||
"danger-soft":
|
||||
"font-medium border border-danger/60 text-danger hover:bg-danger/10 hover:border-danger",
|
||||
"font-medium border border-danger/60 text-danger enabled:hover:bg-danger/10 enabled:hover:border-danger",
|
||||
// No font-weight here on purpose: BASE already sets font-medium, and a second font-* utility would
|
||||
// be a coin-flip (both are single-class utilities of equal specificity, and without tailwind-merge
|
||||
// the winner is decided by the generated stylesheet order, not by the class list order).
|
||||
link: "font-semibold text-accent hover:underline",
|
||||
link: "font-semibold text-accent enabled:hover:underline",
|
||||
};
|
||||
|
||||
// Why every hover above is `enabled:hover:` — Tailwind's `:not(:disabled)`. A disabled button that
|
||||
// still lights up on hover advertises a click that does nothing, and the ONE migrated call site that
|
||||
// had guarded its own hover (TagManager's Add) silently lost the guard when it moved onto this
|
||||
// primitive (E10 review round 1). The contract belongs here, not in ~160 call sites: BASE dims a
|
||||
// disabled button, and the variant must not paint over that dimming.
|
||||
|
||||
// The geometry ladder, and the whole of it — a call site picks a rung, it does not invent one. The
|
||||
// rungs were derived from what the app actually used (counted during E10 S1b): `px-3 py-2 text-sm`
|
||||
// (22 sites), `px-2 py-1 text-xs` (12), `px-3 py-1.5 text-sm` (11). Every value is rem-derived, so
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// (no i18n, no domain), so this asserts the shell only. i18n is key-passthrough.
|
||||
import { useState } from "react";
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import CommandPalette, { type CommandGroup } from "./CommandPalette";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
@@ -149,19 +149,10 @@ describe("CommandPalette", () => {
|
||||
expect(selected()).toBe("ch:1");
|
||||
});
|
||||
|
||||
// The ORDER is the point (closeThenNavigate): the dialog closes and gives its Back-to-close
|
||||
// history entry back BEFORE the command runs, because a command that navigates would otherwise
|
||||
// have its pushState undone by that reclaim.
|
||||
it("closes, and runs the command only once the close has landed", async () => {
|
||||
open();
|
||||
fireEvent.click(screen.getByText("TheVR Podcast"));
|
||||
expect(ran).toEqual(["closed"]);
|
||||
await waitFor(() => expect(ran).toEqual(["closed", "ch:1"]));
|
||||
});
|
||||
|
||||
// Only ONE test here can go through the full close→popstate→run cycle: jsdom gives a file a single
|
||||
// session-history entry, so the first back() consumes it and later ones fire no popstate. Which
|
||||
// command Enter picks is covered by the selection assertions above; this checks it chooses at all.
|
||||
// No test in THIS file goes through the full close→reclaim→run cycle: jsdom gives a file one
|
||||
// session history and history.ts's overlay bookkeeping is module-global, so the cycle only holds
|
||||
// in isolation — it lives in CommandPalette.ordering.dom.test.tsx. Which command Enter picks is
|
||||
// covered by the selection assertions above; this checks it chooses at all.
|
||||
it("chooses the selected row on Enter", () => {
|
||||
open();
|
||||
press("ArrowDown");
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// ONE test, ONE file, on purpose. This is the close→reclaim→run ordering contract of the palette
|
||||
// (see closeThenNavigate), and it is the only assertion in the suite that drives a real
|
||||
// useBackToClose entry through a real programmatic history.back().
|
||||
//
|
||||
// It cannot live in CommandPalette.dom.test.tsx. jsdom gives a test FILE a single session history,
|
||||
// and history.ts's overlay bookkeeping is module-global, so the sibling tests there leave an
|
||||
// in-flight programmatic back() behind — under which this one either hangs or, worse, goes green on
|
||||
// somebody else's popstate. That is exactly how it used to pass: it asserted nothing on its own, and
|
||||
// running it with `-t` failed (E10 review round 1). Its own file gives it clean module state.
|
||||
import { useState } from "react";
|
||||
import { describe, it, expect, vi, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
|
||||
import CommandPalette, { type CommandGroup } from "./CommandPalette";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom has no layout, so scrollIntoView is missing on the elements the active-row effect touches.
|
||||
Element.prototype.scrollIntoView ??= () => {};
|
||||
});
|
||||
|
||||
const ran: string[] = [];
|
||||
|
||||
function Harness() {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const groups: CommandGroup[] = [
|
||||
{
|
||||
id: "channels",
|
||||
label: "Channels",
|
||||
items: [{ id: "ch:1", label: "TheVR Podcast", run: () => ran.push("ch:1") }],
|
||||
},
|
||||
];
|
||||
return (
|
||||
<CommandPalette
|
||||
open={isOpen}
|
||||
onClose={() => {
|
||||
ran.push("closed");
|
||||
setIsOpen(false);
|
||||
}}
|
||||
groups={groups}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("CommandPalette · closeThenNavigate ordering", () => {
|
||||
it("closes, and runs the command only once the close has landed", async () => {
|
||||
render(<Harness />);
|
||||
// Load-bearing: useBackToClose pushes its entry from a coalesced microtask, so a click in the
|
||||
// same synchronous tick as the render finds no entry to reclaim — no back(), no popstate.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("TheVR Podcast"));
|
||||
expect(ran).toEqual(["closed"]);
|
||||
|
||||
// Inside closeThenNavigate's 250ms no-popstate fallback, so a pass means the RECLAIM ran the
|
||||
// command — not the safety net that exists for overlays which never got an entry at all.
|
||||
await waitFor(() => expect(ran).toEqual(["closed", "ch:1"]), { timeout: 200 });
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,8 @@ export default function CommandPalette({
|
||||
// Ref on the highlighted row rather than a selector lookup: item ids come from domain data
|
||||
// (playlist names, channel handles) and would need escaping to be safe in a query string.
|
||||
const activeRef = useRef<HTMLButtonElement | null>(null);
|
||||
// Last pointer position, so a mousemove the BROWSER synthesised can be told from one the user made.
|
||||
const lastPointer = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
// A fresh open starts from a blank field: the palette is for "take me somewhere", and a leftover
|
||||
// query from last time hides everything you might want now.
|
||||
@@ -158,8 +160,22 @@ export default function CommandPalette({
|
||||
role="option"
|
||||
aria-selected={item.id === activeId}
|
||||
type="button"
|
||||
// The combobox input owns the tab stop; the arrows move a VIRTUAL selection over
|
||||
// these rows. Leaving them tabbable put DOM focus on a row while
|
||||
// aria-activedescendant named a different one — two competing "current" options —
|
||||
// and made Tab walk every result (E10 review round 1).
|
||||
tabIndex={-1}
|
||||
onClick={() => choose(item)}
|
||||
onMouseMove={() => setActive(flat.indexOf(item))}
|
||||
onMouseMove={(e) => {
|
||||
// Only a pointer that actually MOVED may take the selection. After the arrow keys
|
||||
// scroll a row past the fold, Blink re-dispatches a mousemove at the unchanged
|
||||
// cursor position to refresh hover — which handed the highlight straight back to
|
||||
// whatever row slid under a resting cursor (E10 review round 1).
|
||||
const prev = lastPointer.current;
|
||||
lastPointer.current = { x: e.clientX, y: e.clientY };
|
||||
if (prev && prev.x === e.clientX && prev.y === e.clientY) return;
|
||||
setActive(flat.indexOf(item));
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm ${
|
||||
item.id === activeId ? "bg-card text-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useGlobalKeymap,
|
||||
useHelpConditions,
|
||||
useKeyScope,
|
||||
type KeyScope,
|
||||
} from "../../lib/keymap";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
@@ -37,8 +38,8 @@ function Host({ children }: { children?: React.ReactNode }) {
|
||||
}
|
||||
|
||||
/** A stand-in for a surface that registers a scope while mounted, the way the players do. */
|
||||
function Surface({ episode }: { episode?: boolean }) {
|
||||
useKeyScope("plexPlayer");
|
||||
function Surface({ episode, scope = "plexPlayer" }: { episode?: boolean; scope?: KeyScope }) {
|
||||
useKeyScope(scope);
|
||||
useHelpConditions({ episode: !!episode });
|
||||
return null;
|
||||
}
|
||||
@@ -81,13 +82,27 @@ describe("KeyboardHelp", () => {
|
||||
const rows = descriptions();
|
||||
// A plexPlayer row first, the app-wide rows last — the reading order the reader wants.
|
||||
expect(rows[0]).toBe("keys.playPause");
|
||||
expect(rows.slice(-3)).toEqual(["keys.palette", "keys.help", "keys.contextMenu"]);
|
||||
expect(rows.slice(-2)).toEqual(["keys.palette", "keys.help"]);
|
||||
});
|
||||
|
||||
it("lists only the app-wide keys when no surface is registered", () => {
|
||||
render(<Host />);
|
||||
pressH();
|
||||
expect(descriptions()).toEqual(["keys.palette", "keys.help", "keys.contextMenu"]);
|
||||
expect(descriptions()).toEqual(["keys.palette", "keys.help"]);
|
||||
});
|
||||
|
||||
// The context menu is NOT app-wide: it has its own scope, registered by `useContextMenu`, so the
|
||||
// sheet only offers Shift+F10 where something actually answers it. Advertising it globally was a
|
||||
// promise the feed, Plex, Downloads and Settings did not keep (E10 review round 1) — the test above
|
||||
// pins the other half, that a bare app lists the two global keys and nothing else.
|
||||
it("offers the context-menu key on a surface that has one", () => {
|
||||
render(
|
||||
<Host>
|
||||
<Surface scope="contextMenu" />
|
||||
</Host>,
|
||||
);
|
||||
pressH();
|
||||
expect(descriptions()).toContain("keys.contextMenu");
|
||||
});
|
||||
|
||||
// "Previous / next episode" inside a movie would be a promise the player does not keep.
|
||||
|
||||
@@ -19,7 +19,10 @@ export default function UndoToolbar({
|
||||
onRedo: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
useKeyScope("undo");
|
||||
// Only while a key actually DOES something: the sheet must not advertise Ctrl+Z on a page where
|
||||
// nothing has been done yet and the handler returns immediately (E10 review round 1) — the same
|
||||
// honest-states gate PlexBrowse uses for its Backspace.
|
||||
useKeyScope("undo", canUndo || canRedo);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
|
||||
import { matchesShortcut, useKeyScope } from "../../lib/keymap";
|
||||
import { useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// E10 S2 — the right-click context menu's INPUT half: the gestures that open it and the row they
|
||||
@@ -60,6 +62,10 @@ export interface ContextMenuController<T> {
|
||||
}
|
||||
|
||||
export function useContextMenu<T>(): ContextMenuController<T> {
|
||||
// Register the scope HERE, so the cheat sheet lists Shift+F10 exactly on the surfaces that answer it.
|
||||
useKeyScope("contextMenu");
|
||||
// `onOpenChange` is built before `restoreFocus` exists; a ref keeps the order from mattering.
|
||||
const restoreFocusRef = useRef<() => void>(() => {});
|
||||
const [target, setTarget] = useState<T | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
@@ -67,31 +73,70 @@ export function useContextMenu<T>(): ContextMenuController<T> {
|
||||
onOpenChange: (next) => {
|
||||
setOpen(next);
|
||||
// Drop the target on close so a stale row can't drive a re-open's first paint.
|
||||
if (!next) setTarget(null);
|
||||
if (!next) {
|
||||
setTarget(null);
|
||||
restoreFocusRef.current();
|
||||
}
|
||||
},
|
||||
// A native context menu drops down-and-right from the pointer; flip()/shift() take it the other
|
||||
// way near an edge.
|
||||
placement: "bottom-start",
|
||||
offset: 0,
|
||||
fitHeight: true,
|
||||
// The anchor is a fixed POINT, not an element, so scrolling cannot move the panel with its row —
|
||||
// it would sit over a different row than the one its actions target. Native menus close; so do we.
|
||||
dismissOnScroll: true,
|
||||
// Same reason: with no `domReference`, floating-ui's own returnFocus restores an unrelated
|
||||
// element off its module-global stack. We know the actual row, so we hand focus back ourselves.
|
||||
returnFocus: false,
|
||||
});
|
||||
|
||||
// The row the open menu belongs to, so focus can go back where it came from on close.
|
||||
const triggerEl = useRef<Element | null>(null);
|
||||
|
||||
const { setPositionReference } = pop.refs;
|
||||
const openAt = useCallback(
|
||||
(x: number, y: number, payload: T, contextElement?: Element) => {
|
||||
setPositionReference({ getBoundingClientRect: () => pointRect(x, y), contextElement });
|
||||
triggerEl.current = contextElement ?? null;
|
||||
setTarget(payload);
|
||||
setOpen(true);
|
||||
},
|
||||
[setPositionReference],
|
||||
);
|
||||
|
||||
/** Hand focus back to the row — but only if that is still the right thing to do.
|
||||
*
|
||||
* After the commit, because both conditions are about the world the close produced. A row that is
|
||||
* simply still there gets its focus back (the keyboard user is where they were). A row that has
|
||||
* been REPLACED keeps whatever replaced it: the rail swaps in an inline rename field on this very
|
||||
* path, and blindly restoring is what used to blur that field into its own commit-and-close (E10
|
||||
* review round 1). Likewise, an action that deliberately moved focus somewhere else keeps it. */
|
||||
const restoreFocus = useCallback(() => {
|
||||
const el = triggerEl.current;
|
||||
triggerEl.current = null;
|
||||
if (!el) return;
|
||||
queueMicrotask(() => {
|
||||
if (!el.isConnected) return;
|
||||
const active = document.activeElement;
|
||||
if (active && active !== document.body && !el.contains(active)) return;
|
||||
if (el instanceof HTMLElement) el.focus();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Long-press bookkeeping. Refs, not state: none of it paints, and a re-render per pointermove
|
||||
// during a scroll would be a real cost on the device this path exists for.
|
||||
const timer = useRef<number | null>(null);
|
||||
const startedAt = useRef<{ x: number; y: number } | null>(null);
|
||||
// A long-press is followed by a click on release. Without this the menu would open and the row's
|
||||
// own default action (play / select) would fire underneath it.
|
||||
//
|
||||
// It is armed by the timer and disarmed by the click it was armed FOR — but plenty of long presses
|
||||
// never produce that click (a pan ends in pointercancel, the finger lifts over the portalled menu,
|
||||
// Android Chrome suppresses the trailing click after its own contextmenu). The flag then stayed
|
||||
// armed, and since it is one ref per LIST, the next genuine tap on ANY row of that list was eaten
|
||||
// and the row looked dead until tapped twice (E10 review round 1). So a fresh press also disarms
|
||||
// it: whatever the last gesture left behind cannot outlive the start of the next one.
|
||||
const swallowClick = useRef(false);
|
||||
const cancelPress = useCallback(() => {
|
||||
if (timer.current !== null) window.clearTimeout(timer.current);
|
||||
@@ -111,6 +156,9 @@ export function useContextMenu<T>(): ContextMenuController<T> {
|
||||
openAt(e.clientX, e.clientY, payload, e.currentTarget);
|
||||
},
|
||||
onPointerDown: (e: ReactPointerEvent<HTMLElement>) => {
|
||||
// Before the pointerType guard: a stale arm left by an earlier touch press must not survive
|
||||
// into an unrelated interaction, mouse click included.
|
||||
swallowClick.current = false;
|
||||
// Mouse has a right button; only touch/pen need the timer.
|
||||
if (e.pointerType === "mouse") return;
|
||||
const { clientX: x, clientY: y } = e;
|
||||
@@ -132,7 +180,12 @@ export function useContextMenu<T>(): ContextMenuController<T> {
|
||||
cancelPress(); // they are scrolling, not pressing
|
||||
},
|
||||
onPointerUp: cancelPress,
|
||||
onPointerCancel: cancelPress,
|
||||
onPointerCancel: () => {
|
||||
// The gesture was taken over (a pan, the browser's own menu): no click is coming, so the
|
||||
// arm has nothing left to swallow and must not wait around for someone else's.
|
||||
swallowClick.current = false;
|
||||
cancelPress();
|
||||
},
|
||||
onClickCapture: (e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!swallowClick.current) return;
|
||||
swallowClick.current = false;
|
||||
@@ -142,7 +195,9 @@ export function useContextMenu<T>(): ContextMenuController<T> {
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => {
|
||||
// The keyboard route every desktop menu has. Anchored to the row's bottom-left rather than a
|
||||
// pointer that isn't there.
|
||||
if (e.key !== "ContextMenu" && !(e.key === "F10" && e.shiftKey)) return;
|
||||
// Through the shared parser, against the table's own row: the chords live in ONE place, and
|
||||
// `matches` is exact on modifiers (the hand-rolled version answered Ctrl+Shift+F10 too).
|
||||
if (!matchesShortcut(e.nativeEvent, "contextMenu")) return;
|
||||
e.preventDefault();
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
openAt(r.left, r.bottom, payload, e.currentTarget);
|
||||
@@ -151,5 +206,17 @@ export function useContextMenu<T>(): ContextMenuController<T> {
|
||||
[openAt, cancelPress],
|
||||
);
|
||||
|
||||
return { target, open, close: () => setOpen(false), triggerProps, popover: pop };
|
||||
restoreFocusRef.current = restoreFocus;
|
||||
|
||||
return {
|
||||
target,
|
||||
open,
|
||||
close: () => {
|
||||
setOpen(false);
|
||||
setTarget(null);
|
||||
restoreFocus();
|
||||
},
|
||||
triggerProps,
|
||||
popover: pop,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user