fix(e10): review round 2 — revert what round 1 got wrong, narrow what it got too broad
Round 2 reviewed round 1's own fix diff, which is where a fix round does
its damage. It found 15 issues in 792 lines — a much higher density than
round 1 found in the whole epic — and most were regressions round 1
introduced. So this follows the rule rather than fighting it: revert the
fixes whose mechanism was wrong, narrow the ones whose guard was too
broad, and stop reaching for a third variant.
REVERTED, because the mechanism does not work:
- The `inert` menu item. Round 1 swapped a native `disabled` for
`aria-disabled` so a row's "why is this off" tooltip could be reached.
floating-ui's list navigation skips `aria-disabled="true"` exactly as it
skips `disabled` (isListIndexDisabled) — and AnchoredPopover's own
comment, written before this branch, already said so. The swap fixed
nothing and made the mouse worse: without native `disabled` the row took
pointer events, the full active highlight and DOM focus, then no-opped
on click. The underlying finding is logged where the prop was, unfixed
and honest: the reason has to go somewhere a disabled row is not.
- `useContextMenu` owning the focus restore. Turning floating-ui's
returnFocus off platform-wide traded one bug for two: Playlists spreads
triggerProps on a bare <tr>, so restoring to it is a spec no-op and
Escape stranded focus on <body>; and with dismissOnScroll now on, the
restore's plain focus() scrolled the row back into view, undoing the
scroll that closed the menu. Patching that needs three more guards on
one fix. The original bug — the rail's rename field dying on arrival —
is fixed at the field instead: it focuses from an effect, which is
scheduled after the microtask that was blurring it.
NARROWED, because the fix was right and the guard was not:
- PlexPlayer's Backspace deferred to `anyLayerAbove`, but the player's own
gear and tracks menus register layers via useDismiss — so Backspace
stopped closing them and did nothing at all. Only the cheat sheet and
the palette own that key, so it names them.
- `historyDepth = 0` also desynced the counter from overlays the
navigation does NOT unmount (About and ReleaseNotes live outside the
page column), leaving one Back to both navigate and close them. The
entries are marked buried and consumed silently by reconcile instead.
- The move's error toast fired for every status, so a transport fault
surfaced twice on top of req's own dialog. Scoped to 403/404.
- The palette's moved-pointer guard could not judge the FIRST move of a
session and went stale across a close. `e.movementX/Y` asks the browser
directly and needs no state.
- `useKeyScope("contextMenu")` runs once per hook, and the Playlists page
mounts two — so the cheat sheet rendered the chords twice under a
duplicated React key. The scope stack dedupes.
- The `enabled:hover:` sweep missed the half that lives in CSS, so a
disabled glass button still lifted. `.glass-hover:not(:disabled):hover`.
- ShareDialog's migrated buttons passed a colour className that the
variant's own colour class outranks under clsx, so Revoke hovered
neutral and a password-protected link looked unprotected. The colour
goes on the icon, as the Copy button beside them already did.
- `matchesShortcut` took an untyped id and ids are only unique within a
scope, so `back` would have answered for the wrong player. Keyed on
scope + id, both literal.
And the systemic one: round 2 reverted eight of round 1's behaviour fixes
as a single mutation and all 763 tests still passed. Round 1 tested the
three fixes that were easy to test and none of the rest. So the guards
with the widest blast radius are pinned now, each mutation-checked to
fail for its own reason: the global keymap's repeat/allowInInput/disabled
handling (a new dom test), the cheat sheet bound to the real
useContextMenu rather than a scope stub, the scope dedupe, the history
burial, and the Button accessible-name union — whose @ts-expect-error
fixtures turn a widened type into a red typecheck lane.
The chipLane contract test that round 1 rewrote to count per lane counted
the import line as a lane, giving every file one free mirror — so the
exact hole it was rewritten to close was still open. Mutation-checked
both ways this time.
This commit is contained in:
@@ -64,8 +64,6 @@ 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({
|
||||
@@ -73,10 +71,7 @@ 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);
|
||||
restoreFocusRef.current();
|
||||
}
|
||||
if (!next) setTarget(null);
|
||||
},
|
||||
// A native context menu drops down-and-right from the pointer; flip()/shift() take it the other
|
||||
// way near an edge.
|
||||
@@ -86,44 +81,18 @@ export function useContextMenu<T>(): ContextMenuController<T> {
|
||||
// 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);
|
||||
@@ -197,7 +166,7 @@ export function useContextMenu<T>(): ContextMenuController<T> {
|
||||
// pointer that isn't there.
|
||||
// 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;
|
||||
if (!matchesShortcut(e.nativeEvent, "contextMenu", "contextMenu")) return;
|
||||
e.preventDefault();
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
openAt(r.left, r.bottom, payload, e.currentTarget);
|
||||
@@ -206,17 +175,5 @@ export function useContextMenu<T>(): ContextMenuController<T> {
|
||||
[openAt, cancelPress],
|
||||
);
|
||||
|
||||
restoreFocusRef.current = restoreFocus;
|
||||
|
||||
return {
|
||||
target,
|
||||
open,
|
||||
close: () => {
|
||||
setOpen(false);
|
||||
setTarget(null);
|
||||
restoreFocus();
|
||||
},
|
||||
triggerProps,
|
||||
popover: pop,
|
||||
};
|
||||
return { target, open, close: () => setOpen(false), triggerProps, popover: pop };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user