Feature 327 Interaction

Drag and Drop ลากและวาง

A Kanban board built with Pointer Events (mouse, pen and touch); the new order is saved in one transaction.

Live demo

Everything here talks to the real Spring Boot + SQL Server back end.

Drag a card with the mouse or a finger. Keyboard: focus a card and press Alt + ← → (column) or ↑ ↓ (order).

To do 4
Write the user guide Docs
Design empty states Design
Add keyboard help Feature
Check colour contrast Design
In progress 3
Dark mode tokens Design
Toast queue Feature
Fix tooltip flip Bug
Done 2
Create Flyway schema Backend
Seed sample data Backend

Saved positions come from task_card.status and task_card.position.

What to notice

  • Pointer Events work on touch screens, unlike the HTML5 drag API.
  • A placeholder shows where the card will land.
  • Keyboard users can move a focused card with Alt + arrows.

Try it

Source code

8 parts, read from the files in this project
src/main/java/com/study/uiux/web/BoardApi.java PUT /api/tasks/board
@PutMapping("/tasks/board")
public Map<String, List<TaskCard>> saveBoard(@Valid @RequestBody BoardUpdate update) {
    return board.save(update.columns());
}
src/main/java/com/study/uiux/service/TaskBoardService.java Save the whole board after a drop, in one transaction
@Transactional
public Map<String, List<TaskCard>> save(Map<String, List<Integer>> columns) {
    Map<Integer, TaskCard> cards = repository.findAll().stream().collect(Collectors.toMap(TaskCard::getId, Function.identity()));
    Set<Integer> seen = new HashSet<>();
    for (Map.Entry<String, List<Integer>> column : columns.entrySet()) {
        if (!COLUMNS.contains(column.getKey())) throw ApiException.badRequest("Unknown column " + column.getKey());
        List<Integer> ids = column.getValue();
        for (int position = 0; position < ids.size(); position++) {
            TaskCard card = cards.get(ids.get(position));
            if (card == null || !seen.add(card.getId())) throw ApiException.badRequest("The board changed. Reload the page.");
            card.setStatus(column.getKey());   // only rows that really changed
            card.setPosition(position);        // get an UPDATE (dirty checking)
        }
    }
    if (seen.size() != cards.size()) throw ApiException.badRequest("The board changed. Reload the page.");
    return board();
}
src/main/resources/db/migration/V1__schema.sql task_card: status + position are saved after every drop
CREATE TABLE task_card (
    id         INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_task_card PRIMARY KEY,
    title      NVARCHAR(120) NOT NULL,
    status     NVARCHAR(10)  NOT NULL,
    position   INT           NOT NULL,
    label      NVARCHAR(20)  NOT NULL,
    updated_at DATETIME2(0)  NOT NULL CONSTRAINT df_task_updated DEFAULT SYSUTCDATETIME(),
    CONSTRAINT ck_task_status CHECK (status IN (N'TODO', N'DOING', N'DONE'))
);
CREATE INDEX ix_task_board ON task_card (status, position);
src/main/resources/templates/demos/l327.html Board markup: one column per status, cards in position order
<div class="board" id="board">
  <section class="column" th:each="col : ${board}" th:attr="data-status=${col.key}" th:aria-label="${col.key}">
    <div class="column-head">
      <span class="dot" th:style="'background:' + ${col.key == 'TODO' ? 'var(--text-3)' : (col.key == 'DOING' ? 'var(--warning)' : 'var(--success)')}"></span>
      <span th:text="${col.key == 'TODO' ? 'To do' : (col.key == 'DOING' ? 'In progress' : 'Done')}">To do</span>
      <span class="count" th:text="${#lists.size(col.value)}">0</span>
    </div>
    <div class="column-body">
      <div class="task" th:each="t : ${col.value}" tabindex="0" th:attr="data-id=${t.id}" role="button" aria-roledescription="draggable card">
        <svg class="i grip"><use href="#i-drag"/></svg>
        <span class="task-title" th:text="${t.title}">Task</span>
        <span class="label" th:classappend="'label-' + ${t.label}" th:text="${t.label}">Label</span>
      </div>
    </div>
  </section>
</div>
src/main/resources/static/css/app.css Kanban board, the drag ghost and the drop placeholder
.board { display: grid; grid-template-columns: repeat(3, minmax(12.5rem, 1fr)); gap: .85rem; overflow-x: auto; padding-bottom: .25rem; }
.column { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: .75rem; display: flex; flex-direction: column; gap: .6rem; min-height: 18rem; }
.column-head { display: flex; align-items: center; gap: .5rem; font-weight: 700; font-size: .85rem; padding: .1rem .25rem .3rem; }
.column-head .dot { width: .6rem; height: .6rem; border-radius: 50%; }
.column-head .count { margin-left: auto; font-size: .72rem; background: var(--surface); padding: 0 .5rem; border-radius: 999px; color: var(--text-3); border: 1px solid var(--border); }
.column-body { display: flex; flex-direction: column; gap: .6rem; flex: 1; border-radius: var(--radius); transition: background .15s; min-height: 3rem; }
.column.over .column-body { background: var(--primary-soft); }
.task {
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: .7rem .8rem; box-shadow: var(--shadow-sm);
  cursor: grab; touch-action: none; display: flex; gap: .55rem; align-items: flex-start; font-size: .88rem; user-select: none;
}
.task .grip { color: var(--text-3); margin-top: .1rem; }
.task:focus-visible { outline: 3px solid var(--ring); }
.task-title { font-weight: 600; flex: 1; }
.task.lifted { opacity: .35; }
.task-ghost { position: fixed; z-index: 600; pointer-events: none; transform: rotate(2.5deg); box-shadow: var(--shadow-lg); cursor: grabbing; opacity: .95; }
.placeholder { border: 2px dashed var(--primary); border-radius: var(--radius); background: var(--primary-soft); flex: none; }
.label { font-size: .68rem; font-weight: 700; padding: .05rem .45rem; border-radius: 999px; }
.label-Design { background: #fce7f3; color: #9d174d; } .label-Feature { background: #dbeafe; color: #1e40af; } .label-Bug { background: #fee2e2; color: #b91c1c; }
.label-Docs { background: #fef9c3; color: #854d0e; } .label-Backend { background: #dcfce7; color: #166534; }
html[data-theme="dark"] .label { filter: brightness(.85) saturate(.9); }
body.dragging, body.dragging * { cursor: grabbing !important; }
src/main/resources/static/js/demos.js Send the new order of every column to the server
async function save(movedTitle) {
  const body = { columns: {} };
  columns().forEach((c) => { body.columns[c.dataset.status] = $$('.task', c).map((t) => Number(t.dataset.id)); });
  updateCounts();
  try {
    await api('/api/tasks/board', { method: 'PUT', body });
    $('#boardStatus').textContent = `Saved "${movedTitle}" at ${new Date().toLocaleTimeString()} (UPDATE task_card SET status, position).`;
  } catch (e) {
    toast({ type: 'error', title: 'Not saved', message: e.message });
  }
}
src/main/resources/static/js/demos.js Pointer Events drag: ghost follows the pointer, placeholder marks the drop spot
let drag = null;
board.addEventListener('pointerdown', (e) => {
  const card = e.target.closest('.task');
  if (!card || e.button !== 0) return;
  const r = card.getBoundingClientRect();
  drag = { card, startX: e.clientX, startY: e.clientY, dx: e.clientX - r.left, dy: e.clientY - r.top, started: false, pointerId: e.pointerId };
});
document.addEventListener('pointermove', (e) => {
  if (!drag) return;
  if (!drag.started) {
    if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 5) return; // a click, not a drag
    drag.started = true;
    const r = drag.card.getBoundingClientRect();
    drag.ghost = drag.card.cloneNode(true);
    drag.ghost.classList.add('task-ghost');
    drag.ghost.style.width = r.width + 'px';
    document.body.appendChild(drag.ghost);
    drag.placeholder = document.createElement('div');
    drag.placeholder.className = 'placeholder';
    drag.placeholder.style.height = r.height + 'px';
    drag.card.after(drag.placeholder);
    drag.card.classList.add('lifted');
    drag.card.style.display = 'none';
    document.body.classList.add('dragging');
  }
  e.preventDefault();
  drag.ghost.style.left = e.clientX - drag.dx + 'px';
  drag.ghost.style.top = e.clientY - drag.dy + 'px';
  const under = document.elementFromPoint(e.clientX, e.clientY);
  const column = under && under.closest('.column');
  columns().forEach((c) => c.classList.toggle('over', c === column));
  if (!column) return;
  const body = $('.column-body', column);
  const after = $$('.task', body).find((t) => t !== drag.card && e.clientY < t.getBoundingClientRect().top + t.offsetHeight / 2);
  if (after) body.insertBefore(drag.placeholder, after); else body.appendChild(drag.placeholder);
});
function endDrag() {
  if (!drag) return;
  const d = drag; drag = null;
  if (!d.started) return;
  d.placeholder.replaceWith(d.card);
  d.card.style.display = '';
  d.card.classList.remove('lifted');
  d.ghost.remove();
  columns().forEach((c) => c.classList.remove('over'));
  document.body.classList.remove('dragging');
  d.card.focus({ preventScroll: true });
  save($('.task-title', d.card).textContent);
}
document.addEventListener('pointerup', endDrag);
document.addEventListener('pointercancel', endDrag);
src/main/resources/static/js/demos.js Keyboard alternative: Alt + arrows
board.addEventListener('keydown', (e) => {
  const card = e.target.closest('.task');
  if (!card || !e.altKey) return;
  const cols = columns(), col = card.closest('.column'), ci = cols.indexOf(col);
  if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
    const target = cols[ci + (e.key === 'ArrowRight' ? 1 : -1)];
    if (!target) return;
    $('.column-body', target).prepend(card);
  } else if (e.key === 'ArrowUp' && card.previousElementSibling) card.previousElementSibling.before(card);
  else if (e.key === 'ArrowDown' && card.nextElementSibling) card.nextElementSibling.after(card);
  else return;
  e.preventDefault();
  card.focus();
  save($('.task-title', card).textContent);
});

Are you sure?

Rename

↑↓ moveEnter openEsc close