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:
parent
3f3401f0d8
commit
ebe06b2522
1 changed files with 44 additions and 44 deletions
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue