onyx-tasks/apps/tauri/src/lib/components/TaskItem.svelte
Tristan Michael 54836f14e7 fix: improve frontend accessibility, consistency, and resource cleanup
Fix nested interactive elements in TaskItem (button inside button) by
restructuring as div + button with aria-label. Replace native confirm()
with ConfirmDialog for workspace removal. Store fs-changed event unlisten
function for cleanup. Log file watcher errors instead of swallowing them.
Fix var usage to const. Add Firefox scrollbar-width support.
2026-04-02 09:35:38 -07:00

168 lines
5.6 KiB
Svelte

<script lang="ts" module>
export const animateInIds = new Set<string>();
</script>
<script lang="ts">
import type { Task } from "../types";
import { app } from "../stores/app.svelte";
let { task, onopen, depth = 0 }: { task: Task; onopen?: (task: Task) => void; depth?: number } = $props();
let subtasks = $derived(app.getSubtasks(task.id));
let subtaskCount = $derived(subtasks.length);
let touchStartX = $state(0);
let swipeX = $state(0);
let swiping = $state(false);
let transitioning = $state(false);
let animatingIn = $state(false);
let isCompleted = $derived(task.status === "completed");
let justChecked = $state(false);
$effect(() => {
const _ = task.status;
if (animateInIds.has(task.id)) {
animateInIds.delete(task.id);
animatingIn = true;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
animatingIn = false;
});
});
}
});
async function handleToggle(e: MouseEvent) {
e.stopPropagation();
justChecked = true;
await new Promise((r) => setTimeout(r, 300));
transitioning = true;
animateInIds.add(task.id);
await new Promise((r) => setTimeout(r, 200));
justChecked = false;
await app.toggleTask(task.id);
}
function handleTouchStart(e: TouchEvent) {
touchStartX = e.touches[0].clientX;
swiping = true;
}
function handleTouchMove(e: TouchEvent) {
if (!swiping) return;
const dx = e.touches[0].clientX - touchStartX;
if (isCompleted) swipeX = Math.max(0, dx);
else swipeX = Math.min(0, dx);
}
function handleTouchEnd() {
if (Math.abs(swipeX) > 100) {
swipeX = 0;
swiping = false;
justChecked = true;
setTimeout(() => {
transitioning = true;
animateInIds.add(task.id);
setTimeout(() => { justChecked = false; app.toggleTask(task.id); }, 200);
}, 300);
return;
}
swipeX = 0;
swiping = false;
}
function formatDate(iso: string): string {
const d = new Date(iso);
const today = new Date();
if (d.toDateString() === today.toDateString()) return "Today";
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
if (d.toDateString() === tomorrow.toDateString()) return "Tomorrow";
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
</script>
<div
class="grid transition-[grid-template-rows,opacity] duration-300 ease-out {animatingIn || transitioning ? 'grid-rows-[0fr] opacity-0' : 'grid-rows-[1fr] opacity-100'}"
>
<div class="overflow-hidden">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative"
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
>
<!-- Swipe background -->
{#if swipeX !== 0}
<div
class="absolute inset-0 flex items-center {swipeX < 0 ? 'justify-end' : 'justify-start'} bg-primary px-4 text-white"
>
<span class="text-sm font-medium">
{isCompleted ? "Undo" : "Complete"}
</span>
</div>
{/if}
<!-- Task content -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group flex w-full cursor-pointer items-start gap-3 bg-surface-light py-3 pr-4 text-left hover:bg-black/5 dark:bg-surface-dark dark:hover:bg-white/5"
style="padding-left: {1 + depth * 1.5}rem; transform: translateX({swipeX}px); transition: {swiping ? 'none' : 'transform 0.2s ease-out'}"
onclick={() => onopen?.(task)}
>
<!-- Checkbox -->
<button
onclick={handleToggle}
aria-label={isCompleted ? "Restore task" : "Complete task"}
class="-m-2 flex shrink-0 items-center justify-center p-2"
>
<div
class="flex h-5 w-5 items-center justify-center rounded-full border-2 transition-colors duration-150 {isCompleted || justChecked
? 'border-primary bg-primary'
: 'border-gray-400 dark:border-gray-500'}"
>
{#if isCompleted || justChecked}
<svg class="h-3 w-3 text-white" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
/>
</svg>
{/if}
</div>
</button>
<!-- Content -->
<div class="min-w-0 flex-1">
<p class="text-sm {isCompleted ? 'line-through opacity-50' : 'font-medium'}">
{task.title}
</p>
{#if task.description}
<p class="mt-0.5 text-xs opacity-40 line-clamp-1">{task.description}</p>
{/if}
{#if task.due_date}
<span class="mt-1 inline-block rounded-full border border-border-light px-2 py-0.5 text-xs opacity-50 dark:border-border-dark">
{formatDate(task.due_date)}
</span>
{/if}
{#if subtaskCount > 0}
<span class="mt-1 inline-flex items-center gap-1 text-xs opacity-40">
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm2 4a1 1 0 011-1h10a1 1 0 110 2H6a1 1 0 01-1-1zm2 4a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1z" />
</svg>
{subtasks.filter(s => s.status === "completed").length}/{subtaskCount}
</span>
{/if}
</div>
<!-- Chevron -->
<svg class="mt-1 h-4 w-4 shrink-0 opacity-0 transition-opacity group-hover:opacity-30" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" />
</svg>
</div>
</div>
</div>
</div>