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 { 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"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
>
|
||||||
<line x1="5" y1="9" x2="19" y2="9"></line>
|
<GripVertical className="w-4 h-4 text-muted-foreground" />
|
||||||
<line x1="5" y1="15" x2="19" y2="15"></line>
|
<Badge variant="default" className="text-xs">{index + 1}</Badge>
|
||||||
</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]
|
||||||
|
);
|
||||||
|
|
||||||
|
const queueIds = useMemo(() => queue.map((item) => item.id), [queue]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
if (over && active.id !== over.id) {
|
if (over && active.id !== over.id) {
|
||||||
const oldIndex = queue.findIndex((item) => item.id === active.id);
|
const oldIndex = queue.findIndex((item) => item.id === active.id);
|
||||||
const newIndex = queue.findIndex((item) => item.id === over.id);
|
const newIndex = queue.findIndex((item) => item.id === over.id);
|
||||||
|
|
||||||
const reorderedQueue = arrayMove(queue, oldIndex, newIndex);
|
const reorderedQueue = arrayMove(queue, oldIndex, newIndex);
|
||||||
const participantIds = reorderedQueue.map((item) => item.participantId);
|
onReorder(reorderedQueue.map((item) => item.participantId));
|
||||||
onReorder(participantIds);
|
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue