DEV Community

linou518
linou518

Posted on

Double-Click to Add Inline Tasks — Putting the Input Where You Touch Without Breaking UX

Double-Click to Add Inline Tasks — Putting the Input Where You Touch Without Breaking UX

I built a Kanban-style card view for my homelab dashboard. At first, I placed a "+ Add" button at the bottom of each project card. It worked, but something bothered me: hunting for a button and clicking it is cognitive overhead.

The moment I thought "what if double-clicking the card just showed an input field right there," I implemented it.

What It Does

  • Double-click on a project card's task list area
  • An inline input field expands in place
  • Enter to submit / Esc to cancel / ✓ button also submits
  • After adding, auto-scrolls to the bottom of the list
  • When there are zero tasks, shows a hint: "Double-click to add a task..."

No modal. No page navigation. Everything happens where you already are.

Implementation

The event listener is attached directly to the task list container via ondblclick:

<div class="simple-task-list"
     id="tasklist-${project.id}"
     ondblclick="inlineAddTask(event, '${project.id}')">
Enter fullscreen mode Exit fullscreen mode

The function itself is about 100 lines — but almost all of it is defensive code:

function inlineAddTask(event, projectId) {
  // Ignore clicks on checkboxes, delete buttons, or existing input fields
  if (event.target.closest(
    '.simple-task-item, .inline-add-task, .simple-task-checkbox, .simple-task-delete'
  )) return;

  const list = document.getElementById('tasklist-' + projectId);
  if (!list) return;

  // If input is already open, just re-focus it instead of creating a duplicate
  if (list.querySelector('.inline-add-task')) {
    list.querySelector('.inline-add-input').focus();
    return;
  }

  // Temporarily hide the hint text
  const hint = list.querySelector('.no-tasks-hint');
  if (hint) hint.style.display = 'none';

  // Dynamically create the inline input row
  const row = document.createElement('div');
  row.className = 'inline-add-task';
  row.innerHTML = `
    <input class="inline-add-input" type="text"
           placeholder="New task..." maxlength="100">
    <button class="inline-add-confirm" title="Confirm (Enter)">✓</button>
    <button class="inline-add-cancel"  title="Cancel (Esc)">✕</button>
  `;
  list.appendChild(row);

  const input      = row.querySelector('.inline-add-input');
  const confirmBtn = row.querySelector('.inline-add-confirm');
  const cancelBtn  = row.querySelector('.inline-add-cancel');

  row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
  input.focus();

  async function submit() {
    const text = input.value.trim();
    if (!text) { cancel(); return; }
    confirmBtn.disabled = true;
    confirmBtn.textContent = '';
    try {
      const r = await fetch('/api/simple-tasks/add', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ project: projectId, text })
      });
      const d = await r.json();
      if (d.ok) {
        await loadSimpleTasksData();
        renderSimpleProjects();
        setTimeout(() => {
          const newList = document.getElementById('tasklist-' + projectId);
          if (newList) newList.scrollTop = newList.scrollHeight;
        }, 50);
      } else {
        alert('Add failed: ' + (d.error || 'Unknown error'));
        cancel();
      }
    } catch(e) {
      alert('Network error');
      cancel();
    }
  }

  function cancel() {
    row.remove();
    if (hint) hint.style.display = '';
  }

  input.addEventListener('keydown', e => {
    if (e.key === 'Enter')  { e.preventDefault(); submit(); }
    if (e.key === 'Escape') cancel();
  });
  confirmBtn.addEventListener('click', submit);
  cancelBtn.addEventListener('click', cancel);
}
Enter fullscreen mode Exit fullscreen mode

Three Design Decisions

1. Use event.target.closest() to coexist with existing UI

Double-click events bubble up. Without filtering, double-clicking a checkbox would trigger the input field too — breaking the existing interaction. event.target.closest() lets you early-return if the click originated from an "already-handled" element. Skip this and UI conflicts happen immediately.

2. Handle the "input already open" state correctly

If the user double-clicks the same card twice, the last thing you want is two input fields stacked on top of each other. Check with querySelector('.inline-add-task'): if it exists, just call focus() on it. Duplicate DOM creation is a classic pitfall worth guarding against explicitly.

3. Restore hint text after cancel

The "Double-click to add..." hint should only appear when there are zero tasks. When the input expands, set display: none to hide it; when canceled or after a successful add, restore with display: ''. Hiding rather than removing the element means no re-render needed.

Scroll Behavior as a Side Note

scrollIntoView({ block: 'nearest' }) does nothing if the card is already in the viewport, and scrolls the minimum amount if it's partially out of view. Using block: 'start' causes jarring page jumps. nearest is the right choice here.

The setTimeout(() => newList.scrollTop = newList.scrollHeight, 50) after adding a task exists because the async render() call rebuilds the DOM asynchronously. Waiting 50ms before scrolling to the bottom is admittedly a hack — if the render exposed a completion callback, you could await it cleanly. That's a limitation of single-file SPA architecture.

Summary

  • Double-click = a strong UI convention for "edit in place" — leverage it
  • The defensive code trio: closest() for conflict avoidance / duplicate-creation guard / hint text restoration
  • scrollIntoView({ block: 'nearest' }) is gentle and predictable
  • The whole change was a 121-line diff. Faster than building a modal

I've been using this on my homelab dashboard daily, and I've stopped reaching for the "+ button" entirely. That's the answer.

Top comments (0)