mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
feat: add overflow menu with checklist actions and action bar cleanup
- 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
5a2a63e2bb
commit
8b9f9dfee8
@@ -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<Collaborator[]>(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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -370,20 +426,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Checklist mode toggle -->
|
||||
<button
|
||||
onclick={() => (checklistMode = !checklistMode)}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title={checklistMode ? 'Switch to text' : 'Checklist mode'}
|
||||
data-testid="checklist-toggle"
|
||||
>
|
||||
{#if checklistMode}
|
||||
<Type class="h-5 w-5" />
|
||||
{:else}
|
||||
<SquareCheck class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Image attachment toggle -->
|
||||
<button
|
||||
onclick={toggleImageUpload}
|
||||
@@ -394,22 +436,7 @@
|
||||
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
|
||||
</button>
|
||||
|
||||
<!-- Raw markdown mode toggle -->
|
||||
<button
|
||||
onclick={toggleMarkdownMode}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title={rawMarkdownMode ? 'Rich text mode' : 'Markdown mode'}
|
||||
disabled={checklistMode}
|
||||
data-testid="markdown-toggle"
|
||||
>
|
||||
{#if rawMarkdownMode}
|
||||
<FileText class="h-5 w-5" />
|
||||
{:else}
|
||||
<FileCode class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Share button (owner only for new shares, all for viewing) -->
|
||||
<!-- Share button -->
|
||||
{#if isOwner}
|
||||
<button
|
||||
onclick={toggleShareDialog}
|
||||
@@ -431,17 +458,28 @@
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- History button (only for saved notes) -->
|
||||
<!-- Archive button (only for saved notes) -->
|
||||
{#if !currentlyNew}
|
||||
<button
|
||||
onclick={toggleHistory}
|
||||
onclick={handleArchive}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Version history"
|
||||
data-testid="history-toggle"
|
||||
title="Archive"
|
||||
data-testid="archive-note-btn"
|
||||
>
|
||||
<History class="h-5 w-5 {showHistory ? 'text-[var(--primary)]' : ''}" />
|
||||
<Archive class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Overflow menu trigger -->
|
||||
<button
|
||||
bind:this={overflowBtnEl}
|
||||
onclick={() => (showOverflowMenu = !showOverflowMenu)}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="More actions"
|
||||
data-testid="overflow-menu-btn"
|
||||
>
|
||||
<EllipsisVertical class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -453,6 +491,97 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overflow menu dropdown (rendered outside the card to escape overflow-hidden) -->
|
||||
{#if showOverflowMenu && overflowBtnEl}
|
||||
{@const rect = overflowBtnEl.getBoundingClientRect()}
|
||||
<div
|
||||
bind:this={overflowMenuEl}
|
||||
class="fixed z-50 w-max whitespace-nowrap rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] py-1 shadow-[var(--card-shadow)]"
|
||||
style="bottom: {window.innerHeight - rect.top + 4}px; right: {window.innerWidth - rect.right}px;"
|
||||
data-testid="overflow-menu"
|
||||
>
|
||||
<!-- Checklist mode toggle -->
|
||||
<button
|
||||
onclick={() => { checklistMode = !checklistMode; showOverflowMenu = false; }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
data-testid="checklist-toggle"
|
||||
>
|
||||
{#if checklistMode}
|
||||
<Type class="h-4 w-4" />
|
||||
Switch to text
|
||||
{:else}
|
||||
<SquareCheck class="h-4 w-4" />
|
||||
Checklist mode
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Markdown mode toggle (not available in checklist mode) -->
|
||||
{#if !checklistMode}
|
||||
<button
|
||||
onclick={() => { toggleMarkdownMode(); showOverflowMenu = false; }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
data-testid="markdown-toggle"
|
||||
>
|
||||
{#if rawMarkdownMode}
|
||||
<FileText class="h-4 w-4" />
|
||||
Rich text mode
|
||||
{:else}
|
||||
<FileCode class="h-4 w-4" />
|
||||
Markdown mode
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- History -->
|
||||
{#if !currentlyNew}
|
||||
<button
|
||||
onclick={() => { toggleHistory(); showOverflowMenu = false; }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
data-testid="history-toggle"
|
||||
>
|
||||
<History class="h-4 w-4 {showHistory ? 'text-[var(--primary)]' : ''}" />
|
||||
Version history
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Trash (only for saved notes) -->
|
||||
{#if !currentlyNew}
|
||||
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={handleTrash}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--destructive)] hover:bg-[var(--border)]/10"
|
||||
data-testid="trash-note-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Move to trash
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Checklist-specific actions -->
|
||||
{#if checklistMode}
|
||||
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
|
||||
{#if hasDoneItems}
|
||||
<button
|
||||
onclick={deleteCheckedItems}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
data-testid="delete-checked-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Delete checked
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={uncheckAllItems}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
data-testid="uncheck-all-btn"
|
||||
>
|
||||
<ListX class="h-4 w-4" />
|
||||
Uncheck all
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showShareDialog && noteId}
|
||||
|
||||
+54
-13
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user