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 { 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({
<div
ref={setNodeRef}
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"
}`}
>
<div className="flex items-center gap-2 flex-1 min-w-0" {...attributes} {...listeners}>
<div className="cursor-grab active:cursor-grabbing flex-shrink-0">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
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 className="flex items-center gap-2 flex-1 min-w-0">
<div
ref={setActivatorNodeRef}
{...listeners}
className="flex items-center gap-2 flex-shrink-0 cursor-grab active:cursor-grabbing touch-none"
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
<Badge variant="default" className="text-xs">{index + 1}</Badge>
</div>
<Badge variant="default" className="text-xs flex-shrink-0">{index + 1}</Badge>
<div className="min-w-0">
<p className="font-semibold text-sm truncate">{participantName}</p>
{sportName && <p className="text-xs text-muted-foreground">{sportName}</p>}
@ -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
</Button>
@ -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"
>
<span className="text-lg">×</span>
@ -133,7 +126,7 @@ function SortableQueueItem({
</div>
</div>
);
}
});
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 (
<div className="p-4">
@ -182,24 +184,22 @@ export const QueueSection = memo(function QueueSection({
onDragEnd={handleDragEnd}
>
<SortableContext
items={queue.map((item) => item.id)}
items={queueIds}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1.5 mb-4">
{queue.map((item, index) => {
const participant = availableParticipants.find(
(p) => p.id === item.participantId
);
const participant = participantMap.get(item.participantId);
return (
<SortableQueueItem
key={item.id}
item={item}
index={index}
participantName={participant?.name || "Unknown"}
participantName={participant?.name ?? "Unknown"}
sportName={participant?.sport.name}
canPick={canPick}
onRemove={() => onRemoveFromQueue(item.id)}
onDraft={onMakePick ? () => onMakePick(item.participantId) : undefined}
onRemove={onRemoveFromQueue}
onDraft={onMakePick}
/>
);
})}