From 3d2505fe54ee4ebb63d0648ac9b242ecbec52037 Mon Sep 17 00:00:00 2001 From: Bart Jaskulski Date: Tue, 25 Nov 2025 23:03:46 +0100 Subject: [PATCH 1/3] dragging --- src/components/DragProvider.tsx | 258 ++++++++++++++++++++++++++++++++ src/components/TaskItem.css | 10 ++ src/components/TaskItem.tsx | 11 +- src/components/TasksList.css | 75 +++++++++- src/components/TasksList.tsx | 105 ++++++++++++- 5 files changed, 447 insertions(+), 12 deletions(-) create mode 100644 src/components/DragProvider.tsx diff --git a/src/components/DragProvider.tsx b/src/components/DragProvider.tsx new file mode 100644 index 0000000..4adc355 --- /dev/null +++ b/src/components/DragProvider.tsx @@ -0,0 +1,258 @@ +import { createContext, useContext, type JSX, type ParentComponent, Show, batch, onCleanup } from "solid-js"; +import { createStore } from "solid-js/store"; +import { Portal } from "solid-js/web"; + +export type DropPosition = "above" | "below" | "inside" | null; + +type DragState = { + status: "IDLE" | "PRESSED" | "DRAGGING" | "DROPPING"; + draggedId: string | null; + targetId: string | null; + dropPosition: DropPosition; + startCoord: { x: number; y: number }; + currentCoord: { x: number; y: number }; + grabOffset: { x: number; y: number }; + originalRect: DOMRect | null; +}; + +type DragContextValue = { + state: DragState; + startDrag: (event: PointerEvent, id: string, el: HTMLElement) => void; +}; + +type DragProviderProps = { + onDrop: (draggedId: string, targetId: string, position: Exclude) => void; + renderOverlay?: (id: string | null) => JSX.Element | null; +}; + +const DragContext = createContext(); + +const MOVEMENT_THRESHOLD = 5; +const HOT_ZONE = 100; + +const calculateDropPosition = (pointerY: number, rect: DOMRect): Exclude => { + const relativeY = (pointerY - rect.top) / rect.height; + if (relativeY < 0.25) return "above"; + if (relativeY > 0.75) return "below"; + return "inside"; +}; + +export const DragProvider: ParentComponent = (props) => { + const [state, setState] = createStore({ + status: "IDLE", + draggedId: null, + targetId: null, + dropPosition: null, + startCoord: { x: 0, y: 0 }, + currentCoord: { x: 0, y: 0 }, + grabOffset: { x: 0, y: 0 }, + originalRect: null, + }); + + let autoScrollFrame: number | undefined; + let announcerTimeout: number | undefined; + + const announce = (message: string) => { + const announcer = document.getElementById("dnd-announcer"); + if (!announcer) return; + announcer.textContent = message; + if (announcerTimeout) { + clearTimeout(announcerTimeout); + } + announcerTimeout = window.setTimeout(() => { + announcer.textContent = ""; + }, 800); + }; + + const startAutoScroll = () => { + const loop = () => { + if (state.status !== "DRAGGING") return; + + const { y } = state.currentCoord; + const height = window.innerHeight; + + if (y > height - HOT_ZONE) { + window.scrollBy(0, 6 + (y - (height - HOT_ZONE)) / 8); + } else if (y < HOT_ZONE) { + window.scrollBy(0, -(6 + (HOT_ZONE - y) / 8)); + } + + autoScrollFrame = requestAnimationFrame(loop); + }; + loop(); + }; + + const resetState = () => { + batch(() => { + setState({ + status: "IDLE", + draggedId: null, + targetId: null, + dropPosition: null, + startCoord: { x: 0, y: 0 }, + currentCoord: { x: 0, y: 0 }, + grabOffset: { x: 0, y: 0 }, + originalRect: null, + }); + }); + }; + + const handlePointerMove = (event: PointerEvent) => { + if (state.status === "PRESSED") { + const dist = Math.hypot( + event.clientX - state.startCoord.x, + event.clientY - state.startCoord.y, + ); + + if (dist > MOVEMENT_THRESHOLD) { + batch(() => { + setState("status", "DRAGGING"); + setState("currentCoord", { x: event.clientX, y: event.clientY }); + }); + startAutoScroll(); + announce("Dragging"); + } + return; + } + + if (state.status !== "DRAGGING") return; + + event.preventDefault(); + setState("currentCoord", { x: event.clientX, y: event.clientY }); + + const targets = document.elementsFromPoint(event.clientX, event.clientY); + const targetEl = targets.find((el) => el.hasAttribute("data-task-id")) as HTMLElement | undefined; + + if (!targetEl) { + setState("targetId", null); + setState("dropPosition", null); + return; + } + + const targetId = targetEl.getAttribute("data-task-id"); + if (!targetId || targetId === state.draggedId) return; + + const rect = targetEl.getBoundingClientRect(); + const position = calculateDropPosition(event.clientY, rect); + + batch(() => { + setState("targetId", targetId); + setState("dropPosition", position); + }); + }; + + const finishDrag = (event: PointerEvent) => { + if (autoScrollFrame) cancelAnimationFrame(autoScrollFrame); + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", finishDrag); + + if (state.status === "DRAGGING" && state.targetId && state.dropPosition) { + props.onDrop(state.draggedId!, state.targetId, state.dropPosition); + } + + if (state.status === "DRAGGING") { + setState("status", "DROPPING"); + announce("Drop complete"); + window.setTimeout(resetState, 220); + } else { + resetState(); + } + + if (event.target instanceof Element) { + event.target.releasePointerCapture(event.pointerId); + } + }; + + const startDrag = (event: PointerEvent, id: string, el: HTMLElement) => { + event.preventDefault(); + event.stopPropagation(); + el.setPointerCapture(event.pointerId); + + const rect = el.closest("[data-task-id]")?.getBoundingClientRect() ?? el.getBoundingClientRect(); + + batch(() => { + setState({ + status: "PRESSED", + draggedId: id, + targetId: null, + dropPosition: null, + startCoord: { x: event.clientX, y: event.clientY }, + currentCoord: { x: event.clientX, y: event.clientY }, + grabOffset: { x: event.clientX - rect.left, y: event.clientY - rect.top }, + originalRect: rect, + }); + }); + + window.addEventListener("pointermove", handlePointerMove, { passive: false }); + window.addEventListener("pointerup", finishDrag); + }; + + onCleanup(() => { + if (autoScrollFrame) cancelAnimationFrame(autoScrollFrame); + if (announcerTimeout) clearTimeout(announcerTimeout); + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", finishDrag); + }); + + const overlayStyle = () => { + if (!state.originalRect) return {}; + + const common = { + width: `${state.originalRect.width}px`, + "z-index": "9999", + "pointer-events": "none", + transition: "transform 160ms cubic-bezier(0.25, 0.9, 0.3, 1), opacity 160ms ease", + } as const; + + if (state.status === "DROPPING") { + return { + ...common, + transform: `translate3d(${state.originalRect.left}px, ${state.originalRect.top}px, 0) scale(1)`, + opacity: 0.6, + }; + } + + const x = state.currentCoord.x - state.grabOffset.x; + const y = state.currentCoord.y - state.grabOffset.y; + + return { + ...common, + transform: `translate3d(${x}px, ${y}px, 0) scale(${state.status === "PRESSED" ? 0.985 : 1.01})`, + opacity: state.status === "PRESSED" ? 0.75 : 1, + }; + }; + + return ( + + {props.children} + + +
+ + Dragging +
+ }> + {props.renderOverlay ? props.renderOverlay(state.draggedId) : null} +
+ + +
+ + + ); +}; + +export const useDrag = () => { + const ctx = useContext(DragContext); + if (!ctx) throw new Error("useDrag must be used within DragProvider"); + return ctx; +}; diff --git a/src/components/TaskItem.css b/src/components/TaskItem.css index aba5ca8..14686da 100644 --- a/src/components/TaskItem.css +++ b/src/components/TaskItem.css @@ -29,6 +29,8 @@ border-color: color-mix(in srgb, var(--accent, #1a9bb2) 25%, var(--border-subtle border-radius: 12px; color: var(--muted, #5b6475); cursor: grab; + touch-action: none; + user-select: none; } :has(input[type="checkbox"]:checked) { @@ -57,6 +59,14 @@ border-color: color-mix(in srgb, var(--accent, #1a9bb2) 25%, var(--border-subtle } } +.task-item__drag-shadow { + background: var(--surface, #fff); + border-radius: var(--radius-lg, 16px); + padding: 0.75rem 1rem; + box-shadow: 0 24px 60px -32px rgba(15, 23, 42, 0.5); + border: 1px solid var(--border-subtle, #d8e2ec); +} + .task-item__checkbox { width: 1.2rem; height: 1.2rem; diff --git a/src/components/TaskItem.tsx b/src/components/TaskItem.tsx index 58e5dcd..ca02a7d 100644 --- a/src/components/TaskItem.tsx +++ b/src/components/TaskItem.tsx @@ -4,6 +4,7 @@ import GripVertical from "lucide-solid/icons/grip-vertical"; import X from "lucide-solid/icons/x"; import { deleteTask, updateTask } from "~/stores/taskStore"; import type { Task } from "~/stores/taskStore"; +import { useDrag } from "./DragProvider"; import './TaskItem.css'; @@ -12,6 +13,7 @@ type TaskItemProps = Task & { }; export default function TaskItem(props: TaskItemProps) { + const drag = useDrag(); const [isEditing, setIsEditing] = createSignal(false); const [draft, setDraft] = createSignal(props.text); let inputRef: HTMLInputElement | undefined; @@ -42,7 +44,14 @@ export default function TaskItem(props: TaskItemProps) { return (
- + drag.startDrag(event, props.id, event.currentTarget as HTMLElement)} + > + +
.node-children { - grid-template-rows: 1fr; + grid-template-rows: minmax(0, 1fr); margin-top: 12px; } @@ -173,3 +206,35 @@ opacity: 0.85; transform: scale(0.98); } + +.drag-overlay__card { + background: var(--surface, #fff); + border-radius: var(--radius-lg, 16px); + border: 1px solid var(--border-subtle, #d8e2ec); + box-shadow: var(--card-shadow, 0 24px 60px -38px rgba(15, 23, 42, 0.52)); + padding: 0.75rem 1rem; + animation: float-lift 160ms ease both; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +@keyframes float-lift { + from { + opacity: 0.4; + transform: scale(0.98); + } + to { + opacity: 1; + transform: scale(1); + } +} diff --git a/src/components/TasksList.tsx b/src/components/TasksList.tsx index d04395c..1d32a2d 100644 --- a/src/components/TasksList.tsx +++ b/src/components/TasksList.tsx @@ -3,7 +3,8 @@ import { createStore } from "solid-js/store"; import { clientOnly } from "@solidjs/start"; import Minus from "lucide-solid/icons/minus"; import TaskItem from "./TaskItem"; -import { type TreeNode } from "~/stores/taskStore"; +import { DragProvider, useDrag, type DropPosition } from "./DragProvider"; +import { moveTask, tasks as tasksStore, type TreeNode } from "~/stores/taskStore"; import './TasksList.css'; export default clientOnly(async () => ({ default: TasksList }), { lazy: true }); @@ -23,17 +24,100 @@ type ExpansionState = { function TasksList(props: TasksListProps) { const [expandedMap, setExpandedMap] = createStore>({}); + const handleDrop = (draggedId: string, targetId: string, position: Exclude) => { + const tree = tasksStore(); + + const nodeIndex = new Map(); + const parentIndex = new Map(); + + const indexTree = (nodes: TreeNode[], parentId: string | null) => { + nodes.forEach(node => { + nodeIndex.set(node.id, node); + parentIndex.set(node.id, parentId); + indexTree(node.children, node.id); + }); + }; + + indexTree(tree, null); + + const draggedNode = nodeIndex.get(draggedId); + const targetNode = nodeIndex.get(targetId); + + if (!draggedNode || !targetNode || draggedId === targetId) return; + + const isDescendant = (candidateId: string, ancestorId: string) => { + let current: string | null | undefined = candidateId; + while (current) { + const parent = parentIndex.get(current); + if (!parent) return false; + if (parent === ancestorId) return true; + current = parent; + } + return false; + }; + + if (isDescendant(targetId, draggedId)) return; // Prevent dropping into own subtree + + const getSiblings = (parentId: string | null) => { + if (parentId === null) return tree; + const parentNode = nodeIndex.get(parentId); + return parentNode ? parentNode.children : []; + }; + + if (position === "inside") { + const siblings = targetNode.children.filter(child => child.id !== draggedId); + const last = siblings[siblings.length - 1]; + moveTask(draggedId, targetId, last?.rank, undefined); + return; + } + + const newParentId = parentIndex.get(targetId) ?? null; + const siblings = getSiblings(newParentId).filter(child => child.id !== draggedId); + const targetIndex = siblings.findIndex(child => child.id === targetId); + if (targetIndex === -1) return; + + const insertIndex = position === "above" ? targetIndex : targetIndex + 1; + const prev = siblings[insertIndex - 1]; + const next = siblings[insertIndex]; + + moveTask(draggedId, newParentId, prev?.rank, next?.rank); + }; + + const renderOverlay = (id: string | null) => { + if (!id) return null; + + const findNode = (nodes: TreeNode[]): TreeNode | undefined => { + for (const node of nodes) { + if (node.id === id) return node; + const nested = findNode(node.children); + if (nested) return nested; + } + return undefined; + }; + + const node = findNode(tasksStore()); + if (!node) return null; + + return ( +
+ {node.text} +
+ ); + }; + const expansion: ExpansionState = { isExpanded: (id) => expandedMap[id] ?? !!props.defaultExpanded, setExpanded: (id, value) => setExpandedMap(id, value), }; return ( - + + + ); } @@ -67,8 +151,11 @@ type TaskNodeProps = { }; function TaskNode(props: TaskNodeProps) { + const drag = useDrag(); const isExpanded = () => props.expansion.isExpanded(props.node.id); const hasChildren = () => props.node.children.length > 0; + const isDragged = () => drag.state.draggedId === props.node.id; + const isTarget = () => drag.state.targetId === props.node.id; const expand = () => { if (hasChildren()) { @@ -84,7 +171,13 @@ function TaskNode(props: TaskNodeProps) { classList={{ "is-expanded": isExpanded(), "has-children": hasChildren(), + "is-dragged": isDragged(), + "is-drop-target": isTarget(), + "drop-above": isTarget() && drag.state.dropPosition === "above", + "drop-below": isTarget() && drag.state.dropPosition === "below", + "drop-inside": isTarget() && drag.state.dropPosition === "inside", }} + data-task-id={props.node.id} >
From b434004c40bcbcf9d5cd6298b8ccca47acdabfca Mon Sep 17 00:00:00 2001 From: Bart Jaskulski Date: Thu, 22 Jan 2026 14:02:05 +0100 Subject: [PATCH 2/3] improve look Signed-off-by: Bart Jaskulski --- src/components/TaskItem.css | 2 + src/components/TasksList.css | 71 +++++++++++++++++++++++++----------- src/components/TasksList.tsx | 20 +++++----- 3 files changed, 63 insertions(+), 30 deletions(-) diff --git a/src/components/TaskItem.css b/src/components/TaskItem.css index 14686da..63d93bb 100644 --- a/src/components/TaskItem.css +++ b/src/components/TaskItem.css @@ -31,6 +31,8 @@ border-color: color-mix(in srgb, var(--accent, #1a9bb2) 25%, var(--border-subtle cursor: grab; touch-action: none; user-select: none; + display: grid; + place-items: center; } :has(input[type="checkbox"]:checked) { diff --git a/src/components/TasksList.css b/src/components/TasksList.css index dbef6c0..a9528fc 100644 --- a/src/components/TasksList.css +++ b/src/components/TasksList.css @@ -12,6 +12,7 @@ width: 100%; margin: 0; padding: 0; + gap: 0.5rem; } .task-node { @@ -20,6 +21,13 @@ flex-direction: column; gap: 0; transition: transform 200ms ease, opacity 180ms ease; + /* Create local stacking context to prevent shelf overlap with siblings */ + z-index: 1; +} + +/* Ensure expanded node is above siblings during animation */ +.task-node.is-expanded { + z-index: 2; } .task-node.is-dragged .task-item { @@ -42,6 +50,7 @@ height: 2px; background: var(--accent, #1a9bb2); border-radius: 999px; + z-index: 10; } .task-node.is-drop-target.drop-above::before { @@ -54,6 +63,8 @@ .task-node__card { position: relative; + /* Ensure card content is above shelf */ + z-index: 2; } .task-node__card .task-item { @@ -88,7 +99,7 @@ box-shadow: var(--card-shadow, 0 24px 60px -38px rgba(15, 23, 42, 0.52)); transform-origin: top center; transition: - transform 360ms cubic-bezier(0.175, 0.885, 0.32, 1.275), + transform 300ms cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 200ms ease, border-color 160ms ease; @@ -120,34 +131,49 @@ .task-node.is-expanded .shelf-card { opacity: 0; - transform: translateY(0) scale(1); + /* Slide slightly up to "hide" behind the main card as it opens */ + transform: translateY(2px) scale(0.98); + transition: transform 200ms ease, opacity 150ms ease; } -.node-children { - overflow: hidden; - display: grid; - grid-template-rows: minmax(0, 0fr); - transition: grid-template-rows 360ms ease; +.node-children-wrapper { + position: relative; margin-left: 22px; padding-left: 14px; - position: relative; + /* Animate the margin/spacing if needed, but keeping it static is safer for layout stability */ + margin-top: 0; + transition: margin-top 360ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.task-node.is-expanded > .node-children-wrapper { + margin-top: 16px; +} + +.node-children-animator { + /* Grid trick for height animation */ + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 360ms cubic-bezier(0.4, 0, 0.2, 1); + /* Overflow hidden is required for the grid animation to clip content */ + overflow: hidden; } .spine-line { position: absolute; left: 0; - top: 0; - bottom: 0; + top: -12px; /* Connect to parent */ + bottom: 10px; /* Don't go all the way down */ width: 2px; background: var(--border-subtle, #d8e2ec); + border-radius: 2px; opacity: 0; transition: opacity 200ms ease; } .spine-tab { position: absolute; - left: -10px; - top: -12px; + left: -11px; + top: -14px; width: 24px; height: 24px; padding: 0; @@ -161,7 +187,8 @@ opacity: 0; transform: scale(0.8); transition: all 260ms cubic-bezier(0.175, 0.885, 0.32, 1.275); - z-index: 3; + z-index: 5; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); } .spine-tab:hover { @@ -172,31 +199,32 @@ .node-children-inner { min-height: 0; - overflow: hidden; + /* Removed overflow: hidden to prevent clipping of nested shadows/items */ opacity: 0; - transform: translateY(-8px); + transform: translateY(-10px); transition: opacity 200ms ease, transform 200ms ease; display: flex; flex-direction: column; gap: 0.8rem; pointer-events: none; + padding-top: 0.25rem; + padding-bottom: 0.5rem; /* Ensure shadows aren't clipped by parent */ } -.task-node.is-expanded > .node-children { - grid-template-rows: minmax(0, 1fr); - margin-top: 12px; +.task-node.is-expanded .node-children-animator { + grid-template-rows: 1fr; } -.task-node.is-expanded > .node-children .spine-line { +.task-node.is-expanded .spine-line { opacity: 1; } -.task-node.is-expanded > .node-children .spine-tab { +.task-node.is-expanded .spine-tab { opacity: 1; transform: scale(1); } -.task-node.is-expanded > .node-children > .node-children-inner { +.task-node.is-expanded .node-children-inner { opacity: 1; transform: translateY(0); pointer-events: auto; @@ -214,6 +242,7 @@ box-shadow: var(--card-shadow, 0 24px 60px -38px rgba(15, 23, 42, 0.52)); padding: 0.75rem 1rem; animation: float-lift 160ms ease both; + z-index: 100; } .sr-only { diff --git a/src/components/TasksList.tsx b/src/components/TasksList.tsx index 1d32a2d..963c3f2 100644 --- a/src/components/TasksList.tsx +++ b/src/components/TasksList.tsx @@ -197,7 +197,7 @@ function TaskNode(props: TaskNodeProps) {
-
+