Draggable List

DraggableList — Deep Dive #

This document explains DraggableList (public/services/DraggableList.js) in detail, with a focus on timing, state transitions, and the deferred scroll-blocking logic. It also flags potential logic flaws and race conditions.


Table of Contents #

  1. What It Is
  2. State Machine
  3. Constructor & Configuration
  4. The Two Input Paths
  5. Deferred Scroll Blocking — Deep Dive
  6. Drop Indicator Logic
  7. CSS Classes Applied
  8. Cleanup & Destruction
  9. Integration with Components
  10. Known Logic Flaws & Race Conditions
  11. Annotated Code Reference
  12. File Reference

What It Is #

DraggableList is a vanilla-JS drag-and-drop reordering utility for the Pockist PWA. It wraps a container element and makes its direct children draggable via a drag handle. It supports both touch and mouse input, but uses radically different strategies for each to solve the “scroll vs. drag” problem on mobile.

Why not use a library? The project is intentionally framework/library-free. This class is ~364 lines of pure DOM event manipulation.


State Machine #

The class can be in one of four conceptual states:

┌─────────┐     touchstart on handle      ┌──────────┐
│  IDLE   │ ─────────────────────────────►│ PENDING  │
│         │                               │ (hold)   │
│         │ ◄──────── move > 10px ────────┤ ~150ms   │
│         │           or touchend           │ timer    │
│         │                               └────┬─────┘
│         │                                    │ timer fires
│         │ ◄──────────────────────────────────┘
│         │     mousedown on handle  ┌──────────┐
│         │ ────────────────────────►│ DRAGGING │
│         │                          │          │
│         │ ◄──────── mouseup / ─────┤ touch    │
│         │          touchend        │ move     │
│         │                          │ events   │
│         │                          └────┬─────┘
│         │                               │
│         │ ◄─────────────────────────────┘
│         │        FINISHING (cleanup + onReorder)
└─────────┘

State Descriptions #

StateInternal CheckDescription
IDLE!_pendingDrag && !_dragStateNo active interaction. Only touchstart and mousedown on the container are listening.
PENDING_pendingDrag && !_dragStateTouch only. A setTimeout is waiting for the long-press delay. touchmove and touchend listeners are on window to detect cancellation.
DRAGGING_dragStateActive drag. The dragged item has transform: translateY(...), .dragging class, and a drop indicator is visible. Different listeners for touch vs. mouse.
FINISHINGtransient_finishDrag() resets styles, removes listeners, fires onReorder(oldIndex, newIndex), and returns to IDLE.

Constructor & Configuration #

1
2
3
4
5
6
7
const dl = new DraggableList(container, {
  itemSelector: ':scope > *',   // Which children are draggable
  handleSelector: '.drag-hint', // Optional: only start drag on this child
  onReorder: (oldIndex, newIndex) => { ... },
  longPressDelay: 150,          // ms before touch drag begins
  moveThreshold: 10             // px: cancel long-press if moved more than this
});

Options #

OptionDefaultPurpose
itemSelector':scope > *'Query selector for draggable children. Defaults to all direct children.
handleSelectornullIf set, drag only starts when the event target is inside this selector. If null, any part of the item is a handle.
onReorder() => {}Callback fired after a successful drop with (oldIndex, newIndex).
longPressDelay150How long (ms) to hold a touch before drag begins.
moveThreshold10Pixels. If finger moves more than this during the hold, the pending drag is cancelled.

Instance Properties #

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
this.container           // The DOM element passed to constructor
this.itemSelector        // From options
this.handleSelector      // From options
this.onReorder          // From options
this.longPressDelay      // From options
this.moveThreshold       // From options

this._pendingDrag        // Object | null — touch hold state
this._dragState          // Object | null — active drag state
this._dropIndicator      // Element | null — the visual line
this._popTimer           // Timeout ID for the "pop" animation cleanup
this._suppressClick      // Boolean — suppress next click after drag
this._previousUserSelect  // String — saved user-select value (mouse path)

The Two Input Paths #

Touch Path: Deferred Hold-to-Drag #

The Problem: On mobile, users need to scroll lists. If you block scrolling immediately on touchstart, the list becomes unscrollable. If you don’t block scrolling, how do you distinguish a scroll from a drag?

The Solution: Don’t block scroll during the hold. Only block it after the long-press timer fires and drag actually begins.

Step-by-Step Flow #

1. touchstart on handle
   └── _handleTouchStart()
       ├── Check: not already pending or dragging
       ├── Check: target is inside itemSelector and container
       ├── Check: target is NOT interactive (button, input, link, contenteditable)
       ├── Check: target IS a handle (if handleSelector is set)
       ├── Capture touch identifier, startX, startY
       ├── Set _pendingDrag = { item, touchId, startX, startY, timer }
       ├── timer = setTimeout(_beginTouchDrag, 150ms)
       └── Add window listeners: touchmove, touchend, touchcancel

2. During the hold (~0-150ms)
   ├── Normal scrolling IS allowed (touchstart was { passive: true })
   ├── touchmove → _onTouchMoveDuringHold()
   │   └── If moved > 10px: _cancelLongPress() → back to IDLE
   └── touchend/touchcancel → _onTouchEndDuringHold()
       └── _cancelLongPress() → back to IDLE

3. Timer fires (150ms elapsed, finger still down)
   └── _beginTouchDrag(item)
       ├── _cancelLongPress() (clears timer, removes hold listeners)
       ├── Set _dragState = { item, isTouch: true, touchId, startY, startIndex, currentY }
       ├── Add .dragging class to item
       ├── requestAnimationFrame → add .drag-pop class
       ├── setTimeout 200ms → remove .drag-pop, set transition: none
       ├── navigator.vibrate(10) (if supported)
       ├── Add NEW window listeners: touchmove ({ passive: false }), touchend, touchcancel
       ├── _createDropIndicator()
       └── NOW scroll is blocked (next touchmove will call e.preventDefault())

4. Active drag
   └── touchmove → _onTouchDragMove(e)
       ├── e.preventDefault()  ← SCROLL BLOCKED HERE
       ├── Update currentY
       ├── Apply transform: translateY(deltaY)
       ├── _computeInsertBeforeIndex(touch.clientY)
       └── _updateDropIndicator(insertBeforeIndex)

5. Drop
   └── touchend/touchcancel → _onTouchDragEnd()
       ├── Find matching changed touch
       └── _finishDrag()
           ├── Compute final insertBeforeIndex from currentY
           ├── Calculate newIndex (adjust if moved downward)
           ├── Remove .dragging, .drag-pop, reset transform/transition
           ├── Remove drop indicator
           ├── Remove touch drag listeners
           ├── Set _suppressClick = true
           ├── If newIndex !== oldIndex:
           │   ├── navigator.vibrate(15)
           │   └── this.onReorder(oldIndex, newIndex)
           └── _dragState = null → IDLE

Mouse Path: Immediate Drag #

The Problem: On desktop, there’s no scroll ambiguity. Mousedown on handle should immediately start dragging.

Step-by-Step Flow #

1. mousedown on handle
   └── _handleMouseDown(e)
       ├── Same guard checks as touch (interactive, handle, etc.)
       ├── e.preventDefault() — prevents text selection
       ├── Set _dragState immediately (no pending state!)
       ├── Add .dragging, .drag-pop (same as touch)
       ├── setTimeout 200ms → remove .drag-pop, set transition: none
       ├── navigator.vibrate(10)
       ├── Add window listeners: mousemove, mouseup
       ├── Save container.style.userSelect, set to 'none'
       └── _createDropIndicator()

2. Active drag
   └── mousemove → _onMouseDragMove(e)
       ├── Update currentY
       ├── Apply transform: translateY(deltaY)
       ├── _computeInsertBeforeIndex(e.clientY)
       └── _updateDropIndicator(insertBeforeIndex)

3. Drop
   └── mouseup → _onMouseDragEnd()
       └── _finishDrag()
           ├── Same cleanup as touch path
           ├── Remove mousemove, mouseup listeners
           ├── Restore container.style.userSelect
           └── Fire onReorder if index changed

Deferred Scroll Blocking — Deep Dive #

This is the most complex and bug-prone part of the class.

The Core Mechanism #

PhaseEventpassivepreventDefaultScroll Allowed?
HoldtouchstarttrueNoYes
HoldtouchmovetrueNoYes
DragtouchmovefalseYesNo

The critical insight: There are two separate sets of touchmove/touchend listeners:

  1. Hold listeners (_boundTouchMove, _boundTouchEnd, _boundTouchCancel) — added during _handleTouchStart(), removed during _cancelLongPress().
  2. Drag listeners (_boundOnTouchDragMove, _boundOnTouchDragEnd, _boundOnTouchDragCancel) — added during _beginTouchDrag(), removed during _finishDrag().

Why Two Sets? #

Because they have different behavior:

  • Hold listeners are { passive: true } — they must not block scroll while the user is deciding whether to drag or scroll.
  • Drag listeners are { passive: false } — they MUST call e.preventDefault() to block scroll once dragging has begun.

The Handoff #

_handleTouchStart()
  └── adds: window.touchmove (passive=true), window.touchend, window.touchcancel
      └── timer fires ──► _beginTouchDrag()
          └── _cancelLongPress()
              └── removes: window.touchmove (passive=true), window.touchend, window.touchcancel
          └── adds: window.touchmove (passive=false), window.touchend, window.touchcancel

Potential Flaw: Listener Leak #

Look at _beginTouchDrag():

1
2
3
4
5
6
7
8
9
_beginTouchDrag(item) {
  this._cancelLongPress();  // removes hold listeners
  // ...
  this._boundOnTouchDragMove = (e) => this._onTouchDragMove(e);
  this._boundOnTouchDragEnd = (e) => this._onTouchDragEnd(e);
  window.addEventListener('touchmove', this._boundOnTouchDragMove, { passive: false });
  window.addEventListener('touchend', this._boundOnTouchDragEnd);
  window.addEventListener('touchcancel', this._boundOnTouchDragEnd);
}

If _cancelLongPress() is called from _onTouchMoveDuringHold() or _onTouchEndDuringHold() and the timer has already fired (race condition), _beginTouchDrag() might still be on the call stack. But actually, _cancelLongPress() clears the timer, so the timer callback won’t fire. However, there’s a subtle issue:

What if _cancelLongPress() is called DURING _beginTouchDrag()? It can’t be — _cancelLongPress() is only called from the hold-phase listeners or from _beginTouchDrag() itself. Once _beginTouchDrag() starts, the hold listeners are already removed.

Real potential flaw: If _finishDrag() fails to run (e.g., an exception in onReorder), the drag listeners are never removed. The class would be in an unrecoverable state.


Drop Indicator Logic #

The drop indicator is a <div class="drop-indicator"> appended to the container. It shows a horizontal line where the dragged item will be inserted.

How Position Is Calculated #

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
_computeInsertBeforeIndex(pointerY) {
  const items = this._getItems();
  for (let i = 0; i < items.length; i++) {
    if (items[i] === draggedItem) continue;  // skip the dragged item itself
    const rect = items[i].getBoundingClientRect();
    const centerY = rect.top + rect.height / 2;
    if (pointerY < centerY) return i;
  }
  return items.length;
}

Logic: The dragged item will be inserted before the first item whose center is below the pointer. If none, it goes at the end.

How the Indicator Is Positioned #

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
_updateDropIndicator(insertBeforeIndex) {
  const items = this._getItems();
  const containerRect = this.container.getBoundingClientRect();

  let top;
  if (insertBeforeIndex >= items.length) {
    // After the last item
    const lastRect = items[items.length - 1].getBoundingClientRect();
    top = lastRect.bottom - containerRect.top;
  } else {
    // Before the target item
    const targetRect = items[insertBeforeIndex].getBoundingClientRect();
    top = targetRect.top - containerRect.top;
  }

  this._dropIndicator.style.top = `${top - 1.5}px`;
  this._dropIndicator.style.display = 'block';
}

Note: The indicator is positioned relative to the container using getBoundingClientRect() math. If the container scrolls during drag, the indicator might drift. However, scroll is blocked during drag, so this shouldn’t happen.

New Index Calculation #

1
2
let newIndex = insertBeforeIndex;
if (newIndex > oldIndex) newIndex -= 1;

Why subtract 1? If the item is dragged downward, the insertBeforeIndex includes the dragged item’s own slot in the array. Since the item will be removed from its old position first, the true new index is one less.


CSS Classes Applied #

The utility applies these classes dynamically. Your CSS must define them.

ClassWhen AppliedPurpose
.draggingImmediately when drag startsStyle the lifted item (e.g., opacity, box-shadow, z-index)
.drag-pop1 frame after drag starts (rAF)Brief scale/elevation animation. Removed after 200ms.
.drop-indicatorWhen drag startsVisual line showing drop position. Created as a <div> and appended to container.

Example CSS (what your app likely has) #

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
.dragging {
  opacity: 0.8;
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
  z-index: 1000;
}

.drag-pop {
  transform: scale(1.02);
  transition: transform 0.2s ease;
}

.drop-indicator {
  position: absolute;
  left: 0;
  right: 0;
  height: 3px;
  background: var(--accent-color, #007bff);
  border-radius: 2px;
  pointer-events: none;
  display: none;
}

Cleanup & Destruction #

destroy() #

1
2
3
4
5
6
7
destroy() {
  this._cancelLongPress();
  this._endDrag();
  this.container.removeEventListener('touchstart', this._boundHandleTouchStart);
  this.container.removeEventListener('mousedown', this._boundHandleMouseDown);
  this.container.removeEventListener('click', this._boundClickSuppressor, { capture: true });
}

Purpose: Remove all listeners. Should be called before the container is removed from the DOM or before re-rendering the list.

When to call:

  • Before innerHTML = '' or re-rendering the list
  • When the component disconnects (e.g., in disconnectedCallback())
  • When navigating away from a page that uses drag-and-drop

Integration with Components #

Where DraggableList Is Instantiated #

Search the codebase for new DraggableList:

  • ListBase.js — likely in _initDraggable() or similar method. Called after rendering items.
  • ListIndexPage.js — drag-to-reorder lists in the grid.
  • Any other component that needs drag-and-drop reordering.

Typical Integration Pattern #

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Inside a component class
_initDraggable() {
  if (this._draggableList) {
    this._draggableList.destroy();
  }

  const container = this.shadowRoot.querySelector('.items-container');
  // or: const container = this.querySelector('.items-container');

  this._draggableList = new DraggableList(container, {
    itemSelector: ':scope > .item',
    handleSelector: '.drag-handle',
    onReorder: async (oldIndex, newIndex) => {
      // 1. Reorder the links array in memory
      const [moved] = this._linkedItems.splice(oldIndex, 1);
      this._linkedItems.splice(newIndex, 0, moved);

      // 2. Update the list item's links
      this._listItem.links = this._linkedItems.map((item, i) => ({
        id: item.id,
        order: i
      }));

      // 3. Save to DB
      await DBManager.saveItem(this._listItem);

      // 4. Re-render (which will call _initDraggable() again)
      this._renderContent();
    }
  });
}

The Re-render Problem #

Critical: If onReorder triggers a re-render (which destroys and recreates the DOM), the DraggableList instance must be destroy()ed first. Otherwise:

  1. Old listeners leak onto window.
  2. The dragged item reference becomes detached/stale.
  3. The drop indicator is orphaned.

Pattern to avoid bugs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
_renderContent() {
  if (this._draggableList) {
    this._draggableList.destroy();
    this._draggableList = null;
  }

  // ... render items ...

  this._draggableList = new DraggableList(container, { ... });
}

Known Logic Flaws & Race Conditions #

1. Potential Double Listener Registration (Touch) #

In _beginTouchDrag():

1
2
3
4
5
this._boundOnTouchDragMove = (e) => this._onTouchDragMove(e);
this._boundOnTouchDragEnd = (e) => this._onTouchDragEnd(e);
window.addEventListener('touchmove', this._boundOnTouchDragMove, { passive: false });
window.addEventListener('touchend', this._boundOnTouchDragEnd);
window.addEventListener('touchcancel', this._boundOnTouchDragEnd);

Issue: These bound functions are recreated every time _beginTouchDrag() is called. If for some reason _beginTouchDrag() is called twice (it shouldn’t be, but no guard), you’d register duplicate listeners. The guard at the top of _handleTouchStart() (if (this._pendingDrag || this._dragState) return) should prevent this, but _beginTouchDrag() itself has no guard.

2. Exception in onReorder Leaves Listeners Behind #

In _finishDrag():

1
2
3
4
5
if (newIndex !== oldIndex) {
  if (navigator.vibrate) navigator.vibrate(15);
  this.onReorder(oldIndex, newIndex);  // <-- if this throws...
}
this._dragState = null;  // <-- ...this never runs

Issue: If onReorder throws an exception, _dragState is never reset. The class remains in DRAGGING state forever. The window listeners are never removed. The user must refresh the page.

Fix: Wrap onReorder in try/finally.

3. Click Suppression May Suppress Legitimate Clicks #

1
2
3
4
5
6
7
8
9
this._suppressClick = true;
// ...
this._boundClickSuppressor = (e) => {
  if (this._suppressClick) {
    this._suppressClick = false;
    e.stopPropagation();
    e.preventDefault();
  }
};

Issue: _suppressClick is set to true on every drag end, even if the item didn’t move. It’s also only cleared on the next click event. If the user taps elsewhere after a drag, that tap is suppressed even though it has nothing to do with the drag.

Impact: A user drags an item (but drops it in the same place), then immediately taps a button elsewhere on the page. The first tap is swallowed.

4. Drop Indicator Not Removed if _finishDrag Early-Returns #

1
2
3
4
_finishDrag() {
  if (!this._dragState) return;  // Early return
  // ... cleanup code that removes indicator ...
}

Issue: If _finishDrag() is called when _dragState is null (e.g., double-call), it returns early. But _endDrag() calls _finishDrag() unconditionally. If _finishDrag() was already called (e.g., by touchend and then destroy()), the second call does nothing. This is probably fine, but worth noting.

5. _getItems() Includes the Drop Indicator in Edge Cases #

1
2
3
4
_getItems() {
  return Array.from(this.container.querySelectorAll(this.itemSelector))
    .filter(el => el !== this._dropIndicator);
}

Issue: The filter checks el !== this._dropIndicator. This works if itemSelector matches the drop indicator. But if itemSelector is specific (e.g., :scope > .list-item) and the drop indicator doesn’t match, the filter is unnecessary. If itemSelector is broad (e.g., :scope > *), the filter is critical. Not a bug, but a fragile assumption.

6. Touch Identifier Matching #

1
2
3
4
5
6
_findTouch(touchList, identifier) {
  for (let i = 0; i < touchList.length; i++) {
    if (touchList[i].identifier === identifier) return touchList[i];
  }
  return null;
}

Issue: If the user uses multiple fingers, the tracked touch might not be in e.touches (it could be in e.changedTouches only). _onTouchDragMove looks in e.touches, but if the tracked finger is the only one and it moves, it might only appear in changedTouches on some browsers. This could cause the drag to stop tracking mid-drag.

Fix: Check both e.touches and e.changedTouches.

7. Long-Press Timer Doesn’t Check if Element Is Still Pressed #

The timer fires after 150ms regardless of whether the finger is still on the element. However, _onTouchEndDuringHold() cancels the timer on touchend, so this is handled. But what about if the finger leaves the element (not the screen)? The touchmove listener checks movement threshold but doesn’t check if the target changed.

8. _beginTouchDrag Uses this._pendingDrag After _cancelLongPress #

1
2
3
4
5
6
_beginTouchDrag(item) {
  const startY = this._pendingDrag ? this._pendingDrag.startY : 0;
  const touchId = this._pendingDrag ? this._pendingDrag.touchId : null;
  this._cancelLongPress();  // <-- clears this._pendingDrag
  // ...
}

Issue: This is actually safe because it reads startY and touchId before calling _cancelLongPress(). But if _cancelLongPress() were refactored to clear before these lines, it would break. It’s a brittle ordering dependency.

9. Passive Listener on touchstart Prevents preventDefault() If Needed #

1
this.container.addEventListener('touchstart', this._boundHandleTouchStart, { passive: true });

Issue: The touchstart listener is passive, which is correct for scrolling. But if the class ever needs to call e.preventDefault() in _handleTouchStart() (e.g., to prevent a browser default action), it can’t. This isn’t currently needed, but it’s a constraint.


Annotated Code Reference #

Event Listener Attachment #

1
2
3
4
5
6
7
8
// Captures clicks to suppress the next one after a drag
this.container.addEventListener('click', this._boundClickSuppressor, { capture: true });

// Touch: passive=true allows scroll during the hold phase
this.container.addEventListener('touchstart', this._boundHandleTouchStart, { passive: true });

// Mouse: standard capture
this.container.addEventListener('mousedown', this._boundHandleMouseDown);

The Guard Checks #

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Prevents starting a new drag while one is active
if (this._pendingDrag || this._dragState) return;

// Ensures the event target is actually a draggable item
const item = e.target.closest(this.itemSelector);
if (!item || !this.container.contains(item)) return;

// Prevents drag from starting on interactive elements
if (this._isInteractive(e.target)) return;

// Ensures drag only starts on the handle (if configured)
if (!this._isHandle(e.target)) return;

The _isHandle Check #

1
2
3
_isHandle(el) {
  return this.handleSelector && el.closest(this.handleSelector);
}

Note: If handleSelector is null (default), this returns null (falsy), and the guard if (!this._isHandle(e.target)) return would block ALL drags. Wait — actually no, look at the constructor:

1
this.handleSelector = options.handleSelector || null;

And the check:

1
if (!this._isHandle(e.target)) return;

If handleSelector is null, _isHandle() returns null, which is falsy, so !this._isHandle(e.target) is true, and it returns. This means if you don’t pass a handleSelector, no drag ever starts.

Wait — is this actually how it’s used? Check the usages. If components always pass a handleSelector, this is fine. But it’s a trap for anyone creating a DraggableList without one.

Touch Move During Hold vs. During Drag #

1
2
3
4
5
// HOLD phase: passive=true, no preventDefault
window.addEventListener('touchmove', this._boundTouchMove, { passive: true });

// DRAG phase: passive=false, calls e.preventDefault()
window.addEventListener('touchmove', this._boundOnTouchDragMove, { passive: false });

These are different functions bound to the same event type on window. They are never active at the same time (the hold ones are removed before the drag ones are added), so there’s no conflict.


File Reference #

FilePurpose
public/services/DraggableList.jsThe drag-and-drop utility itself
public/components/ListBase.jsLikely instantiates DraggableList for item reordering
public/components/ListIndexPage.jsLikely instantiates DraggableList for list reordering
public/app.css or component CSSDefines .dragging, .drag-pop, .drop-indicator styles

Summary of Key Timing #

EventTimeAction
touchstart on handleT+0ms_handleTouchStart: set _pendingDrag, start 150ms timer, add hold listeners
touchmove during holdT+0 to T+150ms_onTouchMoveDuringHold: if moved >10px, cancel
Timer firesT+150ms_beginTouchDrag: remove hold listeners, add drag listeners, block scroll
touchmove during dragT+150ms+_onTouchDragMove: e.preventDefault(), update transform, update indicator
touchenddrag end_onTouchDragEnd_finishDrag: cleanup, fire onReorder
mousedown on handleT+0ms_handleMouseDown: immediately start drag, no pending phase
mousemoveduring drag_onMouseDragMove: update transform, update indicator
mouseupdrag end_onMouseDragEnd_finishDrag: cleanup, fire onReorder