fix: preserve drag position when cursor leaves list bounds

Track last valid sort order via dragOverOrderRef during onDragOver so
that when the pointer leaves the sortable region (triggering cancel or
landing on a non-user-repo row), the item stays at its last reached
position instead of snapping back or jumping to the end of the list.
Also disable pointer-events on HNavBar while dragging to prevent Radix
UI from stealing pointer capture.
This commit is contained in:
Kissa Ruokanen 2026-05-29 13:39:11 +09:00
commit 45ed34f573

View file

@ -4,6 +4,7 @@ import {
closestCenter,
DndContext,
type DragEndEvent,
type DragOverEvent,
PointerSensor,
useSensor,
useSensors,
@ -28,6 +29,7 @@ import {
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { HNavBar, VStack } from "@/components/layout";
@ -131,6 +133,9 @@ function PageBody() {
[userRepos],
);
const [isDragging, setIsDragging] = useState(false);
const dragOverOrderRef = useRef<string[] | null>(null);
const sensors = useSensors(useSensor(PointerSensor));
const queryClient = useQueryClient();
@ -142,14 +147,54 @@ function PageBody() {
},
});
function handleDragEnd(event: DragEndEvent) {
function handleDragStart() {
dragOverOrderRef.current = null;
setIsDragging(true);
}
function handleDragOver(event: DragOverEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = orderedIds.indexOf(active.id as string);
const newIndex = orderedIds.indexOf(over.id as string);
const newIds = arrayMove(orderedIds, oldIndex, newIndex);
setOrderedIds(newIds);
reorderMutation.mutate(newIds);
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
dragOverOrderRef.current = arrayMove(orderedIds, oldIndex, newIndex);
}
}
function handleDragEnd(event: DragEndEvent) {
const pendingOrder = dragOverOrderRef.current;
dragOverOrderRef.current = null;
setIsDragging(false);
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = orderedIds.indexOf(active.id as string);
const newIndex = orderedIds.indexOf(over.id as string);
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
const newIds = arrayMove(orderedIds, oldIndex, newIndex);
setOrderedIds(newIds);
reorderMutation.mutate(newIds);
return;
}
}
// over が無効Official/Curated など orderedIds 外の IDな場合は
// onDragOver で追跡していた最後の有効な順序を使う
if (pendingOrder) {
setOrderedIds(pendingOrder);
reorderMutation.mutate(pendingOrder);
}
}
function handleDragCancel() {
setIsDragging(false);
const pendingOrder = dragOverOrderRef.current;
dragOverOrderRef.current = null;
if (pendingOrder) {
setOrderedIds(pendingOrder);
reorderMutation.mutate(pendingOrder);
}
}
const bodyAnimation = usePrevPathName().startsWith("/packages")
@ -160,43 +205,48 @@ function PageBody() {
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<VStack>
<HNavBar
className="shrink-0"
leading={<HeadingPageName pageType={"/packages/repositories"} />}
trailing={
<DropdownMenu>
<div className={"flex divide-x"}>
<Button
className={"rounded-r-none compact:h-10"}
onClick={() => openAddRepositoryDialog()}
>
{tc("vpm repositories:button:add repository")}
</Button>
<DropdownMenuTrigger
asChild
className={"rounded-l-none pl-2 pr-2 compact:h-10"}
>
<Button>
<ChevronDown className={"w-4 h-4"} />
<div style={isDragging ? { pointerEvents: "none" } : undefined}>
<HNavBar
className="shrink-0"
leading={<HeadingPageName pageType={"/packages/repositories"} />}
trailing={
<DropdownMenu>
<div className={"flex divide-x"}>
<Button
className={"rounded-r-none compact:h-10"}
onClick={() => openAddRepositoryDialog()}
>
{tc("vpm repositories:button:add repository")}
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => importRepositoriesMutation.mutate()}
>
{tc("vpm repositories:button:import repositories")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportRepositories.mutate()}>
{tc("vpm repositories:button:export repositories")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
/>
<DropdownMenuTrigger
asChild
className={"rounded-l-none pl-2 pr-2 compact:h-10"}
>
<Button>
<ChevronDown className={"w-4 h-4"} />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => importRepositoriesMutation.mutate()}
>
{tc("vpm repositories:button:import repositories")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportRepositories.mutate()}>
{tc("vpm repositories:button:export repositories")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
/>
</div>
<main
className={`shrink overflow-hidden flex w-full h-full ${bodyAnimation}`}
>