Spaces:
Paused
Paused
File size: 3,840 Bytes
963e554 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 | /**
* Drag-handle list reordering (direct-manipulation primitive).
*
* Pointer-based (no DnD library): grab the handle, rows reorder live as you
* drag, commit on release. Used for ordering query group-by chips, dashboard
* widget stacking, and anywhere an ordered list needs rearranging.
*/
import { useRef, useState, type ReactNode } from 'react';
import Box from '@mui/material/Box';
import { GripVertical } from 'lucide-react';
import { tokens } from '../../theme';
export interface DragListItem {
id: string;
content: ReactNode;
}
interface DragListProps {
items: DragListItem[];
onReorder: (orderedIds: string[]) => void;
dense?: boolean;
}
export default function DragList({ items, onReorder, dense = false }: DragListProps) {
const [order, setOrder] = useState<string[] | null>(null); // non-null while dragging
const [dragId, setDragId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const ids = order ?? items.map((i) => i.id);
const byId = new Map(items.map((i) => [i.id, i]));
const indexFromPointer = (clientY: number): number => {
const container = containerRef.current;
if (!container) return 0;
const rows = Array.from(container.querySelectorAll<HTMLElement>('[data-dragrow]'));
for (let i = 0; i < rows.length; i += 1) {
const r = rows[i].getBoundingClientRect();
if (clientY < r.top + r.height / 2) return i;
}
return rows.length - 1;
};
return (
<Box ref={containerRef} sx={{ display: 'flex', flexDirection: 'column', gap: dense ? 0.5 : 0.75 }}>
{ids.map((id) => {
const item = byId.get(id);
if (!item) return null;
const dragging = dragId === id;
return (
<Box
key={id}
data-dragrow
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1,
py: dense ? 0.5 : 0.75,
borderRadius: 1,
border: `1px solid ${dragging ? tokens.color.info : tokens.color.border}`,
bgcolor: dragging ? tokens.color.surfaceRaised : tokens.color.surface,
boxShadow: dragging ? tokens.elevation.raised : 'none',
transition: `border-color ${tokens.motion.fast}, box-shadow ${tokens.motion.fast}`,
userSelect: 'none',
}}
>
<Box
component="span"
sx={{
display: 'inline-flex',
color: tokens.color.textFaint,
cursor: 'grab',
touchAction: 'none',
'&:hover': { color: tokens.color.info },
}}
onPointerDown={(e) => {
e.preventDefault();
(e.target as Element).setPointerCapture(e.pointerId);
setDragId(id);
setOrder(items.map((i) => i.id));
}}
onPointerMove={(e) => {
if (dragId !== id || order === null) return;
const target = indexFromPointer(e.clientY);
const current = order.indexOf(id);
if (target !== current && target >= 0) {
const next = [...order];
next.splice(current, 1);
next.splice(target, 0, id);
setOrder(next);
}
}}
onPointerUp={(e) => {
(e.target as Element).releasePointerCapture(e.pointerId);
if (order) onReorder(order);
setOrder(null);
setDragId(null);
}}
>
<GripVertical size={15} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>{item.content}</Box>
</Box>
);
})}
</Box>
);
}
|