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:
@@ -1,8 +1,9 @@
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { useCallback, useRef, type ReactNode } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import { useScrollEdges } from "../../lib/useScrollEdges";
|
||||
import { useListNav } from "../../lib/listNav";
|
||||
|
||||
// Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing
|
||||
// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
|
||||
@@ -43,6 +44,7 @@ export function Carousel({
|
||||
ariaLabel,
|
||||
gap = "gap-3",
|
||||
className,
|
||||
keyboardNav = false,
|
||||
children,
|
||||
}: {
|
||||
/** Optional built-in header (rails). Omit for a bare scroller inside a card that owns its own heading. */
|
||||
@@ -55,10 +57,24 @@ export function Carousel({
|
||||
/** Tailwind gap class for the scroller (e.g. "gap-4"). */
|
||||
gap?: string;
|
||||
className?: string;
|
||||
/** The cards spread `navItemAttrs(i)` on their roots: `j`/`k`, ←→ and Enter walk the rail, and ↑↓
|
||||
* leave it for the rail above or below (lib/listNav). */
|
||||
keyboardNav?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { ref, el, left, right } = useHScroll();
|
||||
// The scroller is the list the keyboard walks. useHScroll holds the node through a callback ref,
|
||||
// listNav reads a ref object — one node, both handed it.
|
||||
const navRef = useRef<HTMLDivElement | null>(null);
|
||||
const setScroller = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
ref(node);
|
||||
navRef.current = node;
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
useListNav(navRef, { enabled: keyboardNav });
|
||||
|
||||
const page = useCallback(
|
||||
(dir: -1 | 1) => {
|
||||
@@ -112,9 +128,11 @@ export function Carousel({
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
{/* The px-1/pt-1 against -mx-1/-mt-1 keep the layout and give the keyboard ring room: a
|
||||
scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx("no-scrollbar flex overflow-x-auto pb-1", gap)}
|
||||
ref={setScroller}
|
||||
className={clsx("no-scrollbar -mx-1 -mt-1 flex overflow-x-auto px-1 pt-1 pb-1", gap)}
|
||||
style={{ maskImage: mask, WebkitMaskImage: mask }}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
|
||||
Reference in New Issue
Block a user