style(layout): the whole row is the drag handle

A list whose rows can only be moved by a sixteen-pixel grip is a list
most people conclude cannot be moved at all. The grip stays as the sign
that it can, and the row itself now carries the gesture — plus a line on
the edge the row would take, because the question a dragging hand asks
is 'between which two', which a highlighted target does not answer.
This commit is contained in:
2026-09-03 16:17:02 +02:00
parent 85061ab673
commit b0bbe3e402
2 changed files with 32 additions and 14 deletions
+30 -12
View File
@@ -333,6 +333,10 @@ function WidgetOrderSection() {
const [order, setOrder] = useState<string[]>(readWidgetOrder);
const dragKey = useRef<string | null>(null);
const [dragging, setDragging] = useState<string | null>(null);
// Where the row would land. Drawn as a line above the target rather than by
// colouring it: the question a dragging hand asks is "between which two", and
// a highlighted row answers a different one.
const [over, setOver] = useState<string | null>(null);
const commit = (keys: string[]) => {
setOrder(keys);
@@ -364,18 +368,32 @@ function WidgetOrderSection() {
</div>
))}
{order.map((k) => (
<div key={k}
onDragOver={(e) => { if (dragKey.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
onDrop={(e) => { if (dragKey.current) { e.preventDefault(); moveTo(dragKey.current, k); } }}
className={cn('flex items-center gap-2 rounded-md border border-border bg-card px-2 py-1.5 text-sm',
dragging === k && 'opacity-50')}>
<span draggable
onDragStart={(e) => { dragKey.current = k; setDragging(k); e.dataTransfer.effectAllowed = 'move'; }}
onDragEnd={() => { dragKey.current = null; setDragging(null); }}
title={t('wo.drag')}
className="cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-foreground">
<GripVertical className="size-4" />
</span>
// The WHOLE row is the handle, not the grip alone: a list whose rows
// can only be moved by a 16-pixel icon is a list most people conclude
// cannot be moved. The grip stays as the sign that it can.
<div key={k} draggable
onDragStart={(e) => { dragKey.current = k; setDragging(k); e.dataTransfer.effectAllowed = 'move'; }}
onDragEnd={() => { dragKey.current = null; setDragging(null); setOver(null); }}
onDragOver={(e) => {
if (!dragKey.current) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (over !== k) setOver(k);
}}
onDragLeave={() => { if (over === k) setOver(null); }}
onDrop={(e) => {
if (!dragKey.current) return;
e.preventDefault();
moveTo(dragKey.current, k);
setOver(null);
}}
title={t('wo.drag')}
className={cn('flex items-center gap-2 rounded-md border bg-card px-2 py-1.5 text-sm select-none',
'cursor-grab active:cursor-grabbing transition-shadow',
dragging === k ? 'opacity-50 border-primary shadow-lg' : 'border-border hover:border-foreground/30',
// The landing line, on the edge the row would take.
over === k && dragging !== k && 'shadow-[inset_0_3px_0_0_var(--primary)]')}>
<GripVertical className="size-4 shrink-0 text-muted-foreground/50" />
<span className="flex-1 min-w-0 truncate">{t(WIDGET_LABELS[k] ?? k)}</span>
</div>
))}