Offset the dragged copy below right of the pointer (Feature 14)
CI / ci (push) Successful in 3m30s
CI / ci (pull_request) Successful in 3m20s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 15:27:49 -04:00
co-authored by Claude Opus 5.5
parent 3d6c1109c8
commit 621fdef42b
4 changed files with 53 additions and 13 deletions
+25 -3
View File
@@ -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()
+3 -2
View File
@@ -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;
+1 -1
View File
@@ -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,
+24 -7
View File
@@ -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))
}