"use client"; import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import { motion, useReducedMotion, type PanInfo } from "motion/react"; export type DragGridItem = { id: string; /** Read out by screen readers while the tile moves. */ label: string; /** Not for links or buttons: the whole tile is the drag handle. */ content: ReactNode; }; type DragGridProps = { items: DragGridItem[]; /** Called with the new order. Store it and pass it back as items. */ onChange: (items: DragGridItem[]) => void; /** Smallest tile width in pixels. The grid fits as many columns as it can. */ minSize?: number; /** Space between tiles in pixels. */ gap?: number; /** Ring colour on the tile being moved. */ accent?: string; /** Name read out by screen readers. */ label?: string; /** Replaces the tile look. */ className?: string; }; const moved = (list: DragGridItem[], from: number, to: number) => { const next = [...list]; next.splice(to, 0, next.splice(from, 1)[0]); return next; }; const clamp = (n: number, max: number) => Math.min(Math.max(n, 0), max); /* DragGrid: pick up a tile and the others slide out of its way. Space lifts a tile, arrows move it, Space drops it. */ export function DragGrid({ items, onChange, minSize = 96, gap = 12, accent = "#a78bfa", label = "Sortable tiles", className = "aspect-square rounded-xl border border-white/10 bg-white/5 text-white", }: DragGridProps) { const reduce = useReducedMotion(); const grid = useRef(null); const [lifted, setLifted] = useState(null); const [status, setStatus] = useState(""); // React may move the DOM node on reorder, which drops focus. useEffect(() => { if (lifted) grid.current?.querySelector(`[data-id="${CSS.escape(lifted)}"]`)?.focus(); }, [items, lifted]); const columns = () => (grid.current ? getComputedStyle(grid.current).gridTemplateColumns.split(" ").length : 1); // Slot comes from grid maths, not from the tiles, because they are mid-animation. const onDrag = (id: string, info: PanInfo) => { if (!grid.current) return; const box = grid.current.getBoundingClientRect(); const cols = columns(); const rows = Math.ceil(items.length / cols); const col = clamp(Math.floor((info.point.x - window.scrollX - box.left) / ((box.width + gap) / cols)), cols - 1); const row = clamp(Math.floor((info.point.y - window.scrollY - box.top) / ((box.height + gap) / rows)), rows - 1); const to = Math.min(row * cols + col, items.length - 1); const from = items.findIndex((item) => item.id === id); if (to !== from) onChange(moved(items, from, to)); }; const onKey = (e: KeyboardEvent, item: DragGridItem, index: number) => { if (e.key === " " || e.key === "Enter") { e.preventDefault(); const up = lifted !== item.id; setLifted(up ? item.id : null); setStatus(`${item.label} ${up ? "lifted. Use arrow keys, then Space to drop." : `dropped at position ${index + 1}.`}`); return; } if (lifted !== item.id) return; if (e.key === "Escape") { setLifted(null); setStatus(`${item.label} dropped at position ${index + 1}.`); return; } const cols = columns(); const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -cols, ArrowDown: cols }[e.key]; if (!step) return; e.preventDefault(); const to = index + step; if (to < 0 || to >= items.length) return; onChange(moved(items, index, to)); setStatus(`${item.label}, position ${to + 1} of ${items.length}.`); }; return (
{items.map((item, i) => ( onDrag(item.id, info)} whileDrag={{ scale: 1.06, zIndex: 10, boxShadow: `0 0 0 2px ${accent}, 0 16px 32px rgb(0 0 0 / 0.4)` }} transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 34 }} role="button" tabIndex={0} aria-pressed={lifted === item.id} aria-label={`${item.label}, position ${i + 1} of ${items.length}. Press Space to move.`} data-id={item.id} onKeyDown={(e) => onKey(e, item, i)} onBlur={() => setLifted((id) => (id === item.id ? null : id))} style={lifted === item.id ? { boxShadow: `0 0 0 2px ${accent}` } : undefined} className={`relative grid cursor-grab touch-none select-none place-items-center active:cursor-grabbing ${className}`} > {item.content} ))}

{status}

); }