mirror of
https://github.com/aculix/negotium.git
synced 2026-09-11 07:28:17 +00:00
b846717db9
A pass over desktop, tablet and phone widths, measuring rather than eyeballing. The one that matters: a task could not be deleted on a phone. The delete button is revealed on hover, touch devices report hover: none, so it sat at zero opacity with nothing to reveal it. The README told people to hover over a task and click it. It is now always visible where hover does not exist. Touch targets were under the 44px minimum: the checkbox at 24, delete at 32, the footer links at 23 tall. They now get 44px hit areas from overlays that leave the drawn size alone, behind a pointer: coarse query so a mouse keeps small precise targets and the full row stays draggable. Two contrast failures in light mode. The date was the brand blue on the near-white background at 3.54:1, so text now uses a darker --accent-text and reads 5.14:1. Completed rows carried opacity 0.8 over an already mid-contrast blue, which came to 3.34:1; the tint, the strikethrough and the colour say "done" well enough without it, and dropping it gives 4.78:1. Both themes now pass AA everywhere measured. The checkbox sat vertically centred, so on a task wrapping to five lines it floated in the middle of the block. Rows align to the top and task text gets a 24px line box, matching the checkbox exactly, which also spaces wrapped lines better. Reduced motion was only honoured by the empty-state drawing. Svelte's transitions run in JavaScript and never saw the media query, so tasks still flew in on a stagger. The component reads the preference directly and collapses its durations, and CSS covers the rest. The lift on a dragged card stays, since it tracks the pointer rather than playing at you. Long unbroken words already wrapped and no breakpoint scrolled sideways.
634 lines
24 KiB
Svelte
634 lines
24 KiB
Svelte
<script>
|
|
import { onMount, tick, flushSync } from 'svelte';
|
|
import { fly, fade } from 'svelte/transition';
|
|
import { flip } from 'svelte/animate';
|
|
import { cubicOut } from 'svelte/easing';
|
|
import './style.css';
|
|
|
|
import { toKey, addDays, fromKey, labelFor, formatLong } from './lib/dates.js';
|
|
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';
|
|
import { shouldHandleUndo } from './lib/shortcuts.js';
|
|
import { buildExport, serialize, parseImport, mergeImport } from './lib/backup.js';
|
|
|
|
const storage = createStorage();
|
|
const undoStack = createUndoStack();
|
|
|
|
// Storage is synchronous, so the first render can already have the real list.
|
|
// Loading in onMount instead meant one frame of the empty state on every
|
|
// launch. That was hidden behind the splash screen before, and very visible once
|
|
// that went away.
|
|
storage.migrateLegacyKeys();
|
|
const bootNow = new Date();
|
|
|
|
let tasks = $state(rollover(storage, bootNow));
|
|
let newTask = $state('');
|
|
// Seeded from the class the pre-paint script in index.html already set, so
|
|
// there is one source of truth and no post-mount correction to flash.
|
|
let darkMode = $state(document.documentElement.classList.contains('dark'));
|
|
let isInitialized = false;
|
|
let todayKey = $state(toKey(bootNow));
|
|
let selectedKey = $state(toKey(bootNow));
|
|
let draggedId = $state(null);
|
|
let settlingId = $state(null);
|
|
let dragOffsetY = $state(0);
|
|
let settleTimer = null;
|
|
let midnightTimer = null;
|
|
|
|
/** Single write path, so persistence cannot drift out of step with the list.
|
|
* Loading a different day assigns `tasks` directly and deliberately skips
|
|
* this, because there is nothing new to save. */
|
|
function setTasks(next) {
|
|
tasks = next;
|
|
if (isInitialized) storage.saveTasks(selectedKey, tasks);
|
|
}
|
|
|
|
function addTask() {
|
|
const next = taskOps.addTask(tasks, newTask);
|
|
if (next === tasks) return;
|
|
setTasks(next);
|
|
newTask = '';
|
|
}
|
|
|
|
function toggleTask(id) {
|
|
setTasks(taskOps.toggleTask(tasks, 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));
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Svelte's transitions are driven in JavaScript, so the CSS media query in
|
|
// style.css cannot reach them. Read the same preference here and collapse the
|
|
// durations. The lift on a dragged card stays: it tracks the pointer rather
|
|
// than playing at you, and losing it would make dragging harder to follow.
|
|
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
let reduceMotion = $state(motionQuery.matches);
|
|
|
|
const flyIn = (index) =>
|
|
reduceMotion ? { duration: 0 } : { y: -10, duration: 300, delay: index * 30, easing: cubicOut };
|
|
|
|
const flyOut = (index) =>
|
|
reduceMotion ? { duration: 0 } : { x: 30, opacity: 0, duration: 250, delay: index * 20, easing: cubicOut };
|
|
|
|
let fileInput;
|
|
let status = $state('');
|
|
let statusIsError = $state(false);
|
|
let statusTimer = null;
|
|
|
|
function showStatus(message, isError = false) {
|
|
status = message;
|
|
statusIsError = isError;
|
|
clearTimeout(statusTimer);
|
|
statusTimer = setTimeout(() => { status = ''; }, STATUS_MS);
|
|
}
|
|
|
|
function exportTasks() {
|
|
const text = serialize(buildExport(storage));
|
|
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
|
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `negotium-${todayKey}.json`;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
|
|
showStatus('Exported.');
|
|
}
|
|
|
|
async function importTasks(event) {
|
|
const file = event.target.files?.[0];
|
|
// Reset first, so picking the same file twice still fires a change event.
|
|
event.target.value = '';
|
|
if (!file) return;
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = parseImport(await file.text());
|
|
} catch {
|
|
showStatus("That file couldn't be read.", true);
|
|
return;
|
|
}
|
|
|
|
if (!parsed.ok) {
|
|
showStatus(parsed.error, true);
|
|
return;
|
|
}
|
|
|
|
const { imported, duplicates, days } = mergeImport(storage, parsed);
|
|
tasks = storage.loadTasks(selectedKey);
|
|
|
|
if (imported === 0) {
|
|
showStatus(duplicates > 0 ? 'Already up to date.' : 'Nothing to import.');
|
|
return;
|
|
}
|
|
|
|
const taskWord = imported === 1 ? 'task' : 'tasks';
|
|
const dayWord = days === 1 ? 'day' : 'days';
|
|
showStatus(`Imported ${imported} ${taskWord} across ${days} ${dayWord}.`);
|
|
}
|
|
|
|
/** 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 (!shouldHandleUndo(event)) 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();
|
|
deleteTask(taskId);
|
|
return;
|
|
}
|
|
|
|
// Reordering must not be pointer-only. The keyed each block moves the
|
|
// existing DOM node, so focus travels with the row.
|
|
if (event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
|
event.preventDefault();
|
|
moveTask(taskId, event.key === 'ArrowUp' ? -1 : 1);
|
|
}
|
|
}
|
|
|
|
// Reordering runs on Pointer Events rather than HTML5 drag-and-drop, which
|
|
// never fired on touch at all, so a documented feature was desktop-only.
|
|
//
|
|
// Touch and mouse need different entry conditions. A mouse drag begins once
|
|
// the pointer has moved past a small threshold. A touch drag cannot, because
|
|
// a vertical swipe on a list is far more likely to mean "scroll"; it begins
|
|
// on a long press instead, and any movement before that cancels the intent
|
|
// and lets the page scroll normally.
|
|
const DRAG_THRESHOLD_PX = 8;
|
|
const LONG_PRESS_MS = 400;
|
|
const SETTLE_MS = 240;
|
|
const STATUS_MS = 4000;
|
|
|
|
let drag = null;
|
|
|
|
function blockTouchScroll(event) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
function beginDrag() {
|
|
if (!drag || drag.active) return;
|
|
drag.active = true;
|
|
draggedId = drag.id;
|
|
dragOffsetY = 0;
|
|
|
|
// Layout position of the row, which offsetTop reports free of any
|
|
// transform. It is the fixed reference the pointer offset is measured
|
|
// against, and it stays correct as the row changes slots mid-drag.
|
|
drag.originTop = drag.row.offsetTop;
|
|
|
|
// A settling card from a previous drop must not keep its transition, or
|
|
// it would fight the new gesture.
|
|
clearTimeout(settleTimer);
|
|
settlingId = null;
|
|
|
|
try {
|
|
drag.row.setPointerCapture(drag.pointerId);
|
|
} catch {
|
|
// Capture is an optimisation; the gesture still works without it.
|
|
}
|
|
|
|
if (drag.pointerType !== 'mouse') {
|
|
// touch-action alone cannot stop a gesture already in flight.
|
|
document.addEventListener('touchmove', blockTouchScroll, { passive: false });
|
|
}
|
|
}
|
|
|
|
function endDrag() {
|
|
if (!drag) return;
|
|
|
|
clearTimeout(drag.timer);
|
|
document.removeEventListener('touchmove', blockTouchScroll);
|
|
try {
|
|
drag.row.releasePointerCapture(drag.pointerId);
|
|
} catch {
|
|
// Already released, or never captured.
|
|
}
|
|
|
|
drag = null;
|
|
draggedId = null;
|
|
dragOffsetY = 0;
|
|
}
|
|
|
|
/**
|
|
* Inline styling for the one row the pointer is carrying.
|
|
*
|
|
* Only the lifted card is styled here. Everything else is moved by
|
|
* animate:flip, so one mechanism owns `transform` per element. When both
|
|
* did, flip measured a "before" rect that already had a manual offset in
|
|
* it, computed a bogus delta, and slid the whole list on drop.
|
|
*
|
|
* The lifted card takes no transition, so it stays welded to the pointer.
|
|
* On release it keeps its transform but gains one via the settling class,
|
|
* which eases it into its slot instead of snapping.
|
|
*/
|
|
function rowStyle(taskId) {
|
|
if (taskId !== draggedId) return '';
|
|
return `transform: translateY(${dragOffsetY}px) scale(1.02) rotate(-0.4deg); transition: none;`;
|
|
}
|
|
|
|
function targetIndexFor(clientY) {
|
|
const list = drag.row.parentElement;
|
|
// offsetTop and offsetHeight are layout values, unaffected by the
|
|
// transforms in play, so the target cannot feed back into itself.
|
|
const y = clientY - list.getBoundingClientRect().top;
|
|
const children = [...list.children];
|
|
|
|
for (let i = 0; i < children.length; i += 1) {
|
|
const child = children[i];
|
|
if (y < child.offsetTop + child.offsetHeight / 2) return i;
|
|
}
|
|
return children.length - 1;
|
|
}
|
|
|
|
function handlePointerDown(event, index) {
|
|
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
|
// Let the checkbox and delete button have their clicks.
|
|
if (event.target.closest('button')) return;
|
|
|
|
drag = {
|
|
id: tasks[index].id,
|
|
originIndex: index,
|
|
pointerId: event.pointerId,
|
|
pointerType: event.pointerType,
|
|
startY: event.clientY,
|
|
row: event.currentTarget,
|
|
active: false,
|
|
timer: null,
|
|
};
|
|
|
|
if (event.pointerType !== 'mouse') {
|
|
drag.timer = setTimeout(beginDrag, LONG_PRESS_MS);
|
|
}
|
|
}
|
|
|
|
function handlePointerMove(event) {
|
|
if (!drag) return;
|
|
|
|
if (!drag.active) {
|
|
const moved = Math.abs(event.clientY - drag.startY);
|
|
if (drag.pointerType === 'mouse') {
|
|
if (moved > DRAG_THRESHOLD_PX) beginDrag();
|
|
} else if (moved > DRAG_THRESHOLD_PX) {
|
|
// Moved before the long press landed: this is a scroll, not a drag.
|
|
endDrag();
|
|
}
|
|
if (!drag?.active) return;
|
|
}
|
|
|
|
// Reorder as the pointer crosses each boundary rather than waiting for the
|
|
// drop. animate:flip then eases the displaced card across, one swap at a
|
|
// time. By release the list is already in its final order, so letting
|
|
// go changes nothing but the lifted card settling into place.
|
|
const from = tasks.findIndex(task => task.id === drag.id);
|
|
const to = targetIndexFor(event.clientY);
|
|
|
|
if (from !== -1 && to !== from) {
|
|
tasks = taskOps.reorderTask(tasks, from, to);
|
|
// Apply now, so the row's new offsetTop is readable on the next line.
|
|
flushSync();
|
|
}
|
|
|
|
// Measured against layout, so the card stays under the pointer even though
|
|
// the slot beneath it just changed.
|
|
dragOffsetY = (event.clientY - drag.startY) - (drag.row.offsetTop - drag.originTop);
|
|
}
|
|
|
|
function handlePointerUp() {
|
|
if (!drag) return;
|
|
|
|
const wasActive = drag.active;
|
|
const landedAt = tasks.findIndex(task => task.id === drag.id);
|
|
const settling = drag.id;
|
|
|
|
// The order is already correct, applied swap by swap during the
|
|
// drag, so this only writes it through to storage.
|
|
if (wasActive && landedAt !== drag.originIndex) setTasks(tasks);
|
|
|
|
endDrag();
|
|
|
|
if (!wasActive) return;
|
|
|
|
// 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.
|
|
settlingId = settling;
|
|
clearTimeout(settleTimer);
|
|
settleTimer = setTimeout(() => { settlingId = null; }, SETTLE_MS);
|
|
}
|
|
|
|
async function moveTask(taskId, offset) {
|
|
const from = tasks.findIndex(task => task.id === taskId);
|
|
const to = from + offset;
|
|
if (from === -1 || to < 0 || to >= tasks.length) return;
|
|
|
|
setTasks(taskOps.reorderTask(tasks, from, to));
|
|
|
|
// Reconciling the keyed list drops focus, which would make Alt+Arrow a
|
|
// one-shot: the second press would land on nothing. Put it back on the
|
|
// task that moved so the key can be held down.
|
|
await tick();
|
|
const row = [...document.querySelectorAll('.task-item')]
|
|
.find(el => el.dataset.taskId === String(taskId));
|
|
row?.querySelector('.checkbox')?.focus();
|
|
}
|
|
|
|
function tomorrowKey() {
|
|
return toKey(addDays(fromKey(todayKey), 1));
|
|
}
|
|
|
|
function switchDate() {
|
|
selectedKey = selectedKey === todayKey ? tomorrowKey() : todayKey;
|
|
tasks = storage.loadTasks(selectedKey);
|
|
}
|
|
|
|
/**
|
|
* Re-evaluates the day boundary and reloads the visible list. Safe to call
|
|
* repeatedly, since rollover is idempotent within a day.
|
|
*
|
|
* If the day changed while the user was looking at Today, they stay on the
|
|
* new Today. If they were looking at Tomorrow, that key has become Today and
|
|
* they follow it there, which preserves the existing mental model.
|
|
*/
|
|
function runRollover() {
|
|
const now = new Date();
|
|
const wasViewingToday = selectedKey === todayKey;
|
|
|
|
todayKey = toKey(now);
|
|
const todayTasks = rollover(storage, now);
|
|
|
|
if (wasViewingToday) {
|
|
selectedKey = todayKey;
|
|
tasks = todayTasks;
|
|
} else {
|
|
tasks = storage.loadTasks(selectedKey);
|
|
}
|
|
}
|
|
|
|
function scheduleMidnight() {
|
|
clearTimeout(midnightTimer);
|
|
midnightTimer = setTimeout(() => {
|
|
runRollover();
|
|
scheduleMidnight();
|
|
}, msUntilNextMidnight(new Date()));
|
|
}
|
|
|
|
function handleMotionPreference(event) {
|
|
reduceMotion = event.matches;
|
|
}
|
|
|
|
function handleVisibility() {
|
|
if (document.visibilityState === 'visible') runRollover();
|
|
}
|
|
|
|
const currentDateDisplay = $derived(formatLong(selectedKey));
|
|
const buttonText = $derived(labelFor(selectedKey, todayKey));
|
|
|
|
const remainingTasks = $derived(tasks.filter(task => !task.completed).length);
|
|
const completedTasks = $derived(tasks.filter(task => task.completed).length);
|
|
|
|
$effect(() => {
|
|
document.documentElement.classList.toggle('dark', darkMode);
|
|
if (!isInitialized) return;
|
|
storage.saveTheme(darkMode ? 'dark' : 'light');
|
|
});
|
|
|
|
onMount(() => {
|
|
// State is already loaded above; this only opens the write path and lets
|
|
// the theme effect run once without persisting on a plain page load.
|
|
isInitialized = true;
|
|
|
|
// Four triggers, because no single one is sufficient. The timer covers a
|
|
// pinned tab crossing midnight unattended; visibility and focus cover
|
|
// machine sleep, where timers do not reliably fire.
|
|
scheduleMidnight();
|
|
document.addEventListener('visibilitychange', handleVisibility);
|
|
window.addEventListener('focus', runRollover);
|
|
motionQuery.addEventListener('change', handleMotionPreference);
|
|
|
|
return () => {
|
|
clearTimeout(midnightTimer);
|
|
document.removeEventListener('visibilitychange', handleVisibility);
|
|
window.removeEventListener('focus', runRollover);
|
|
motionQuery.removeEventListener('change', handleMotionPreference);
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<svelte:window onkeydown={handleGlobalKeydown} />
|
|
|
|
<div class="app">
|
|
<header class="header">
|
|
<div class="header-content">
|
|
<div class="logo-section">
|
|
<svg class="logo-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M12.37 8.87988H17.62" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
<path d="M6.38 8.87988L7.13 9.62988L9.38 7.37988" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
<path d="M12.37 15.8799H17.62" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
<path d="M6.38 15.8799L7.13 16.6299L9.38 14.3799" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
<path d="M9 22H15C20 22 22 20 22 15V9C22 4 20 2 15 2H9C4 2 2 4 2 9V15C2 20 4 22 9 22Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
<h1 class="app-title">Negotium</h1>
|
|
</div>
|
|
|
|
<div class="header-actions">
|
|
<button class="today-btn" aria-label="Switch between Today and Tomorrow" onclick={switchDate}>
|
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
|
|
<line x1="16" y1="2" x2="16" y2="6" stroke="currentColor" stroke-width="2"/>
|
|
<line x1="8" y1="2" x2="8" y2="6" stroke="currentColor" stroke-width="2"/>
|
|
<line x1="3" y1="10" x2="21" y2="10" stroke="currentColor" stroke-width="2"/>
|
|
</svg>
|
|
{buttonText}
|
|
</button>
|
|
|
|
<button
|
|
class="theme-toggle"
|
|
onclick={toggleTheme}
|
|
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
|
>
|
|
<svg class="theme-icon" class:rotated={darkMode} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
{#if darkMode}
|
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" fill="currentColor"/>
|
|
{:else}
|
|
<circle cx="12" cy="12" r="5" fill="currentColor"/>
|
|
<line x1="12" y1="1" x2="12" y2="3" stroke="currentColor" stroke-width="2"/>
|
|
<line x1="12" y1="21" x2="12" y2="23" stroke="currentColor" stroke-width="2"/>
|
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" stroke="currentColor" stroke-width="2"/>
|
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" stroke="currentColor" stroke-width="2"/>
|
|
<line x1="1" y1="12" x2="3" y2="12" stroke="currentColor" stroke-width="2"/>
|
|
<line x1="21" y1="12" x2="23" y2="12" stroke="currentColor" stroke-width="2"/>
|
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" stroke="currentColor" stroke-width="2"/>
|
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" stroke="currentColor" stroke-width="2"/>
|
|
{/if}
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="main">
|
|
<div class="container">
|
|
<div class="content-header">
|
|
<h2 class="section-title">To-dos</h2>
|
|
<div class="date-display">{currentDateDisplay}</div>
|
|
</div>
|
|
|
|
<div class="task-input-container">
|
|
<input
|
|
bind:value={newTask}
|
|
type="text"
|
|
placeholder="+ Add a task"
|
|
class="task-input"
|
|
onkeydown={handleInputKeydown}
|
|
/>
|
|
</div>
|
|
|
|
<div class="task-stats" class:visible={tasks.length > 0}>
|
|
<span class="task-count">
|
|
{remainingTasks} {remainingTasks === 1 ? 'task' : 'tasks'} remaining
|
|
</span>
|
|
{#if completedTasks > 0}
|
|
<button class="clear-completed" onclick={clearCompleted}>
|
|
Clear completed
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="task-list">
|
|
{#key selectedKey}
|
|
{#if tasks.length === 0}
|
|
<div class="empty-state" transition:fade={{ duration: reduceMotion ? 0 : 200 }}>
|
|
<svg class="empty-art" viewBox="0 0 120 120" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
|
<g class="empty-art-motes" stroke="currentColor" stroke-width="3" stroke-linecap="round">
|
|
<path d="M40 34 L40 34" />
|
|
<path d="M60 26 L60 26" />
|
|
<path d="M80 34 L80 34" />
|
|
</g>
|
|
<g class="empty-art-box" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M30 58 L30 92 L90 92 L90 58" />
|
|
<path d="M24 58 L96 58" />
|
|
<path d="M30 58 L18 45" />
|
|
<path d="M90 58 L102 45" />
|
|
<path d="M48 74 L72 74" opacity="0.35" />
|
|
</g>
|
|
</svg>
|
|
<p>No tasks yet. Add one above to get started!</p>
|
|
</div>
|
|
{:else}
|
|
<ul class="task-items">
|
|
{#each tasks as task, index (task.id)}
|
|
<!-- 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"
|
|
data-task-id={task.id}
|
|
class:completed={task.completed}
|
|
class:dragging={task.id === draggedId}
|
|
class:settling={task.id === settlingId}
|
|
style={rowStyle(task.id)}
|
|
animate:flip={{ duration: (reduceMotion || task.id === draggedId) ? 0 : 240, easing: cubicOut }}
|
|
in:fly={flyIn(index)}
|
|
out:fly={flyOut(index)}
|
|
onpointerdown={(e) => handlePointerDown(e, index)}
|
|
onpointermove={handlePointerMove}
|
|
onpointerup={handlePointerUp}
|
|
onpointercancel={endDrag}
|
|
onkeydown={(e) => handleTaskKeydown(e, task.id)}
|
|
>
|
|
<button
|
|
class="checkbox"
|
|
class:checked={task.completed}
|
|
onclick={() => toggleTask(task.id)}
|
|
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">
|
|
<path d="M9 12L11 14L15 10" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
|
|
<span class="task-text">{task.text}</span>
|
|
|
|
<button
|
|
class="delete-btn"
|
|
onclick={() => deleteTask(task.id)}
|
|
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"/>
|
|
<path d="M19,6V20A2,2 0 0,1 17,22H7A2,2 0 0,1 5,20V6M8,6V4A2,2 0 0,1 10,2H14A2,2 0 0,1 16,4V6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
<line x1="10" y1="11" x2="10" 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>
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
{/key}
|
|
</div>
|
|
|
|
<footer class="data-footer">
|
|
<button class="data-link" onclick={exportTasks}>Export</button>
|
|
<span class="data-sep" aria-hidden="true">·</span>
|
|
<button class="data-link" onclick={() => fileInput.click()}>Import</button>
|
|
<input
|
|
bind:this={fileInput}
|
|
type="file"
|
|
accept="application/json,.json"
|
|
class="visually-hidden"
|
|
onchange={importTasks}
|
|
/>
|
|
<span class="data-status" class:error={statusIsError} role="status" aria-live="polite">
|
|
{status}
|
|
</span>
|
|
</footer>
|
|
</div>
|
|
</main>
|
|
</div>
|