feat(keys): j/k and the arrows walk Home's rails and every Plex grid and rail
Second slice of the E11 keyboard layer (S4b). S4a gave the feed, the library and the channel page `j`/`k`, the arrows and Enter; Home's shelves and the whole Plex module were still Tab-stop country — a Plex poster is its own Tab stop, and a rail's cards sat behind every control of the cards before. - `lib/listNav`: a list that mounts every item no longer declares a count or columns — both are measured off the DOM when a key is pressed, so an auto-fill grid's columns follow its width and a rail is one row. ↑ on a list's top row and ↓ on its bottom row go on to the neighbouring list, onto the item horizontally nearest the one left (owner decision D4: ←→ within a rail, ↑↓ to the rail above or below). The first `j` skips cards a rail has scrolled out to the left. - `Carousel keyboardNav` registers the scroller; the scroller gets a 4px inset (against a -4px margin, layout unchanged) so the focus ring is not clipped, and `[data-nav-index]` a 2.5rem inline scroll margin so a card stepped into view clears the rail's edge fade and paging arrow. - `ui/NavGrid`: the plain, fully mounted grid that registers itself. - `navIndex` on PlexPosterCard / SeasonCard / EpisodeCard (the card root is the item, the poster the open element — the ring goes round poster and title, D2), PosterTile and Home's ShelfTile (the button is the item); cast, related and collection-strip buttons carry the attrs directly. - Wired: Home's comfort and resume rails; Plex Browse titles + episodes, Discover rails + genres, collection view, playlists/collections browse, person known-for + filmography, show seasons, season episodes, cast/related strips, info-page cast + collection strips. - `listNav.wiring.contract.test`: every `Carousel` says `keyboardNav`, every card says `navIndex`, and a card with `navIndex` sits inside a NavGrid / Carousel / VirtualGrid — both halves are opt-in, and either one missing leaves a list the keyboard silently skips.
This commit is contained in:
@@ -671,3 +671,47 @@ describe("Home", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// E11 S4b, owner decision D4: the rails are one keyboard page — `j` walks a rail's cards and goes on
|
||||
// into the next rail, whichever order the page stacks them in.
|
||||
describe("Home · keyboard", () => {
|
||||
it("walks the rails with j, from one rail on into the next", async () => {
|
||||
discover.mockResolvedValue({
|
||||
...payload("v1"),
|
||||
continue_watching: [
|
||||
ytCard("v1", "Hero v1"),
|
||||
ytCard("v2", "Second thing"),
|
||||
ytCard("v3", "Third thing"),
|
||||
],
|
||||
// A pinned channel has no card of its own: the shelf draws it as a ShelfTile.
|
||||
comfort: [
|
||||
ytCard("v4", "Comfort thing"),
|
||||
{
|
||||
...ytCard("UC9", "Pinned channel"),
|
||||
kind: "channel",
|
||||
video: null,
|
||||
next: null,
|
||||
unwatched_count: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
mount();
|
||||
await screen.findByText("Comfort thing");
|
||||
const titles = ["Comfort thing", "Pinned channel", "Second thing", "Third thing"];
|
||||
const seen: (string | undefined)[] = [];
|
||||
for (let i = 0; i < titles.length; i++) {
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "j", bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
const item = (document.activeElement as HTMLElement).closest("[data-nav-index]");
|
||||
seen.push(titles.find((title) => item?.textContent?.includes(title)));
|
||||
}
|
||||
// Every card once, across both rails: the shelf tile, and on from one rail into the next.
|
||||
expect(new Set(seen)).toEqual(new Set(titles));
|
||||
// Every card of every rail is an item of it — the shelf tile too, not only the native cards.
|
||||
const rails = [...document.querySelectorAll("[data-nav-list]")];
|
||||
expect(rails.map((rail) => rail.querySelectorAll("[data-nav-index]").length)).toEqual([2, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user