From 44e5d5d76f73280c1df2ebaaa5dbd9cd0b3e7cdb Mon Sep 17 00:00:00 2001 From: Ki3mON Date: Sat, 20 Jun 2026 21:43:00 +0200 Subject: [PATCH 1/5] fix(ios): drag and drop question --- .../components/quiz/questions/Ordering.tsx | 76 ++++++++++++++----- 1 file changed, 59 insertions(+), 17 deletions(-) diff --git a/frontend/app/components/quiz/questions/Ordering.tsx b/frontend/app/components/quiz/questions/Ordering.tsx index 43c2350..6de5f48 100644 --- a/frontend/app/components/quiz/questions/Ordering.tsx +++ b/frontend/app/components/quiz/questions/Ordering.tsx @@ -1,7 +1,7 @@ 'use client'; import { GripVertical } from 'lucide-react'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import SubmitButton from '@/app/components/common/SubmitButton'; import { cn } from '@/lib/cn'; @@ -32,12 +32,60 @@ export default function Ordering({ items.map((text, origIdx) => ({ text, origIdx })), ); const [dragging, setDragging] = useState(null); + const listRef = useRef(null); + // Authoritative current index of the dragged row. Kept in a ref (not just + // state) so consecutive pointermove events read a fresh value instead of a + // stale closure between renders. + const draggingRef = useRef(null); const moveItem = (from: number, to: number) => { - const next = [...ordered]; - const [item] = next.splice(from, 1); - next.splice(to, 0, item); - setOrdered(next); + setOrdered((prev) => { + const next = [...prev]; + const [item] = next.splice(from, 1); + next.splice(to, 0, item); + return next; + }); + }; + + /** Index of the row whose bounds contain clientY, or null between/outside rows. */ + const rowUnderPointer = (clientY: number): number | null => { + const rows = listRef.current?.children; + if (!rows) return null; + for (let k = 0; k < rows.length; k++) { + const r = rows[k].getBoundingClientRect(); + if (clientY >= r.top && clientY <= r.bottom) return k; + } + return null; + }; + + const startDrag = (i: number) => (e: React.PointerEvent) => { + if (e.button !== 0) return; // ignore non-primary mouse buttons + e.preventDefault(); // suppress text selection / native image drag + draggingRef.current = i; + setDragging(i); + // Capture keeps pointermove/up firing on this row even as the finger moves + // over its siblings - this is what makes the drag track on touch. + e.currentTarget.setPointerCapture(e.pointerId); + }; + + const onDragMove = (e: React.PointerEvent) => { + const from = draggingRef.current; + if (from === null) return; + const to = rowUnderPointer(e.clientY); + if (to !== null && to !== from) { + moveItem(from, to); + draggingRef.current = to; + setDragging(to); + } + }; + + const endDrag = (e: React.PointerEvent) => { + if (draggingRef.current === null) return; + draggingRef.current = null; + setDragging(null); + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } }; return ( @@ -48,24 +96,18 @@ export default function Ordering({ > -
+
{ordered.map(({ text, origIdx }, i) => { const isDragging = dragging === i; return (
setDragging(i)} - onDragOver={(e) => { - e.preventDefault(); - if (dragging !== null && dragging !== i) { - moveItem(dragging, i); - setDragging(i); - } - }} - onDragEnd={() => setDragging(null)} + onPointerDown={startDrag(i)} + onPointerMove={onDragMove} + onPointerUp={endDrag} + onPointerCancel={endDrag} className={cn( - 'flex w-full cursor-grab items-center gap-3 rounded-2xl px-4 py-3.5 transition-all', + 'flex w-full cursor-grab touch-none items-center gap-3 rounded-2xl px-4 py-3.5 transition-all select-none [-webkit-touch-callout:none] active:cursor-grabbing', isDragging ? 'border-2 border-[var(--selected-border)] bg-[var(--selected-bg)] shadow-[0_4px_12px_rgba(246,162,0,0.25)]' : 'border-[1.5px] border-[var(--border)] bg-[var(--card-bg)]', From 2f4f1f7fa5d42dc7e1f5cfcecf82f242f2a5000f Mon Sep 17 00:00:00 2001 From: Ki3mON Date: Sat, 20 Jun 2026 21:51:25 +0200 Subject: [PATCH 2/5] fix(hot-fix): review lol --- frontend/app/components/quiz/questions/Ordering.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/app/components/quiz/questions/Ordering.tsx b/frontend/app/components/quiz/questions/Ordering.tsx index 6de5f48..87b1d32 100644 --- a/frontend/app/components/quiz/questions/Ordering.tsx +++ b/frontend/app/components/quiz/questions/Ordering.tsx @@ -59,6 +59,7 @@ export default function Ordering({ }; const startDrag = (i: number) => (e: React.PointerEvent) => { + if (draggingRef.current !== null) return; // ignore extra fingers mid-drag if (e.button !== 0) return; // ignore non-primary mouse buttons e.preventDefault(); // suppress text selection / native image drag draggingRef.current = i; From 21d4f0a98c31c1da60cab8882f0df492fc3a8d73 Mon Sep 17 00:00:00 2001 From: Ki3mON Date: Sat, 20 Jun 2026 21:56:58 +0200 Subject: [PATCH 3/5] fix(review): krulik --- .../components/quiz/questions/Ordering.tsx | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/frontend/app/components/quiz/questions/Ordering.tsx b/frontend/app/components/quiz/questions/Ordering.tsx index 87b1d32..a69141f 100644 --- a/frontend/app/components/quiz/questions/Ordering.tsx +++ b/frontend/app/components/quiz/questions/Ordering.tsx @@ -37,6 +37,8 @@ export default function Ordering({ // state) so consecutive pointermove events read a fresh value instead of a // stale closure between renders. const draggingRef = useRef(null); + // Screen-reader announcement for keyboard-driven reordering. + const [announcement, setAnnouncement] = useState(''); const moveItem = (from: number, to: number) => { setOrdered((prev) => { @@ -47,6 +49,17 @@ export default function Ordering({ }); }; + // Keyboard reordering: move the focused row up/down one slot. Focus follows + // the row's stable key, so repeated arrow presses keep moving the same item. + const moveByKeyboard = (i: number, dir: -1 | 1) => { + const to = i + dir; + if (to < 0 || to >= ordered.length) return; + moveItem(i, to); + setAnnouncement( + `${ordered[i].text}: pozycja ${to + 1} z ${ordered.length}`, + ); + }; + /** Index of the row whose bounds contain clientY, or null between/outside rows. */ const rowUnderPointer = (clientY: number): number | null => { const rows = listRef.current?.children; @@ -103,12 +116,24 @@ export default function Ordering({ return (
{ + if (e.key === 'ArrowUp') { + e.preventDefault(); + moveByKeyboard(i, -1); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + moveByKeyboard(i, 1); + } + }} className={cn( - 'flex w-full cursor-grab touch-none items-center gap-3 rounded-2xl px-4 py-3.5 transition-all select-none [-webkit-touch-callout:none] active:cursor-grabbing', + 'flex w-full cursor-grab touch-none items-center gap-3 rounded-2xl px-4 py-3.5 transition-all outline-none select-none [-webkit-touch-callout:none] focus-visible:ring-2 focus-visible:ring-[var(--orange)] active:cursor-grabbing', isDragging ? 'border-2 border-[var(--selected-border)] bg-[var(--selected-bg)] shadow-[0_4px_12px_rgba(246,162,0,0.25)]' : 'border-[1.5px] border-[var(--border)] bg-[var(--card-bg)]', @@ -138,6 +163,10 @@ export default function Ordering({ })}
+
+ {announcement} +
+ onSubmit?.(ordered.map((x) => x.origIdx))} From e3c4e5240039971efacabe8d3d704a944eeab050 Mon Sep 17 00:00:00 2001 From: Ki3mON Date: Sat, 20 Jun 2026 22:10:29 +0200 Subject: [PATCH 4/5] fix(comments): remove --- .../app/components/quiz/questions/Ordering.tsx | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/frontend/app/components/quiz/questions/Ordering.tsx b/frontend/app/components/quiz/questions/Ordering.tsx index a69141f..94b4a8e 100644 --- a/frontend/app/components/quiz/questions/Ordering.tsx +++ b/frontend/app/components/quiz/questions/Ordering.tsx @@ -33,11 +33,7 @@ export default function Ordering({ ); const [dragging, setDragging] = useState(null); const listRef = useRef(null); - // Authoritative current index of the dragged row. Kept in a ref (not just - // state) so consecutive pointermove events read a fresh value instead of a - // stale closure between renders. const draggingRef = useRef(null); - // Screen-reader announcement for keyboard-driven reordering. const [announcement, setAnnouncement] = useState(''); const moveItem = (from: number, to: number) => { @@ -49,8 +45,6 @@ export default function Ordering({ }); }; - // Keyboard reordering: move the focused row up/down one slot. Focus follows - // the row's stable key, so repeated arrow presses keep moving the same item. const moveByKeyboard = (i: number, dir: -1 | 1) => { const to = i + dir; if (to < 0 || to >= ordered.length) return; @@ -60,7 +54,6 @@ export default function Ordering({ ); }; - /** Index of the row whose bounds contain clientY, or null between/outside rows. */ const rowUnderPointer = (clientY: number): number | null => { const rows = listRef.current?.children; if (!rows) return null; @@ -72,13 +65,11 @@ export default function Ordering({ }; const startDrag = (i: number) => (e: React.PointerEvent) => { - if (draggingRef.current !== null) return; // ignore extra fingers mid-drag - if (e.button !== 0) return; // ignore non-primary mouse buttons - e.preventDefault(); // suppress text selection / native image drag + if (draggingRef.current !== null) return; + if (e.button !== 0) return; + e.preventDefault(); draggingRef.current = i; setDragging(i); - // Capture keeps pointermove/up firing on this row even as the finger moves - // over its siblings - this is what makes the drag track on touch. e.currentTarget.setPointerCapture(e.pointerId); }; From 7d9db6d26e62ab1206c6022d48ac373ea3d3208f Mon Sep 17 00:00:00 2001 From: Ki3mON Date: Sat, 20 Jun 2026 22:29:40 +0200 Subject: [PATCH 5/5] fix(review): michal's concerns --- .../components/quiz/questions/Ordering.tsx | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/frontend/app/components/quiz/questions/Ordering.tsx b/frontend/app/components/quiz/questions/Ordering.tsx index 94b4a8e..60bdbbe 100644 --- a/frontend/app/components/quiz/questions/Ordering.tsx +++ b/frontend/app/components/quiz/questions/Ordering.tsx @@ -64,7 +64,7 @@ export default function Ordering({ return null; }; - const startDrag = (i: number) => (e: React.PointerEvent) => { + const startDrag = (i: number) => (e: React.PointerEvent) => { if (draggingRef.current !== null) return; if (e.button !== 0) return; e.preventDefault(); @@ -73,7 +73,7 @@ export default function Ordering({ e.currentTarget.setPointerCapture(e.pointerId); }; - const onDragMove = (e: React.PointerEvent) => { + const onDragMove = (e: React.PointerEvent) => { const from = draggingRef.current; if (from === null) return; const to = rowUnderPointer(e.clientY); @@ -84,7 +84,7 @@ export default function Ordering({ } }; - const endDrag = (e: React.PointerEvent) => { + const endDrag = (e: React.PointerEvent) => { if (draggingRef.current === null) return; draggingRef.current = null; setDragging(null); @@ -110,10 +110,6 @@ export default function Ordering({ role="button" tabIndex={0} aria-label={`Pozycja ${i + 1} z ${ordered.length}: ${text}. Użyj strzałek w górę i w dół, aby zmienić kolejność.`} - onPointerDown={startDrag(i)} - onPointerMove={onDragMove} - onPointerUp={endDrag} - onPointerCancel={endDrag} onKeyDown={(e) => { if (e.key === 'ArrowUp') { e.preventDefault(); @@ -124,20 +120,29 @@ export default function Ordering({ } }} className={cn( - 'flex w-full cursor-grab touch-none items-center gap-3 rounded-2xl px-4 py-3.5 transition-all outline-none select-none [-webkit-touch-callout:none] focus-visible:ring-2 focus-visible:ring-[var(--orange)] active:cursor-grabbing', + 'flex w-full items-center gap-3 rounded-2xl px-4 py-3.5 transition-all outline-none select-none focus-visible:ring-2 focus-visible:ring-[var(--orange)]', isDragging ? 'border-2 border-[var(--selected-border)] bg-[var(--selected-bg)] shadow-[0_4px_12px_rgba(246,162,0,0.25)]' : 'border-[1.5px] border-[var(--border)] bg-[var(--card-bg)]', )} > - + {i + 1}