diff --git a/frontend/app/explore/page.tsx b/frontend/app/explore/page.tsx index 545649a..4019587 100644 --- a/frontend/app/explore/page.tsx +++ b/frontend/app/explore/page.tsx @@ -1,6 +1,15 @@ "use client" -import { useState, useEffect, useMemo, useCallback, Suspense } from "react" +import { + useState, + useEffect, + useMemo, + useCallback, + useRef, + Suspense, + type KeyboardEvent, + type MouseEvent, +} from "react" import { useRouter, useSearchParams } from "next/navigation" import { Card } from "@/components/ui/card" import { Button } from "@/components/ui/button" @@ -98,16 +107,36 @@ function PoolCard({ pool, onRequestJoin, isJoining, + tabIndex, + cardRef, + onFocus, + onClick, + onKeyDown, }: { pool: Pool onRequestJoin: (poolId: string) => void isJoining: boolean + tabIndex: number + cardRef: (node: HTMLDivElement | null) => void + onFocus: () => void + onClick: (event: MouseEvent) => void + onKeyDown: (event: KeyboardEvent) => void }) { const typeLabel = pool.type.charAt(0).toUpperCase() + pool.type.slice(1) const statusLabel = pool.status === "active" ? "Active" : pool.status === "completed" ? "Completed" : "Paused" return ( - +
@@ -162,7 +191,10 @@ function PoolCard({ variant="default" size="sm" className="flex-1" - onClick={() => onRequestJoin(pool.id)} + onClick={(event) => { + event.stopPropagation() + onRequestJoin(pool.id) + }} disabled={isJoining || pool.status !== "active"} > {isJoining ? ( @@ -173,7 +205,12 @@ function PoolCard({ Request to Join
@@ -188,11 +225,14 @@ function ExploreContent() { const { toast } = useToast() const router = useRouter() const searchParams = useSearchParams() + const gridRef = useRef(null) + const poolCardRefs = useRef>([]) const [pools, setPools] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState("") const [joining, setJoining] = useState(null) + const [focusedPoolIndex, setFocusedPoolIndex] = useState(0) // Filter state is derived from the URL so it survives refresh and can be shared. const search = searchParams.get("search") || "" @@ -283,6 +323,91 @@ function ExploreContent() { }) }, [pools, search, filterType, filterStatus]) + const activePoolIndex = + filteredPools.length > 0 + ? Math.min(focusedPoolIndex, filteredPools.length - 1) + : 0 + + useEffect(() => { + poolCardRefs.current = poolCardRefs.current.slice(0, filteredPools.length) + setFocusedPoolIndex((index) => + Math.min(index, Math.max(filteredPools.length - 1, 0)) + ) + }, [filteredPools.length]) + + const getGridColumnCount = useCallback(() => { + if (!gridRef.current) return 1 + + const columns = window + .getComputedStyle(gridRef.current) + .gridTemplateColumns.split(" ") + .filter(Boolean).length + + return Math.max(columns, 1) + }, []) + + const focusPoolCard = useCallback((index: number) => { + poolCardRefs.current[index]?.focus() + }, []) + + const handleViewPool = useCallback( + (poolId: string) => { + router.push(`/dashboard/group/${poolId}`) + }, + [router] + ) + + const handlePoolCardClick = useCallback( + (event: MouseEvent, poolId: string) => { + if ((event.target as HTMLElement).closest("a,button")) return + handleViewPool(poolId) + }, + [handleViewPool] + ) + + const handlePoolCardKeyDown = useCallback( + (event: KeyboardEvent, index: number, poolId: string) => { + if (event.target !== event.currentTarget) return + + const columnCount = getGridColumnCount() + const columnIndex = index % columnCount + let nextIndex = index + + if (event.key === "ArrowLeft") { + event.preventDefault() + if (columnIndex > 0) nextIndex = index - 1 + } else if (event.key === "ArrowRight") { + event.preventDefault() + if (columnIndex < columnCount - 1 && index < filteredPools.length - 1) { + nextIndex = index + 1 + } + } else if (event.key === "ArrowUp") { + event.preventDefault() + if (index - columnCount >= 0) nextIndex = index - columnCount + } else if (event.key === "ArrowDown") { + event.preventDefault() + if (index + columnCount < filteredPools.length) { + nextIndex = index + columnCount + } + } else if (event.key === "Enter") { + handleViewPool(poolId) + return + } else if (event.key === " " || event.key === "Spacebar") { + event.preventDefault() + handleViewPool(poolId) + return + } else { + return + } + + if (nextIndex !== index) { + setFocusedPoolIndex(nextIndex) + focusPoolCard(nextIndex) + } + }, + [filteredPools.length, focusPoolCard, getGridColumnCount, handleViewPool] + ) + // Join request handler const handleRequestJoin = useCallback( async (poolId: string) => { @@ -404,17 +529,27 @@ function ExploreContent() { {/* Pool Grid */} {!loading && filteredPools.length > 0 && ( - {filteredPools.map((pool) => ( + {filteredPools.map((pool, index) => ( { + poolCardRefs.current[index] = node + }} + onFocus={() => setFocusedPoolIndex(index)} + onClick={(event) => handlePoolCardClick(event, pool.id)} + onKeyDown={(event) => + handlePoolCardKeyDown(event, index, pool.id) + } /> ))}