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}
|
||||
*/
|
||||
export let onSwipe;
|
||||
export let onSwipeRight;
|
||||
|
||||
/**
|
||||
* @type {() => void}
|
||||
*/
|
||||
export let onSwipeLeft;
|
||||
|
||||
/**
|
||||
* @type {'start' | 'stop'}
|
||||
*/
|
||||
export let leftIcon;
|
||||
|
||||
let offsetX = 0;
|
||||
let swiping = false;
|
||||
let locked = false;
|
||||
let completed = false;
|
||||
let triggered = false;
|
||||
|
||||
/** @type {number|null} */
|
||||
let startX = null;
|
||||
@@ -52,8 +63,7 @@
|
||||
|
||||
if (swiping) {
|
||||
if (e.cancelable) e.preventDefault();
|
||||
// Only allow right swipe
|
||||
offsetX = Math.max(0, deltaX);
|
||||
offsetX = deltaX;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,11 +74,20 @@
|
||||
}
|
||||
|
||||
if (offsetX >= THRESHOLD) {
|
||||
// Right swipe — complete (row collapses)
|
||||
completed = true;
|
||||
// Animate to full width before firing callback
|
||||
offsetX = window.innerWidth;
|
||||
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);
|
||||
} else {
|
||||
offsetX = 0;
|
||||
@@ -86,7 +105,8 @@
|
||||
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;
|
||||
</script>
|
||||
|
||||
@@ -97,15 +117,28 @@
|
||||
on:touchend={handleTouchEnd}
|
||||
on:touchcancel={resetState}
|
||||
>
|
||||
<div
|
||||
class="swipe-background"
|
||||
style:opacity={progress}
|
||||
>
|
||||
<svg class="check-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<!-- Left background (revealed on RIGHT swipe — complete) -->
|
||||
<div class="swipe-bg swipe-bg-right"
|
||||
style:opacity={rightProgress}>
|
||||
<svg class="swipe-icon 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" />
|
||||
</svg>
|
||||
</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
|
||||
class="swipe-content"
|
||||
class:transitioning
|
||||
@@ -122,21 +155,44 @@
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.swipe-background {
|
||||
.swipe-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--color-success);
|
||||
display: flex;
|
||||
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;
|
||||
height: 1.5rem;
|
||||
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 {
|
||||
position: relative;
|
||||
background-color: var(--bg-primary);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,21 @@
|
||||
*/
|
||||
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;
|
||||
|
||||
$: overdue = task.due && isOverdue(task.due);
|
||||
$: dueToday = task.due && isTodayFn(new Date(task.due * 1000));
|
||||
$: active = task.start !== null && task.start !== undefined;
|
||||
|
||||
function handleCheckbox() {
|
||||
if (completing) return;
|
||||
@@ -34,13 +45,18 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<SwipeAction onSwipe={() => onComplete(task.uuid)}>
|
||||
<div class="task-item" class:completing on:transitionend={handleTransitionEnd}>
|
||||
<SwipeAction
|
||||
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">
|
||||
<Checkbox checked={task.status === 'C'} />
|
||||
</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">
|
||||
<span class="task-description" class:completed={task.status === 'C'}>
|
||||
{task.description}
|
||||
@@ -48,6 +64,10 @@
|
||||
</div>
|
||||
|
||||
<div class="task-meta">
|
||||
{#if active}
|
||||
<span class="meta-item active-pill">Active</span>
|
||||
{/if}
|
||||
|
||||
{#if task.project}
|
||||
<span class="meta-item project">{task.project}</span>
|
||||
{/if}
|
||||
@@ -109,6 +129,11 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-item.active {
|
||||
border-left: 3px solid var(--color-primary);
|
||||
padding-left: calc(var(--spacing-md) - 3px);
|
||||
}
|
||||
|
||||
.task-checkbox {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
@@ -127,6 +152,7 @@
|
||||
.task-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
@@ -158,6 +184,11 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.active-pill {
|
||||
background-color: var(--color-active-bg);
|
||||
color: var(--color-active-text);
|
||||
}
|
||||
|
||||
.project {
|
||||
background-color: var(--color-project-bg);
|
||||
color: var(--color-project-text);
|
||||
|
||||
@@ -11,6 +11,16 @@
|
||||
*/
|
||||
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 activeReport = 'list';
|
||||
|
||||
@@ -50,6 +60,8 @@
|
||||
<TaskItem
|
||||
{task}
|
||||
{onComplete}
|
||||
{onTap}
|
||||
{onStartStop}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user