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}')">
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);
}
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)