Add smooth task toggle animations with collapse/expand transitions
This commit is contained in:
parent
3f6f327c92
commit
1cf05584e6
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
let editingTaskId = $state<string | null>(null);
|
let editingTaskId = $state<string | null>(null);
|
||||||
|
export const animateInIds = new Set<string>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|
@ -18,9 +19,32 @@
|
||||||
let titleInputEl = $state<HTMLInputElement | null>(null);
|
let titleInputEl = $state<HTMLInputElement | null>(null);
|
||||||
let showMenu = $state(false);
|
let showMenu = $state(false);
|
||||||
let menuEl = $state<HTMLDivElement | null>(null);
|
let menuEl = $state<HTMLDivElement | null>(null);
|
||||||
|
let transitioning = $state(false);
|
||||||
|
let animatingIn = $state(false);
|
||||||
|
|
||||||
let isCompleted = $derived(task.status === "completed");
|
let isCompleted = $derived(task.status === "completed");
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Check on status change whether this task should animate in
|
||||||
|
const _ = task.status; // track reactively
|
||||||
|
if (animateInIds.has(task.id)) {
|
||||||
|
animateInIds.delete(task.id);
|
||||||
|
animatingIn = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
animatingIn = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleToggle() {
|
||||||
|
transitioning = true;
|
||||||
|
animateInIds.add(task.id);
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
await app.toggleTask(task.id);
|
||||||
|
}
|
||||||
|
|
||||||
function handleMenuClickOutside(e: MouseEvent) {
|
function handleMenuClickOutside(e: MouseEvent) {
|
||||||
if (showMenu && menuEl && !menuEl.contains(e.target as Node)) {
|
if (showMenu && menuEl && !menuEl.contains(e.target as Node)) {
|
||||||
showMenu = false;
|
showMenu = false;
|
||||||
|
|
@ -39,7 +63,6 @@
|
||||||
editingTaskId = task.id;
|
editingTaskId = task.id;
|
||||||
editTitle = task.title;
|
editTitle = task.title;
|
||||||
editDesc = task.description;
|
editDesc = task.description;
|
||||||
// Wait for expand/contract animation (200ms) to settle before focusing
|
|
||||||
setTimeout(() => titleInputEl?.focus(), 220);
|
setTimeout(() => titleInputEl?.focus(), 220);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,7 +77,6 @@
|
||||||
|
|
||||||
function handleFocusOut(e: FocusEvent) {
|
function handleFocusOut(e: FocusEvent) {
|
||||||
if (containerEl?.contains(e.relatedTarget as Node)) return;
|
if (containerEl?.contains(e.relatedTarget as Node)) return;
|
||||||
// Delay so that clicking a different task can set editingTaskId first
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (editingTaskId === task.id) save();
|
if (editingTaskId === task.id) save();
|
||||||
});
|
});
|
||||||
|
|
@ -74,7 +96,10 @@
|
||||||
|
|
||||||
function handleTouchEnd() {
|
function handleTouchEnd() {
|
||||||
if (Math.abs(swipeX) > 100) {
|
if (Math.abs(swipeX) > 100) {
|
||||||
app.toggleTask(task.id);
|
swipeX = 0;
|
||||||
|
swiping = false;
|
||||||
|
handleToggle();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
swipeX = 0;
|
swipeX = 0;
|
||||||
swiping = false;
|
swiping = false;
|
||||||
|
|
@ -91,6 +116,10 @@
|
||||||
}
|
}
|
||||||
</script>
|
</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">
|
||||||
<div
|
<div
|
||||||
bind:this={containerEl}
|
bind:this={containerEl}
|
||||||
class="relative {showMenu ? 'z-40' : ''}"
|
class="relative {showMenu ? 'z-40' : ''}"
|
||||||
|
|
@ -112,13 +141,13 @@
|
||||||
<!-- Task content -->
|
<!-- Task content -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="group relative flex items-start gap-3 bg-surface-light px-4 py-3 transition-colors hover:bg-black/5 dark:bg-surface-dark dark:hover:bg-white/5"
|
class="group relative flex items-start gap-3 bg-surface-light px-4 py-3 hover:bg-black/5 dark:bg-surface-dark dark:hover:bg-white/5"
|
||||||
style="transform: translateX({swipeX}px); transition: {swiping ? 'none' : 'transform 0.2s ease-out'}"
|
style="transform: translateX({swipeX}px); transition: {swiping ? 'none' : 'transform 0.2s ease-out'}"
|
||||||
onmousedown={startEditing}
|
onmousedown={startEditing}
|
||||||
>
|
>
|
||||||
<!-- Checkbox -->
|
<!-- Checkbox -->
|
||||||
<button
|
<button
|
||||||
onmousedown={(e) => { e.stopPropagation(); app.toggleTask(task.id) }}
|
onmousedown={(e) => { e.stopPropagation(); handleToggle(); }}
|
||||||
class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors {isCompleted
|
class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors {isCompleted
|
||||||
? 'border-primary bg-primary'
|
? 'border-primary bg-primary'
|
||||||
: 'border-gray-400 dark:border-gray-500'}"
|
: 'border-gray-400 dark:border-gray-500'}"
|
||||||
|
|
@ -167,7 +196,7 @@
|
||||||
class="mt-1 w-full resize-none bg-transparent text-xs opacity-60 outline-none"
|
class="mt-1 w-full resize-none bg-transparent text-xs opacity-60 outline-none"
|
||||||
tabindex={editing ? 0 : -1}
|
tabindex={editing ? 0 : -1}
|
||||||
onkeydown={(e) => { if (e.key === "Escape") { editTitle = task.title; editDesc = task.description; editingTaskId = null; } }}
|
onkeydown={(e) => { if (e.key === "Escape") { editTitle = task.title; editDesc = task.description; editingTaskId = null; } }}
|
||||||
/>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -199,3 +228,5 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -343,7 +343,7 @@
|
||||||
ondragover={(e) => handleDragOver(e, task.id)}
|
ondragover={(e) => handleDragOver(e, task.id)}
|
||||||
ondragend={handleDragEnd}
|
ondragend={handleDragEnd}
|
||||||
ondrop={(e) => handleDrop(e, task.id)}
|
ondrop={(e) => handleDrop(e, task.id)}
|
||||||
class="transition-all duration-150 {dragId === task.id ? 'opacity-30' : ''} {dragOverId === task.id && dragId !== task.id ? 'border-t-2 border-t-primary' : ''}"
|
class="{dragId === task.id ? 'opacity-30' : ''} {dragOverId === task.id && dragId !== task.id ? 'border-t-2 border-t-primary' : ''}"
|
||||||
>
|
>
|
||||||
<TaskItem {task} />
|
<TaskItem {task} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,13 @@ async function toggleTask(taskId: string) {
|
||||||
listId: activeListId,
|
listId: activeListId,
|
||||||
taskId,
|
taskId,
|
||||||
});
|
});
|
||||||
|
// Move to top of list locally, then persist order in background
|
||||||
|
if (updated.status === "backlog") {
|
||||||
|
tasks = [updated, ...tasks.filter((t) => t.id !== taskId)];
|
||||||
|
invoke("reorder_task", { listId: activeListId, taskId, newPosition: 0 }).catch(() => {});
|
||||||
|
} else {
|
||||||
tasks = tasks.map((t) => (t.id === taskId ? updated : t));
|
tasks = tasks.map((t) => (t.id === taskId ? updated : t));
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e);
|
error = String(e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue