From 8b9f9dfee83df58da346293cb7ab4b8546d4af6f Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Sun, 22 Mar 2026 19:47:02 +0100 Subject: [PATCH] feat: add overflow menu with checklist actions and action bar cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add vertical dots (⋮) overflow menu to NoteEditor bottom action bar - New checklist actions: "Delete checked" and "Uncheck all" - Move secondary actions to menu: checklist toggle, markdown toggle, version history, move to trash - Promote archive to the action bar for quick access - Keep in bar: color picker, image upload, share, archive, ⋮, close - Menu renders as fixed-position portal to escape card overflow-hidden - Click-outside handler closes the menu - Update e2e tests to use overflow menu for checklist/markdown toggles Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/components/NoteEditor.svelte | 201 ++++++++++++++++++++++----- tests/e2e/checklist.spec.ts | 67 +++++++-- tests/e2e/formatting.spec.ts | 14 +- 3 files changed, 229 insertions(+), 53 deletions(-) diff --git a/src/lib/components/NoteEditor.svelte b/src/lib/components/NoteEditor.svelte index f900548..5954d1e 100644 --- a/src/lib/components/NoteEditor.svelte +++ b/src/lib/components/NoteEditor.svelte @@ -7,7 +7,7 @@ import ImageUpload from './ImageUpload.svelte'; import ShareDialog from './ShareDialog.svelte'; import NoteHistory from './NoteHistory.svelte'; - import { updateNote, createNote } from '$lib/stores/notes.js'; + import { updateNote, createNote, trashNote, archiveNote } from '$lib/stores/notes.js'; import { notes } from '$lib/stores/notes.js'; import { getNoteColor } from '$lib/utils/colors.js'; import { getIsDarkMode } from '$lib/utils/theme.svelte.js'; @@ -24,6 +24,11 @@ import Users from 'lucide-svelte/icons/users'; import Globe from 'lucide-svelte/icons/globe'; import History from 'lucide-svelte/icons/history'; + import EllipsisVertical from 'lucide-svelte/icons/ellipsis-vertical'; + import Trash2 from 'lucide-svelte/icons/trash-2'; + import ListX from 'lucide-svelte/icons/list-x'; + import Archive from 'lucide-svelte/icons/archive'; + import { parseChecklist, serializeChecklist } from '$lib/utils/checklist.js'; interface Props { note: Note | null; @@ -70,6 +75,29 @@ let showShareDialog = $state(false); let showHistory = $state(false); + let showOverflowMenu = $state(false); + let overflowBtnEl: HTMLButtonElement | undefined = $state(); + let overflowMenuEl: HTMLDivElement | undefined = $state(); + + // Close overflow menu when clicking outside + $effect(() => { + if (!showOverflowMenu) return; + function handleClickOutside(e: MouseEvent) { + const target = e.target as Node; + if (overflowBtnEl?.contains(target) || overflowMenuEl?.contains(target)) return; + showOverflowMenu = false; + } + // Use setTimeout to avoid the current click from closing the menu + const timer = setTimeout(() => document.addEventListener('click', handleClickOutside), 0); + return () => { + clearTimeout(timer); + document.removeEventListener('click', handleClickOutside); + }; + }); + + const hasDoneItems = $derived( + checklistMode && content.includes('[x]') + ); // svelte-ignore state_referenced_locally let collaboratorsList = $state(note?.collaborators ?? []); // svelte-ignore state_referenced_locally @@ -267,6 +295,34 @@ requestAnimationFrame(() => textareaEl?.focus()); } } + + async function handleArchive() { + showOverflowMenu = false; + if (noteId) { + await archiveNote(noteId); + onClose(); + } + } + + async function handleTrash() { + showOverflowMenu = false; + if (noteId) { + await trashNote(noteId); + onClose(); + } + } + + function deleteCheckedItems() { + const items = parseChecklist(content).filter((i) => !i.checked); + content = serializeChecklist(items); + showOverflowMenu = false; + } + + function uncheckAllItems() { + const items = parseChecklist(content).map((i) => ({ ...i, checked: false })); + content = serializeChecklist(items); + showOverflowMenu = false; + } @@ -370,20 +426,6 @@ {/if} - - - - - - - + {#if isOwner} {/if} + + + + + + {#if !checklistMode} + + {/if} + + + {#if !currentlyNew} + + {/if} + + + {#if !currentlyNew} +
+ + {/if} + + + {#if checklistMode} +
+ {#if hasDoneItems} + + {/if} + + {/if} + + {/if} {#if showShareDialog && noteId} diff --git a/tests/e2e/checklist.spec.ts b/tests/e2e/checklist.spec.ts index 0f4c9a5..71a99a3 100644 --- a/tests/e2e/checklist.spec.ts +++ b/tests/e2e/checklist.spec.ts @@ -1,11 +1,17 @@ import { test, expect, noteCard } from './helpers/fixtures.js'; import type { Page } from '@playwright/test'; +/** Toggle checklist mode via the overflow menu. */ +async function toggleChecklistMode(page: Page) { + await page.getByTestId('overflow-menu-btn').click(); + await page.getByTestId('checklist-toggle').click(); +} + /** Create a checklist note via UI. Leaves the editor closed. */ async function createChecklistNote(page: Page, title: string, items: string[]) { await page.getByTestId('new-note-btn').click(); await page.getByTestId('note-title-input').fill(title); - await page.getByTestId('checklist-toggle').click(); + await toggleChecklistMode(page); for (let i = 0; i < items.length; i++) { await page.getByTestId('checklist-input').nth(i).fill(items[i]); if (i < items.length - 1) { @@ -22,7 +28,7 @@ test.describe('Checklist', () => { await page.getByTestId('new-note-btn').click(); // When the user enables checklist mode - await page.getByTestId('checklist-toggle').click(); + await toggleChecklistMode(page); // Then the checklist component is displayed await expect(page.getByTestId('checklist')).toBeVisible(); @@ -88,7 +94,7 @@ test.describe('Checklist', () => { test('Scenario: Enter key adds a new checklist item', async ({ authenticatedPage: page }) => { // Given a checklist with one item await page.getByTestId('new-note-btn').click(); - await page.getByTestId('checklist-toggle').click(); + await toggleChecklistMode(page); await page.getByTestId('checklist-input').first().fill('First item'); // When the user presses Enter @@ -127,7 +133,7 @@ test.describe('Checklist', () => { test('Scenario: Backspace on empty item removes it from the list', async ({ authenticatedPage: page }) => { // Given a checklist with two items where the second is empty await page.getByTestId('new-note-btn').click(); - await page.getByTestId('checklist-toggle').click(); + await toggleChecklistMode(page); await page.getByTestId('checklist-input').first().fill('First item'); await page.getByTestId('checklist-input').first().press('Enter'); await expect(page.getByTestId('checklist-input')).toHaveCount(2); @@ -143,7 +149,7 @@ test.describe('Checklist', () => { // Given a checklist note with two items await page.getByTestId('new-note-btn').click(); await page.getByTestId('note-title-input').fill('Hide Done Test'); - await page.getByTestId('checklist-toggle').click(); + await toggleChecklistMode(page); 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'); @@ -164,7 +170,7 @@ test.describe('Checklist', () => { test('Scenario: Arrow keys navigate between checklist items', async ({ authenticatedPage: page }) => { // Given a checklist with three items await page.getByTestId('new-note-btn').click(); - await page.getByTestId('checklist-toggle').click(); + await toggleChecklistMode(page); await page.getByTestId('checklist-input').first().fill('First'); await page.getByTestId('checklist-input').first().press('Enter'); await page.getByTestId('checklist-input').nth(1).fill('Second'); @@ -187,7 +193,7 @@ test.describe('Checklist', () => { test('Scenario: Tab indents an item and Shift+Tab outdents it', async ({ authenticatedPage: page }) => { // Given a checklist with two top-level items await page.getByTestId('new-note-btn').click(); - await page.getByTestId('checklist-toggle').click(); + await toggleChecklistMode(page); await page.getByTestId('checklist-input').first().fill('Parent'); await page.getByTestId('checklist-input').first().press('Enter'); await page.getByTestId('checklist-input').nth(1).fill('Child'); @@ -208,7 +214,7 @@ test.describe('Checklist', () => { test('Scenario: Checking a parent checks all its children', async ({ authenticatedPage: page }) => { // Given a checklist with a parent and two children await page.getByTestId('new-note-btn').click(); - await page.getByTestId('checklist-toggle').click(); + await toggleChecklistMode(page); await page.getByTestId('checklist-input').first().fill('Buy groceries'); await page.getByTestId('checklist-input').first().press('Enter'); await page.getByTestId('checklist-input').nth(1).fill('Milk'); @@ -227,7 +233,7 @@ test.describe('Checklist', () => { test('Scenario: Checking a child shows read-only parent label in done section', async ({ authenticatedPage: page }) => { // Given a checklist with a parent and a child await page.getByTestId('new-note-btn').click(); - await page.getByTestId('checklist-toggle').click(); + await toggleChecklistMode(page); await page.getByTestId('checklist-input').first().fill('Groceries'); await page.getByTestId('checklist-input').first().press('Enter'); await page.getByTestId('checklist-input').nth(1).fill('Milk'); @@ -245,7 +251,7 @@ test.describe('Checklist', () => { test('Scenario: Unchecking a parent from done restores group to active', async ({ authenticatedPage: page }) => { // Given a checklist with a parent and child, all checked await page.getByTestId('new-note-btn').click(); - await page.getByTestId('checklist-toggle').click(); + await toggleChecklistMode(page); await page.getByTestId('checklist-input').first().fill('Shopping'); await page.getByTestId('checklist-input').first().press('Enter'); await page.getByTestId('checklist-input').nth(1).fill('Milk'); @@ -281,7 +287,7 @@ test.describe('Checklist', () => { test('Scenario: Enter on a child creates a sibling at the same level', async ({ authenticatedPage: page }) => { // Given a checklist with a parent and a child await page.getByTestId('new-note-btn').click(); - await page.getByTestId('checklist-toggle').click(); + await toggleChecklistMode(page); await page.getByTestId('checklist-input').first().fill('Parent'); await page.getByTestId('checklist-input').first().press('Enter'); await page.getByTestId('checklist-input').nth(1).fill('Child 1'); @@ -310,14 +316,49 @@ test.describe('Checklist', () => { test('Scenario: Disabling checklist mode restores the rich text editor', async ({ authenticatedPage: page }) => { // Given checklist mode is enabled on a new note await page.getByTestId('new-note-btn').click(); - await page.getByTestId('checklist-toggle').click(); + await toggleChecklistMode(page); await expect(page.getByTestId('checklist')).toBeVisible(); // When the user disables checklist mode - await page.getByTestId('checklist-toggle').click(); + await toggleChecklistMode(page); // Then the rich text editor is displayed again await expect(page.getByTestId('tiptap-editor')).toBeVisible(); await expect(page.getByTestId('checklist')).not.toBeVisible(); }); + + test('Scenario: Delete checked items removes only done items', async ({ authenticatedPage: page }) => { + // Given a checklist note with two checked and one unchecked item + await createChecklistNote(page, 'Cleanup', ['Keep me', 'Done 1', 'Done 2']); + await noteCard(page, 'Cleanup').click(); + await page.getByTestId('checklist-checkbox').nth(1).click(); + await page.getByTestId('checklist-checkbox').nth(1).click(); + await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done'); + + // When the user deletes checked items via the overflow menu + await page.getByTestId('overflow-menu-btn').click(); + await page.getByTestId('delete-checked-btn').click(); + + // Then only the unchecked item remains + await expect(page.getByTestId('checklist-input')).toHaveCount(1); + await expect(page.getByTestId('checklist-input').first()).toHaveValue('Keep me'); + await expect(page.getByTestId('checklist-toggle-done')).not.toBeVisible(); + }); + + test('Scenario: Uncheck all items moves everything back to active', async ({ authenticatedPage: page }) => { + // Given a checklist note with checked items + await createChecklistNote(page, 'Reset', ['Item A', 'Item B']); + await noteCard(page, 'Reset').click(); + await page.getByTestId('checklist-checkbox').first().click(); + await page.getByTestId('checklist-checkbox').first().click(); + await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done'); + + // When the user unchecks all via the overflow menu + await page.getByTestId('overflow-menu-btn').click(); + await page.getByTestId('uncheck-all-btn').click(); + + // Then all items are back in the active section + await expect(page.getByTestId('checklist-input')).toHaveCount(2); + await expect(page.getByTestId('checklist-toggle-done')).not.toBeVisible(); + }); }); diff --git a/tests/e2e/formatting.spec.ts b/tests/e2e/formatting.spec.ts index 997c8fb..56192c0 100644 --- a/tests/e2e/formatting.spec.ts +++ b/tests/e2e/formatting.spec.ts @@ -13,11 +13,17 @@ async function runTiptapCommand(page: Page, commandFn: string) { ); } +/** Toggle markdown mode via the overflow menu. */ +async function toggleMarkdownMode(page: Page) { + await page.getByTestId('overflow-menu-btn').click(); + await page.getByTestId('markdown-toggle').click(); +} + /** Type content into the TipTap editor via markdown mode (reliable for e2e) */ async function typeViaMarkdown(page: Page, content: string) { - await page.getByTestId('markdown-toggle').click(); + await toggleMarkdownMode(page); await page.getByTestId('note-content-input').fill(content); - await page.getByTestId('markdown-toggle').click(); + await toggleMarkdownMode(page); } test.describe('Rich text formatting', () => { @@ -34,7 +40,7 @@ test.describe('Rich text formatting', () => { await expect(editor.locator('strong')).toHaveText('world'); // And the markdown content contains bold syntax - await page.getByTestId('markdown-toggle').click(); + await toggleMarkdownMode(page); await expect(page.getByTestId('note-content-input')).toHaveValue('hello **world**'); }); @@ -95,7 +101,7 @@ test.describe('Rich text formatting', () => { await expect(editor.locator('strong')).toHaveText('bold'); // When the user toggles markdown mode - await page.getByTestId('markdown-toggle').click(); + await toggleMarkdownMode(page); // Then the raw markdown textarea is shown with the markdown source await expect(page.getByTestId('note-content-input')).toBeVisible();