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:
+68
-15
@@ -20,7 +20,12 @@ import { scrollParent } from "./listPaging";
|
||||
//
|
||||
// Every page-level list shares ONE window listener, and the lists are walked in DOCUMENT order: `j`
|
||||
// at the end of one list continues at the start of the next (owner decision D4, "reading order").
|
||||
// The arrows stay inside the list they are in.
|
||||
// ←→ stay inside the list they are in; ↑ on a list's top row and ↓ on its bottom row go on to the
|
||||
// neighbouring list — the rail above or below on Home and Discover — landing on the item nearest the
|
||||
// one you left, so a column of rails reads like one grid.
|
||||
//
|
||||
// A list that mounts everything (a rail, a plain grid) need not say how many items or columns it has:
|
||||
// both are read off the DOM when the key is pressed, so an auto-fill grid's columns follow its width.
|
||||
|
||||
interface NavList {
|
||||
readonly el: HTMLElement;
|
||||
@@ -65,22 +70,47 @@ function current(): { list: NavList; index: number; item: HTMLElement } | null {
|
||||
return item && list ? { list, index: indexOf(item), item } : null;
|
||||
}
|
||||
|
||||
/** The mounted items that belong to `el` itself (not to a list nested inside it), in index order. */
|
||||
function ownItems(el: HTMLElement): HTMLElement[] {
|
||||
return [...el.querySelectorAll<HTMLElement>(`[${INDEX}]`)]
|
||||
.filter((item) => item.parentElement?.closest(`[${LIST}]`) === el)
|
||||
.sort((a, b) => indexOf(a) - indexOf(b));
|
||||
}
|
||||
|
||||
// Items whose tops are this close share a row (sub-pixel layout, a card a hair taller than the next).
|
||||
const ROW_SLACK = 4;
|
||||
const topOf = (el: Element) => el.getBoundingClientRect().top;
|
||||
const centerX = (el: Element) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.left + r.width / 2;
|
||||
};
|
||||
|
||||
/** Items per row, measured: the leading items that share the first item's top. A rail is one row. */
|
||||
function measuredCols(el: HTMLElement): number {
|
||||
const items = ownItems(el);
|
||||
if (!items.length) return 1;
|
||||
const first = topOf(items[0]!);
|
||||
const inRow = items.findIndex((item) => Math.abs(topOf(item) - first) > ROW_SLACK);
|
||||
return inRow < 0 ? items.length : inRow;
|
||||
}
|
||||
|
||||
/** Where `j` lands when nothing in a list has focus yet: the first item the reader can see, not the
|
||||
* top of the list — after scrolling down a feed, the first press must not throw them back up. "Can
|
||||
* see" = its bottom edge is below the top of the scroller's visible band, which starts under the
|
||||
* floating header (the item's own `scroll-margin-top` is exactly that header clearance). */
|
||||
* floating header (the item's own `scroll-margin-top` is exactly that header clearance), and — in a
|
||||
* rail scrolled sideways — its right edge is past the rail's left edge. */
|
||||
function firstVisible(): { list: NavList; index: number } | null {
|
||||
let fallback: { list: NavList; index: number } | null = null;
|
||||
for (const list of ordered()) {
|
||||
const items = [...list.el.querySelectorAll<HTMLElement>(`[${INDEX}]`)]
|
||||
.filter((el) => el.parentElement?.closest(`[${LIST}]`) === list.el)
|
||||
.sort((a, b) => indexOf(a) - indexOf(b));
|
||||
const items = ownItems(list.el);
|
||||
if (!items.length) continue;
|
||||
fallback ??= { list, index: indexOf(items[0]!) };
|
||||
const top = scrollParent(list.el)?.getBoundingClientRect().top ?? 0;
|
||||
const left = list.el.getBoundingClientRect().left;
|
||||
for (const el of items) {
|
||||
const band = top + (parseFloat(getComputedStyle(el).scrollMarginTop) || 0);
|
||||
if (el.getBoundingClientRect().bottom > band) return { list, index: indexOf(el) };
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.bottom > band && r.right > left) return { list, index: indexOf(el) };
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
@@ -132,9 +162,10 @@ function step(delta: 1 | -1): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Move inside the focused item's list. ←→ step one item (wrapping rows, the reading order); ↑↓ one
|
||||
* row, landing on the last item when the row below is a short last row. Returns false when focus is
|
||||
* not in a list, so the arrow keeps its native meaning (scrolling) there. */
|
||||
/** Move from the focused item. ←→ step one item inside its list (wrapping rows, the reading order);
|
||||
* ↑↓ one row, landing on the last item when the row below is a short last row, and from the top or
|
||||
* bottom row on into the neighbouring list. Returns false when focus is not in a list, so the arrow
|
||||
* keeps its native meaning (scrolling) there. */
|
||||
function move(dx: -1 | 0 | 1, dy: -1 | 0 | 1): boolean {
|
||||
const cur = current();
|
||||
if (!cur) return false;
|
||||
@@ -146,10 +177,31 @@ function move(dx: -1 | 0 | 1, dy: -1 | 0 | 1): boolean {
|
||||
else if (dy > 0 && row < Math.floor((count - 1) / cols))
|
||||
target = Math.min(cur.index + cols, count - 1);
|
||||
else if (dy < 0 && row > 0) target = cur.index - cols;
|
||||
else if (dy) crossRow(cur.list, cur.item, dy);
|
||||
if (target !== cur.index && target >= 0 && target < count) focusIndex(cur.list, target);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** ↓ past a list's bottom row / ↑ past its top row: the nearest row of the next non-empty list that
|
||||
* way, on the item horizontally nearest the one being left. Only MOUNTED items are candidates — a
|
||||
* virtualized neighbour offers the rows it has rendered, which next to the edge you cross from are
|
||||
* the rows nearest it. At the page's first or last list it stays put, like `j`/`k`. */
|
||||
function crossRow(from: NavList, item: HTMLElement, dy: -1 | 1): void {
|
||||
const all = ordered();
|
||||
const x = centerX(item);
|
||||
for (let at = all.indexOf(from) + dy; at >= 0 && at < all.length; at += dy) {
|
||||
const items = ownItems(all[at]!.el);
|
||||
if (!items.length) continue;
|
||||
const tops = items.map(topOf);
|
||||
const edge = dy > 0 ? Math.min(...tops) : Math.max(...tops);
|
||||
const row = items.filter((_, i) => Math.abs(tops[i]! - edge) <= ROW_SLACK);
|
||||
const nearest = row.reduce((a, b) =>
|
||||
Math.abs(centerX(b) - x) < Math.abs(centerX(a) - x) ? b : a,
|
||||
);
|
||||
return focusItem(nearest);
|
||||
}
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
const arrow = (dx: -1 | 0 | 1, dy: -1 | 0 | 1) => (ev: KeyboardEvent) => {
|
||||
if (move(dx, dy)) ev.preventDefault();
|
||||
@@ -181,12 +233,13 @@ function onKey(e: KeyboardEvent): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Register `ref` as a keyboard-navigable list while `enabled`. `count` is the number of items (the
|
||||
* loaded ones, for an infinite list), `cols` the items per row (1 for a column), and `reveal` scrolls
|
||||
* an item that is not mounted into view — omit it for a list that mounts everything. */
|
||||
/** 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:
|
||||
* the count and the columns are then measured from the DOM at the moment a key is pressed. */
|
||||
export function useListNav(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
opts: { enabled: boolean; count: number; cols: number; reveal?: (index: number) => void },
|
||||
opts: { enabled: boolean; count?: number; cols?: number; reveal?: (index: number) => void },
|
||||
): void {
|
||||
const optsRef = useRef(opts);
|
||||
optsRef.current = opts;
|
||||
@@ -196,8 +249,8 @@ export function useListNav(
|
||||
if (!opts.enabled || !el) return;
|
||||
lists.set(el, {
|
||||
el,
|
||||
count: () => optsRef.current.count,
|
||||
cols: () => optsRef.current.cols,
|
||||
count: () => optsRef.current.count ?? ownItems(el).length,
|
||||
cols: () => optsRef.current.cols ?? measuredCols(el),
|
||||
reveal: (i) => optsRef.current.reveal?.(i),
|
||||
});
|
||||
el.setAttribute(LIST, "");
|
||||
|
||||
Reference in New Issue
Block a user