From 243c09d97ce08360e1e4f1fc7e7dcbb058348ad3 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 8 Apr 2026 22:55:31 +0200 Subject: [PATCH] refactor(mana/web): extract useItemContextMenu helper for ListView right-click menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine files (8 ListViews + todo's TaskList) reimplemented the same context-menu state machinery character-for-character: a typed $state object with visible/x/y/, a handleItemContextMenu function that calls preventDefault and stuffs the click position in, and a close handler that resets the entity field. Extract `useItemContextMenu()` in $lib/data/item-context-menu.svelte that returns a reactive handle with `.state` (visible/x/y/target), `.open(e, target)`, and `.close()`. Consumers derive their menu items from `ctxMenu.state.target` and pass `ctxMenu.close` directly to . Per file: ~10 LOC of state declaration + handler removed; consumer items array switches from `ctxMenu.` to `ctxMenu.state.target`. Across the 9 files this is ~−90 LOC of pure boilerplate; helper itself is 50 LOC. Net small (~−40 LOC) but the boilerplate is gone and the shape is one helper away from being adjustable globally. Note: shared-ui already exports a `createContextMenuState` factory, but it's a plain default-value object — not a Svelte 5 reactive helper. This new wrapper composes with the existing `ContextMenuState` type from shared-ui rather than replacing it. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/data/item-context-menu.svelte.ts | 52 +++++++++++++++++++ .../src/lib/modules/calendar/ListView.svelte | 36 +++++-------- .../src/lib/modules/contacts/ListView.svelte | 43 ++++++--------- .../src/lib/modules/dreams/ListView.svelte | 37 ++++++------- .../src/lib/modules/habits/ListView.svelte | 43 +++++++-------- .../src/lib/modules/moodlit/ListView.svelte | 39 ++++++-------- .../web/src/lib/modules/notes/ListView.svelte | 37 ++++++------- .../src/lib/modules/places/ListView.svelte | 37 ++++++------- .../web/src/lib/modules/todo/ListView.svelte | 44 ++++++---------- .../modules/todo/components/TaskList.svelte | 37 +++++++------ 10 files changed, 191 insertions(+), 214 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/data/item-context-menu.svelte.ts diff --git a/apps/mana/apps/web/src/lib/data/item-context-menu.svelte.ts b/apps/mana/apps/web/src/lib/data/item-context-menu.svelte.ts new file mode 100644 index 000000000..34f3475a1 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/item-context-menu.svelte.ts @@ -0,0 +1,52 @@ +/** + * useItemContextMenu — Svelte 5 reactive wrapper around `ContextMenuState`. + * + * Every workbench-style ListView reimplemented the same boilerplate: + * + * let ctxMenu = $state<{...}>({ visible: false, x: 0, y: 0, item: null }); + * function handleItemContextMenu(e: MouseEvent, item: T) { + * e.preventDefault(); + * ctxMenu = { visible: true, x: e.clientX, y: e.clientY, item }; + * } + * // ... and a close handler that resets the target + * + * This helper collapses that to: + * + * const ctxMenu = useItemContextMenu(); + * + * The consumer derives its menu items from `ctxMenu.state.target`, wires + * `oncontextmenu={(e) => ctxMenu.open(e, item)}` on the row, and passes + * `ctxMenu.close` to ``. + */ + +import type { ContextMenuState } from '@mana/shared-ui'; + +export interface ItemContextMenuHandle { + readonly state: ContextMenuState; + /** Call from `oncontextmenu` to open the menu at the click position. */ + open: (e: MouseEvent, target: T) => void; + /** Hide the menu and clear the target. Pass to ``. */ + close: () => void; +} + +export function useItemContextMenu(): ItemContextMenuHandle { + let state = $state>({ + visible: false, + x: 0, + y: 0, + target: null, + }); + + return { + get state() { + return state; + }, + open(e: MouseEvent, target: T) { + e.preventDefault(); + state = { visible: true, x: e.clientX, y: e.clientY, target }; + }, + close() { + state = { ...state, visible: false, target: null }; + }, + }; +} diff --git a/apps/mana/apps/web/src/lib/modules/calendar/ListView.svelte b/apps/mana/apps/web/src/lib/modules/calendar/ListView.svelte index 1a82a300b..451828405 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/calendar/ListView.svelte @@ -15,6 +15,7 @@ import type { TagDragData } from '@mana/shared-ui/dnd'; import { useAllTags, getTagsByIds } from '@mana/shared-stores'; import { addTagId } from '$lib/data/tag-mutations'; + import { useItemContextMenu } from '$lib/data/item-context-menu.svelte'; let { navigate, goBack, params }: ViewProps = $props(); @@ -59,30 +60,18 @@ const dayNames = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; - // Context menu - let ctxMenu = $state<{ visible: boolean; x: number; y: number; event: CalendarEvent | null }>({ - visible: false, - x: 0, - y: 0, - event: null, - }); - - function handleItemContextMenu(e: MouseEvent, event: CalendarEvent) { - e.preventDefault(); - ctxMenu = { visible: true, x: e.clientX, y: e.clientY, event }; - } + const ctxMenu = useItemContextMenu(); let ctxMenuItems = $derived( - ctxMenu.event + ctxMenu.state.target ? [ { id: 'open', label: 'Öffnen', icon: PencilSimple, action: () => { - if (ctxMenu.event) { - navigate('detail', { eventId: ctxMenu.event.id }); - } + const target = ctxMenu.state.target; + if (target) navigate('detail', { eventId: target.id }); }, }, { id: 'div', label: '', type: 'divider' as const }, @@ -92,9 +81,8 @@ icon: Trash, variant: 'danger' as const, action: () => { - if (ctxMenu.event) { - eventsStore.deleteEvent(ctxMenu.event.id); - } + const target = ctxMenu.state.target; + if (target) eventsStore.deleteEvent(target.id); }, }, ] @@ -177,7 +165,7 @@ _siblingIds: todayEvents.map((e) => e.id), _siblingKey: 'eventId', })} - oncontextmenu={(e) => handleItemContextMenu(e, event)} + oncontextmenu={(e) => ctxMenu.open(e, event)} use:dragSource={{ type: 'event', data: () => ({ @@ -227,11 +215,11 @@ (ctxMenu = { ...ctxMenu, visible: false, event: null })} + onClose={ctxMenu.close} /> diff --git a/apps/mana/apps/web/src/lib/modules/contacts/ListView.svelte b/apps/mana/apps/web/src/lib/modules/contacts/ListView.svelte index ce19e7bf3..b8a2050d9 100644 --- a/apps/mana/apps/web/src/lib/modules/contacts/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/contacts/ListView.svelte @@ -15,6 +15,7 @@ import type { TagDragData } from '@mana/shared-ui/dnd'; import { useAllTags, getTagsByIds } from '@mana/shared-stores'; import { addTagId } from '$lib/data/tag-mutations'; + import { useItemContextMenu } from '$lib/data/item-context-menu.svelte'; let { navigate, goBack, params }: ViewProps = $props(); @@ -61,40 +62,27 @@ return (f + l).toUpperCase() || '?'; } - // Context menu - let ctxMenu = $state<{ visible: boolean; x: number; y: number; contact: LocalContact | null }>({ - visible: false, - x: 0, - y: 0, - contact: null, - }); - - function handleItemContextMenu(e: MouseEvent, contact: LocalContact) { - e.preventDefault(); - ctxMenu = { visible: true, x: e.clientX, y: e.clientY, contact }; - } + const ctxMenu = useItemContextMenu(); let ctxMenuItems = $derived( - ctxMenu.contact + ctxMenu.state.target ? [ { id: 'open', label: 'Öffnen', icon: PencilSimple, action: () => { - if (ctxMenu.contact) { - navigate('detail', { contactId: ctxMenu.contact.id }); - } + const target = ctxMenu.state.target; + if (target) navigate('detail', { contactId: target.id }); }, }, { id: 'favorite', - label: ctxMenu.contact.isFavorite ? 'Favorit entfernen' : 'Als Favorit', + label: ctxMenu.state.target.isFavorite ? 'Favorit entfernen' : 'Als Favorit', icon: Star, action: () => { - if (ctxMenu.contact) { - contactsStore.toggleFavorite(ctxMenu.contact.id); - } + const target = ctxMenu.state.target; + if (target) contactsStore.toggleFavorite(target.id); }, }, { id: 'div', label: '', type: 'divider' as const }, @@ -104,9 +92,8 @@ icon: Trash, variant: 'danger' as const, action: () => { - if (ctxMenu.contact) { - contactsStore.deleteContact(ctxMenu.contact.id); - } + const target = ctxMenu.state.target; + if (target) contactsStore.deleteContact(target.id); }, }, ] @@ -157,7 +144,7 @@ _siblingIds: filtered().map((c) => c.id), _siblingKey: 'contactId', })} - oncontextmenu={(e) => handleItemContextMenu(e, contact)} + oncontextmenu={(e) => ctxMenu.open(e, contact)} use:dragSource={{ type: 'contact', data: () => ({ @@ -203,11 +190,11 @@ (ctxMenu = { ...ctxMenu, visible: false, contact: null })} + onClose={ctxMenu.close} /> diff --git a/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte b/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte index 8285b6ad6..bca97e264 100644 --- a/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte @@ -15,6 +15,7 @@ import { MOOD_COLORS, MOOD_LABELS, type Dream, type DreamMood, type SleepQuality } from './types'; import type { ViewProps } from '$lib/app-registry'; import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui'; + import { useItemContextMenu } from '$lib/data/item-context-menu.svelte'; import { PencilSimple, PushPin, Trash } from '@mana/shared-icons'; import SymbolsView from './views/SymbolsView.svelte'; @@ -132,36 +133,27 @@ if (editingId === id) editingId = null; } - // Context menu - let ctxMenu = $state<{ visible: boolean; x: number; y: number; dream: Dream | null }>({ - visible: false, - x: 0, - y: 0, - dream: null, - }); - - function handleItemContextMenu(e: MouseEvent, dream: Dream) { - e.preventDefault(); - ctxMenu = { visible: true, x: e.clientX, y: e.clientY, dream }; - } + const ctxMenu = useItemContextMenu(); let ctxMenuItems = $derived( - ctxMenu.dream + ctxMenu.state.target ? [ { id: 'edit', label: 'Bearbeiten', icon: PencilSimple, action: () => { - if (ctxMenu.dream) startEdit(ctxMenu.dream); + const target = ctxMenu.state.target; + if (target) startEdit(target); }, }, { id: 'pin', - label: ctxMenu.dream.isPinned ? 'Lösen' : 'Pinnen', + label: ctxMenu.state.target.isPinned ? 'Lösen' : 'Pinnen', icon: PushPin, action: () => { - if (ctxMenu.dream) dreamsStore.togglePin(ctxMenu.dream.id); + const target = ctxMenu.state.target; + if (target) dreamsStore.togglePin(target.id); }, }, { id: 'div', label: '', type: 'divider' as const }, @@ -171,7 +163,8 @@ icon: Trash, variant: 'danger' as const, action: () => { - if (ctxMenu.dream) handleDelete(ctxMenu.dream.id); + const target = ctxMenu.state.target; + if (target) handleDelete(target.id); }, }, ] @@ -425,7 +418,7 @@ startEdit(dream); } }} - oncontextmenu={(e) => handleItemContextMenu(e, dream)} + oncontextmenu={(e) => ctxMenu.open(e, dream)} > {#if dream.mood} @@ -485,11 +478,11 @@ {/if} (ctxMenu = { ...ctxMenu, visible: false, dream: null })} + onClose={ctxMenu.close} /> {/if} diff --git a/apps/mana/apps/web/src/lib/modules/habits/ListView.svelte b/apps/mana/apps/web/src/lib/modules/habits/ListView.svelte index 92bf34dbe..b6d43bb8d 100644 --- a/apps/mana/apps/web/src/lib/modules/habits/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/habits/ListView.svelte @@ -15,6 +15,7 @@ import type { Habit, HabitLog } from './types'; import type { ViewProps } from '$lib/app-registry'; import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui'; + import { useItemContextMenu } from '$lib/data/item-context-menu.svelte'; import { toastStore } from '@mana/shared-ui/toast'; import { DynamicIcon } from '@mana/shared-ui/atoms'; import { IconPicker } from '@mana/shared-ui/molecules'; @@ -95,38 +96,29 @@ showIconPicker = false; } - // Context menu - let ctxMenu = $state<{ visible: boolean; x: number; y: number; habit: Habit | null }>({ - visible: false, - x: 0, - y: 0, - habit: null, - }); - - function handleItemContextMenu(e: MouseEvent, habit: Habit) { - e.preventDefault(); - ctxMenu = { visible: true, x: e.clientX, y: e.clientY, habit }; - } + const ctxMenu = useItemContextMenu(); let ctxMenuItems = $derived( - ctxMenu.habit + ctxMenu.state.target ? [ { id: 'log', label: 'Loggen', icon: Play, action: () => { - if (ctxMenu.habit) handleTap(ctxMenu.habit.id); + const target = ctxMenu.state.target; + if (target) handleTap(target.id); }, }, { id: 'archive', - label: ctxMenu.habit.isArchived ? 'Aktivieren' : 'Archivieren', - icon: ctxMenu.habit.isArchived ? Play : Pause, + label: ctxMenu.state.target.isArchived ? 'Aktivieren' : 'Archivieren', + icon: ctxMenu.state.target.isArchived ? Play : Pause, action: () => { - if (ctxMenu.habit) - habitsStore.updateHabit(ctxMenu.habit.id, { - isArchived: !ctxMenu.habit.isArchived, + const target = ctxMenu.state.target; + if (target) + habitsStore.updateHabit(target.id, { + isArchived: !target.isArchived, }); }, }, @@ -137,7 +129,8 @@ icon: Trash, variant: 'danger' as const, action: () => { - if (ctxMenu.habit) habitsStore.deleteHabit(ctxMenu.habit.id); + const target = ctxMenu.state.target; + if (target) habitsStore.deleteHabit(target.id); }, }, ] @@ -175,7 +168,7 @@ class:over-target={overTarget} class:pulse={animatingId === habit.id} onclick={() => handleTap(habit.id)} - oncontextmenu={(e) => handleItemContextMenu(e, habit)} + oncontextmenu={(e) => ctxMenu.open(e, habit)} > @@ -274,11 +267,11 @@ {/if} (ctxMenu = { ...ctxMenu, visible: false, habit: null })} + onClose={ctxMenu.close} /> {#if activeHabits.length === 0 && !showCreate} diff --git a/apps/mana/apps/web/src/lib/modules/moodlit/ListView.svelte b/apps/mana/apps/web/src/lib/modules/moodlit/ListView.svelte index 46db9c1e6..26d7b99b6 100644 --- a/apps/mana/apps/web/src/lib/modules/moodlit/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/moodlit/ListView.svelte @@ -9,6 +9,7 @@ import type { LocalMood } from './types'; import { moodsStore } from './stores/moods.svelte'; import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui'; + import { useItemContextMenu } from '$lib/data/item-context-menu.svelte'; import { Trash, Power } from '@mana/shared-icons'; const moodsQuery = useLiveQueryWithDefault(async () => { @@ -27,29 +28,18 @@ return `background: linear-gradient(135deg, ${colors.join(', ')})`; } - // Context menu - let ctxMenu = $state<{ visible: boolean; x: number; y: number; mood: LocalMood | null }>({ - visible: false, - x: 0, - y: 0, - mood: null, - }); - - function handleItemContextMenu(e: MouseEvent, mood: LocalMood) { - e.preventDefault(); - ctxMenu = { visible: true, x: e.clientX, y: e.clientY, mood }; - } + const ctxMenu = useItemContextMenu(); let ctxMenuItems = $derived( - ctxMenu.mood + ctxMenu.state.target ? [ { id: 'activate', - label: activeMoodId === ctxMenu.mood.id ? 'Deaktivieren' : 'Aktivieren', + label: activeMoodId === ctxMenu.state.target.id ? 'Deaktivieren' : 'Aktivieren', icon: Power, action: () => { - if (ctxMenu.mood) - activeMoodId = activeMoodId === ctxMenu.mood.id ? null : ctxMenu.mood.id; + const target = ctxMenu.state.target; + if (target) activeMoodId = activeMoodId === target.id ? null : target.id; }, }, { id: 'div', label: '', type: 'divider' as const }, @@ -59,9 +49,10 @@ icon: Trash, variant: 'danger' as const, action: () => { - if (ctxMenu.mood) { - if (activeMoodId === ctxMenu.mood.id) activeMoodId = null; - moodsStore.deleteMood(ctxMenu.mood.id); + const target = ctxMenu.state.target; + if (target) { + if (activeMoodId === target.id) activeMoodId = null; + moodsStore.deleteMood(target.id); } }, }, @@ -96,7 +87,7 @@ {#snippet item(mood)}