diff --git a/apps/todo/apps/web/src/lib/components/TagStrip.svelte b/apps/todo/apps/web/src/lib/components/TagStrip.svelte index 0829dff00..ccf0f30f4 100644 --- a/apps/todo/apps/web/src/lib/components/TagStrip.svelte +++ b/apps/todo/apps/web/src/lib/components/TagStrip.svelte @@ -5,6 +5,16 @@ import { DotsThree, Plus, X } from '@manacore/shared-icons'; import TagStripModal from './TagStripModal.svelte'; import { t } from 'svelte-i18n'; + import { dragSource, passiveDropZone } from '@manacore/shared-ui/dnd'; + import type { DragPayload, TaskDragData } from '@manacore/shared-ui/dnd'; + import { tasksStore } from '$lib/stores/tasks.svelte'; + import { taskCollection, type LocalTask } from '$lib/data/local-store'; + + interface Props { + bottomOffset?: string; + } + + let { bottomOffset = '72px' }: Props = $props(); const tagsCtx: { readonly value: Tag[] } = getContext('tags'); const activeTagFilter: { readonly ids: string[]; set(ids: string[]): void } = @@ -12,6 +22,17 @@ let showModal = $state(false); + // ── DnD: assign tag when task is dropped on tag pill ──── + async function handleTaskDropOnTag(tagId: string, payload: DragPayload) { + const taskData = payload.data as TaskDragData; + const task = await taskCollection.get(taskData.id); + if (!task) return; + const currentLabels: string[] = (task.metadata as { labelIds?: string[] })?.labelIds ?? []; + if (!currentLabels.includes(tagId)) { + tasksStore.updateLabels(taskData.id, [...currentLabels, tagId]); + } + } + function handleTagClick(tagId: string) { const current = activeTagFilter.ids; if (current.includes(tagId)) { @@ -42,7 +63,7 @@ const hasTags = $derived(tagsCtx.value.length > 0); -
+
-
- {/each} - -
-
- {/if} - - + {#if $page.url.pathname === '/'} { - let offset = 16; - if (!isPillNavCollapsed) offset += 68; - if (!isPillNavCollapsed && isFilterStripVisible) offset += 50; - if (hasMinimizedTabs) offset += MINIMIZED_TABS_HEIGHT; - return `${offset}px`; - })()} + bottomOffset="16px" /> {/if} - - + + + + + {#if isFilterStripVisible} + + {/if} + + + + tasksStore.deleteTask(payload.data.id)} + variant="danger" + label="Löschen" + /> {/if} @@ -670,149 +620,4 @@ padding-bottom: calc(100px + env(safe-area-inset-bottom)); } } - - /* FAB to toggle PillNav — sits right next to the centered QuickInputBar */ - .pillnav-fab { - position: fixed; - bottom: calc(var(--fab-bottom, 16px) + env(safe-area-inset-bottom, 0px)); - /* Anchor to center, then offset by half of InputBar max-width (350px) + gap */ - left: calc(50% + 350px + 0.75rem); - width: 56px; - height: 56px; - border-radius: 50%; - background: var(--color-surface-elevated-2); - border: 1px solid var(--color-border); - box-shadow: var(--shadow-xl); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - z-index: 1002; - transition: all 0.2s ease; - } - - /* On narrower screens, FAB sits at the right edge of the padded input area */ - @media (max-width: 900px) { - .pillnav-fab { - left: auto; - right: 1rem; - } - } - - .pillnav-fab:hover { - transform: scale(1.05); - } - - .pillnav-fab:active { - transform: scale(0.95); - } - - .pillnav-fab :global(svg) { - color: var(--color-foreground); - } - - /* ── Minimized Page Tabs Bar ─────────────────────────── */ - .minimized-tabs-bar { - position: fixed; - bottom: calc(var(--tabs-bottom, 16px) + env(safe-area-inset-bottom, 0px)); - left: 50%; - transform: translateX(-50%); - z-index: 1001; - } - - .minimized-tabs-inner { - display: flex; - align-items: center; - gap: 0.25rem; - padding: 0.3rem 0.5rem; - background: var(--color-surface-elevated, #fffef5); - border: 1px solid var(--color-border, rgba(0, 0, 0, 0.12)); - border-radius: 0.625rem; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); - overflow-x: auto; - scrollbar-width: none; - } - :global(.dark) .minimized-tabs-inner { - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); - } - .minimized-tabs-inner::-webkit-scrollbar { - display: none; - } - - .minimized-tab { - display: flex; - align-items: center; - gap: 0.375rem; - padding: 0.25rem 0.5rem; - background: transparent; - border: none; - border-radius: 0.3rem; - cursor: pointer; - transition: all 0.15s; - white-space: nowrap; - flex-shrink: 0; - font-family: inherit; - } - .minimized-tab:hover { - background: rgba(0, 0, 0, 0.05); - } - :global(.dark) .minimized-tab:hover { - background: rgba(255, 255, 255, 0.08); - } - - .minimized-tab-dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 9999px; - flex-shrink: 0; - } - - .minimized-tab-title { - font-size: 0.75rem; - font-weight: 500; - color: var(--color-muted-foreground, #6b7280); - } - - .minimized-tab-close { - display: flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - border: none; - background: transparent; - color: var(--color-muted-foreground, #d1d5db); - border-radius: 0.125rem; - cursor: pointer; - padding: 0; - transition: all 0.15s; - opacity: 0.5; - } - .minimized-tab-close:hover { - opacity: 1; - background: rgba(0, 0, 0, 0.06); - } - :global(.dark) .minimized-tab-close:hover { - background: rgba(255, 255, 255, 0.08); - } - - .minimized-tab-add { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border-radius: 0.3rem; - border: none; - background: transparent; - color: var(--color-muted-foreground, #9ca3af); - cursor: pointer; - flex-shrink: 0; - transition: all 0.15s; - opacity: 0.6; - } - .minimized-tab-add:hover { - opacity: 1; - color: var(--color-primary, #8b5cf6); - } diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index b7fdf7c24..ff9728cb7 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -26,6 +26,11 @@ "svelte": "./src/organisms/index.ts", "types": "./src/organisms/index.ts", "default": "./src/organisms/index.ts" + }, + "./dnd": { + "svelte": "./src/dnd/index.ts", + "types": "./src/dnd/index.ts", + "default": "./src/dnd/index.ts" } }, "scripts": { diff --git a/packages/shared-ui/src/dnd/ActionZone.svelte b/packages/shared-ui/src/dnd/ActionZone.svelte new file mode 100644 index 000000000..6df0a59ba --- /dev/null +++ b/packages/shared-ui/src/dnd/ActionZone.svelte @@ -0,0 +1,202 @@ + + +{#if visible} +
+ + {#if label} + {label} + {/if} +
+{/if} + + diff --git a/packages/shared-ui/src/dnd/DragPreview.svelte b/packages/shared-ui/src/dnd/DragPreview.svelte new file mode 100644 index 000000000..5ddbfaa7f --- /dev/null +++ b/packages/shared-ui/src/dnd/DragPreview.svelte @@ -0,0 +1,98 @@ + + +{#if dragState.isDragging} +
+ {#if tagData} + + {tagData.name} + {:else if dragState.activeDrag} + {dragState.activeDrag.type} + {/if} +
+{/if} + + diff --git a/packages/shared-ui/src/dnd/README.md b/packages/shared-ui/src/dnd/README.md new file mode 100644 index 000000000..2473c3a6f --- /dev/null +++ b/packages/shared-ui/src/dnd/README.md @@ -0,0 +1,142 @@ +# Cross-Type Drag & Drop System + +Shared DnD system for ManaCore apps. Enables dragging items between different +component types (e.g. Tag onto Task, Task onto Trash zone). + +Designed to coexist with `svelte-dnd-action` which handles same-type reordering. + +## Architecture + +Two layers: + +- **Layer 1 (Pointer Events):** For items NOT managed by svelte-dnd-action. + Tag pills in the TagStrip use `dragSource` to become draggable, TaskItems use + `dropTarget` to accept tags. Works on mouse, touch, and pen via Pointer Events. + +- **Layer 2 (Passive Overlay):** For items already managed by svelte-dnd-action. + When a Task is being reordered via svelte-dnd-action, `passiveDropZone` detects + if the pointer hovers over a Tag pill or ActionZone and fires the appropriate + action on drop. No conflict with existing DnD. + +## Usage + +### Make an element draggable (Layer 1) + +```svelte + + + +``` + +- Desktop: drag starts after 5px mouse movement +- Mobile: drag starts after 300ms long-press (with haptic feedback) + +### Make an element a drop target (Layer 1) + +```svelte + + +
assignTag(item.id, payload.data.id), + canDrop: (payload) => !item.tagIds.includes(payload.data.id), +}}> + {item.title} +
+``` + +CSS class `mana-drop-target-hover` is added during hover, +`mana-drop-target-success` briefly after a successful drop. + +### React to svelte-dnd-action drags (Layer 2) + +```svelte + + + +
{ + items = e.detail.items; + registerSvelteActionDrag({ type: 'task', data: { id: e.detail.info.id } }); + }} + onfinalize={(e) => { + // ... normal handling ... + clearSvelteActionDrag(); + }} +> + + +