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
+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.