feat: add arrow navigation to horizontal scroll strips
This commit is contained in:
71
spa/src/components/horizontal-strip.tsx
Normal file
71
spa/src/components/horizontal-strip.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useRef, useState, useEffect, useCallback } from "react"
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
type HorizontalStripProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
gap?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HorizontalStrip({ children, className, gap = "gap-3" }: HorizontalStripProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const [canScrollLeft, setCanScrollLeft] = useState(false)
|
||||||
|
const [canScrollRight, setCanScrollRight] = useState(false)
|
||||||
|
|
||||||
|
const update = useCallback(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
setCanScrollLeft(el.scrollLeft > 0)
|
||||||
|
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
update()
|
||||||
|
el.addEventListener("scroll", update, { passive: true })
|
||||||
|
const ro = new ResizeObserver(update)
|
||||||
|
ro.observe(el)
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener("scroll", update)
|
||||||
|
ro.disconnect()
|
||||||
|
}
|
||||||
|
}, [update])
|
||||||
|
|
||||||
|
function scroll(dir: -1 | 1) {
|
||||||
|
ref.current?.scrollBy({ left: dir * ref.current.clientWidth * 0.75, behavior: "smooth" })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`group relative ${className ?? ""}`}>
|
||||||
|
{canScrollLeft && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="absolute -left-1 top-1/3 z-10 size-8 rounded-full opacity-0 shadow-md transition-opacity group-hover:opacity-100"
|
||||||
|
onClick={() => scroll(-1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={`-mx-4 flex ${gap} overflow-x-auto overscroll-x-contain px-4 pb-2`}
|
||||||
|
style={{ scrollbarWidth: "thin", scrollbarColor: "rgba(255,255,255,0.15) transparent" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{canScrollRight && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="absolute -right-1 top-1/3 z-10 size-8 rounded-full opacity-0 shadow-md transition-opacity group-hover:opacity-100"
|
||||||
|
onClick={() => scroll(1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { BackButton } from "@/components/back-button"
|
|||||||
import { StarDisplay } from "@/components/star-display"
|
import { StarDisplay } from "@/components/star-display"
|
||||||
import { RatingHistogram } from "@/components/rating-histogram"
|
import { RatingHistogram } from "@/components/rating-histogram"
|
||||||
import { EmptyState } from "@/components/empty-state"
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { HorizontalStrip } from "@/components/horizontal-strip"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
@@ -231,7 +232,7 @@ function HeroSection({
|
|||||||
|
|
||||||
function PersonStrip({ items, type }: { items: (CastMemberDto | CrewMemberDto)[]; type: "cast" | "crew" }) {
|
function PersonStrip({ items, type }: { items: (CastMemberDto | CrewMemberDto)[]; type: "cast" | "crew" }) {
|
||||||
return (
|
return (
|
||||||
<div className="-mx-4 flex gap-2.5 overflow-x-auto overscroll-x-contain px-4 pb-2" style={{ scrollbarWidth: "thin", scrollbarColor: "rgba(255,255,255,0.15) transparent" }}>
|
<HorizontalStrip gap="gap-2.5">
|
||||||
{items.map((person, i) => {
|
{items.map((person, i) => {
|
||||||
const subtitle = type === "cast"
|
const subtitle = type === "cast"
|
||||||
? (person as CastMemberDto).character
|
? (person as CastMemberDto).character
|
||||||
@@ -253,7 +254,7 @@ function PersonStrip({ items, type }: { items: (CastMemberDto | CrewMemberDto)[]
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</HorizontalStrip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Calendar, ChevronDown, ExternalLink, Film, Globe, MapPin, User } from "
|
|||||||
import { Link } from "@tanstack/react-router"
|
import { Link } from "@tanstack/react-router"
|
||||||
import { BackButton } from "@/components/back-button"
|
import { BackButton } from "@/components/back-button"
|
||||||
import { EmptyState } from "@/components/empty-state"
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { HorizontalStrip } from "@/components/horizontal-strip"
|
||||||
import { SwipeTabs } from "@/components/swipe-tabs"
|
import { SwipeTabs } from "@/components/swipe-tabs"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -167,7 +168,7 @@ type FilmStripItem = {
|
|||||||
|
|
||||||
function FilmStrip({ items }: { items: FilmStripItem[] }) {
|
function FilmStrip({ items }: { items: FilmStripItem[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="-mx-4 flex gap-3 overflow-x-auto overscroll-x-contain px-4 pb-2" style={{ scrollbarWidth: "thin", scrollbarColor: "rgba(255,255,255,0.15) transparent" }}>
|
<HorizontalStrip>
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) => (
|
||||||
<Link key={`${item.movieId}-${i}`} to="/movies/$id" params={{ id: item.movieId }} className="w-28 flex-shrink-0">
|
<Link key={`${item.movieId}-${i}`} to="/movies/$id" params={{ id: item.movieId }} className="w-28 flex-shrink-0">
|
||||||
<div className="aspect-[2/3] overflow-hidden rounded-xl bg-muted">
|
<div className="aspect-[2/3] overflow-hidden rounded-xl bg-muted">
|
||||||
@@ -184,7 +185,7 @@ function FilmStrip({ items }: { items: FilmStripItem[] }) {
|
|||||||
<p className="truncate text-[10px] italic text-muted-foreground">{item.subtitle}</p>
|
<p className="truncate text-[10px] italic text-muted-foreground">{item.subtitle}</p>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</HorizontalStrip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user