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