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:
@@ -17,6 +17,7 @@ function List({
|
||||
cols = 1,
|
||||
mounted,
|
||||
reveal,
|
||||
measure,
|
||||
onOpen = () => {},
|
||||
}: {
|
||||
id: string;
|
||||
@@ -25,10 +26,12 @@ function List({
|
||||
/** Mount only the first N items, the way a virtualized grid leaves the rest out of the DOM. */
|
||||
mounted?: number;
|
||||
reveal?: (index: number) => void;
|
||||
/** Register without a count or columns — a rail or plain grid, measured from the DOM. */
|
||||
measure?: boolean;
|
||||
onOpen?: (id: string) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useListNav(ref, { enabled: true, count, cols, reveal });
|
||||
useListNav(ref, measure ? { enabled: true } : { enabled: true, count, cols, reveal });
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{Array.from({ length: mounted ?? count }, (_, i) => (
|
||||
@@ -218,3 +221,94 @@ describe("useListNav", () => {
|
||||
expect(press("j").defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// jsdom lays nothing out, so the tests below give each item a box: `boxes` maps an item's test id
|
||||
// (`a0`, `b3`) to where it sits. Everything else — the list containers — is a zero box at the origin.
|
||||
function withLayout(
|
||||
boxes: (id: string, n: number) => { left: number; top: number } | null,
|
||||
run: () => void,
|
||||
) {
|
||||
const spy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (
|
||||
this: Element,
|
||||
) {
|
||||
const m = /^([a-z])(\d+)$/.exec((this as HTMLElement).dataset?.testid ?? "");
|
||||
const at = m ? boxes(m[1]!, Number(m[2])) : null;
|
||||
const { left, top } = at ?? { left: 0, top: 0 };
|
||||
const size = at ? 90 : 0;
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
right: left + size,
|
||||
bottom: top + size,
|
||||
width: size,
|
||||
height: size,
|
||||
x: left,
|
||||
y: top,
|
||||
} as DOMRect;
|
||||
});
|
||||
// Restored in `finally`: a spy left behind by a failing assertion would fail every later test.
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
describe("useListNav · measured lists (rails and plain grids)", () => {
|
||||
it("reads the item count and the columns off the DOM when the list does not give them", () => {
|
||||
// Three to a row: [0 1 2] [3 4 5] [6]
|
||||
withLayout(
|
||||
(_, n) => ({ left: (n % 3) * 100, top: Math.floor(n / 3) * 100 }),
|
||||
() => {
|
||||
render(<List id="a" count={7} measure />);
|
||||
press("j");
|
||||
press("ArrowDown");
|
||||
expect(focused()).toBe("a3");
|
||||
press("ArrowDown");
|
||||
expect(focused()).toBe("a6");
|
||||
press("ArrowRight"); // the last item: nothing past it
|
||||
expect(focused()).toBe("a6");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Owner decision D4: ↑↓ from a rail go to the rail above or below, not nowhere.
|
||||
it("goes on from a list's bottom row to the next list's nearest item, and back up", () => {
|
||||
withLayout(
|
||||
// Two rails of four; the lower one sits 30px further right.
|
||||
(list, n) => (list === "a" ? { left: n * 100, top: 0 } : { left: n * 100 + 30, top: 200 }),
|
||||
() => {
|
||||
render(
|
||||
<>
|
||||
<List id="a" count={4} measure />
|
||||
<List id="b" count={4} measure />
|
||||
</>,
|
||||
);
|
||||
press("j");
|
||||
press("ArrowRight");
|
||||
press("ArrowRight");
|
||||
expect(focused()).toBe("a2");
|
||||
press("ArrowDown");
|
||||
expect(focused()).toBe("b2"); // centre 275 is nearest a2's 45+200
|
||||
press("ArrowDown"); // the page's last list: stays
|
||||
expect(focused()).toBe("b2");
|
||||
press("ArrowUp");
|
||||
expect(focused()).toBe("a2");
|
||||
press("ArrowRight");
|
||||
press("ArrowRight"); // ←→ never leave the rail
|
||||
expect(focused()).toBe("a3");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("lands the first j on the first card a rail shows, not one scrolled out to the left", () => {
|
||||
withLayout(
|
||||
(_, n) => ({ left: (n - 2) * 100, top: 0 }), // a0 and a1 sit left of the rail's edge
|
||||
() => {
|
||||
render(<List id="a" count={4} measure />);
|
||||
press("j");
|
||||
expect(focused()).toBe("a2");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user