north-star / commit
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;