Merge pull request #4855 from rommapp/fix/v2-spatial-nav-vertical

fix(v2): move focus by arrow key and D-pad between page regions
This commit is contained in:
Georges-Antoine Assi
2026-09-28 08:15:59 -04:00
committed by GitHub
8 changed files with 480 additions and 30 deletions
+13 -19
View File
@@ -16,32 +16,26 @@
// * ArrowLeft / ArrowRight → prev / next cell in the current row.
// * ArrowUp / ArrowDown → same column in the prev / next row (clamped).
// * On autofocus we remember a "preferred column" and restore it when
// moving up/down through rows of different lengths — common media-UI
// moving up/down through rows of different lengths, a common media-UI
// pattern so row switching doesn't permanently lose horizontal place.
//
// Integration:
// * Input modality flipping to `"pad"` (gamepad connected / pressed)
// autofocuses the first cell, so `useGamepad`'s synthetic arrow keys
// immediately land somewhere useful.
// * `useGamepad` itself dispatches keydowns — so everything here is
// * A key that would leave the grid (up from the first row, …) is left
// unclaimed, so `useSpatialNav` carries focus to the next region.
// * `useGamepad` itself dispatches keydowns, so everything here is
// plain keyboard code; gamepad users transparently benefit.
//
// For wrapping CSS grids (PlatformsIndex / CollectionsIndex) — where
// there are no per-row DOM containers — use `useWrapGridNav` instead;
// For wrapping CSS grids (PlatformsIndex / CollectionsIndex), where
// there are no per-row DOM containers, use `useWrapGridNav` instead;
// it detects rows spatially from cell rects.
import { onBeforeUnmount, onMounted, watch, type Ref } from "vue";
import { useRoute } from "vue-router";
import { useInputModality } from "@/v2/composables/useInputModality";
import storeFocusRestoration from "@/v2/stores/focusRestoration";
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
import { FOCUSABLE_SELECTOR } from "@/v2/utils/spatialNav";
export interface UseGridNavOptions {
/** Selector that resolves to one DOM element per logical row.
@@ -50,7 +44,7 @@ export interface UseGridNavOptions {
rowSelector?: string;
/** Returns the row elements. Overrides `rowSelector`. Useful when
* the root element itself IS the only row (single-row toolbars
* like GameDetails' action ribbon) — pass `() => [rootEl.value!]`. */
* like GameDetails' action ribbon): pass `() => [rootEl.value!]`. */
getRows?: () => HTMLElement[];
/** Returns the cells of a given row. Defaults to the row's direct
* children. Pass `(row) => [row]` for list-mode rows where the row
@@ -134,7 +128,7 @@ export function useGridNav(
const cell = cs[clamped];
const target = focusableIn(cell);
// Roving tabindex — only the current cell is a tab stop, every other
// Roving tabindex: only the current cell is a tab stop, every other
// cell sets tabindex="-1". Lets the user land on the last focused
// card via Tab from outside the grid, and keeps Shift+Tab escape
// behaviour clean. Borrowed from the v1 console useRovingDom.
@@ -210,7 +204,7 @@ export function useGridNav(
return;
}
if (!rootRef.value) return;
// Only steer when focus is already inside the grid — don't hijack
// Only steer when focus is already inside the grid; don't hijack
// arrow keys meant for input fields, menus, or other widgets.
const active = document.activeElement;
if (!(active instanceof Node) || !rootRef.value.contains(active)) return;
@@ -249,7 +243,7 @@ export function useGridNav(
// Watches card rows for late-arriving children (data fetching finishes
// after mount, skeletons swap to real cards). When the first cell shows
// up — and the user is in pad modality without focus in the grid — we
// up (and the user is in pad modality without focus in the grid), we
// land focus on it.
let observer: MutationObserver | null = null;
@@ -273,7 +267,7 @@ export function useGridNav(
}
onMounted(() => {
window.addEventListener("keydown", onKey);
document.addEventListener("keydown", onKey);
window.addEventListener("focusin", onFocusIn);
if (rootRef.value) {
observer = new MutationObserver(() => maybeAutofocus());
@@ -283,7 +277,7 @@ export function useGridNav(
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", onKey);
document.removeEventListener("keydown", onKey);
window.removeEventListener("focusin", onFocusIn);
observer?.disconnect();
observer = null;
@@ -0,0 +1,160 @@
import { mount } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent, h, ref } from "vue";
import storePlaying from "@/stores/playing";
import { useGridNav } from "@/v2/composables/useGridNav";
import { useSpatialNav } from "./index";
vi.mock("vue-router", () => ({
useRoute: () => ({ fullPath: "/rom/1" }),
}));
// Laid out by data attributes since jsdom has no layout engine: a top bar
// link, the game page's action ribbon (a single-row useGridNav) under it,
// and a tab below the ribbon.
const Page = defineComponent({
setup() {
const ribbon = ref<HTMLElement | null>(null);
useGridNav(ribbon, {
getRows: () => (ribbon.value ? [ribbon.value] : []),
});
useSpatialNav().install();
const btn = (id: string, x: number, y: number) =>
h("button", { id, "data-x": x, "data-y": y });
return () =>
h("div", [
btn("nav", 0, 0),
h("div", { ref: ribbon }, [btn("play", 0, 300), btn("fav", 100, 300)]),
btn("tab", 0, 500),
]);
},
});
describe("useSpatialNav", () => {
const scrollIntoView = vi.fn();
let wrapper: ReturnType<typeof mount> | null = null;
const restores: (() => void)[] = [];
function stub<K extends keyof HTMLElement>(
key: K,
descriptor: PropertyDescriptor,
) {
const original = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
key,
);
Object.defineProperty(HTMLElement.prototype, key, {
configurable: true,
...descriptor,
});
restores.push(() => {
if (original) Object.defineProperty(HTMLElement.prototype, key, original);
else delete (HTMLElement.prototype as Partial<HTMLElement>)[key];
});
}
function el(id: string): HTMLElement {
return document.getElementById(id)!;
}
function press(key: string): KeyboardEvent {
const event = new KeyboardEvent("keydown", {
key,
bubbles: true,
cancelable: true,
});
document.activeElement!.dispatchEvent(event);
return event;
}
beforeEach(() => {
setActivePinia(createPinia());
scrollIntoView.mockClear();
stub("getBoundingClientRect", {
value(this: HTMLElement) {
if (this.dataset.x === undefined) return new DOMRect(0, 0, 0, 0);
return new DOMRect(
Number(this.dataset.x),
Number(this.dataset.y),
40,
40,
);
},
});
stub("scrollIntoView", { value: scrollIntoView });
wrapper = mount(Page, { attachTo: document.body });
});
afterEach(() => {
wrapper?.unmount();
wrapper = null;
restores.splice(0).forEach((restore) => restore());
});
it("moves up and down off a single-row grid", () => {
el("play").focus();
expect(press("ArrowUp").defaultPrevented).toBe(true);
expect(document.activeElement).toBe(el("nav"));
press("ArrowDown");
expect(document.activeElement).toBe(el("play"));
press("ArrowDown");
expect(document.activeElement).toBe(el("tab"));
});
it("leaves moves inside the grid to the grid", () => {
el("play").focus();
press("ArrowRight");
expect(document.activeElement).toBe(el("fav"));
});
it("does nothing when no control lies in the direction", () => {
el("nav").focus();
expect(press("ArrowUp").defaultPrevented).toBe(false);
expect(document.activeElement).toBe(el("nav"));
});
it("scrolls a target outside the viewport into view", () => {
vi.stubGlobal("innerHeight", 400);
el("play").focus();
press("ArrowDown");
expect(scrollIntoView).toHaveBeenCalledWith(
expect.objectContaining({ block: "center" }),
);
vi.unstubAllGlobals();
});
it("skips controls a roving tabindex has taken out of the tab order", () => {
el("tab").setAttribute("tabindex", "-1");
el("play").focus();
expect(press("ArrowDown").defaultPrevented).toBe(false);
expect(document.activeElement).toBe(el("play"));
});
it("leaves the arrows to an open popup's activator", () => {
el("tab").setAttribute("aria-haspopup", "listbox");
el("tab").setAttribute("aria-expanded", "true");
el("tab").focus();
expect(press("ArrowUp").defaultPrevented).toBe(false);
expect(document.activeElement).toBe(el("tab"));
});
it("stays out of the way while a game is running", () => {
storePlaying().setPlaying(true);
el("play").focus();
press("ArrowUp");
expect(document.activeElement).toBe(el("play"));
});
});
@@ -0,0 +1,138 @@
// useSpatialNav: moves focus by arrow key (and so by D-pad) for keys no grid
// or widget claimed with preventDefault, such as a move off a grid's edge.
// The grids listen on `document`, which an event reaches before `window`.
import { onBeforeUnmount } from "vue";
import storePlaying from "@/stores/playing";
import { hasOpenEscapable } from "@/v2/lib/overlays/RDialog/escapeStack";
import {
ARROW_DIRECTIONS,
FOCUSABLE_SELECTOR,
pickSpatialTarget,
type SpatialDirection,
} from "@/v2/utils/spatialNav";
// Input types whose arrow keys move a caret or change the value.
const BUTTON_LIKE_INPUTS = new Set(["button", "checkbox", "reset", "submit"]);
// Roles whose arrow keys belong to the widget itself.
const ARROW_OWNING_ROLES = new Set([
"combobox",
"grid",
"gridcell",
"listbox",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"radio",
"slider",
"spinbutton",
"textbox",
"tree",
"treeitem",
]);
function ownsArrowKeys(el: HTMLElement): boolean {
if (el.isContentEditable) return true;
// An open popup's activator, such as RSelect's, steers its list by arrow.
const popup = el.getAttribute("aria-haspopup");
if (
popup &&
popup !== "false" &&
el.getAttribute("aria-expanded") === "true"
) {
return true;
}
if (el instanceof HTMLInputElement) return !BUTTON_LIKE_INPUTS.has(el.type);
if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
return true;
}
return ARROW_OWNING_ROLES.has(el.getAttribute("role") ?? "");
}
function isNavigable(el: HTMLElement): boolean {
if (el.closest("[inert], [aria-hidden='true']")) return false;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;
return getComputedStyle(el).visibility !== "hidden";
}
function isFixed(el: HTMLElement): boolean {
for (let node: HTMLElement | null = el; node; node = node.parentElement) {
if (getComputedStyle(node).position === "fixed") return true;
}
return false;
}
// Visible and not covered by fixed chrome such as the top bar.
function isInView(el: HTMLElement): boolean {
const rect = el.getBoundingClientRect();
if (rect.top < 0 || rect.bottom > window.innerHeight) return false;
if (rect.left < 0 || rect.right > window.innerWidth) return false;
if (typeof document.elementFromPoint !== "function") return true;
const hit = document.elementFromPoint(
(rect.left + rect.right) / 2,
(rect.top + rect.bottom) / 2,
);
return !hit || el.contains(hit) || hit.contains(el);
}
/** Moves focus from `from` to the nearest control in `dir`, if any. */
export function moveFocus(from: HTMLElement, dir: SpatialDirection): boolean {
const candidates = Array.from(
document.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
)
.filter((el) => el !== from && !el.contains(from) && isNavigable(el))
.map((el) => ({ item: el, box: el.getBoundingClientRect() }));
const target = pickSpatialTarget(
from.getBoundingClientRect(),
candidates,
dir,
);
if (!target) return false;
target.focus({ preventScroll: true });
if (!isFixed(target) && !isInView(target)) {
// Centring vertical moves keeps the target clear of the fixed bars.
const vertical = dir === "up" || dir === "down";
target.scrollIntoView({
block: vertical ? "center" : "nearest",
inline: "nearest",
behavior: "smooth",
});
}
return true;
}
let installed = false;
export function useSpatialNav() {
function install() {
if (installed) return;
if (typeof window === "undefined") return;
installed = true;
const playingStore = storePlaying();
function onKey(e: KeyboardEvent) {
const dir = ARROW_DIRECTIONS[e.key];
if (!dir || e.defaultPrevented) return;
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
// A running game reads the arrows itself, and an open overlay keeps
// focus inside its own panel.
if (playingStore.playing || hasOpenEscapable()) return;
const active = document.activeElement;
if (!(active instanceof HTMLElement) || active === document.body) return;
if (ownsArrowKeys(active)) return;
if (moveFocus(active, dir)) e.preventDefault();
}
window.addEventListener("keydown", onKey);
onBeforeUnmount(() => {
window.removeEventListener("keydown", onKey);
installed = false;
});
}
return { install };
}
@@ -24,21 +24,16 @@
// auto-focus while in pad modality, reactivity to modality flipping to
// pad mid-session.
//
// A key that would leave the grid is left unclaimed, so `useSpatialNav`
// carries focus to the next region (the toolbar, the top bar).
//
// Integration: this is plain keyboard code; `useGamepad` dispatches
// synthetic Arrow events so gamepad users transparently benefit.
import { onBeforeUnmount, onMounted, watch, type Ref } from "vue";
import { useRoute } from "vue-router";
import { useInputModality } from "@/v2/composables/useInputModality";
import storeFocusRestoration from "@/v2/stores/focusRestoration";
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
import { FOCUSABLE_SELECTOR } from "@/v2/utils/spatialNav";
// Within this many CSS pixels two cells are considered to share a row.
// Tile heights are uniform per grid so anything within a couple of px is
@@ -257,7 +252,7 @@ export function useWrapGridNav(
}
onMounted(() => {
window.addEventListener("keydown", onKey);
document.addEventListener("keydown", onKey);
window.addEventListener("focusin", onFocusIn);
if (rootRef.value) {
observer = new MutationObserver(() => maybeAutofocus());
@@ -267,7 +262,7 @@ export function useWrapGridNav(
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", onKey);
document.removeEventListener("keydown", onKey);
window.removeEventListener("focusin", onFocusIn);
observer?.disconnect();
observer = null;
+3
View File
@@ -46,6 +46,7 @@ import { installPendingAssetSync } from "@/v2/composables/usePendingAssetSync";
import { prefetchPlatformIcons } from "@/v2/composables/usePlatformIconCache";
import { useReducedMotion } from "@/v2/composables/useReducedMotion";
import { installScanLifecycle } from "@/v2/composables/useScanLifecycle";
import { useSpatialNav } from "@/v2/composables/useSpatialNav";
import { installStageActiveClass } from "@/v2/composables/useStageActive";
import { installSyncConflictToast } from "@/v2/composables/useSyncConflictToast";
import { installBackMorph } from "@/v2/composables/useViewTransition";
@@ -178,6 +179,7 @@ provide(BACKGROUND_ART_KEY, setBackgroundArt);
const { install: installInputModality } = useInputModality();
const { install: installGamepad } = useGamepad();
const { install: installGlobalHotkeys } = useGlobalHotkeys();
const { install: installSpatialNav } = useSpatialNav();
const router = useRouter();
let removeBackMorph: (() => void) | null = null;
@@ -189,6 +191,7 @@ onMounted(() => {
installInputModality();
installGamepad();
installGlobalHotkeys();
installSpatialNav();
// Dialogs and drawers are mounted above the router view, so nothing else
// dismisses them when the route changes under them (browser back included).
removeOverlayRouteDismiss = installOverlayRouteDismiss(router);
@@ -1,11 +1,13 @@
import type { Meta, StoryObj } from "@storybook/vue3-vite";
import { expect, userEvent, within } from "storybook/test";
import { ref } from "vue";
import { useSpatialNav } from "@/v2/composables/useSpatialNav";
import { useWrapGridNav } from "@/v2/composables/useWrapGridNav";
import RBtn from "@/v2/lib/primitives/RBtn/RBtn.vue";
// Pick an input in the toolbar: "Keyboard" / "Gamepad" show the focus rings;
// "Gamepad" and "Live" drive the grid from a connected controller (D-pad moves, A clicks).
// Up from the first row leaves the grid for the toolbar, as it reaches the top bar in the app.
const TITLES = [
"Super Metroid",
@@ -28,10 +30,15 @@ const meta: Meta = {
const gridRoot = ref<HTMLElement | null>(null);
const lastPicked = ref<string | null>(null);
useWrapGridNav(gridRoot, { cellSelector: ".input-nav-cell" });
useSpatialNav().install();
return { gridRoot, lastPicked, TITLES };
},
template: `
<div style="display: grid; gap: 16px; max-width: 560px">
<div style="display: flex; gap: 8px">
<RBtn variant="text" @click="lastPicked = 'Home'">Home</RBtn>
<RBtn variant="text" @click="lastPicked = 'Search'">Search</RBtn>
</div>
<div
ref="gridRoot"
style="display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px"
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { pickSpatialTarget, spatialScore } from "./spatialNav";
function box(left: number, top: number, width = 40, height = 40) {
return { left, top, right: left + width, bottom: top + height };
}
describe("spatialScore", () => {
const from = box(100, 100);
it("rejects candidates behind or beside the move", () => {
expect(spatialScore(from, box(100, 20), "down")).toBeNull();
expect(spatialScore(from, box(200, 100), "down")).toBeNull();
expect(spatialScore(from, box(100, 200), "up")).toBeNull();
expect(spatialScore(from, box(20, 100), "right")).toBeNull();
});
it("ranks diagonal candidates after those inside the cone", () => {
const diagonal = spatialScore(from, box(160, 300), "right")!;
const farAhead = spatialScore(from, box(900, 160), "right")!;
expect(diagonal).toBeGreaterThan(farAhead);
});
it("accepts candidates past the edge in each direction", () => {
expect(spatialScore(from, box(100, 200), "down")).not.toBeNull();
expect(spatialScore(from, box(100, 20), "up")).not.toBeNull();
expect(spatialScore(from, box(200, 100), "right")).not.toBeNull();
expect(spatialScore(from, box(20, 100), "left")).not.toBeNull();
});
});
describe("pickSpatialTarget", () => {
it("prefers the control straight ahead over a nearer one off to the side", () => {
const from = box(100, 300);
const ahead = { item: "ahead", box: box(90, 160, 200, 40) };
const aside = { item: "aside", box: box(190, 200) };
expect(pickSpatialTarget(from, [aside, ahead], "up")).toBe("ahead");
});
it("picks the nearest of several aligned controls", () => {
const from = box(100, 100);
const near = { item: "near", box: box(100, 160) };
const far = { item: "far", box: box(100, 400) };
expect(pickSpatialTarget(from, [far, near], "down")).toBe("near");
});
it("falls back to a diagonal when nothing lies straight ahead", () => {
const from = box(300, 300);
const topBar = { item: "top-bar", box: box(0, 250) };
expect(pickSpatialTarget(from, [topBar], "up")).toBe("top-bar");
});
it("returns null when nothing lies in the direction", () => {
expect(
pickSpatialTarget(box(0, 0), [{ item: "x", box: box(0, 100) }], "up"),
).toBeNull();
});
});
+92
View File
@@ -0,0 +1,92 @@
// Geometry for useSpatialNav's arrow-key moves between arbitrary controls.
// Elements that take part in keyboard and pad navigation. `tabindex="-1"`
// is left out so roving-tabindex grids expose only their current cell.
export const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]",
]
.map((selector) => `${selector}:not([tabindex='-1'])`)
.join(",");
export type SpatialDirection = "up" | "down" | "left" | "right";
export const ARROW_DIRECTIONS: Record<string, SpatialDirection | undefined> = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
};
type Box = Pick<DOMRect, "top" | "bottom" | "left" | "right">;
// Absorbs sub-pixel rounding and 1px negative margins between neighbours.
const EDGE_TOLERANCE = 2;
// Sideways offset costs more than distance along the move, so the control
// straight ahead beats a nearer one off to the side.
const ORTHO_WEIGHT = 2;
// Among candidates that all overlap sideways, prefer the best centred one.
const CENTER_WEIGHT = 0.1;
// Larger than any on-screen score, so it ranks diagonals after the rest.
const DIAGONAL_PENALTY = 1e6;
// Projects both boxes so "down" is the primary axis, whatever `dir` is.
function project(box: Box, dir: SpatialDirection) {
switch (dir) {
case "down":
return { start: box.top, end: box.bottom, lo: box.left, hi: box.right };
case "up":
return { start: -box.bottom, end: -box.top, lo: box.left, hi: box.right };
case "right":
return { start: box.left, end: box.right, lo: box.top, hi: box.bottom };
case "left":
return { start: -box.right, end: -box.left, lo: box.top, hi: box.bottom };
}
}
/**
* Scores how far `to` is from `from` when moving in `dir`.
*
* Returns:
* A lower-is-closer score, or null when `to` does not lie in that direction.
*/
export function spatialScore(
from: Box,
to: Box,
dir: SpatialDirection,
): number | null {
const a = project(from, dir);
const b = project(to, dir);
const beyondEdge = b.start >= a.end - EDGE_TOLERANCE;
const centreBeyondEdge = (b.start + b.end) / 2 >= a.end;
if (!beyondEdge && !centreBeyondEdge) return null;
const gap = Math.max(0, b.start - a.end);
const orthoGap = Math.max(0, b.lo - a.hi, a.lo - b.hi);
const centreOffset = Math.abs((b.lo + b.hi) / 2 - (a.lo + a.hi) / 2);
const score = gap + orthoGap * ORTHO_WEIGHT + centreOffset * CENTER_WEIGHT;
// A candidate outside the 45 degree cone is a diagonal: taken only when
// nothing lies inside it, such as a top bar off to one side.
return orthoGap > gap ? score + DIAGONAL_PENALTY : score;
}
/** The candidate closest to `from` in `dir`, or null when none lies that way. */
export function pickSpatialTarget<T>(
from: Box,
candidates: { item: T; box: Box }[],
dir: SpatialDirection,
): T | null {
let best: T | null = null;
let bestScore = Infinity;
for (const { item, box } of candidates) {
const score = spatialScore(from, box, dir);
if (score !== null && score < bestScore) {
best = item;
bestScore = score;
}
}
return best;
}