From ebe06b252294828d1076799847761133360d84ba Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:19:44 -0700 Subject: [PATCH] Improve drag handle UX in queue items with activator node (#188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix queue drag to only activate on handle and number, not entire row Restricts drag listeners to the grab handle icon and order number badge using setActivatorNodeRef, and removes touch-none from the whole row so mobile users can scroll without accidentally reordering the queue. https://claude.ai/code/session_01HPtNkL5m9xhYgtzWaGViSC * Code review cleanup: use GripVertical icon and add drag activation constraint - Replace inline SVG drag handle with GripVertical from lucide-react, which is already used project-wide - Add activationConstraint (distance: 8px) to PointerSensor so a touch on the handle doesn't immediately hijack scroll before the user has moved far enough to signal intent to drag https://claude.ai/code/session_01HPtNkL5m9xhYgtzWaGViSC * Fix O(n²) lookup, memoize components and callbacks in QueueSection - Build a participantMap (Map) with useMemo so each queue item lookup is O(1) instead of O(n) per render - Memoize queueIds array for SortableContext to avoid churn - Wrap handleDragEnd in useCallback so DndContext gets a stable reference - Wrap SortableQueueItem in memo so it skips re-renders when props haven't changed (important since the parent re-renders every second during a live draft from timer-update socket events) - Pass onRemoveFromQueue and onMakePick directly as props instead of creating new arrow functions per item per render; SortableQueueItem now calls them with the relevant id itself https://claude.ai/code/session_01HPtNkL5m9xhYgtzWaGViSC --------- Co-authored-by: Claude --- app/components/draft/QueueSection.tsx | 88 +++++++++++++-------------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/app/components/draft/QueueSection.tsx b/app/components/draft/QueueSection.tsx index b68193e..6fbb38e 100644 --- a/app/components/draft/QueueSection.tsx +++ b/app/components/draft/QueueSection.tsx @@ -1,4 +1,5 @@ -import { memo } from "react"; +import { memo, useCallback, useMemo } from "react"; +import { GripVertical } from "lucide-react"; import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; import { AutodraftSettings } from "~/components/AutodraftSettings"; @@ -46,7 +47,7 @@ interface QueueSectionProps { } // Sortable queue item component -function SortableQueueItem({ +const SortableQueueItem = memo(function SortableQueueItem({ item, index, participantName, @@ -60,13 +61,14 @@ function SortableQueueItem({ participantName: string; sportName?: string; canPick: boolean; - onRemove: () => void; - onDraft?: () => void; + onRemove: (queueId: string) => void; + onDraft?: (participantId: string) => void; }) { const { attributes, listeners, setNodeRef, + setActivatorNodeRef, transform, transition, isDragging, @@ -82,29 +84,20 @@ function SortableQueueItem({
-
-
- - - - +
+
+ + {index + 1}
- {index + 1}

{participantName}

{sportName &&

{sportName}

} @@ -116,7 +109,7 @@ function SortableQueueItem({ variant="default" size="sm" className="h-7 text-xs bg-electric text-background hover:bg-electric/90" - onClick={onDraft} + onClick={() => onDraft(item.participantId)} > Draft @@ -125,7 +118,7 @@ function SortableQueueItem({ variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10" - onClick={onRemove} + onClick={() => onRemove(item.id)} title="Remove from queue" > × @@ -133,7 +126,7 @@ function SortableQueueItem({
); -} +}); export const QueueSection = memo(function QueueSection({ queue, @@ -149,24 +142,33 @@ export const QueueSection = memo(function QueueSection({ onMakePick, }: QueueSectionProps) { const sensors = useSensors( - useSensor(PointerSensor), + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; + const participantMap = useMemo( + () => new Map(availableParticipants.map((p) => [p.id, p])), + [availableParticipants] + ); - if (over && active.id !== over.id) { - const oldIndex = queue.findIndex((item) => item.id === active.id); - const newIndex = queue.findIndex((item) => item.id === over.id); + const queueIds = useMemo(() => queue.map((item) => item.id), [queue]); - const reorderedQueue = arrayMove(queue, oldIndex, newIndex); - const participantIds = reorderedQueue.map((item) => item.participantId); - onReorder(participantIds); - } - }; + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over.id) { + const oldIndex = queue.findIndex((item) => item.id === active.id); + const newIndex = queue.findIndex((item) => item.id === over.id); + const reorderedQueue = arrayMove(queue, oldIndex, newIndex); + onReorder(reorderedQueue.map((item) => item.participantId)); + } + }, + [queue, onReorder] + ); return (
@@ -182,24 +184,22 @@ export const QueueSection = memo(function QueueSection({ onDragEnd={handleDragEnd} > item.id)} + items={queueIds} strategy={verticalListSortingStrategy} >
{queue.map((item, index) => { - const participant = availableParticipants.find( - (p) => p.id === item.participantId - ); + const participant = participantMap.get(item.participantId); return ( onRemoveFromQueue(item.id)} - onDraft={onMakePick ? () => onMakePick(item.participantId) : undefined} + onRemove={onRemoveFromQueue} + onDraft={onMakePick} /> ); })}