feat: add TaskDetail, bidirectional swipe, and active indicator

- SwipeAction: bidirectional with onSwipeRight/onSwipeLeft, dual backgrounds
- TaskItem: onTap for bottom sheet, onStartStop, active border + pill
- TaskDetail: full field layout with inline editing, action buttons
- TaskList: passes onTap and onStartStop through to TaskItem

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 15:47:39 +01:00
parent b53e77a8ec
commit aa2ca9aec3
4 changed files with 1141 additions and 18 deletions
+71 -15
View File
@@ -2,12 +2,23 @@
/** /**
* @type {() => void} * @type {() => void}
*/ */
export let onSwipe; export let onSwipeRight;
/**
* @type {() => void}
*/
export let onSwipeLeft;
/**
* @type {'start' | 'stop'}
*/
export let leftIcon;
let offsetX = 0; let offsetX = 0;
let swiping = false; let swiping = false;
let locked = false; let locked = false;
let completed = false; let completed = false;
let triggered = false;
/** @type {number|null} */ /** @type {number|null} */
let startX = null; let startX = null;
@@ -52,8 +63,7 @@
if (swiping) { if (swiping) {
if (e.cancelable) e.preventDefault(); if (e.cancelable) e.preventDefault();
// Only allow right swipe offsetX = deltaX;
offsetX = Math.max(0, deltaX);
} }
} }
@@ -64,11 +74,20 @@
} }
if (offsetX >= THRESHOLD) { if (offsetX >= THRESHOLD) {
// Right swipe — complete (row collapses)
completed = true; completed = true;
// Animate to full width before firing callback
offsetX = window.innerWidth; offsetX = window.innerWidth;
setTimeout(() => { setTimeout(() => {
onSwipe(); onSwipeRight();
}, 200);
} else if (offsetX <= -THRESHOLD) {
// Left swipe — start/stop (row stays)
triggered = true;
offsetX = -window.innerWidth;
setTimeout(() => {
onSwipeLeft();
offsetX = 0;
triggered = false;
}, 200); }, 200);
} else { } else {
offsetX = 0; offsetX = 0;
@@ -86,7 +105,8 @@
startY = null; startY = null;
} }
$: progress = Math.min(offsetX / THRESHOLD, 1); $: rightProgress = offsetX > 0 ? Math.min(offsetX / THRESHOLD, 1) : 0;
$: leftProgress = offsetX < 0 ? Math.min(Math.abs(offsetX) / THRESHOLD, 1) : 0;
$: transitioning = !swiping && offsetX !== 0; $: transitioning = !swiping && offsetX !== 0;
</script> </script>
@@ -97,15 +117,28 @@
on:touchend={handleTouchEnd} on:touchend={handleTouchEnd}
on:touchcancel={resetState} on:touchcancel={resetState}
> >
<div <!-- Left background (revealed on RIGHT swipe — complete) -->
class="swipe-background" <div class="swipe-bg swipe-bg-right"
style:opacity={progress} style:opacity={rightProgress}>
> <svg class="swipe-icon check-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<svg class="check-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
<!-- Right background (revealed on LEFT swipe — start/stop) -->
<div class="swipe-bg swipe-bg-left"
style:opacity={leftProgress}>
{#if leftIcon === 'stop'}
<svg class="swipe-icon fill-icon" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="1" />
</svg>
{:else}
<svg class="swipe-icon fill-icon" viewBox="0 0 24 24">
<polygon points="6,4 20,12 6,20" />
</svg>
{/if}
</div>
<div <div
class="swipe-content" class="swipe-content"
class:transitioning class:transitioning
@@ -122,21 +155,44 @@
touch-action: pan-y; touch-action: pan-y;
} }
.swipe-background { .swipe-bg {
position: absolute; position: absolute;
inset: 0; inset: 0;
background-color: var(--color-success);
display: flex; display: flex;
align-items: center; align-items: center;
padding-left: var(--spacing-lg);
} }
.check-icon { .swipe-bg-right {
background-color: var(--color-success);
padding-left: var(--spacing-lg);
justify-content: flex-start;
}
.swipe-bg-left {
background-color: var(--color-primary);
padding-right: var(--spacing-lg);
justify-content: flex-end;
}
.swipe-icon {
width: 1.5rem; width: 1.5rem;
height: 1.5rem; height: 1.5rem;
color: white; color: white;
} }
.check-icon {
fill: none;
stroke: currentColor;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.fill-icon {
fill: white;
stroke: none;
}
.swipe-content { .swipe-content {
position: relative; position: relative;
background-color: var(--bg-primary); background-color: var(--bg-primary);
File diff suppressed because it is too large Load Diff
+34 -3
View File
@@ -14,10 +14,21 @@
*/ */
export let onComplete; export let onComplete;
/**
* @type {(task: import('$lib/api/types.js').Task) => void}
*/
export let onTap;
/**
* @type {(uuid: string) => void}
*/
export let onStartStop;
let completing = false; let completing = false;
$: overdue = task.due && isOverdue(task.due); $: overdue = task.due && isOverdue(task.due);
$: dueToday = task.due && isTodayFn(new Date(task.due * 1000)); $: dueToday = task.due && isTodayFn(new Date(task.due * 1000));
$: active = task.start !== null && task.start !== undefined;
function handleCheckbox() { function handleCheckbox() {
if (completing) return; if (completing) return;
@@ -34,13 +45,18 @@
} }
</script> </script>
<SwipeAction onSwipe={() => onComplete(task.uuid)}> <SwipeAction
<div class="task-item" class:completing on:transitionend={handleTransitionEnd}> onSwipeRight={() => onComplete(task.uuid)}
onSwipeLeft={() => onStartStop(task.uuid)}
leftIcon={active ? 'stop' : 'start'}
>
<div class="task-item" class:completing class:active on:transitionend={handleTransitionEnd}>
<button class="task-checkbox" on:click|stopPropagation={handleCheckbox} type="button" aria-label="Complete task"> <button class="task-checkbox" on:click|stopPropagation={handleCheckbox} type="button" aria-label="Complete task">
<Checkbox checked={task.status === 'C'} /> <Checkbox checked={task.status === 'C'} />
</button> </button>
<div class="task-content"> <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="task-content" on:click={() => onTap(task)}>
<div class="task-header"> <div class="task-header">
<span class="task-description" class:completed={task.status === 'C'}> <span class="task-description" class:completed={task.status === 'C'}>
{task.description} {task.description}
@@ -48,6 +64,10 @@
</div> </div>
<div class="task-meta"> <div class="task-meta">
{#if active}
<span class="meta-item active-pill">Active</span>
{/if}
{#if task.project} {#if task.project}
<span class="meta-item project">{task.project}</span> <span class="meta-item project">{task.project}</span>
{/if} {/if}
@@ -109,6 +129,11 @@
overflow: hidden; overflow: hidden;
} }
.task-item.active {
border-left: 3px solid var(--color-primary);
padding-left: calc(var(--spacing-md) - 3px);
}
.task-checkbox { .task-checkbox {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
@@ -127,6 +152,7 @@
.task-content { .task-content {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
cursor: pointer;
} }
.task-header { .task-header {
@@ -158,6 +184,11 @@
font-weight: 500; font-weight: 500;
} }
.active-pill {
background-color: var(--color-active-bg);
color: var(--color-active-text);
}
.project { .project {
background-color: var(--color-project-bg); background-color: var(--color-project-bg);
color: var(--color-project-text); color: var(--color-project-text);
@@ -11,6 +11,16 @@
*/ */
export let onComplete; export let onComplete;
/**
* @type {(task: import('$lib/api/types.js').Task) => void}
*/
export let onTap;
/**
* @type {(uuid: string) => void}
*/
export let onStartStop;
export let loading = false; export let loading = false;
export let activeReport = 'list'; export let activeReport = 'list';
@@ -50,6 +60,8 @@
<TaskItem <TaskItem
{task} {task}
{onComplete} {onComplete}
{onTap}
{onStartStop}
/> />
{/each} {/each}
{/if} {/if}