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
+2 -2
View File
@@ -3,10 +3,10 @@
"version": "0.27.10", "version": "0.27.10",
"date": "", "date": "",
"en": [ "en": [
"Widget order (Settings → Appearance): the row to the right of the entry can be rearranged by dragging. QSO entry and the F1-F5 panel head the list, locked — they are not part of that row and nothing should be allowed to push what you type into behind a rotator dial. A widget you have switched off keeps its place and comes back where you left it, and the main view follows as you drag." "Widget order (Settings → Appearance): the row to the right of the entry can be rearranged by dragging — the whole row is the handle, and a line shows where it will land. QSO entry and the F1-F5 panel head the list, locked — they are not part of that row and nothing should be allowed to push what you type into behind a rotator dial. A widget you have switched off keeps its place and comes back where you left it, and the main view follows as you drag."
], ],
"fr": [ "fr": [
"Ordre des widgets (Réglages → Apparence) : la rangée à droite de la saisie se réorganise par glisser-déposer. La saisie du QSO et le panneau F1-F5 ouvrent la liste, verrouillés — ils ne font pas partie de cette rangée, et rien ne doit pouvoir repousser ce dans quoi vous tapez derrière une boussole de rotor. Un widget désactivé garde sa place et revient là où vous laviez laissé, et la vue principale suit pendant que vous glissez." "Ordre des widgets (Réglages → Apparence) : la rangée à droite de la saisie se réorganise par glisser-déposer — toute la ligne se saisit, et un trait montre où elle atterrira. La saisie du QSO et le panneau F1-F5 ouvrent la liste, verrouillés — ils ne font pas partie de cette rangée, et rien ne doit pouvoir repousser ce dans quoi vous tapez derrière une boussole de rotor. Un widget désactivé garde sa place et revient là où vous laviez laissé, et la vue principale suit pendant que vous glissez."
] ]
}, },
{ {
+30 -12
View File
@@ -333,6 +333,10 @@ function WidgetOrderSection() {
const [order, setOrder] = useState<string[]>(readWidgetOrder); const [order, setOrder] = useState<string[]>(readWidgetOrder);
const dragKey = useRef<string | null>(null); const dragKey = useRef<string | null>(null);
const [dragging, setDragging] = useState<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[]) => { const commit = (keys: string[]) => {
setOrder(keys); setOrder(keys);
@@ -364,18 +368,32 @@ function WidgetOrderSection() {
</div> </div>
))} ))}
{order.map((k) => ( {order.map((k) => (
<div key={k} // The WHOLE row is the handle, not the grip alone: a list whose rows
onDragOver={(e) => { if (dragKey.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }} // can only be moved by a 16-pixel icon is a list most people conclude
onDrop={(e) => { if (dragKey.current) { e.preventDefault(); moveTo(dragKey.current, k); } }} // cannot be moved. The grip stays as the sign that it can.
className={cn('flex items-center gap-2 rounded-md border border-border bg-card px-2 py-1.5 text-sm', <div key={k} draggable
dragging === k && 'opacity-50')}> onDragStart={(e) => { dragKey.current = k; setDragging(k); e.dataTransfer.effectAllowed = 'move'; }}
<span draggable onDragEnd={() => { dragKey.current = null; setDragging(null); setOver(null); }}
onDragStart={(e) => { dragKey.current = k; setDragging(k); e.dataTransfer.effectAllowed = 'move'; }} onDragOver={(e) => {
onDragEnd={() => { dragKey.current = null; setDragging(null); }} if (!dragKey.current) return;
title={t('wo.drag')} e.preventDefault();
className="cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-foreground"> e.dataTransfer.dropEffect = 'move';
<GripVertical className="size-4" /> if (over !== k) setOver(k);
</span> }}
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> <span className="flex-1 min-w-0 truncate">{t(WIDGET_LABELS[k] ?? k)}</span>
</div> </div>
))} ))}