mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user