refactor(mana/web): extract useItemContextMenu helper for ListView right-click menus

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/<itemKey>, a handleItemContextMenu
function that calls preventDefault and stuffs the click position
in, and a close handler that resets the entity field.

Extract `useItemContextMenu<T>()` 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 <ContextMenu onClose>.

Per file: ~10 LOC of state declaration + handler removed; consumer
items array switches from `ctxMenu.<entity>` 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<T>`
type from shared-ui rather than replacing it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-08 22:55:31 +02:00
parent 0af9094096
commit 243c09d97c
10 changed files with 191 additions and 214 deletions

View file

@ -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<LocalContact>();
*
* 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 `<ContextMenu onClose>`.
*/
import type { ContextMenuState } from '@mana/shared-ui';
export interface ItemContextMenuHandle<T> {
readonly state: ContextMenuState<T>;
/** 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 `<ContextMenu onClose>`. */
close: () => void;
}
export function useItemContextMenu<T>(): ItemContextMenuHandle<T> {
let state = $state<ContextMenuState<T>>({
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 };
},
};
}

View file

@ -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<CalendarEvent>();
let ctxMenuItems = $derived<ContextMenuItem[]>(
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 @@
</div>
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
y={ctxMenu.state.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, event: null })}
onClose={ctxMenu.close}
/>
</div>

View file

@ -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<LocalContact>();
let ctxMenuItems = $derived<ContextMenuItem[]>(
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 @@
</div>
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
y={ctxMenu.state.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, contact: null })}
onClose={ctxMenu.close}
/>
</div>

View file

@ -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<Dream>();
let ctxMenuItems = $derived<ContextMenuItem[]>(
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}
<span class="mood-dot-row" style="background: {MOOD_COLORS[dream.mood]}"></span>
@ -485,11 +478,11 @@
{/if}
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
y={ctxMenu.state.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, dream: null })}
onClose={ctxMenu.close}
/>
{/if}
</div>

View file

@ -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<Habit>();
let ctxMenuItems = $derived<ContextMenuItem[]>(
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)}
>
<span class="tally-icon">
<DynamicIcon name={habit.icon} size={20} weight="bold" />
@ -274,11 +267,11 @@
{/if}
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
y={ctxMenu.state.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, habit: null })}
onClose={ctxMenu.close}
/>
{#if activeHabits.length === 0 && !showCreate}

View file

@ -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<LocalMood>();
let ctxMenuItems = $derived<ContextMenuItem[]>(
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)}
<button
onclick={() => (activeMoodId = activeMoodId === mood.id ? null : mood.id)}
oncontextmenu={(e) => handleItemContextMenu(e, mood)}
oncontextmenu={(e) => ctxMenu.open(e, mood)}
class="group flex flex-col items-center gap-1.5 rounded-lg p-2 transition-colors hover:bg-white/5
{activeMoodId === mood.id ? 'ring-1 ring-white/30' : ''}"
>
@ -107,9 +98,9 @@
</BaseListView>
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
y={ctxMenu.state.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, mood: null })}
onClose={ctxMenu.close}
/>

View file

@ -8,6 +8,7 @@
import type { Note } 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, Trash, PushPin } from '@mana/shared-icons';
import VoiceCaptureBar from '$lib/components/voice/VoiceCaptureBar.svelte';
@ -78,36 +79,27 @@
await notesStore.togglePin(id);
}
// Context menu
let ctxMenu = $state<{ visible: boolean; x: number; y: number; note: Note | null }>({
visible: false,
x: 0,
y: 0,
note: null,
});
function handleItemContextMenu(e: MouseEvent, note: Note) {
e.preventDefault();
ctxMenu = { visible: true, x: e.clientX, y: e.clientY, note };
}
const ctxMenu = useItemContextMenu<Note>();
let ctxMenuItems = $derived<ContextMenuItem[]>(
ctxMenu.note
ctxMenu.state.target
? [
{
id: 'edit',
label: 'Bearbeiten',
icon: PencilSimple,
action: () => {
if (ctxMenu.note) startEdit(ctxMenu.note);
const target = ctxMenu.state.target;
if (target) startEdit(target);
},
},
{
id: 'pin',
label: ctxMenu.note.isPinned ? 'Lösen' : 'Pinnen',
label: ctxMenu.state.target.isPinned ? 'Lösen' : 'Pinnen',
icon: PushPin,
action: () => {
if (ctxMenu.note) notesStore.togglePin(ctxMenu.note.id);
const target = ctxMenu.state.target;
if (target) notesStore.togglePin(target.id);
},
},
{ id: 'div', label: '', type: 'divider' as const },
@ -117,7 +109,8 @@
icon: Trash,
variant: 'danger' as const,
action: () => {
if (ctxMenu.note) handleDelete(ctxMenu.note.id);
const target = ctxMenu.state.target;
if (target) handleDelete(target.id);
},
},
]
@ -184,7 +177,7 @@
<button
class="note-item"
onclick={() => startEdit(note)}
oncontextmenu={(e) => handleItemContextMenu(e, note)}
oncontextmenu={(e) => ctxMenu.open(e, note)}
>
{#if note.color}
<span class="color-dot" style="background: {note.color}"></span>
@ -213,11 +206,11 @@
{/if}
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
y={ctxMenu.state.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, note: null })}
onClose={ctxMenu.close}
/>
</div>

View file

@ -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();
@ -93,36 +94,27 @@
navigate('detail', { placeId, _siblingIds: ids, _siblingKey: 'placeId' });
}
// Context menu
let ctxMenu = $state<{ visible: boolean; x: number; y: number; place: LocalPlace | null }>({
visible: false,
x: 0,
y: 0,
place: null,
});
function handleItemContextMenu(e: MouseEvent, place: LocalPlace) {
e.preventDefault();
ctxMenu = { visible: true, x: e.clientX, y: e.clientY, place };
}
const ctxMenu = useItemContextMenu<LocalPlace>();
let ctxMenuItems = $derived<ContextMenuItem[]>(
ctxMenu.place
ctxMenu.state.target
? [
{
id: 'open',
label: 'Öffnen',
icon: PencilSimple,
action: () => {
if (ctxMenu.place) openDetail(ctxMenu.place.id);
const target = ctxMenu.state.target;
if (target) openDetail(target.id);
},
},
{
id: 'favorite',
label: ctxMenu.place.isFavorite ? 'Favorit entfernen' : 'Als Favorit',
label: ctxMenu.state.target.isFavorite ? 'Favorit entfernen' : 'Als Favorit',
icon: Star,
action: () => {
if (ctxMenu.place) placesStore.toggleFavorite(ctxMenu.place.id);
const target = ctxMenu.state.target;
if (target) placesStore.toggleFavorite(target.id);
},
},
{ id: 'div', label: '', type: 'divider' as const },
@ -132,7 +124,8 @@
icon: Trash,
variant: 'danger' as const,
action: () => {
if (ctxMenu.place) placesStore.deletePlace(ctxMenu.place.id);
const target = ctxMenu.state.target;
if (target) placesStore.deletePlace(target.id);
},
},
]
@ -197,7 +190,7 @@
<button
class="place-item"
onclick={() => openDetail(place.id)}
oncontextmenu={(e) => handleItemContextMenu(e, place)}
oncontextmenu={(e) => ctxMenu.open(e, place)}
use:dropTarget={{
accepts: ['tag'],
onDrop: (payload) => handleTagDrop(place.id, payload.data as unknown as TagDragData),
@ -244,11 +237,11 @@
</div>
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
y={ctxMenu.state.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, place: null })}
onClose={ctxMenu.close}
/>
{#if filtered().length === 0 && !search}

View file

@ -21,6 +21,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';
import VoiceCaptureBar from '$lib/components/voice/VoiceCaptureBar.svelte';
let { navigate, goBack, params }: ViewProps = $props();
@ -76,41 +77,27 @@
await tasksStore.createFromVoice(blob, durationMs, 'de');
}
// Context menu
let ctxMenu = $state<{
visible: boolean;
x: number;
y: number;
task: import('./types').Task | null;
}>({
visible: false,
x: 0,
y: 0,
task: null,
});
function handleItemContextMenu(e: MouseEvent, task: import('./types').Task) {
e.preventDefault();
ctxMenu = { visible: true, x: e.clientX, y: e.clientY, task };
}
const ctxMenu = useItemContextMenu<import('./types').Task>();
let ctxMenuItems = $derived<ContextMenuItem[]>(
ctxMenu.task
ctxMenu.state.target
? [
{
id: 'open',
label: 'Öffnen',
icon: PencilSimple,
action: () => {
if (ctxMenu.task) navigate('detail', { taskId: ctxMenu.task.id });
const target = ctxMenu.state.target;
if (target) navigate('detail', { taskId: target.id });
},
},
{
id: 'complete',
label: ctxMenu.task.isCompleted ? 'Wieder öffnen' : 'Erledigen',
icon: ctxMenu.task.isCompleted ? ArrowCounterClockwise : Check,
label: ctxMenu.state.target.isCompleted ? 'Wieder öffnen' : 'Erledigen',
icon: ctxMenu.state.target.isCompleted ? ArrowCounterClockwise : Check,
action: () => {
if (ctxMenu.task) tasksStore.toggleComplete(ctxMenu.task.id);
const target = ctxMenu.state.target;
if (target) tasksStore.toggleComplete(target.id);
},
},
{ id: 'div', label: '', type: 'divider' as const },
@ -120,7 +107,8 @@
icon: Trash,
variant: 'danger' as const,
action: () => {
if (ctxMenu.task) tasksStore.deleteTask(ctxMenu.task.id);
const target = ctxMenu.state.target;
if (target) tasksStore.deleteTask(target.id);
},
},
]
@ -186,7 +174,7 @@
_siblingIds: filtered().map((t) => t.id),
_siblingKey: 'taskId',
})}
oncontextmenu={(e) => handleItemContextMenu(e, task)}
oncontextmenu={(e) => ctxMenu.open(e, task)}
class="task-item"
use:dragSource={{
type: 'task',
@ -239,11 +227,11 @@
</div>
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
y={ctxMenu.state.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, task: null })}
onClose={ctxMenu.close}
/>
</div>

View file

@ -6,6 +6,7 @@
import { dndzone, SOURCES, TRIGGERS } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
import { PencilSimple, Check, ArrowCounterClockwise, Trash, Circle } from '@mana/shared-icons';
interface Props {
@ -24,35 +25,31 @@
items = [...tasks];
});
// Context menu
let ctxMenu = $state<{ visible: boolean; x: number; y: number; task: Task | null }>({
visible: false,
x: 0,
y: 0,
task: null,
});
const ctxMenu = useItemContextMenu<Task>();
function handleContextMenu(task: Task, e: MouseEvent) {
ctxMenu = { visible: true, x: e.clientX, y: e.clientY, task };
ctxMenu.open(e, task);
}
let ctxMenuItems = $derived<ContextMenuItem[]>(
ctxMenu.task
ctxMenu.state.target
? [
{
id: 'open',
label: $_('todo.edit'),
icon: PencilSimple,
action: () => {
if (ctxMenu.task) onOpenTask(ctxMenu.task);
const target = ctxMenu.state.target;
if (target) onOpenTask(target);
},
},
{
id: 'complete',
label: ctxMenu.task.isCompleted ? $_('todo.reopen') : $_('todo.markDone'),
icon: ctxMenu.task.isCompleted ? ArrowCounterClockwise : Check,
label: ctxMenu.state.target.isCompleted ? $_('todo.reopen') : $_('todo.markDone'),
icon: ctxMenu.state.target.isCompleted ? ArrowCounterClockwise : Check,
action: () => {
if (ctxMenu.task) tasksStore.toggleComplete(ctxMenu.task.id);
const target = ctxMenu.state.target;
if (target) tasksStore.toggleComplete(target.id);
},
},
{ id: 'div-priority', label: '', type: 'divider' as const },
@ -68,7 +65,8 @@
: $_('todo.priorityLow'),
icon: Circle,
action: () => {
if (ctxMenu.task) tasksStore.updateTask(ctxMenu.task.id, { priority: p });
const target = ctxMenu.state.target;
if (target) tasksStore.updateTask(target.id, { priority: p });
},
})),
{ id: 'div-delete', label: '', type: 'divider' as const },
@ -78,7 +76,8 @@
icon: Trash,
variant: 'danger' as const,
action: () => {
if (ctxMenu.task) tasksStore.deleteTask(ctxMenu.task.id);
const target = ctxMenu.state.target;
if (target) tasksStore.deleteTask(target.id);
},
},
]
@ -143,9 +142,9 @@
{/if}
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
y={ctxMenu.state.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, task: null })}
onClose={ctxMenu.close}
/>