From 621fdef42b19866d638b79f1f264b054f7607004 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 15:27:49 -0400 Subject: [PATCH] Offset the dragged copy below right of the pointer (Feature 14) The floating copy sat where it was grabbed, over the hovered day's "Drop to swap / Replaces ..." text. It now floats 48 px below and to the right of the pointer, flipping left or up where it would leave the viewport, so the day's current dinner stays readable during a drag. Co-Authored-By: Claude Opus 5.5 --- web/src/plan/PlanPage.test.tsx | 28 +++++++++++++++++++++++++--- web/src/plan/PlanPanel.module.css | 5 +++-- web/src/plan/PlanPanel.tsx | 2 +- web/src/plan/drag.tsx | 31 ++++++++++++++++++++++++------- 4 files changed, 53 insertions(+), 13 deletions(-) diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 1205ef9..329dcc2 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -1035,13 +1035,14 @@ describe.each([ const float = () => screen.queryByTestId('drag-float') // press, move and release drive pointer 3 on the draggable: down at - // (100, 100), moved over an element (or none), and let go there. + // (100, 100), moved over an element (or none) at (160, 140) or the given + // point, and let go there. function press(draggable: Element) { fireEvent.pointerDown(draggable, { pointerId: 3, isPrimary: true, button: 0, clientX: 100, clientY: 100 }) } - function move(draggable: Element, over: Element | null) { + function move(draggable: Element, over: Element | null, [clientX, clientY] = [160, 140]) { hovered = over - fireEvent.pointerMove(draggable, { pointerId: 3, isPrimary: true, clientX: 160, clientY: 140 }) + fireEvent.pointerMove(draggable, { pointerId: 3, isPrimary: true, clientX, clientY }) } function release(draggable: Element) { fireEvent.pointerUp(draggable, { pointerId: 3, isPrimary: true, button: 0, clientX: 160, clientY: 140 }) @@ -1079,6 +1080,10 @@ describe.each([ expect(declaredStyle(copy).boxShadow).toBe('var(--shadow-lg)') expect(declaredStyle(copy).transform).toBe('rotate(-3deg)') expect(declaredStyle(copy).pointerEvents).toBe('none') + // Offset 48 px below right of the pointer at (160, 140), clear of the + // day's text under it. + expect(copy.style.left).toBe('208px') + expect(copy.style.top).toBe('188px') expect(announcer()).toHaveTextContent('Picked up Lemon orzo.') const target = within(column('Friday')).getByText('Drop to swap').parentElement! expect(target).toHaveTextContent('Drop to swapReplaces Pizza night') @@ -1110,6 +1115,23 @@ describe.each([ expect(puts(fetchMock)).toHaveLength(1) }) + it('flips the floating copy to the left of and above the pointer where it would leave the viewport', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists }) + await renderPage() + await screen.findByText('Pizza night') + const orzo = await quest('Lemon orzo') + vi.spyOn(orzo, 'getBoundingClientRect').mockReturnValue(DOMRect.fromRect({ x: 90, y: 90, width: 240, height: 60 })) + + press(orzo) + const [x, y] = [window.innerWidth - 100, window.innerHeight - 40] + move(orzo, document.body, [x, y]) + const copy = float()! + expect(copy.style.width).toBe('240px') + expect(copy.style.left).toBe(`${x - 48 - 240}px`) + expect(copy.style.top).toBe(`${y - 48 - 60}px`) + release(orzo) + }) + it('cancels a drag let go over no day, or cancelled, with no swap', async () => { const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists }) await renderPage() diff --git a/web/src/plan/PlanPanel.module.css b/web/src/plan/PlanPanel.module.css index 5693571..99eea60 100644 --- a/web/src/plan/PlanPanel.module.css +++ b/web/src/plan/PlanPanel.module.css @@ -208,8 +208,9 @@ opacity: 0.55; } -/* The dragged copy floating under the pointer: lifted with the large - shadow and tilted -3deg. It lets the pointer through to the day under it. */ +/* The dragged copy floating below right of the pointer: lifted with the + large shadow and tilted -3deg. It lets the pointer through to the day + under it. */ .floating { position: fixed; z-index: 10; diff --git a/web/src/plan/PlanPanel.tsx b/web/src/plan/PlanPanel.tsx index a2a4760..8bf035d 100644 --- a/web/src/plan/PlanPanel.tsx +++ b/web/src/plan/PlanPanel.tsx @@ -60,7 +60,7 @@ function Grip() { // Draggable is a dinner of the panel or the strip, dragged onto a day to // swap it in: a button whose content is the row or the chip. While it is // dragged it is a dashed ghost, and with a pointer a copy of it floats -// under the pointer, lifted and tilted. +// below right of the pointer, lifted and tilted. function Draggable({ dinner, fromQuestLog, diff --git a/web/src/plan/drag.tsx b/web/src/plan/drag.tsx index deee358..724da6e 100644 --- a/web/src/plan/drag.tsx +++ b/web/src/plan/drag.tsx @@ -195,14 +195,32 @@ function dateAt(x: number, y: number): string | null { // not one. const DRAG_THRESHOLD = 4 -// Press is a pointer held on a draggable: its id, where it went down, how -// far that was from the draggable's top left, and whether it has become a -// drag. -type Press = { pointerId: number; x: number; y: number; dx: number; dy: number; width: number; dragging: boolean } +// The dragged copy floats this far below and to the right of the pointer, +// as a drag image does, so it stays clear of the "Drop to swap" text +// centred in the day the pointer is over. Where it would leave the viewport it flips to the +// left of the pointer, or above it. +const FLOAT_OFFSET = 48 + +// Press is a pointer held on a draggable: its id, where it went down, the +// draggable's size, and whether it has become a drag. +type Press = { pointerId: number; x: number; y: number; width: number; height: number; dragging: boolean } // Float is where the dragged copy of a draggable is drawn, and how wide. export type Float = { left: number; top: number; width: number } +// floatAt is where the dragged copy of the held draggable is drawn for the +// pointer at (x, y): offset below right of it, flipped where that would +// leave the viewport. +function floatAt(held: Press, x: number, y: number): Float { + const right = x + FLOAT_OFFSET + const below = y + FLOAT_OFFSET + return { + left: right + held.width > window.innerWidth ? x - FLOAT_OFFSET - held.width : right, + top: below + held.height > window.innerHeight ? y - FLOAT_OFFSET - held.height : below, + width: held.width, + } +} + // useDraggable makes a panel row or a strip chip a draggable dinner. With a // pointer it is pressed, captured and moved past DRAG_THRESHOLD to pick it // up, its copy floats with the pointer, and letting go over a day drops it @@ -243,9 +261,8 @@ export function useDraggable(pickup: Pickup) { pointerId: event.pointerId, x: event.clientX, y: event.clientY, - dx: event.clientX - rect.left, - dy: event.clientY - rect.top, width: rect.width, + height: rect.height, dragging: false, } } @@ -261,7 +278,7 @@ export function useDraggable(pickup: Pickup) { } held.dragging = true } - setFloat({ left: event.clientX - held.dx, top: event.clientY - held.dy, width: held.width }) + setFloat(floatAt(held, event.clientX, event.clientY)) plan.over(dateAt(event.clientX, event.clientY)) }