fix(keys): a list inside the topmost layer walks by keyboard

The list keys are the page's and an open layer silences them, so the
player's related videos could only be reached by Tab. A layer now names
its root element; with the focus on a row inside the topmost one, j/k,
the arrows and Enter walk that list, the walk stays inside the layer,
and the player's seek and volume arrows leave a focused row alone.
This commit is contained in:
2026-10-24 22:45:26 +02:00
parent 5205091116
commit 7d45c4f59f
8 changed files with 194 additions and 42 deletions
+6 -3
View File
@@ -25,7 +25,8 @@ import type { Page } from "./urlState";
* `page`, `search` and `list` are the PAGE-LEVEL scopes: they act on the page underneath
* every overlay, so `dispatch` keeps them quiet while a layer that owns the keyboard is open. That is
* also what keeps them clear of the players' bare letters — the Plex player's `s` and `k` only ever
* run with the player's own layer up. */
* run with the player's own layer up. The one exception is a list INSIDE the topmost layer, with the
* focus in it (`dispatch`'s `inTopLayer`, decided by lib/listNav). */
export type KeyScope =
| "global"
| "page"
@@ -358,11 +359,13 @@ const stepsOf = (chord: string): string[] => chord.split(" ");
* implementation cannot drift without tsc saying so. Skipped while typing unless the row opts in
* with `allowInInput` (Escape, and only Escape), on a key a focused widget already handled, and — for a page-level scope — while a layer that
* owns the keyboard is open. The rule lives here rather than in each listener so a new page-level
* surface cannot forget it. */
* surface cannot forget it. `inTopLayer` is the one way past it: the caller has checked that the
* key acts inside that topmost layer, not on the page behind (a list in the player). */
export function dispatch<S extends KeyScope>(
e: KeyboardEvent,
scope: S,
handlers: { readonly [K in ShortcutId<S>]: (e: KeyboardEvent) => void },
{ inTopLayer = false }: { inTopLayer?: boolean } = {},
): boolean {
if (sequenceEvents.has(e)) return false;
// A key a focused widget already handled is not a shortcut: every listener here is on `window`,
@@ -374,7 +377,7 @@ export function dispatch<S extends KeyScope>(
if (e.key === "Escape" && escapeHandled(e)) return false;
const live = liveSequence(e);
if (live && live.scope !== scope) return false;
const quiet = isPageLevelScope(scope) && anyBlockingLayer();
const quiet = isPageLevelScope(scope) && anyBlockingLayer() && !inTopLayer;
const typing = isTypingTarget(e);
const run = (id: string) => (handlers as Record<string, (ev: KeyboardEvent) => void>)[id]?.(e);
+19 -1
View File
@@ -26,12 +26,21 @@ const passive = new Set<number>();
// none, and Tab from anything outside the prompt walked into the page behind the scrim. The two
// questions are separate; a layer answers each for itself.
const defers = new Set<number>();
// The element a layer draws into, for the layers that hold content of their own to navigate (a
// dialog, the player): a list inside the TOPMOST such layer is walked by the keyboard although a
// layer is open — it is the one the user is looking at, not the page behind.
const roots = new Map<number, () => HTMLElement | null>();
export function pushLayer(opts?: { passive?: boolean; trapsTab?: boolean }): number {
export function pushLayer(opts?: {
passive?: boolean;
trapsTab?: boolean;
root?: () => HTMLElement | null;
}): number {
const id = nextId++;
stack.push(id);
if (opts?.passive) passive.add(id);
if (opts?.trapsTab === false) defers.add(id);
if (opts?.root) roots.set(id, opts.root);
return id;
}
@@ -39,6 +48,15 @@ export function removeLayer(id: number): void {
stack = stack.filter((x) => x !== id);
passive.delete(id);
defers.delete(id);
roots.delete(id);
}
/** The element of the topmost layer that owns the keyboard — null while none is open, or while that
* layer gave none (a menu: its own keys move inside it). */
export function topBlockingLayerRoot(): HTMLElement | null {
const blocking = stack.filter((id) => !passive.has(id));
const top = blocking[blocking.length - 1];
return top === undefined ? null : (roots.get(top)?.() ?? null);
}
/** True while a layer that owns the keyboard is open — a modal, a player, a click-opened menu. The
+29
View File
@@ -202,6 +202,35 @@ describe("useListNav", () => {
removeLayer(tooltip);
});
// A list INSIDE the topmost layer (the player's related videos) is the one the user is in.
it("walks a list inside the topmost layer, and only inside it", () => {
render(
<>
<List id="a" count={2} />
<div data-testid="layer">
<List id="b" count={2} />
</div>
</>,
);
const dialog = pushLayer({ root: () => screen.getByTestId("layer") });
act(() => screen.getByTestId("b0-action").focus());
press("j");
expect(focused()).toBe("b1");
press("k");
press("k"); // its first row: the walk stops there, not on the page's list behind
expect(focused()).toBe("b0");
// A menu opened over it moves with its own keys: the list stays still.
const menu = pushLayer();
press("j");
expect(focused()).toBe("b0");
removeLayer(menu);
// Focus left behind on the page's list does not walk it while the layer is up.
act(() => screen.getByTestId("a0-action").focus());
press("j");
expect(focused()).toBe("a0-action");
removeLayer(dialog);
});
// A virtualized grid only mounts a screen of rows: the next item may not exist yet.
it("asks the list to reveal an unmounted item, then focuses it once it renders", async () => {
const reveal = vi.fn();
+58 -34
View File
@@ -1,5 +1,6 @@
import { useEffect, useEffectEvent, useState, type RefObject } from "react";
import { dispatch, useKeyScope } from "./keymap";
import { anyBlockingLayer, topBlockingLayerRoot } from "./layerStack";
import { scrollParent } from "./listPaging";
import { focusIsUnclaimed } from "./useFocusKeeper";
@@ -73,11 +74,15 @@ function itemAt(list: NavList, index: number): HTMLElement | null {
}
function ordered(): NavList[] {
// Inside an open layer (the player) the walk stays in it: `k` on its first row must not land on
// the feed behind the scrim.
const layer = anyBlockingLayer() ? topBlockingLayerRoot() : null;
return (
[...lists.values()]
// Only lists on screen: a list can be mounted yet hidden (display:none in a collapsed panel or
// an inactive tab), and `j` must not walk into rows nobody can see.
.filter((l) => l.el.isConnected && l.el.checkVisibility?.() !== false)
.filter((l) => !layer || layer.contains(l.el))
.sort((a, b) =>
a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
)
@@ -92,6 +97,10 @@ function current(): { list: NavList; index: number; item: HTMLElement } | null {
return item && list ? { list, index: indexOf(item), item } : null;
}
/** True while the focus is on an item of a keyboard-walked list (or a control inside one): its arrows
* move between the items, so a surface that also binds them (the player's seek) leaves them alone. */
export const focusInNavList = (): boolean => current() !== null;
/** The mounted items that belong to `el` itself (not to a list nested inside it), in index order. */
function ownItems(el: HTMLElement): HTMLElement[] {
return [...el.querySelectorAll<HTMLElement>(`[${INDEX}]`)]
@@ -325,42 +334,57 @@ function onKey(e: KeyboardEvent): void {
const arrow = (dx: -1 | 0 | 1, dy: -1 | 0 | 1) => (ev: KeyboardEvent) => {
if (move(dx, dy)) ev.preventDefault();
};
dispatch(e, "list", {
next: (ev) => {
ev.preventDefault();
step(1);
// The list keys act on the page, so an open layer silences them — unless the focus is in a list
// INSIDE the topmost layer: that list is what the user is in (the player's related videos).
const layer = topBlockingLayerRoot();
const cur = current();
const inTopLayer = !!layer && !!cur && layer.contains(cur.item);
dispatch(
e,
"list",
{
next: (ev) => {
ev.preventDefault();
step(1);
},
prev: (ev) => {
ev.preventDefault();
step(-1);
},
left: arrow(-1, 0),
right: arrow(1, 0),
up: arrow(0, -1),
down: arrow(0, 1),
// Only on the item ROOT: Enter on a button inside the card must stay that button's Enter.
open: (ev) => {
const cur = current();
if (!cur || document.activeElement !== cur.item) return;
const target = cur.item.querySelector<HTMLElement>("[data-nav-open]");
if (!target) return;
ev.preventDefault();
// A real click on the item's own "open" element, so Enter runs exactly the click path — not a
// second copy of what opening means that could drift from it.
target.click();
},
},
prev: (ev) => {
ev.preventDefault();
step(-1);
{ inTopLayer },
);
dispatch(
e,
"listDelete",
{
// On the item root, like Enter — and only in a list that registered a remover.
remove: (ev) => {
const cur = current();
const remove = cur?.list.remove();
if (!cur || !remove || document.activeElement !== cur.item) return;
ev.preventDefault();
remove(cur.index);
refocusSlot(cur.list, cur.index, cur.item);
},
},
left: arrow(-1, 0),
right: arrow(1, 0),
up: arrow(0, -1),
down: arrow(0, 1),
// Only on the item ROOT: Enter on a button inside the card must stay that button's Enter.
open: (ev) => {
const cur = current();
if (!cur || document.activeElement !== cur.item) return;
const target = cur.item.querySelector<HTMLElement>("[data-nav-open]");
if (!target) return;
ev.preventDefault();
// A real click on the item's own "open" element, so Enter runs exactly the click path — not a
// second copy of what opening means that could drift from it.
target.click();
},
});
dispatch(e, "listDelete", {
// On the item root, like Enter — and only in a list that registered a remover.
remove: (ev) => {
const cur = current();
const remove = cur?.list.remove();
if (!cur || !remove || document.activeElement !== cur.item) return;
ev.preventDefault();
remove(cur.index);
refocusSlot(cur.list, cur.index, cur.item);
},
});
{ inTopLayer },
);
}
// How long a removal gets to take its row out of the DOM before the focus gives up following it.