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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user