feat(keys): Back returns focus to the item a view was opened from; "Feed" everywhere; every backend id named

Owner answers to the E11 S5 raises (2026-09-13): 1 → A, 2 → "Feed" in every language, 3 → translate the
proxy label and fix every config key missing in BOTH languages ("maintenance", "tmdb" — old bugs).

Focus across a view change (1-A). Opening a Plex title from Home's rail, a channel from the Channels
table or a title from a Plex grid switches the page or the sub-view, which unmounts the list; Back used
to hand focus to <body>, so the next `j` started over at the top. The YouTube player (an overlay)
already restored onto the card.
- `lib/listNav`: `navPosition()` (list's place in reading order, the page's list count, item index)
  and `restoreNavPosition()`: focuses the item once the page has as many lists on screen as when it was
  left, polls on a timer (a hidden tab runs no animation frames), gives up after 2 s or at once when
  something already holds focus, focuses without scrolling (the scroll restore owns the position).
- `lib/history`: `rememberNavPosition()` stamps `sfNav` onto the entry being left and returns the state
  without it (a new entry must not inherit it); one popstate listener restores and spends the stamp,
  so a later landing on the same entry (an overlay closing over it) cannot pull focus to an old item.
- Called before every view push: `setPage`, `openChannel`, `enterYtSearch`, `useHistorySubview.open`,
  the Channels tab push. `navPosition.wiring.contract.test` fails on any `history.pushState(` without
  it (the overlay stack's own push is the one exemption).

"Feed" (2). The module is "Feed" in both languages: every "hírfolyam" in the Hungarian UI (≈45 strings,
suffixed: Feedből, Feeded, Feedben…) and the prose "feed" in English that means the module ("your
Feed", "Hide from Feed"); "RSS feed" keeps its meaning. The welcome email says it too.

Backend ids without a name (3). The Configuration page showed `maintenance`, `tmdb`,
`maintenance_revalidate_batch`, `download_upload_max_bytes`, `audit_retention_days`, `plex_overlay_map`,
`tmdb_api_key` and `tmdb_language` as raw identifiers — the lookup falls back to the id, and the i18n
parity test stayed green because both languages missed the same keys. Added labels + hints (EN/HU),
the two groups (and their tab order), the missing `scheduler.jobDesc.demo_reset`, removed the stale
`plex_sync_interval_min` field, and "YouTube API kimenő proxy". `i18n/backendIds.contract.test` reads
`sysconfig.SPECS`, `scheduler.JOB_INTERVALS` and the audit/quota action enums from the backend sources
and fails on an id without its name in either language, or a name left for an id the backend dropped.
This commit is contained in:
2026-09-13 07:36:41 +02:00
parent 8731508645
commit bbd31c7143
38 changed files with 519 additions and 113 deletions
@@ -0,0 +1,106 @@
// @vitest-environment jsdom
//
// Back hands focus to the list item a view was left from (E11 S5): opening a Plex title from Home's
// rail switches the page, which unmounts the rail, so focus used to come back to <body>. The position is
// stamped onto the entry being left (`rememberNavPosition`) and a popstate landing on it focuses the item
// once the page's lists are back. A landing is simulated the way the browser delivers one: the entry's
// state is current and a popstate carries it. Its own file: history.ts and listNav keep module state.
import { useRef } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { rememberNavPosition } from "./history";
import { navItemAttrs, useListNav } from "./listNav";
function List({ id, count }: { id: string; count: number }) {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true });
return (
<div ref={ref}>
{Array.from({ length: count }, (_, i) => (
<div key={i} data-testid={`${id}${i}`} {...navItemAttrs(i)}>
{id}
{i}
</div>
))}
</div>
);
}
const Page = () => (
<>
<List id="a" count={2} />
<List id="b" count={3} />
</>
);
const press = (key: string) =>
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
const focused = () => (document.activeElement as HTMLElement | null)?.dataset.testid;
const sleep = (ms: number) => act(() => new Promise((r) => setTimeout(r, ms)));
/** Walk `presses` items in, stamp the entry and open another view over it; returns the stamped state. */
function leaveFrom(item: string, presses: number): unknown {
for (let i = 0; i < presses; i++) press("j");
expect(focused()).toBe(item);
const spread = rememberNavPosition();
const stamped = window.history.state;
window.history.pushState({ ...spread, view: "other" }, "");
return stamped;
}
function landOn(state: unknown) {
window.history.replaceState(state, "");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state }));
});
}
afterEach(() => {
cleanup();
(document.activeElement as HTMLElement | null)?.blur?.();
window.history.replaceState(null, "");
});
describe("rememberNavPosition · Back focuses the item a view was left from", () => {
it("focuses the item once the page's lists are back, and spends the stamp", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("b1", 4);
expect(window.history.state.sfNav).toBeUndefined();
unmount();
landOn(stamped);
await sleep(120);
render(<Page />);
await waitFor(() => expect(focused()).toBe("b1"));
expect(window.history.state.sfNav).toBeUndefined();
});
// Left from a1 (list 0 of 2). If the second list mounts first, "list 0" on screen is list b — so the
// restore must wait until the page has both lists again, not land on b1.
it("waits for every list the page had — the first list to mount is not list 0", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("a1", 2);
unmount();
landOn(stamped);
const { rerender } = render(<List id="b" count={3} />);
await sleep(120);
expect(focused()).toBeUndefined();
rerender(<Page />);
await waitFor(() => expect(focused()).toBe("a1"));
});
it("leaves focus where it is when something already has it", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("b1", 4);
unmount();
const { getByRole } = render(<input aria-label="search" />);
landOn(stamped);
getByRole("textbox").focus();
render(<Page />);
await sleep(150);
expect(document.activeElement).toBe(getByRole("textbox"));
});
});
+32 -1
View File
@@ -10,6 +10,7 @@
//
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
import { useEffect, useRef, useState } from "react";
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
import { urlWithParams } from "./urlState";
// --- Per-history-entry scroll id + a nav bus (for scroll restoration) ------------------------
@@ -64,6 +65,36 @@ export function subscribeNav(cb: () => void): () => void {
return () => navListeners.delete(cb);
}
// --- The list item a view was left from (history.state.sfNav) -------------------------------------
// See lib/listNav "The item a view was left from". Stamped onto the entry being LEFT, right before a
// push opens a new view over it; read when Back/Forward lands on that entry, and dropped from it there,
// so a later landing on the same entry — an overlay closing over it — cannot pull focus to an old item.
let cancelNavRestore = () => {};
let navRestoreInstalled = false;
function ensureNavRestore(): void {
if (navRestoreInstalled) return;
navRestoreInstalled = true;
window.addEventListener("popstate", (e) => {
cancelNavRestore();
const pos = (e.state as { sfNav?: NavPosition } | null)?.sfNav;
if (!pos) return;
const { sfNav: _spent, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
window.history.replaceState(st, "");
cancelNavRestore = restoreNavPosition(pos);
});
}
/** Call right before a pushState that opens a new view over the current entry (a page, a sub-view, a
* channel, a tab). Returns the current state WITHOUT the stamp: a push that spreads the current state
* must spread this, so the new entry does not inherit a position it was never left from. */
export function rememberNavPosition(): Record<string, unknown> {
ensureNavRestore();
const { sfNav: _stale, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
const pos = navPosition();
window.history.replaceState(pos ? { ...st, sfNav: pos } : st, "");
return st;
}
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
/** Optional address-bar mirroring for a sub-view, so a deep sub-view (e.g. a Plex show page) is
* bookmarkable. history.state._sub stays the source of truth for Back/Forward; this only adds the URL. */
@@ -127,7 +158,7 @@ export function useHistorySubview<T>(
// A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back
// restores the view we came from) — then notify PageScroller, which this push doesn't re-render.
window.history.pushState(
{ ...window.history.state, _sub: next, sfScroll: nextNavId() },
{ ...rememberNavPosition(), _sub: next, sfScroll: nextNavId() },
"",
href,
);
+49 -2
View File
@@ -120,7 +120,7 @@ function firstVisible(): { list: NavList; index: number } | null {
return fallback;
}
function focusItem(el: HTMLElement): void {
function focusItem(el: HTMLElement, reveal = true): void {
if (roved && roved !== el) roved.removeAttribute("tabindex");
roved = null;
// An item that is focusable in its own right (a Plex card is a role=button) keeps its tabindex.
@@ -131,7 +131,7 @@ function focusItem(el: HTMLElement): void {
el.focus({ preventScroll: true });
// `nearest` + the item's scroll margins: a visible item does not move, an item under the floating
// header or past the bottom edge scrolls just far enough to clear it.
el.scrollIntoView?.({ block: "nearest" });
if (reveal) el.scrollIntoView?.({ block: "nearest" });
}
function focusIndex(list: NavList, index: number): void {
@@ -241,6 +241,53 @@ function onKey(e: KeyboardEvent): void {
});
}
// ── The item a view was left from ────────────────────────────────────────────────────────────────
// Opening something that switches the page or the view — a Plex title from Home's rail, a channel from
// the Channels table — unmounts the list the keyboard was on, so Back used to hand focus to <body> and
// the next `j` started over at the top (E11 S5 UAT). The history entry being left remembers the
// position (lib/history `rememberNavPosition`) and Back gives focus back to that item once its list is
// on screen again. An overlay (the YouTube player) never unmounts the list; it restores focus itself.
/** Where focus sits in the page's lists: the list's place in reading order, how many lists the page
* had on screen, and the item's index. */
export interface NavPosition {
readonly list: number;
readonly lists: number;
readonly index: number;
}
export function navPosition(): NavPosition | null {
const cur = current();
if (!cur) return null;
const all = ordered();
const list = all.indexOf(cur.list);
return list < 0 ? null : { list, lists: all.length, index: cur.index };
}
// How long a returning page gets to put its lists back (a lazy chunk, cached data), and how often we
// look — on a timer, not animation frames: a hidden tab runs none.
const RESTORE_MS = 2000;
const RESTORE_POLL_MS = 50;
/** Focus the item at `pos` once the page has as many lists on screen as when it was left — before
* that, list N may not yet be the list it was. Gives up when the time is up, and at once when focus
* is already somewhere (the reader moved on, a dialog took it). The item is focused where the scroll
* restore put it, never scrolled to. Returns a cancel. */
export function restoreNavPosition(pos: NavPosition): () => void {
const deadline = Date.now() + RESTORE_MS;
let timer: ReturnType<typeof setTimeout> | undefined;
const attempt = () => {
const active = document.activeElement;
if (active && active !== document.body) return;
const all = ordered();
const el = all.length >= pos.lists ? itemAt(all[pos.list]!, pos.index) : null;
if (el) return focusItem(el, false);
if (Date.now() < deadline) timer = setTimeout(attempt, RESTORE_POLL_MS);
};
attempt();
return () => clearTimeout(timer);
}
/** Register `ref` as a keyboard-navigable list while `enabled`. A virtualized list must say how many
* items it has (`count` — the loaded ones, for an infinite list), how many per row (`cols`) and how
* to scroll an unmounted item into view (`reveal`). A list that mounts everything omits all three:
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
// Every push that opens a new view over the current one remembers the list item it was opened from
// (`lib/history.rememberNavPosition`), so Back hands focus back to that item (E11 S5). A push that
// forgets strands keyboard focus on <body> again with no test noticing — the flow only shows in a real
// browser, after a round trip. So the source is the check: each `history.pushState(` in the app has a
// `rememberNavPosition(` right before it or inside its arguments. The overlay stack's own push is the
// one exception — an overlay entry opens no view, and its list stays mounted underneath.
//
// The assertion pins the SET of offending sites and checks the scan found pushes at all: a guard whose
// glob stops matching is green over nothing (E10 review round 4).
const files = import.meta.glob(["../**/*.ts", "../**/*.tsx", "!../**/*.test.*"], {
query: "?raw",
import: "default",
eager: true,
}) as Record<string, string>;
const EXEMPT: Record<string, string> = {
"lib/history.ts#_ov": "the overlay stack: an overlay entry opens no view over the page",
};
// Lines before a push, and lines of the push call itself, that may carry the remember call.
const BEFORE = 4;
const WITHIN = 4;
function offenders(): { hits: string[]; pushes: number } {
const hits: string[] = [];
let pushes = 0;
for (const [path, code] of Object.entries(files)) {
// Glob keys are relative to this file: `./x.ts` is lib, `../components/x.tsx` is a sibling folder.
const rel = path.startsWith("./") ? `lib/${path.slice(2)}` : path.replace(/^\.\.\//, "");
const lines = code.split("\n");
lines.forEach((line, i) => {
if (!line.includes("history.pushState(")) return;
pushes++;
const around = lines.slice(Math.max(0, i - BEFORE), i + WITHIN).join("\n");
if (around.includes("rememberNavPosition(")) return;
const exempt = Object.keys(EXEMPT).find(
(id) => id.startsWith(`${rel}#`) && around.includes(id.split("#")[1]!),
);
if (!exempt) hits.push(`${rel}:${i + 1}`);
});
}
return { hits: hits.sort(), pushes };
}
describe("every view push remembers the item it was opened from", () => {
it("finds no pushState without rememberNavPosition", () => {
const { hits, pushes } = offenders();
expect(pushes).toBeGreaterThan(4);
expect(hits).toEqual([]);
});
});