fix: keyboard-visible delete, valid list semantics, undo support

The delete button was opacity:0 and only revealed on hover, so tabbing to
it showed nothing at all. The focus outline was being drawn on an
invisible element. It shows on :focus-within now too.

The row was role="button" with tabindex=0 while containing two real
buttons, which isn't valid. Rows are <li> in a <ul> now, so the list gets
announced with its count and position, and the checkbox carries the task
text plus aria-pressed.

Backspace no longer deletes. It's the key people hit meaning "go back",
and there was no way to get the task back afterwards. Delete still works,
and Cmd/Ctrl+Z reverses it.

Enter and Escape move from window to the input. On window, pressing Enter
with a task focused toggled that task and also added whatever was sitting
in the input.
This commit is contained in:
Aculix Technologies
2026-08-16 00:09:58 +05:30
parent 756b4254b4
commit cf64159322
2 changed files with 67 additions and 21 deletions
+53 -18
View File
@@ -8,8 +8,10 @@
import { createStorage } from './lib/storage.js'; import { createStorage } from './lib/storage.js';
import { rollover, msUntilNextMidnight } from './lib/rollover.js'; import { rollover, msUntilNextMidnight } from './lib/rollover.js';
import * as taskOps from './lib/tasks.js'; import * as taskOps from './lib/tasks.js';
import { createUndoStack, applyUndo } from './lib/undo.js';
const storage = createStorage(); const storage = createStorage();
const undoStack = createUndoStack();
let tasks = $state([]); let tasks = $state([]);
let newTask = $state(''); let newTask = $state('');
@@ -43,27 +45,55 @@
} }
function deleteTask(id) { function deleteTask(id) {
const index = tasks.findIndex(task => task.id === id);
if (index === -1) return;
undoStack.push({ type: 'delete', task: tasks[index], index });
setTasks(taskOps.deleteTask(tasks, id)); setTasks(taskOps.deleteTask(tasks, id));
} }
function clearCompleted() { function clearCompleted() {
// Built in ascending index order, which applyUndo relies on to put each
// task back where it was.
const removed = tasks
.map((task, index) => ({ task, index }))
.filter(({ task }) => task.completed);
if (removed.length === 0) return;
undoStack.push({ type: 'clearCompleted', removed });
setTasks(taskOps.clearCompleted(tasks)); setTasks(taskOps.clearCompleted(tasks));
} }
function undo() {
const entry = undoStack.pop();
if (entry) setTasks(applyUndo(tasks, entry));
}
function toggleTheme() { function toggleTheme() {
darkMode = !darkMode; darkMode = !darkMode;
} }
function handleKeydown(event) { /** Scoped to the input. Previously this also sat on window, so Enter while a
* task was focused would toggle that task *and* add whatever was in the
* input. */
function handleInputKeydown(event) {
if (event.key === 'Enter') addTask(); if (event.key === 'Enter') addTask();
else if (event.key === 'Escape') newTask = ''; else if (event.key === 'Escape') newTask = '';
} }
function handleGlobalKeydown(event) {
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'z') return;
// Leave the text field its own native undo.
if (event.target instanceof HTMLInputElement) return;
event.preventDefault();
undo();
}
/** Backspace no longer deletes: it is the key people press meaning "go back",
* and a task destroyed that way used to be unrecoverable. Delete still does,
* and Cmd/Ctrl+Z now reverses it. */
function handleTaskKeydown(event, taskId) { function handleTaskKeydown(event, taskId) {
if (event.key === ' ' || event.key === 'Enter') { if (event.key === 'Delete') {
event.preventDefault(); event.preventDefault();
toggleTask(taskId);
} else if (event.key === 'Delete' || event.key === 'Backspace') {
deleteTask(taskId); deleteTask(taskId);
} }
} }
@@ -170,7 +200,7 @@
}); });
</script> </script>
<svelte:window onkeydown={handleKeydown} /> <svelte:window onkeydown={handleGlobalKeydown} />
<div class="app"> <div class="app">
<header class="header"> <header class="header">
@@ -235,7 +265,7 @@
type="text" type="text"
placeholder="+ Add a task" placeholder="+ Add a task"
class="task-input" class="task-input"
onkeydown={handleKeydown} onkeydown={handleInputKeydown}
/> />
</div> </div>
@@ -271,9 +301,15 @@
<p>No tasks yet. Add one above to get started!</p> <p>No tasks yet. Add one above to get started!</p>
</div> </div>
{:else} {:else}
<ul class="task-items">
{#each tasks as task, index (task.id)} {#each tasks as task, index (task.id)}
<div <!-- The row is not itself focusable. Its handlers act on events
class="task-item" bubbling up from the buttons inside it, and every action they
provide is reachable from the keyboard: Delete removes a task,
Alt+Arrow reorders one. -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<li
class="task-item"
class:completed={task.completed} class:completed={task.completed}
class:dragging={draggedItem === index} class:dragging={draggedItem === index}
class:drag-over={draggedOverIndex === index} class:drag-over={draggedOverIndex === index}
@@ -285,15 +321,13 @@
ondragend={handleDragEnd} ondragend={handleDragEnd}
ondragleave={handleDragLeave} ondragleave={handleDragLeave}
onkeydown={(e) => handleTaskKeydown(e, task.id)} onkeydown={(e) => handleTaskKeydown(e, task.id)}
tabindex="0"
role="button"
aria-label={task.completed ? `Completed: ${task.text}` : `Incomplete: ${task.text}`}
> >
<button <button
class="checkbox" class="checkbox"
class:checked={task.completed} class:checked={task.completed}
onclick={() => toggleTask(task.id)} onclick={() => toggleTask(task.id)}
aria-label={task.completed ? 'Mark as incomplete' : 'Mark as complete'} aria-pressed={task.completed}
aria-label={task.text}
> >
{#if task.completed} {#if task.completed}
<svg class="checkmark" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg class="checkmark" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -304,10 +338,10 @@
<span class="task-text">{task.text}</span> <span class="task-text">{task.text}</span>
<button <button
class="delete-btn" class="delete-btn"
onclick={() => deleteTask(task.id)} onclick={() => deleteTask(task.id)}
aria-label="Delete task" aria-label="Delete {task.text}"
> >
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<polyline points="3,6 5,6 21,6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <polyline points="3,6 5,6 21,6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -316,8 +350,9 @@
<line x1="14" y1="11" x2="14" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <line x1="14" y1="11" x2="14" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
</button> </button>
</div> </li>
{/each} {/each}
</ul>
{/if} {/if}
{/key} {/key}
</div> </div>
+14 -3
View File
@@ -242,11 +242,18 @@ body {
} }
.task-list { .task-list {
min-height: 400px;
position: relative;
}
/* A real list, so screen readers announce item counts and position. */
.task-items {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
min-height: 400px; list-style: none;
position: relative; margin: 0;
padding: 0;
} }
.task-item { .task-item {
@@ -362,7 +369,11 @@ body {
flex-shrink: 0; flex-shrink: 0;
} }
.task-item:hover .delete-btn { /* focus-within matters as much as hover here: the button is opacity 0 by
default, so a keyboard user tabbing to it previously saw nothing at all —
the focus outline was drawn on an invisible element. */
.task-item:hover .delete-btn,
.task-item:focus-within .delete-btn {
opacity: 1; opacity: 1;
} }