refactor(e5): deferred review-tail (frontend) — Carousel reuse + shared scroll core
The last of the deep-review deferred reuse items: - extract the scroll-affordance machinery (watch the scroller AND its swappable/ lazily-growing children, diff two edge booleans) that useScrollFade (vertical) and Carousel's useHScroll (horizontal) each hand-duplicated into one shared lib/useScrollEdges(axis); both hooks now just map start/end to their own shape. - migrate PlexInfo's two remaining hand-rolled `flex overflow-x-auto` strips — the cast row and the collection strips — to the S1 <Carousel> primitive, so they get the edge fade + hover/focus scroll buttons (only on the clipped side) for free, matching the Discover rails / CastStrip / RelatedStrip. Pure refactor, no behaviour change. Gate green (tsc, vitest 351, eslint, prettier, knip). Browser-verified on a movie info page: cast row + collection strip render as Carousels with the edge fade and the right-scroll button on the clipped cast row.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import { useScrollEdges } from "../lib/useScrollEdges";
|
||||
|
||||
// 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
|
||||
@@ -19,50 +20,18 @@ function scrollBehavior(): ScrollBehavior {
|
||||
return reduce ? "auto" : "smooth";
|
||||
}
|
||||
|
||||
// Horizontal scroll-affordance: tracks whether the scroller is clipped on the left / right so the rail
|
||||
// can show a button + fade only where there's more to see. The X-axis twin of useScrollFade — same
|
||||
// node-state callback ref (re-runs on attach) and same watch-the-children discipline (a rail whose
|
||||
// cards load in later grows scrollWidth without ever firing a scroll event), and it also hands back the
|
||||
// element so the buttons can scroll it.
|
||||
// Horizontal scroll-affordance: whether the scroller is clipped on the left / right, so the rail shows
|
||||
// a button + fade only where there's more to see. The X-axis face of the shared `useScrollEdges` core
|
||||
// (the same watch-the-scroller-and-its-children discipline as the vertical `useScrollFade`), plus the
|
||||
// element itself so the paging buttons can scroll it.
|
||||
function useHScroll(): {
|
||||
ref: (node: HTMLElement | null) => void;
|
||||
el: HTMLElement | null;
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
} {
|
||||
const [el, setEl] = useState<HTMLElement | null>(null);
|
||||
const [edge, setEdge] = useState({ left: false, right: false });
|
||||
|
||||
useEffect(() => {
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
const left = el.scrollLeft > 1;
|
||||
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
|
||||
setEdge((prev) => (prev.left === left && prev.right === right ? prev : { left, right }));
|
||||
};
|
||||
update();
|
||||
el.addEventListener("scroll", update, { passive: true });
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
for (const child of Array.from(el.children)) ro.observe(child);
|
||||
const mo = new MutationObserver((records) => {
|
||||
for (const rec of records) {
|
||||
for (const node of Array.from(rec.addedNodes))
|
||||
if (node instanceof Element) ro.observe(node);
|
||||
for (const node of Array.from(rec.removedNodes))
|
||||
if (node instanceof Element) ro.unobserve(node);
|
||||
}
|
||||
update();
|
||||
});
|
||||
mo.observe(el, { childList: true });
|
||||
return () => {
|
||||
el.removeEventListener("scroll", update);
|
||||
ro.disconnect();
|
||||
mo.disconnect();
|
||||
};
|
||||
}, [el]);
|
||||
|
||||
return { ref: setEl, el, left: edge.left, right: edge.right };
|
||||
const { ref, el, start, end } = useScrollEdges("x");
|
||||
return { ref, el, left: start, right: end };
|
||||
}
|
||||
|
||||
const FADE_PX = 28;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useDetailPrefs,
|
||||
} from "../lib/plexDetailUi";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import { Carousel } from "./Carousel";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||
|
||||
@@ -614,7 +615,7 @@ export default function PlexInfo({
|
||||
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
||||
{t("plex.info.cast")}
|
||||
</h2>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
<Carousel ariaLabel={t("plex.info.cast")} gap="gap-3">
|
||||
{cast.map((c, i) => {
|
||||
const clickable = !!(onOpenPerson || onFilter);
|
||||
const inner = (
|
||||
@@ -669,7 +670,7 @@ export default function PlexInfo({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Carousel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -694,7 +695,7 @@ export default function PlexInfo({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
<Carousel ariaLabel={col.title} gap="gap-3">
|
||||
{col.items.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
@@ -739,7 +740,7 @@ export default function PlexInfo({
|
||||
{m.year ? <div className="text-2xs text-muted">{m.year}</div> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Carousel>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user