mirror of
https://github.com/aculix/negotium.git
synced 2026-09-11 07:28:17 +00:00
feat: edit a task in place
Fixing a typo meant deleting the task and typing it again, which also cost you its position in the list. Click the text to edit it. Enter saves, Escape cancels, clicking away saves. The rename rule lives in tasks.js and is tested. It refuses blank input rather than treating it as a delete, since selecting all and hitting enter by accident should give you your task back, and it returns the original array untouched when nothing changed so an idle edit does not write to storage. The interesting part was what editing collides with. Keystrokes are stopped from reaching the row, or Delete would remove the task you are typing into and Alt+Arrow would reorder it mid-edit. A drag cannot start from inside the field, and the click a drag leaves behind is swallowed, or dropping a row would open it for editing. Switching day, deferring a task and the midnight rollover all close an open editor, so it cannot be left hanging over a row that is no longer there.
This commit is contained in:
@@ -17,6 +17,7 @@ Built with Svelte for speed and simplicity. No overwhelming features, no endless
|
||||
- ↩️ **Undo** - `Cmd/Ctrl+Z` brings back a deleted task, in its original position
|
||||
- 📅 **Today & Tomorrow lists** - Plan ahead with separate task lists
|
||||
- 🔄 **Unfinished work carries over** - When a new day begins, tasks you didn't finish move to Today; completed ones are cleared away
|
||||
- ✏️ **Edit a task** - Fix a typo without deleting and retyping it
|
||||
- ➡️ **Push a task to tomorrow** - Didn't get to it? Move it across without retyping it
|
||||
- 🎯 **Reorder by drag or keyboard** - Drag with a mouse, long-press and drag on touch, or use `Alt+↑`/`Alt+↓`
|
||||
- 💾 **Persistent storage** - All tasks saved locally in your browser
|
||||
@@ -89,6 +90,7 @@ The date, storage, rollover, task and undo logic lives in `src/lib/` as plain mo
|
||||
### Managing Tasks
|
||||
- **Add a task**: Type in the input field and press Enter
|
||||
- **Complete a task**: Click the checkbox next to the task
|
||||
- **Edit a task**: Click its text. Enter saves, Escape cancels, and clicking away saves too
|
||||
- **Delete a task**: Click the delete icon on the task (it shows on hover, and is always visible on touch), or press `Delete` with the task focused
|
||||
- **Undo a delete**: Press `Cmd/Ctrl+Z`. The task returns to where it was
|
||||
- **Move a task to Tomorrow**: Click the arrow on the task, or press `Alt+→`. From Tomorrow, the arrow points back and `Alt+←` returns it to Today
|
||||
|
||||
+66
-1
@@ -78,9 +78,53 @@
|
||||
|
||||
const viewingToday = $derived(selectedKey === todayKey);
|
||||
|
||||
let editingId = $state(null);
|
||||
let editingText = $state('');
|
||||
// A drag ends with a click event, which would otherwise drop the row you
|
||||
// just moved straight into edit mode.
|
||||
let suppressClick = false;
|
||||
|
||||
function startEditing(task) {
|
||||
if (suppressClick || draggedId) return;
|
||||
editingId = task.id;
|
||||
editingText = task.text;
|
||||
}
|
||||
|
||||
function commitEdit() {
|
||||
if (editingId === null) return;
|
||||
// renameTask refuses blank input, so an accidental select-all and enter
|
||||
// leaves the task as it was rather than wiping it.
|
||||
setTasks(taskOps.renameTask(tasks, editingId, editingText));
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
function handleEditKeydown(event) {
|
||||
// Stop these reaching the row, which would delete or reorder the task
|
||||
// being typed into.
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commitEdit();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
}
|
||||
|
||||
function focusOnMount(node) {
|
||||
node.focus();
|
||||
node.select();
|
||||
}
|
||||
|
||||
/** "Not today, tomorrow" and its reverse. Which direction depends only on
|
||||
* which day you are looking at, so one control covers both. */
|
||||
function deferTask(taskId) {
|
||||
cancelEdit();
|
||||
const destination = viewingToday ? tomorrowKey() : todayKey;
|
||||
tasks = moveTaskToDay(storage, selectedKey, destination, taskId);
|
||||
}
|
||||
@@ -305,6 +349,7 @@
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
// Let the checkbox and delete button have their clicks.
|
||||
if (event.target.closest('button')) return;
|
||||
if (event.target.closest('.task-edit')) return;
|
||||
|
||||
drag = {
|
||||
id: tasks[index].id,
|
||||
@@ -369,6 +414,10 @@
|
||||
|
||||
if (!wasActive) return;
|
||||
|
||||
// Swallow the click the browser fires after this drag.
|
||||
suppressClick = true;
|
||||
setTimeout(() => { suppressClick = false; }, 0);
|
||||
|
||||
// Clearing the inline transform while the settling class supplies a
|
||||
// transition eases the card into its slot. Nothing else on the list moves,
|
||||
// because nothing else changed.
|
||||
@@ -398,6 +447,7 @@
|
||||
}
|
||||
|
||||
function switchDate() {
|
||||
cancelEdit();
|
||||
selectedKey = selectedKey === todayKey ? tomorrowKey() : todayKey;
|
||||
tasks = storage.loadTasks(selectedKey);
|
||||
}
|
||||
@@ -411,6 +461,7 @@
|
||||
* they follow it there, which preserves the existing mental model.
|
||||
*/
|
||||
function runRollover() {
|
||||
cancelEdit();
|
||||
const now = new Date();
|
||||
const wasViewingToday = selectedKey === todayKey;
|
||||
|
||||
@@ -613,7 +664,21 @@
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<span class="task-text">{task.text}</span>
|
||||
{#if task.id === editingId}
|
||||
<input
|
||||
class="task-edit"
|
||||
type="text"
|
||||
bind:value={editingText}
|
||||
onkeydown={handleEditKeydown}
|
||||
onblur={commitEdit}
|
||||
use:focusOnMount
|
||||
aria-label="Edit {task.text}"
|
||||
/>
|
||||
{:else}
|
||||
<button class="task-text" onclick={() => startEditing(task)} title="Click to edit">
|
||||
{task.text}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="row-btn defer-btn"
|
||||
|
||||
@@ -39,3 +39,21 @@ export function reorderTask(tasks, from, to) {
|
||||
export function clearCompleted(tasks) {
|
||||
return tasks.filter((task) => !task.completed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a task's text in place, keeping its id, position and completion.
|
||||
*
|
||||
* Blank input is refused rather than treated as a delete. Someone who selects
|
||||
* all and hits enter by accident should get their task back, not lose it, and
|
||||
* an empty row would be unreadable anyway. Returning the original array when
|
||||
* nothing changed also keeps a pointless write out of storage.
|
||||
*/
|
||||
export function renameTask(tasks, id, text) {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return tasks
|
||||
|
||||
const current = tasks.find((task) => task.id === id)
|
||||
if (!current || current.text === trimmed) return tasks
|
||||
|
||||
return tasks.map((task) => (task.id === id ? { ...task, text: trimmed } : task))
|
||||
}
|
||||
|
||||
+55
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { addTask, toggleTask, deleteTask, reorderTask, clearCompleted } from './tasks.js'
|
||||
import { addTask, toggleTask, deleteTask, reorderTask, clearCompleted, renameTask } from './tasks.js'
|
||||
|
||||
const task = (id, completed = false) => ({ id, text: id, completed })
|
||||
|
||||
@@ -126,3 +126,57 @@ describe('clearCompleted', () => {
|
||||
expect(clearCompleted([task('a', true)])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('renameTask', () => {
|
||||
const task = (id, text = id, completed = false) => ({ id, text, completed, createdAt: 1 })
|
||||
|
||||
it('replaces the text', () => {
|
||||
expect(renameTask([task('a', 'old')], 'a', 'new')[0].text).toBe('new')
|
||||
})
|
||||
|
||||
it('trims what it is given', () => {
|
||||
expect(renameTask([task('a', 'old')], 'a', ' spaced ')[0].text).toBe('spaced')
|
||||
})
|
||||
|
||||
it('refuses to blank a task', () => {
|
||||
const before = [task('a', 'keep me')]
|
||||
expect(renameTask(before, 'a', '')).toBe(before)
|
||||
expect(renameTask(before, 'a', ' ')).toBe(before)
|
||||
})
|
||||
|
||||
it('keeps completion, id and creation time', () => {
|
||||
const before = [{ id: 'a', text: 'old', completed: true, createdAt: 99 }]
|
||||
expect(renameTask(before, 'a', 'new')[0]).toEqual({ id: 'a', text: 'new', completed: true, createdAt: 99 })
|
||||
})
|
||||
|
||||
it('leaves the other tasks alone', () => {
|
||||
const result = renameTask([task('a'), task('b'), task('c')], 'b', 'changed')
|
||||
expect(result.map(t => t.text)).toEqual(['a', 'changed', 'c'])
|
||||
})
|
||||
|
||||
it('keeps the task in place', () => {
|
||||
const result = renameTask([task('a'), task('b')], 'a', 'changed')
|
||||
expect(result.map(t => t.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('ignores an unknown id', () => {
|
||||
const before = [task('a')]
|
||||
expect(renameTask(before, 'zzz', 'nope')).toBe(before)
|
||||
})
|
||||
|
||||
it('is a no-op when the text has not changed', () => {
|
||||
const before = [task('a', 'same')]
|
||||
expect(renameTask(before, 'a', 'same')).toBe(before)
|
||||
})
|
||||
|
||||
it('handles emoji and non-latin text', () => {
|
||||
expect(renameTask([task('a')], 'a', '買い物 🛒')[0].text).toBe('買い物 🛒')
|
||||
expect(renameTask([task('a')], 'a', 'اشتر الحليب')[0].text).toBe('اشتر الحليب')
|
||||
})
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const before = [task('a', 'old')]
|
||||
renameTask(before, 'a', 'new')
|
||||
expect(before[0].text).toBe('old')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -367,6 +367,13 @@ html.dark .task-item.dragging {
|
||||
|
||||
.task-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
cursor: text;
|
||||
font-size: 16px;
|
||||
/* 24px line box, the same height as the checkbox, so the two align on the
|
||||
first line without nudging either. */
|
||||
@@ -381,6 +388,23 @@ html.dark .task-item.dragging {
|
||||
color: var(--completed-text);
|
||||
}
|
||||
|
||||
/* Sized and spaced to match .task-text exactly, so committing an edit does
|
||||
not shift the row by a pixel. */
|
||||
.task-edit {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
padding: 0 6px;
|
||||
margin: 0 -7px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.row-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
Reference in New Issue
Block a user