fuzzyworld

north-star / commit

changed the participant reorder ui

faeb8ad8a667b510c27c9b6e0e44bdb0ae668a85 · Pavel Safronov · 2026-03-15 00:05:23 -0700 · parents 89b0c38fcf12

changed the participant reorder ui
 src/app.js    | 156 +++++++++++++++++++++++++++++++++++++++++++++-------------
 src/style.css |  33 +++++++------
 2 files changed, 139 insertions(+), 50 deletions(-)

diff --git a/src/app.js b/src/app.js
index 59afd82..f89d56f 100644
--- a/src/app.js
+++ b/src/app.js
@@ -586,17 +586,38 @@ const sortParticipantsForDisplay = (participantsToRender) => {
     });
 };
 
-const moveParticipantInOrder = (participantId, direction) => {
-    const index = participantOrder.indexOf(participantId);
-    if (index < 0) return;
+let draggedParticipantId = null;
+let draggedPointerId = null;
+let dropTargetParticipantId = null;
+let dropAfterTarget = false;
 
-    const nextIndex = index + direction;
-    if (nextIndex < 0 || nextIndex >= participantOrder.length) return;
+const clearParticipantDragVisuals = () => {
+    if (!participantsPanel) return;
 
-    [participantOrder[index], participantOrder[nextIndex]] = [participantOrder[nextIndex], participantOrder[index]];
+    participantsPanel.querySelectorAll('.participant-row').forEach((row) => {
+        row.classList.remove('is-dragging', 'drag-over-before', 'drag-over-after');
+    });
+};
+
+const moveParticipantToIndex = (participantId, targetIndex) => {
+    const fromIndex = participantOrder.indexOf(participantId);
+    if (fromIndex < 0) return false;
+
+    const boundedIndex = Math.max(0, Math.min(targetIndex, participantOrder.length - 1));
+    if (fromIndex === boundedIndex) return false;
+
+    participantOrder.splice(fromIndex, 1);
+    participantOrder.splice(boundedIndex, 0, participantId);
     saveParticipantOrder();
-    renderParticipantControls();
-    requestRender();
+    return true;
+};
+
+const endParticipantDrag = () => {
+    draggedParticipantId = null;
+    draggedPointerId = null;
+    dropTargetParticipantId = null;
+    dropAfterTarget = false;
+    clearParticipantDragVisuals();
 };
 
 const getInitialParticipantsPanelVisible = () => {
@@ -643,6 +664,78 @@ document.addEventListener('pointerdown', (event) => {
     applyParticipantsPanelVisibility();
 });
 
+document.addEventListener('pointermove', (event) => {
+    if (!draggedParticipantId || event.pointerId !== draggedPointerId) return;
+
+    event.preventDefault();
+
+    const pointed = document.elementFromPoint(event.clientX, event.clientY);
+    if (!(pointed instanceof Element) || !participantsPanel) {
+        dropTargetParticipantId = null;
+        dropAfterTarget = false;
+        clearParticipantDragVisuals();
+        return;
+    }
+
+    const targetRow = pointed.closest('.participant-row');
+    clearParticipantDragVisuals();
+
+    const draggedRow = participantsPanel.querySelector(`[data-participant-id="${draggedParticipantId}"]`);
+    if (draggedRow) {
+        draggedRow.classList.add('is-dragging');
+    }
+
+    if (!(targetRow instanceof HTMLElement) || !participantsPanel.contains(targetRow)) {
+        dropTargetParticipantId = null;
+        dropAfterTarget = false;
+        return;
+    }
+
+    const targetId = targetRow.dataset.participantId;
+    if (!targetId || targetId === draggedParticipantId) {
+        dropTargetParticipantId = null;
+        dropAfterTarget = false;
+        return;
+    }
+
+    const rect = targetRow.getBoundingClientRect();
+    const after = event.clientY > rect.top + rect.height / 2;
+
+    dropTargetParticipantId = targetId;
+    dropAfterTarget = after;
+    targetRow.classList.add(after ? 'drag-over-after' : 'drag-over-before');
+});
+
+document.addEventListener('pointerup', (event) => {
+    if (!draggedParticipantId || event.pointerId !== draggedPointerId) return;
+
+    const draggedId = draggedParticipantId;
+    const targetId = dropTargetParticipantId;
+    const insertAfter = dropAfterTarget;
+    endParticipantDrag();
+
+    if (!targetId) return;
+
+    const targetIndex = participantOrder.indexOf(targetId);
+    if (targetIndex < 0) return;
+
+    let nextIndex = insertAfter ? targetIndex + 1 : targetIndex;
+    const currentIndex = participantOrder.indexOf(draggedId);
+    if (currentIndex >= 0 && currentIndex < nextIndex) {
+        nextIndex -= 1;
+    }
+
+    if (!moveParticipantToIndex(draggedId, nextIndex)) return;
+
+    renderParticipantControls();
+    requestRender();
+});
+
+document.addEventListener('pointercancel', () => {
+    if (!draggedParticipantId) return;
+    endParticipantDrag();
+});
+
 applyParticipantsPanelVisibility();
 
 const isParticipantVisible = (participantId) => participantVisibility[participantId] !== false;
@@ -901,9 +994,10 @@ const renderParticipantControls = () => {
     heading.textContent = 'Participants';
     participantsPanel.appendChild(heading);
 
-    for (const [index, participant] of participantsToRender.entries()) {
+    for (const participant of participantsToRender) {
         const row = document.createElement('div');
         row.className = 'participant-row';
+        row.dataset.participantId = participant.id;
 
         const checkbox = document.createElement('input');
         checkbox.type = 'checkbox';
@@ -938,34 +1032,26 @@ const renderParticipantControls = () => {
         }
         row.appendChild(nameWrapper);
 
-        const orderControls = document.createElement('div');
-        orderControls.className = 'participant-order-controls';
-
-        const moveUpButton = document.createElement('button');
-        moveUpButton.type = 'button';
-        moveUpButton.className = 'participant-order-button';
-        moveUpButton.textContent = '↑';
-        moveUpButton.title = `Move ${participant.name} up`;
-        moveUpButton.setAttribute('aria-label', `Move ${participant.name} up`);
-        moveUpButton.disabled = index === 0;
-        moveUpButton.addEventListener('click', () => {
-            moveParticipantInOrder(participant.id, -1);
-        });
-
-        const moveDownButton = document.createElement('button');
-        moveDownButton.type = 'button';
-        moveDownButton.className = 'participant-order-button';
-        moveDownButton.textContent = '↓';
-        moveDownButton.title = `Move ${participant.name} down`;
-        moveDownButton.setAttribute('aria-label', `Move ${participant.name} down`);
-        moveDownButton.disabled = index === participantsToRender.length - 1;
-        moveDownButton.addEventListener('click', () => {
-            moveParticipantInOrder(participant.id, 1);
+        const dragHandle = document.createElement('button');
+        dragHandle.type = 'button';
+        dragHandle.className = 'participant-drag-handle';
+        dragHandle.textContent = '☰';
+        dragHandle.title = `Drag to reorder ${participant.name}`;
+        dragHandle.setAttribute('aria-label', `Drag to reorder ${participant.name}`);
+        dragHandle.addEventListener('pointerdown', (event) => {
+            if (event.button !== undefined && event.button !== 0) return;
+
+            event.preventDefault();
+            draggedParticipantId = participant.id;
+            draggedPointerId = event.pointerId;
+            dropTargetParticipantId = null;
+            dropAfterTarget = false;
+
+            clearParticipantDragVisuals();
+            row.classList.add('is-dragging');
         });
 
-        orderControls.appendChild(moveUpButton);
-        orderControls.appendChild(moveDownButton);
-        row.appendChild(orderControls);
+        row.appendChild(dragHandle);
         participantsPanel.appendChild(row);
     }
 };
diff --git a/src/style.css b/src/style.css
index 9007ff1..8cdfd30 100644
--- a/src/style.css
+++ b/src/style.css
@@ -257,12 +257,7 @@ a {
     text-decoration: none;
 }
 
-.participant-order-controls {
-    display: flex;
-    gap: 4px;
-}
-
-.participant-order-button {
+.participant-drag-handle {
     width: 24px;
     height: 24px;
     border: 0;
@@ -272,12 +267,24 @@ a {
     font-size: 14px;
     font-weight: 700;
     line-height: 1;
-    cursor: pointer;
+    cursor: grab;
+    touch-action: none;
+}
+
+.participant-drag-handle:active {
+    cursor: grabbing;
 }
 
-.participant-order-button:disabled {
-    opacity: 0.45;
-    cursor: default;
+.participant-row.drag-over-before {
+    box-shadow: inset 0 2px 0 rgba(248, 250, 252, 0.8);
+}
+
+.participant-row.drag-over-after {
+    box-shadow: inset 0 -2px 0 rgba(248, 250, 252, 0.8);
+}
+
+.participant-row.is-dragging {
+    opacity: 0.55;
 }
 
 .arrow {
@@ -377,11 +384,7 @@ a {
         gap: 6px;
     }
 
-    .participant-order-controls {
-        gap: 3px;
-    }
-
-    .participant-order-button {
+    .participant-drag-handle {
         width: 22px;
         height: 22px;
         font-size: 12px;