Files
paseo/packages/app/src/components/draggable-list.web.tsx
T

183 lines
4.4 KiB
TypeScript

import { useCallback, useState, useRef, type ReactElement } from "react";
import { View, ScrollView } from "react-native";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type {
DraggableListProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
export type { DraggableListProps, DraggableRenderItemInfo };
interface SortableItemProps<T> {
id: string;
item: T;
index: number;
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
activeId: string | null;
}
function SortableItem<T>({
id,
item,
index,
renderItem,
activeId,
}: SortableItemProps<T>) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const dragRef = useRef<(() => void) | null>(null);
const drag = useCallback(() => {
// dnd-kit handles drag initiation via listeners
// This is a no-op but matches the mobile API
}, []);
// Store listeners in ref so drag handle can access them
dragRef.current = () => {
// Trigger drag - handled by dnd-kit's listeners
};
const baseTransform = CSS.Transform.toString(transform);
const scaleTransform = isDragging ? "scale(1.02)" : "";
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
const style = {
transform: combinedTransform || undefined,
transition,
opacity: isDragging ? 0.9 : 1,
zIndex: isDragging ? 1000 : 1,
};
const info: DraggableRenderItemInfo<T> = {
item,
index,
drag,
isActive: activeId === id,
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
{renderItem(info)}
</div>
);
}
export function DraggableList<T>({
data,
keyExtractor,
renderItem,
onDragEnd,
style,
contentContainerStyle,
ListFooterComponent,
ListHeaderComponent,
ListEmptyComponent,
showsVerticalScrollIndicator = true,
// simultaneousGestureRef is native-only, ignored on web
}: DraggableListProps<T>) {
const [activeId, setActiveId] = useState<string | null>(null);
const [items, setItems] = useState(data);
// Sync items with data prop
if (data !== items && !activeId) {
setItems(data);
}
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(String(event.active.id));
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (over && active.id !== over.id) {
const oldIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === active.id
);
const newIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === over.id
);
const newItems = arrayMove(items, oldIndex, newIndex);
setItems(newItems);
onDragEnd(newItems);
}
},
[items, keyExtractor, onDragEnd]
);
const ids = items.map((item, index) => keyExtractor(item, index));
return (
<ScrollView
style={style}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
>
{ListHeaderComponent}
{items.length === 0 && ListEmptyComponent}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{items.map((item, index) => {
const id = keyExtractor(item, index);
return (
<SortableItem
key={id}
id={id}
item={item}
index={index}
renderItem={renderItem}
activeId={activeId}
/>
);
})}
</SortableContext>
</DndContext>
{ListFooterComponent}
</ScrollView>
);
}