mirror of
https://github.com/aculix/negotium.git
synced 2026-09-11 07:28:17 +00:00
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:
+48
-13
@@ -8,8 +8,10 @@
|
||||
import { createStorage } from './lib/storage.js';
|
||||
import { rollover, msUntilNextMidnight } from './lib/rollover.js';
|
||||
import * as taskOps from './lib/tasks.js';
|
||||
import { createUndoStack, applyUndo } from './lib/undo.js';
|
||||
|
||||
const storage = createStorage();
|
||||
const undoStack = createUndoStack();
|
||||
|
||||
let tasks = $state([]);
|
||||
let newTask = $state('');
|
||||
@@ -43,27 +45,55 @@
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
function undo() {
|
||||
const entry = undoStack.pop();
|
||||
if (entry) setTasks(applyUndo(tasks, entry));
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
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();
|
||||
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) {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
if (event.key === 'Delete') {
|
||||
event.preventDefault();
|
||||
toggleTask(taskId);
|
||||
} else if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
deleteTask(taskId);
|
||||
}
|
||||
}
|
||||
@@ -170,7 +200,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
<svelte:window onkeydown={handleGlobalKeydown} />
|
||||
|
||||
<div class="app">
|
||||
<header class="header">
|
||||
@@ -235,7 +265,7 @@
|
||||
type="text"
|
||||
placeholder="+ Add a task"
|
||||
class="task-input"
|
||||
onkeydown={handleKeydown}
|
||||
onkeydown={handleInputKeydown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -271,8 +301,14 @@
|
||||
<p>No tasks yet. Add one above to get started!</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="task-items">
|
||||
{#each tasks as task, index (task.id)}
|
||||
<div
|
||||
<!-- The row is not itself focusable. Its handlers act on events
|
||||
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:dragging={draggedItem === index}
|
||||
@@ -285,15 +321,13 @@
|
||||
ondragend={handleDragEnd}
|
||||
ondragleave={handleDragLeave}
|
||||
onkeydown={(e) => handleTaskKeydown(e, task.id)}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label={task.completed ? `Completed: ${task.text}` : `Incomplete: ${task.text}`}
|
||||
>
|
||||
<button
|
||||
class="checkbox"
|
||||
class:checked={task.completed}
|
||||
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}
|
||||
<svg class="checkmark" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -307,7 +341,7 @@
|
||||
<button
|
||||
class="delete-btn"
|
||||
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">
|
||||
<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"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
+14
-3
@@ -242,11 +242,18 @@ body {
|
||||
}
|
||||
|
||||
.task-list {
|
||||
min-height: 400px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* A real list, so screen readers announce item counts and position. */
|
||||
.task-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 400px;
|
||||
position: relative;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
@@ -362,7 +369,11 @@ body {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user