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:
+49
-14
@@ -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 handleTaskKeydown(event, taskId) {
|
function handleGlobalKeydown(event) {
|
||||||
if (event.key === ' ' || event.key === 'Enter') {
|
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 === '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,8 +301,14 @@
|
|||||||
<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
|
||||||
|
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="task-item"
|
||||||
class:completed={task.completed}
|
class:completed={task.completed}
|
||||||
class:dragging={draggedItem === index}
|
class:dragging={draggedItem === 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">
|
||||||
@@ -307,7 +341,7 @@
|
|||||||
<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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user