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:
@@ -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
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user