Improve drag handle UX in queue items with activator node (#188)

* 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<id, participant>) 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 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-20 16:19:44 -07:00 committed by GitHub
parent 3f3401f0d8
commit ebe06b2522
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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 { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { AutodraftSettings } from "~/components/AutodraftSettings"; import { AutodraftSettings } from "~/components/AutodraftSettings";
@ -46,7 +47,7 @@ interface QueueSectionProps {
} }
// Sortable queue item component // Sortable queue item component
function SortableQueueItem({ const SortableQueueItem = memo(function SortableQueueItem({
item, item,
index, index,
participantName, participantName,
@ -60,13 +61,14 @@ function SortableQueueItem({
participantName: string; participantName: string;
sportName?: string; sportName?: string;
canPick: boolean; canPick: boolean;
onRemove: () => void; onRemove: (queueId: string) => void;
onDraft?: () => void; onDraft?: (participantId: string) => void;
}) { }) {
const { const {
attributes, attributes,
listeners, listeners,
setNodeRef, setNodeRef,
setActivatorNodeRef,
transform, transform,
transition, transition,
isDragging, isDragging,
@ -82,29 +84,20 @@ function SortableQueueItem({
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={`flex items-center justify-between p-2 rounded-lg touch-none ${ {...attributes}
className={`flex items-center justify-between p-2 rounded-lg ${
canPick ? "bg-electric/10 border border-electric/40" : "bg-muted" canPick ? "bg-electric/10 border border-electric/40" : "bg-muted"
}`} }`}
> >
<div className="flex items-center gap-2 flex-1 min-w-0" {...attributes} {...listeners}> <div className="flex items-center gap-2 flex-1 min-w-0">
<div className="cursor-grab active:cursor-grabbing flex-shrink-0"> <div
<svg ref={setActivatorNodeRef}
xmlns="http://www.w3.org/2000/svg" {...listeners}
width="16" className="flex items-center gap-2 flex-shrink-0 cursor-grab active:cursor-grabbing touch-none"
height="16" >
viewBox="0 0 24 24" <GripVertical className="w-4 h-4 text-muted-foreground" />
fill="none" <Badge variant="default" className="text-xs">{index + 1}</Badge>
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
>
<line x1="5" y1="9" x2="19" y2="9"></line>
<line x1="5" y1="15" x2="19" y2="15"></line>
</svg>
</div> </div>
<Badge variant="default" className="text-xs flex-shrink-0">{index + 1}</Badge>
<div className="min-w-0"> <div className="min-w-0">
<p className="font-semibold text-sm truncate">{participantName}</p> <p className="font-semibold text-sm truncate">{participantName}</p>
{sportName && <p className="text-xs text-muted-foreground">{sportName}</p>} {sportName && <p className="text-xs text-muted-foreground">{sportName}</p>}
@ -116,7 +109,7 @@ function SortableQueueItem({
variant="default" variant="default"
size="sm" size="sm"
className="h-7 text-xs bg-electric text-background hover:bg-electric/90" className="h-7 text-xs bg-electric text-background hover:bg-electric/90"
onClick={onDraft} onClick={() => onDraft(item.participantId)}
> >
Draft Draft
</Button> </Button>
@ -125,7 +118,7 @@ function SortableQueueItem({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10" className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={onRemove} onClick={() => onRemove(item.id)}
title="Remove from queue" title="Remove from queue"
> >
<span className="text-lg">×</span> <span className="text-lg">×</span>
@ -133,7 +126,7 @@ function SortableQueueItem({
</div> </div>
</div> </div>
); );
} });
export const QueueSection = memo(function QueueSection({ export const QueueSection = memo(function QueueSection({
queue, queue,
@ -149,24 +142,33 @@ export const QueueSection = memo(function QueueSection({
onMakePick, onMakePick,
}: QueueSectionProps) { }: QueueSectionProps) {
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor), useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates, coordinateGetter: sortableKeyboardCoordinates,
}) })
); );
const handleDragEnd = (event: DragEndEvent) => { const participantMap = useMemo(
const { active, over } = event; () => new Map(availableParticipants.map((p) => [p.id, p])),
[availableParticipants]
);
if (over && active.id !== over.id) { const queueIds = useMemo(() => queue.map((item) => item.id), [queue]);
const oldIndex = queue.findIndex((item) => item.id === active.id);
const newIndex = queue.findIndex((item) => item.id === over.id);
const reorderedQueue = arrayMove(queue, oldIndex, newIndex); const handleDragEnd = useCallback(
const participantIds = reorderedQueue.map((item) => item.participantId); (event: DragEndEvent) => {
onReorder(participantIds); 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 ( return (
<div className="p-4"> <div className="p-4">
@ -182,24 +184,22 @@ export const QueueSection = memo(function QueueSection({
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SortableContext <SortableContext
items={queue.map((item) => item.id)} items={queueIds}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div className="space-y-1.5 mb-4"> <div className="space-y-1.5 mb-4">
{queue.map((item, index) => { {queue.map((item, index) => {
const participant = availableParticipants.find( const participant = participantMap.get(item.participantId);
(p) => p.id === item.participantId
);
return ( return (
<SortableQueueItem <SortableQueueItem
key={item.id} key={item.id}
item={item} item={item}
index={index} index={index}
participantName={participant?.name || "Unknown"} participantName={participant?.name ?? "Unknown"}
sportName={participant?.sport.name} sportName={participant?.sport.name}
canPick={canPick} canPick={canPick}
onRemove={() => onRemoveFromQueue(item.id)} onRemove={onRemoveFromQueue}
onDraft={onMakePick ? () => onMakePick(item.participantId) : undefined} onDraft={onMakePick}
/> />
); );
})} })}