diff --git a/apps/client/src/features/base/components/property/choice-editor.tsx b/apps/client/src/features/base/components/property/choice-editor.tsx index de9aef045..450e5e7f9 100644 --- a/apps/client/src/features/base/components/property/choice-editor.tsx +++ b/apps/client/src/features/base/components/property/choice-editor.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { useState, useCallback, useMemo, useEffect, useRef, useLayoutEffect } from "react"; import { TextInput, Group, @@ -16,22 +16,21 @@ import { IconGripVertical, IconArrowsSort, } from "@tabler/icons-react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { - DndContext, - closestCenter, - PointerSensor, - useSensor, - useSensors, - DragEndEvent, -} from "@dnd-kit/core"; + draggable, + dropTargetForElements, +} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { - SortableContext, - verticalListSortingStrategy, - useSortable, - arrayMove, -} from "@dnd-kit/sortable"; -import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; -import { CSS } from "@dnd-kit/utilities"; + attachClosestEdge, + extractClosestEdge, + type Edge, +} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; +import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index"; +import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder"; +import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash"; +import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region"; +import { BaseDropEdgeIndicator } from "@/features/base/components/grid/base-drop-edge-indicator"; import { Choice } from "@/features/base/types/base.types"; import { choiceColor } from "@/features/base/components/cells/choice-color"; import { useTranslation } from "react-i18next"; @@ -143,23 +142,44 @@ export function ChoiceEditor({ onClose(); }, [initialChoices, onDirtyChange, onClose]); - const handleReorder = useCallback((activeId: string, overId: string) => { - setDraft((prev) => { - const oldIndex = prev.findIndex((c) => c.id === activeId); - const newIndex = prev.findIndex((c) => c.id === overId); - if (oldIndex === -1 || newIndex === -1) return prev; - return arrayMove(prev, oldIndex, newIndex); - }); - }, []); + const handleReorder = useCallback( + (activeId: string, targetId: string, edge: Edge) => { + setDraft((prev) => { + const startIndex = prev.findIndex((c) => c.id === activeId); + const indexOfTarget = prev.findIndex((c) => c.id === targetId); + if (startIndex === -1 || indexOfTarget === -1) return prev; + const finishIndex = getReorderDestinationIndex({ + startIndex, + indexOfTarget, + closestEdgeOfTarget: edge, + axis: "vertical", + }); + if (finishIndex === startIndex) return prev; + return reorder({ list: prev, startIndex, finishIndex }); + }); + }, + [], + ); const handleCategoryReorder = useCallback( - (category: string, activeId: string, overId: string) => { + (category: string, activeId: string, targetId: string, edge: Edge) => { setDraft((prev) => { const catChoices = prev.filter((c) => (c.category ?? "todo") === category); - const oldIndex = catChoices.findIndex((c) => c.id === activeId); - const newIndex = catChoices.findIndex((c) => c.id === overId); - if (oldIndex === -1 || newIndex === -1) return prev; - const reordered = arrayMove(catChoices, oldIndex, newIndex); + const startIndex = catChoices.findIndex((c) => c.id === activeId); + const indexOfTarget = catChoices.findIndex((c) => c.id === targetId); + if (startIndex === -1 || indexOfTarget === -1) return prev; + const finishIndex = getReorderDestinationIndex({ + startIndex, + indexOfTarget, + closestEdgeOfTarget: edge, + axis: "vertical", + }); + if (finishIndex === startIndex) return prev; + const reordered = reorder({ + list: catChoices, + startIndex, + finishIndex, + }); const result: Choice[] = []; for (const cat of ["todo", "inProgress", "complete"]) { if (cat === category) { @@ -245,48 +265,25 @@ function FlatChoiceList({ onColorChange: (id: string, color: string) => void; onRemove: (id: string) => void; onAdd: () => void; - onReorder: (activeId: string, overId: string) => void; + onReorder: (activeId: string, targetId: string, edge: Edge) => void; }) { const { t } = useTranslation(); - const choiceIds = useMemo(() => draft.map((c) => c.id), [draft]); - - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), - ); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - if (!over || active.id === over.id) return; - onReorder(active.id as string, over.id as string); - }, - [onReorder], - ); - - const modifiers = useMemo(() => [restrictToVerticalAxis], []); return ( - - - {draft.map((choice) => ( - - ))} - - + {draft.map((choice) => ( + + ))} onAdd()} @@ -316,7 +313,7 @@ function StatusChoiceList({ onColorChange: (id: string, color: string) => void; onRemove: (id: string) => void; onAdd: (category: "todo" | "inProgress" | "complete") => void; - onCategoryReorder: (category: string, activeId: string, overId: string) => void; + onCategoryReorder: (category: string, activeId: string, targetId: string, edge: Edge) => void; }) { const grouped = useMemo(() => { const groups: Record = { todo: [], inProgress: [], complete: [] }; @@ -369,52 +366,45 @@ function CategorySection({ onColorChange: (id: string, color: string) => void; onRemove: (id: string) => void; onAdd: (category: "todo" | "inProgress" | "complete") => void; - onReorder: (category: string, activeId: string, overId: string) => void; + onReorder: ( + category: string, + activeId: string, + targetId: string, + edge: Edge, + ) => void; }) { const { t } = useTranslation(); - const choiceIds = useMemo(() => choices.map((c) => c.id), [choices]); - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), - ); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - if (!over || active.id === over.id) return; - onReorder(category, active.id as string, over.id as string); + const handleRowReorder = useCallback( + (activeId: string, targetId: string, edge: Edge) => { + onReorder(category, activeId, targetId, edge); }, [category, onReorder], ); - const modifiers = useMemo(() => [restrictToVerticalAxis], []); - return ( {t(label)} - - - {choices.map((choice) => ( - - ))} - - + {choices.map((choice) => ( + + ))} onAdd(category)} @@ -429,28 +419,37 @@ function CategorySection({ function SortableChoiceRow({ choice, + dragType, autoFocus, onFocused, onRename, onColorChange, onRemove, + onReorder, }: { choice: Choice; + dragType: string; autoFocus?: boolean; onFocused?: () => void; onRename: (id: string, name: string) => void; onColorChange: (id: string, color: string) => void; onRemove: (id: string) => void; + onReorder: (activeId: string, targetId: string, edge: Edge) => void; }) { const inputRef = useRef(null); - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: choice.id }); + const rowRef = useRef(null); + const handleRef = useRef(null); + + const [isDragging, setIsDragging] = useState(false); + const [closestEdge, setClosestEdge] = useState(null); + + // Same rationale as grid-header-cell: keep `onReorder` out of the DnD + // effect's deps so we don't tear down the adapter when the parent + // re-renders with a new closure. + const onReorderRef = useRef(onReorder); + useLayoutEffect(() => { + onReorderRef.current = onReorder; + }); useEffect(() => { if (autoFocus) { @@ -459,20 +458,65 @@ function SortableChoiceRow({ } }, [autoFocus, onFocused]); - const style = { - transform: CSS.Transform.toString(transform ? { ...transform, scaleX: 1, scaleY: 1 } : null), - transition, - opacity: isDragging ? 0.5 : 1, - zIndex: isDragging ? 10 : undefined, - }; + useEffect(() => { + const row = rowRef.current; + const handle = handleRef.current; + if (!row || !handle) return; + return combine( + draggable({ + element: row, + // Only the grip icon initiates the drag (preserves text-input clicks + // and close-button clicks). The native preview is still derived from + // `element` (the full row). + dragHandle: handle, + getInitialData: () => ({ type: dragType, choiceId: choice.id }), + onDragStart: () => setIsDragging(true), + onDrop: () => setIsDragging(false), + }), + dropTargetForElements({ + element: row, + canDrop: ({ source }) => + source.data.type === dragType && + source.data.choiceId !== choice.id, + getData: ({ input, element }) => + attachClosestEdge( + { choiceId: choice.id }, + { input, element, allowedEdges: ["top", "bottom"] }, + ), + onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)), + onDragLeave: () => setClosestEdge(null), + onDrop: ({ source, self }) => { + setClosestEdge(null); + const edge = extractClosestEdge(self.data); + if (!edge) return; + onReorderRef.current( + source.data.choiceId as string, + choice.id, + edge, + ); + triggerPostMoveFlash(row); + liveRegion.announce("Moved option"); + }, + }), + ); + }, [choice.id, dragType]); const hasError = !choice.name.trim(); return ( - +
@@ -488,6 +532,7 @@ function SortableChoiceRow({ styles={hasError ? { input: { borderColor: "var(--mantine-color-red-6)" } } : undefined} /> onRemove(choice.id)} /> + {closestEdge && } ); }