mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
feat: checklist improvements — drag reorder and hide completed
- Add drag-to-reorder via svelte-dnd-action with grip handles - Add "hide completed" toggle with collapsible done section - Each checklist item gets a unique ID for stable drag operations - E2E test for hide/show completed items Closes #11 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
948d855ed6
commit
e5688c99b9
@@ -45,6 +45,7 @@
|
|||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"lucide-svelte": "^0.577.0",
|
"lucide-svelte": "^0.577.0",
|
||||||
"markdown-it": "^14.1.1",
|
"markdown-it": "^14.1.1",
|
||||||
|
"svelte-dnd-action": "^0.9.69",
|
||||||
"tiptap-markdown": "^0.9.0",
|
"tiptap-markdown": "^0.9.0",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+12
@@ -61,6 +61,9 @@ importers:
|
|||||||
markdown-it:
|
markdown-it:
|
||||||
specifier: ^14.1.1
|
specifier: ^14.1.1
|
||||||
version: 14.1.1
|
version: 14.1.1
|
||||||
|
svelte-dnd-action:
|
||||||
|
specifier: ^0.9.69
|
||||||
|
version: 0.9.69(svelte@5.53.7)
|
||||||
tiptap-markdown:
|
tiptap-markdown:
|
||||||
specifier: ^0.9.0
|
specifier: ^0.9.0
|
||||||
version: 0.9.0(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
|
version: 0.9.0(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
|
||||||
@@ -2900,6 +2903,11 @@ packages:
|
|||||||
svelte: ^4.0.0 || ^5.0.0-next.0
|
svelte: ^4.0.0 || ^5.0.0-next.0
|
||||||
typescript: '>=5.0.0'
|
typescript: '>=5.0.0'
|
||||||
|
|
||||||
|
svelte-dnd-action@0.9.69:
|
||||||
|
resolution: {integrity: sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw==}
|
||||||
|
peerDependencies:
|
||||||
|
svelte: '>=3.23.0 || ^5.0.0-next.0'
|
||||||
|
|
||||||
svelte@5.53.7:
|
svelte@5.53.7:
|
||||||
resolution: {integrity: sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==}
|
resolution: {integrity: sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -6037,6 +6045,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- picomatch
|
- picomatch
|
||||||
|
|
||||||
|
svelte-dnd-action@0.9.69(svelte@5.53.7):
|
||||||
|
dependencies:
|
||||||
|
svelte: 5.53.7
|
||||||
|
|
||||||
svelte@5.53.7:
|
svelte@5.53.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/remapping': 2.3.5
|
'@jridgewell/remapping': 2.3.5
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import XIcon from 'lucide-svelte/icons/x';
|
import XIcon from 'lucide-svelte/icons/x';
|
||||||
import Plus from 'lucide-svelte/icons/plus';
|
import Plus from 'lucide-svelte/icons/plus';
|
||||||
|
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
||||||
|
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||||
|
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
|
||||||
interface ChecklistItem {
|
interface ChecklistItem {
|
||||||
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
}
|
}
|
||||||
@@ -15,14 +20,25 @@
|
|||||||
let { content, onChange }: Props = $props();
|
let { content, onChange }: Props = $props();
|
||||||
|
|
||||||
let items = $state<ChecklistItem[]>(parseChecklist(content));
|
let items = $state<ChecklistItem[]>(parseChecklist(content));
|
||||||
|
let hideCompleted = $state(false);
|
||||||
|
let showDoneSection = $state(false);
|
||||||
|
const flipDurationMs = 150;
|
||||||
|
|
||||||
|
let activeItems = $derived(hideCompleted ? items.filter((i) => !i.checked) : items);
|
||||||
|
let doneItems = $derived(items.filter((i) => i.checked));
|
||||||
|
let doneCount = $derived(doneItems.length);
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
function parseChecklist(text: string): ChecklistItem[] {
|
function parseChecklist(text: string): ChecklistItem[] {
|
||||||
if (!text.trim()) return [{ text: '', checked: false }];
|
if (!text.trim()) return [{ id: generateId(), text: '', checked: false }];
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
return lines.map((line) => {
|
return lines.map((line) => {
|
||||||
if (line.startsWith('- [x] ')) return { text: line.slice(6), checked: true };
|
if (line.startsWith('- [x] ')) return { id: generateId(), text: line.slice(6), checked: true };
|
||||||
if (line.startsWith('- [ ] ')) return { text: line.slice(6), checked: false };
|
if (line.startsWith('- [ ] ')) return { id: generateId(), text: line.slice(6), checked: false };
|
||||||
return { text: line, checked: false };
|
return { id: generateId(), text: line, checked: false };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,78 +52,106 @@
|
|||||||
onChange(serializeChecklist(items));
|
onChange(serializeChecklist(items));
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleItem(index: number) {
|
function toggleItem(id: string) {
|
||||||
items[index].checked = !items[index].checked;
|
const item = items.find((i) => i.id === id);
|
||||||
emitChange();
|
if (item) {
|
||||||
|
item.checked = !item.checked;
|
||||||
|
emitChange();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateText(index: number, text: string) {
|
function updateText(id: string, text: string) {
|
||||||
items[index].text = text;
|
const item = items.find((i) => i.id === id);
|
||||||
emitChange();
|
if (item) {
|
||||||
|
item.text = text;
|
||||||
|
emitChange();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addItem(afterIndex: number) {
|
function addItem(afterIndex: number) {
|
||||||
items.splice(afterIndex + 1, 0, { text: '', checked: false });
|
const newItem = { id: generateId(), text: '', checked: false };
|
||||||
|
items.splice(afterIndex + 1, 0, newItem);
|
||||||
items = [...items];
|
items = [...items];
|
||||||
emitChange();
|
emitChange();
|
||||||
// Focus new input on next tick
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||||
inputs[afterIndex + 1]?.focus();
|
inputs[afterIndex + 1]?.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeItem(index: number) {
|
function removeItem(id: string) {
|
||||||
if (items.length <= 1) return;
|
if (items.length <= 1) return;
|
||||||
|
const index = items.findIndex((i) => i.id === id);
|
||||||
items.splice(index, 1);
|
items.splice(index, 1);
|
||||||
items = [...items];
|
items = [...items];
|
||||||
emitChange();
|
emitChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent, index: number) {
|
function handleKeydown(e: KeyboardEvent, id: string) {
|
||||||
|
const index = items.findIndex((i) => i.id === id);
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
addItem(index);
|
addItem(index);
|
||||||
} else if (e.key === 'Backspace' && items[index].text === '' && items.length > 1) {
|
} else if (e.key === 'Backspace' && items[index].text === '' && items.length > 1) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
removeItem(index);
|
removeItem(id);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||||
inputs[Math.max(0, index - 1)]?.focus();
|
inputs[Math.max(0, index - 1)]?.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDndConsider(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
||||||
|
items = e.detail.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDndFinalize(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
||||||
|
items = e.detail.items;
|
||||||
|
emitChange();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-1" data-testid="checklist">
|
<div class="space-y-1" data-testid="checklist">
|
||||||
{#each items as item, index}
|
<section
|
||||||
<div class="flex items-center gap-2">
|
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: {} }}
|
||||||
<input
|
onconsider={handleDndConsider}
|
||||||
type="checkbox"
|
onfinalize={handleDndFinalize}
|
||||||
checked={item.checked}
|
class="space-y-1"
|
||||||
onchange={() => toggleItem(index)}
|
>
|
||||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
{#each activeItems as item (item.id)}
|
||||||
data-testid="checklist-checkbox"
|
<div class="group flex items-center gap-1" animate:flip={{ duration: flipDurationMs }}>
|
||||||
/>
|
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
|
||||||
<input
|
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
|
||||||
type="text"
|
</div>
|
||||||
value={item.text}
|
<input
|
||||||
oninput={(e) => updateText(index, (e.target as HTMLInputElement).value)}
|
type="checkbox"
|
||||||
onkeydown={(e) => handleKeydown(e, index)}
|
checked={item.checked}
|
||||||
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
onchange={() => toggleItem(item.id)}
|
||||||
placeholder="List item"
|
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||||
data-testid="checklist-input"
|
data-testid="checklist-checkbox"
|
||||||
/>
|
/>
|
||||||
<button
|
<input
|
||||||
onclick={() => removeItem(index)}
|
type="text"
|
||||||
class="opacity-0 hover:opacity-100 focus:opacity-100"
|
value={item.text}
|
||||||
aria-label="Remove item"
|
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
|
||||||
data-testid="checklist-remove"
|
onkeydown={(e) => handleKeydown(e, item.id)}
|
||||||
>
|
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
placeholder="List item"
|
||||||
</button>
|
data-testid="checklist-input"
|
||||||
</div>
|
/>
|
||||||
{/each}
|
<button
|
||||||
|
onclick={() => removeItem(item.id)}
|
||||||
|
class="opacity-0 hover:opacity-100 focus:opacity-100"
|
||||||
|
aria-label="Remove item"
|
||||||
|
data-testid="checklist-remove"
|
||||||
|
>
|
||||||
|
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={() => addItem(items.length - 1)}
|
onclick={() => addItem(items.length - 1)}
|
||||||
class="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
|
class="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||||
@@ -116,4 +160,43 @@
|
|||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4" />
|
||||||
Add item
|
Add item
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{#if doneCount > 0}
|
||||||
|
<div class="mt-2 border-t border-[var(--border-subtle)] pt-2">
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
if (!hideCompleted) {
|
||||||
|
hideCompleted = true;
|
||||||
|
showDoneSection = false;
|
||||||
|
} else {
|
||||||
|
showDoneSection = !showDoneSection;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="flex items-center gap-1 text-xs text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||||
|
data-testid="checklist-toggle-done"
|
||||||
|
>
|
||||||
|
<ChevronRight class="h-3 w-3 transition-transform duration-150 {hideCompleted && showDoneSection ? 'rotate-90' : ''}" />
|
||||||
|
{doneCount} done
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if !hideCompleted}
|
||||||
|
<!-- Completed items are inline in the main list, toggle hides them -->
|
||||||
|
{:else if showDoneSection}
|
||||||
|
<div class="mt-1 space-y-1 pl-5" data-testid="checklist-done-section">
|
||||||
|
{#each doneItems as item (item.id)}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.checked}
|
||||||
|
onchange={() => toggleItem(item.id)}
|
||||||
|
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||||
|
data-testid="checklist-done-checkbox"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-muted)] line-through">{item.text}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -80,6 +80,35 @@ test.describe('Checklist', () => {
|
|||||||
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Scenario: Completed checklist items can be hidden and shown', async ({ authenticatedPage: page }) => {
|
||||||
|
// Given a checklist note with a completed item and an uncompleted item
|
||||||
|
await page.getByTestId('new-note-btn').click();
|
||||||
|
await page.getByTestId('note-title-input').fill('Hide Done Test');
|
||||||
|
await page.getByTestId('checklist-toggle').click();
|
||||||
|
await page.getByTestId('checklist-input').first().fill('Done task');
|
||||||
|
await page.getByTestId('checklist-input').first().press('Enter');
|
||||||
|
await page.getByTestId('checklist-input').nth(1).fill('Pending task');
|
||||||
|
await page.getByTestId('checklist-checkbox').first().check();
|
||||||
|
|
||||||
|
// Then the "done" toggle is visible
|
||||||
|
await expect(page.getByTestId('checklist-toggle-done')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
||||||
|
|
||||||
|
// When the user clicks the "done" toggle to hide completed items
|
||||||
|
await page.getByTestId('checklist-toggle-done').click();
|
||||||
|
|
||||||
|
// Then the completed item is hidden from the main list
|
||||||
|
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
||||||
|
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Pending task');
|
||||||
|
|
||||||
|
// When the user expands the done section
|
||||||
|
await page.getByTestId('checklist-toggle-done').click();
|
||||||
|
|
||||||
|
// Then the completed items are shown in the done section
|
||||||
|
await expect(page.getByTestId('checklist-done-section')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('checklist-done-section')).toContainText('Done task');
|
||||||
|
});
|
||||||
|
|
||||||
test('Scenario: Switching back from checklist mode restores the text area', async ({ authenticatedPage: page }) => {
|
test('Scenario: Switching back from checklist mode restores the text area', async ({ authenticatedPage: page }) => {
|
||||||
// Given the user has checklist mode enabled
|
// Given the user has checklist mode enabled
|
||||||
await page.getByTestId('new-note-btn').click();
|
await page.getByTestId('new-note-btn').click();
|
||||||
|
|||||||
Reference in New Issue
Block a user