From 3d6c1109c812fa468e2a2ea39ac39da591ba1b60 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 15:03:57 -0400 Subject: [PATCH 1/3] Drag dinners onto days, by pointer or keyboard (Feature 14) From tablet up, a Quest-log or Regular dinner is dragged from desktop's right panel or tablet's Quest-log strip onto a day of the week to swap it in. Pointer events with capture: the source becomes a dashed ghost at 55% opacity, a copy floats with shadow-lg at -3deg, and the day under the pointer shows a dashed 2.5 px grape border on grape-100 with "Drop to swap / Replaces {dinner}". Letting go over a day calls useSwapDay's mutate, so the swap is optimistic and rolls back with the existing error; anywhere else, a pointer cancel or Escape cancels with no request. Keyboard alternative: Enter or Space picks a row up onto Monday, the arrow keys choose the day, Enter drops (moving focus to that day's swap button), Escape or leaving the row cancels. Each step is announced in a polite live region. No drag starts while a swap of the week is pending, nor for a dinner already this week, matching the swap sheet. Mobile is unchanged. Task: 5c2842a8-5ac1-4141-9575-c0f461d90c3e Co-Authored-By: Claude Opus 5.5 --- web/src/plan/PlanPage.module.css | 35 +++ web/src/plan/PlanPage.test.tsx | 340 +++++++++++++++++++++++++++++- web/src/plan/PlanPage.tsx | 41 +++- web/src/plan/PlanPanel.module.css | 51 +++++ web/src/plan/PlanPanel.tsx | 90 +++++++- web/src/plan/SwapSheet.tsx | 19 +- web/src/plan/drag.module.css | 12 ++ web/src/plan/drag.tsx | 336 +++++++++++++++++++++++++++++ 8 files changed, 891 insertions(+), 33 deletions(-) create mode 100644 web/src/plan/drag.module.css create mode 100644 web/src/plan/drag.tsx diff --git a/web/src/plan/PlanPage.module.css b/web/src/plan/PlanPage.module.css index f75cf9d..10af1ef 100644 --- a/web/src/plan/PlanPage.module.css +++ b/web/src/plan/PlanPage.module.css @@ -464,6 +464,7 @@ } .card { + position: relative; box-sizing: border-box; flex: 1; display: flex; @@ -531,6 +532,40 @@ gap: 4px; } +/* The day a dragged dinner drops on: a dashed 2.5 px grape border on + grape-100 over the card, saying which dinner it replaces. */ +.dropTarget { + position: absolute; + inset: 0; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 4px; + padding: 12px; + border: 2.5px dashed var(--color-accent); + border-radius: inherit; + background: var(--color-accent-100); + text-align: center; +} + +.dropTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 16px; + color: var(--color-accent-700); +} + +.dropReplaces { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); + overflow-wrap: break-word; +} + /* The swap icon sits bare on the card, beside the tag: its 44 px hit box keeps the 16 px footprint of its icon, reaching over the card's padding and the tag. */ diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index fe23d57..1205ef9 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -810,8 +810,8 @@ describe.each([ const orzo = within(within(panel).getByRole('list', { name: 'Quest log' })).getByRole('listitem') expect(within(orzo).getByText('Lemon orzo').nextElementSibling).toHaveTextContent('Easy') expect(declaredStyle(within(orzo).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('48px') - // The 6-dot grip. - expect(orzo.lastElementChild!.children).toHaveLength(6) + // The 6-dot grip, last in the row's button. + expect(within(orzo).getByRole('button').lastElementChild!.children).toHaveLength(6) fireEvent.click(within(panel).getByRole('radio', { name: 'Regulars · 2' })) const regulars = within(within(panel).getByRole('list', { name: 'Regulars' })).getAllByRole('listitem') @@ -974,6 +974,342 @@ describe.each([ }) }) +// Dragging a dinner from desktop's panel or tablet's strip onto a day swaps +// it in, by pointer or keyboard. +describe.each([ + { width: 1180, layout: 'tablet' }, + { width: 1440, layout: 'desktop' }, +])('PlanPage drag and drop at $width px', ({ width, layout }) => { + const FRIDAY = `${THIS_WEEK}/days/2026-10-09` + let capture: Mock<(pointerId: number) => void> + // The element document.elementFromPoint finds under the pointer. + let hovered: Element | null = null + + // jsdom has no pointer capture and no layout to hit-test. + beforeEach(() => { + mockViewport(width) + capture = vi.fn() + Element.prototype.setPointerCapture = capture + hovered = null + document.elementFromPoint = () => hovered + }) + + afterEach(() => { + Reflect.deleteProperty(Element.prototype, 'setPointerCapture') + Reflect.deleteProperty(document, 'elementFromPoint') + }) + + // The Quest log has lemon orzo and miso salmon. + const dragLists = { + ...lists, + [`GET ${QUEST_LOG}`]: () => + jsonResponse(200, { dinners: [dinner('q1', 'Lemon orzo', 'easy', 25), dinner('q2', 'Miso salmon', 'medium', 30)] }), + } + + // swappedBody is the plan as the server answers a swap of Friday to lemon + // orzo, a Quest-log pick. + function swappedBody() { + const body = planBody('draft') + body.days[4] = { ...body.days[4]!, dinner: { ...body.days[4]!.dinner, id: 'q1', name: 'Lemon orzo' }, is_new: true } + return body + } + + function puts(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([, init]) => init?.method === 'PUT') + .map(([input, init]) => `${String(input)} ${String(init?.body)}`) + } + + function column(day: string) { + return within(screen.getByRole('list', { name: 'Dinners this week' })).getByRole('listitem', { name: day }) + } + + // quest is the Quest-log dinner's draggable: a row of the panel on + // desktop, a chip of the strip on tablet. + async function quest(name: string) { + const list = await screen.findByRole('list', { name: layout === 'desktop' ? 'Quest log' : 'Quest log dinners' }) + return within(list).getByRole('button', { name: new RegExp(name) }) + } + + const announcer = () => screen.getByTestId('drag-announcer') + 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. + function press(draggable: Element) { + fireEvent.pointerDown(draggable, { pointerId: 3, isPrimary: true, button: 0, clientX: 100, clientY: 100 }) + } + function move(draggable: Element, over: Element | null) { + hovered = over + fireEvent.pointerMove(draggable, { pointerId: 3, isPrimary: true, clientX: 160, clientY: 140 }) + } + function release(draggable: Element) { + fireEvent.pointerUp(draggable, { pointerId: 3, isPrimary: true, button: 0, clientX: 160, clientY: 140 }) + } + + it('drops a dinner dragged onto a day, swapping it in at once and landing the plan the server answers', async () => { + let answer!: (response: Response) => void + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...dragLists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (answer = resolve)), + }) + await renderPage() + await screen.findByText('Pizza night') + const orzo = await quest('Lemon orzo') + expect(declaredStyle(orzo).getPropertyValue('touch-action')).toBe('none') + expect(orzo).toHaveAccessibleDescription( + 'Press Enter or Space to pick it up, the arrow keys to choose a day, Enter to drop it on that day and Escape to cancel.', + ) + + press(orzo) + expect(capture).toHaveBeenCalledExactlyOnceWith(3) + expect(capture.mock.contexts[0]).toBe(orzo) + // A press alone is no drag. + expect(float()).not.toBeInTheDocument() + + // Over Friday: the source a dashed ghost at 55%, its copy floating with + // the large shadow at -3deg, and Friday the drop target. + move(orzo, within(column('Friday')).getByText('Pizza night')) + expect(declaredStyle(orzo).opacity).toBe('0.55') + expect(declaredStyle(orzo).borderStyle).toBe('dashed') + const copy = float()! + expect(copy).toHaveTextContent('Lemon orzo') + expect(copy).toHaveAttribute('aria-hidden', 'true') + expect(declaredStyle(copy).boxShadow).toBe('var(--shadow-lg)') + expect(declaredStyle(copy).transform).toBe('rotate(-3deg)') + expect(declaredStyle(copy).pointerEvents).toBe('none') + 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') + expect(declaredStyle(target).border).toBe('2.5px dashed var(--color-accent)') + expect(declaredStyle(target).background).toBe('var(--color-accent-100)') + expect(within(column('Thursday')).queryByText('Drop to swap')).not.toBeInTheDocument() + + // Moving on to Thursday moves the target. + move(orzo, column('Thursday')) + expect(within(column('Friday')).queryByText('Drop to swap')).not.toBeInTheDocument() + expect(within(column('Thursday')).getByText('Replaces Chicken stir-fry')).toBeInTheDocument() + + move(orzo, within(column('Friday')).getByRole('button', { name: 'Swap Friday' })) + release(orzo) + expect(float()).not.toBeInTheDocument() + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + expect(declaredStyle(orzo).opacity).toBe('') + expect(announcer()).toHaveTextContent('Dropped Lemon orzo on Friday, replacing Pizza night.') + + // Optimistic: Friday is the Side quest at once. + const friday = await waitFor(() => within(column('Friday')).getByText('Lemon orzo').parentElement!) + expect(within(friday).getByText('Side quest')).toBeInTheDocument() + await waitFor(() => expect(puts(fetchMock)).toEqual([`${FRIDAY} {"dinner_id":"q1"}`])) + + await act(async () => answer(jsonResponse(200, swappedBody()))) + expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument() + expect(screen.queryByText('Pizza night')).not.toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(puts(fetchMock)).toHaveLength(1) + }) + + 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() + await screen.findByText('Pizza night') + const orzo = await quest('Lemon orzo') + + press(orzo) + move(orzo, column('Friday')) + expect(within(column('Friday')).getByText('Drop to swap')).toBeInTheDocument() + // Off the grid, onto the page around it. + move(orzo, document.body) + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + expect(float()).toBeInTheDocument() + release(orzo) + expect(float()).not.toBeInTheDocument() + expect(declaredStyle(orzo).opacity).toBe('') + expect(announcer()).toHaveTextContent('Cancelled. Lemon orzo was not swapped in.') + + // A cancelled pointer, over a day, is no drop either. + press(orzo) + move(orzo, column('Monday')) + expect(within(column('Monday')).getByText('Replaces Beef tacos')).toBeInTheDocument() + fireEvent.pointerCancel(orzo, { pointerId: 3, isPrimary: true }) + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + expect(float()).not.toBeInTheDocument() + + // Nor is a drag Escape ends. + press(orzo) + move(orzo, column('Monday')) + fireEvent.keyDown(orzo, { key: 'Escape' }) + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + release(orzo) + + expect(within(column('Friday')).getByText('Pizza night')).toBeInTheDocument() + expect(within(column('Monday')).getByText('Beef tacos')).toBeInTheDocument() + expect(puts(fetchMock)).toEqual([]) + }) + + it('puts the day back and shows the failure when the dropped swap is refused', async () => { + let refuse!: () => void + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...dragLists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (refuse = () => resolve(errorResponse(409, 'dinner_not_plannable')))), + }) + await renderPage() + await screen.findByText('Pizza night') + const orzo = await quest('Lemon orzo') + + press(orzo) + move(orzo, column('Friday')) + release(orzo) + await waitFor(() => expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument()) + await waitFor(() => expect(puts(fetchMock)).toHaveLength(1)) + + // While the swap is pending no other drag starts, by pointer or keyboard. + const salmon = await quest('Miso salmon') + expect(salmon).toHaveAttribute('aria-disabled', 'true') + press(salmon) + move(salmon, column('Monday')) + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + expect(float()).not.toBeInTheDocument() + release(salmon) + fireEvent.keyDown(salmon, { key: 'Enter' }) + expect(announcer()).toHaveTextContent('A swap is still being saved. Pick Miso salmon up once it is done.') + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + + await act(async () => refuse()) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message") + expect(within(column('Friday')).getByText('Pizza night')).toBeInTheDocument() + expect(within(column('Friday')).queryByText('Lemon orzo')).not.toBeInTheDocument() + expect(puts(fetchMock)).toHaveLength(1) + await waitFor(() => expect(salmon).not.toHaveAttribute('aria-disabled')) + }) + + it('swaps a dinner in with the keyboard, announcing each step', async () => { + let answer!: (response: Response) => void + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...dragLists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (answer = resolve)), + }) + await renderPage() + await screen.findByText('Pizza night') + const orzo = await quest('Lemon orzo') + expect(announcer()).toHaveAttribute('aria-live', 'polite') + + // Enter picks it up onto Monday. + orzo.focus() + fireEvent.keyDown(orzo, { key: 'Enter' }) + expect(announcer()).toHaveTextContent( + 'Picked up Lemon orzo. Monday: replaces Beef tacos. Use the arrow keys to choose a day, Enter to drop, Escape to cancel.', + ) + expect(declaredStyle(orzo).opacity).toBe('0.55') + expect(within(column('Monday')).getByText('Replaces Beef tacos')).toBeInTheDocument() + // No pointer, so no floating copy. + expect(float()).not.toBeInTheDocument() + + // The arrow keys choose the day, going no further than Monday. + fireEvent.keyDown(orzo, { key: 'ArrowLeft' }) + expect(within(column('Monday')).getByText('Drop to swap')).toBeInTheDocument() + fireEvent.keyDown(orzo, { key: 'ArrowRight' }) + expect(announcer()).toHaveTextContent('Tuesday: replaces Spaghetti bolognese.') + expect(within(column('Monday')).queryByText('Drop to swap')).not.toBeInTheDocument() + fireEvent.keyDown(orzo, { key: 'ArrowDown' }) + fireEvent.keyDown(orzo, { key: 'ArrowRight' }) + fireEvent.keyDown(orzo, { key: 'ArrowRight' }) + fireEvent.keyDown(orzo, { key: 'ArrowRight' }) + fireEvent.keyDown(orzo, { key: 'ArrowUp' }) + expect(announcer()).toHaveTextContent('Friday: replaces Pizza night.') + expect(within(column('Friday')).getByText('Replaces Pizza night')).toBeInTheDocument() + expect(puts(fetchMock)).toEqual([]) + + // Enter drops it, swapping it in at once; focus moves to the day. + fireEvent.keyDown(orzo, { key: 'Enter' }) + expect(announcer()).toHaveTextContent('Dropped Lemon orzo on Friday, replacing Pizza night.') + expect(screen.getByRole('button', { name: 'Swap Friday' })).toHaveFocus() + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + await waitFor(() => expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument()) + await waitFor(() => expect(puts(fetchMock)).toEqual([`${FRIDAY} {"dinner_id":"q1"}`])) + await act(async () => answer(jsonResponse(200, swappedBody()))) + expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument() + expect(puts(fetchMock)).toHaveLength(1) + }) + + it('cancels a keyboard drag with Escape, or by leaving it', async () => { + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists }) + await renderPage() + await screen.findByText('Pizza night') + const orzo = await quest('Lemon orzo') + + // Space picks it up too, but does not drop it. + orzo.focus() + fireEvent.keyDown(orzo, { key: ' ' }) + fireEvent.keyDown(orzo, { key: 'ArrowRight' }) + fireEvent.keyDown(orzo, { key: ' ' }) + expect(within(column('Tuesday')).getByText('Drop to swap')).toBeInTheDocument() + fireEvent.keyDown(orzo, { key: 'Escape' }) + expect(announcer()).toHaveTextContent('Cancelled. Lemon orzo was not swapped in.') + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + expect(declaredStyle(orzo).opacity).toBe('') + expect(orzo).toHaveFocus() + + // The arrow keys do nothing until it is picked up again; Enter does. + fireEvent.keyDown(orzo, { key: 'ArrowRight' }) + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + fireEvent.keyDown(orzo, { key: 'Enter' }) + expect(within(column('Monday')).getByText('Drop to swap')).toBeInTheDocument() + // Tabbing away cancels. + fireEvent.blur(orzo) + expect(announcer()).toHaveTextContent('Cancelled. Lemon orzo was not swapped in.') + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + + expect(within(column('Monday')).getByText('Beef tacos')).toBeInTheDocument() + expect(puts(fetchMock)).toEqual([]) + }) + + if (layout === 'desktop') { + it('does not pick up a Regular already this week', async () => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...dragLists, + [`PUT ${THIS_WEEK}/days/2026-10-07`]: () => new Promise(() => undefined), + }) + await renderPage() + await screen.findByText('Pizza night') + fireEvent.click(await screen.findByRole('radio', { name: 'Regulars · 2' })) + const regulars = screen.getByRole('list', { name: 'Regulars' }) + const tacos = within(regulars).getByRole('button', { name: /Beef tacos/ }) + expect(tacos).toHaveAttribute('aria-disabled', 'true') + fireEvent.keyDown(tacos, { key: 'Enter' }) + expect(announcer()).toHaveTextContent('Beef tacos is already this week.') + expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() + + // A Regular not this week is dragged in as one, not NEW. + const fish = within(regulars).getByRole('button', { name: /Fish tacos/ }) + expect(fish).not.toHaveAttribute('aria-disabled') + press(fish) + move(fish, column('Wednesday')) + release(fish) + await waitFor(() => expect(within(column('Wednesday')).getByText('Fish tacos').parentElement).toHaveTextContent('Regular')) + await waitFor(() => expect(puts(fetchMock)).toEqual([`${THIS_WEEK}/days/2026-10-07 {"dinner_id":"r1"}`])) + }) + } +}) + +// Mobile's Draft board has no drag: its tiles open the swap sheet. +describe('PlanPage drag and drop on mobile', () => { + it('has no draggables, drop targets or drag announcements', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) + const { container } = await renderPage() + + await screen.findByText('Pizza night') + expect(container.querySelector('[data-drop-date]')).toBeNull() + expect(screen.queryByTestId('drag-announcer')).not.toBeInTheDocument() + expect(screen.queryByText('Drag a dinner onto a day to swap it in.')).not.toBeInTheDocument() + }) +}) + // At tablet's narrow end seven usable columns do not fit beside the rail: // the week keeps its columns usable and scrolls sideways. describe('PlanPage at 768 px', () => { diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index f543439..884ca54 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -28,6 +28,7 @@ import { type PlanStatus, type Swap, } from './api.ts' +import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx' import { PlanPanel, QuestLogStrip } from './PlanPanel.tsx' import { SwapSheet } from './SwapSheet.tsx' import { @@ -138,13 +139,16 @@ function DayTile({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: // over a card with the dinner's washed photo, name, method and time, a // crock-pot dinner's start-by time in the household's zone, and its Regular // or Side quest tag, a Side quest's card in teal. The swap icon opens the swap -// sheet; while that is open the card is swapping, ringed in grape. +// sheet; while that is open the card is swapping, ringed in grape. A dinner +// dragged over the day drops on it: the card is swapping, covered by a +// dashed grape "Drop to swap" saying which dinner it replaces. function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) { const { dinner } = day const time = cooking(dinner) - const classes = [styles.card, day.is_new && styles.sideQuestCard, swapping && styles.swapping].filter(Boolean).join(' ') + const target = useDropTarget(day.date) !== null + const classes = [styles.card, day.is_new && styles.sideQuestCard, (swapping || target) && styles.swapping].filter(Boolean).join(' ') return ( -
  • +
  • +

    Drop to swap

    +

    Replaces {dinner.name}

    +
    + )}
  • ) @@ -316,6 +326,12 @@ function WeekBoard({ setSwappingDate(null) swap.mutate(chosen) } + // dropIn swaps in a dinner dragged onto a day. A drag is not picked up, + // nor dropped, while a swap of the week is pending. + function dropIn(chosen: Swap) { + swap.dismiss() + swapIn(chosen) + } // openSwap opens the swap sheet on a day. One swap of the week is sent at // a time, even across leaving the week and coming back: the plan the // server answers it with must not overwrite another still pending. @@ -359,6 +375,7 @@ function WeekBoard({ body = layout !== 'mobile' ? ( <> +
      {days.map((day) => ( } return ( -
      -
      - {content} - {layout === 'tablet' && } + +
      +
      + {content} + {layout === 'tablet' && } +
      + {layout === 'desktop' && } + {layout !== 'mobile' && }
      - {layout === 'desktop' && } -
      + ) } diff --git a/web/src/plan/PlanPanel.module.css b/web/src/plan/PlanPanel.module.css index 9243782..5693571 100644 --- a/web/src/plan/PlanPanel.module.css +++ b/web/src/plan/PlanPanel.module.css @@ -25,12 +25,15 @@ } .row { + box-sizing: border-box; + width: 100%; display: flex; align-items: center; gap: 12px; padding: 10px; border-radius: 22px; background: var(--color-surface); + text-align: left; } /* The 48 px washed photo; Photo applies the washed filter. The placeholder's @@ -169,3 +172,51 @@ height: 28px; font-size: 0; } + +/* A row or a chip is a button dragged onto a day: by a pointer (no + scrolling or text selection under it) or with the keyboard. Its 2 px + border is clear until it is the ghost, inside its padding. */ +.draggable { + margin: 0; + border: 2px solid transparent; + font: inherit; + color: var(--color-text); + cursor: grab; + touch-action: none; + user-select: none; +} + +.row.draggable { + padding: 8px; +} + +.chip.draggable { + padding: 3px 12px 3px 3px; +} + +/* A dinner that cannot be picked up now: no grid to drop on, a swap + pending, or already this week. */ +.draggable[aria-disabled='true'] { + cursor: default; +} + +/* The row or chip being dragged: a dashed ghost at 55% opacity. */ +.ghost { + border-style: dashed; + border-color: var(--color-neutral-500); + background: transparent; + 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. */ +.floating { + position: fixed; + z-index: 10; + box-sizing: border-box; + margin: 0; + box-shadow: var(--shadow-lg); + transform: rotate(-3deg); + pointer-events: none; + cursor: grabbing; +} diff --git a/web/src/plan/PlanPanel.tsx b/web/src/plan/PlanPanel.tsx index 0386d4c..a2a4760 100644 --- a/web/src/plan/PlanPanel.tsx +++ b/web/src/plan/PlanPanel.tsx @@ -1,5 +1,6 @@ import { Compass } from 'lucide-react' -import { useState, type ReactNode } from 'react' +import { useId, useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' import { Link } from 'react-router' import { useRegulars } from '../dinners/api.ts' import { useQuestLog, type QuestLogDinner } from '../discover/api.ts' @@ -9,14 +10,16 @@ import { Icon } from '../ui/Icon.tsx' import { Photo } from '../ui/Photo.tsx' import { Segmented } from '../ui/Segmented.tsx' import { Skeleton } from '../ui/Skeleton.tsx' -import { emptyLists, sourceNames, type Source } from './SwapSheet.tsx' +import { useDraggable, type Float } from './drag.tsx' +import { emptyLists, planDinner, sourceNames, type Source } from './SwapSheet.tsx' import styles from './PlanPanel.module.css' // DRAG_HINT says how a dinner is swapped in from the panel or the strip. -// TODO(5c2842a8): drag-and-drop and its keyboard alternative; until then the -// rows and chips are not draggable. const DRAG_HINT = 'Drag a dinner onto a day to swap it in.' +// KEYS_HINT says how a dinner is swapped in with the keyboard. +const KEYS_HINT = 'Press Enter or Space to pick it up, the arrow keys to choose a day, Enter to drop it on that day and Escape to cancel.' + type List = ReturnType // ListBody is a list of the panel or the strip once it has loaded, or why it @@ -54,6 +57,64 @@ 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. +function Draggable({ + dinner, + fromQuestLog, + className, + keysHintId, + children, +}: { + dinner: QuestLogDinner + fromQuestLog: boolean + className: string | undefined + keysHintId: string + children: ReactNode +}) { + const { drag, float, props } = useDraggable({ dinner: planDinner(dinner), fromQuestLog }) + return ( +
    • + + {float !== null && {children}} +
    • + ) +} + +// Floating is the dragged copy of a row or a chip, drawn over the page. +function Floating({ float, className, children }: { float: Float; className: string | undefined; children: ReactNode }) { + return createPortal( + , + document.body, + ) +} + +// KeysHint is the keyboard's way to swap a dinner in, describing each +// draggable. +function KeysHint({ id }: { id: string }) { + return ( + + ) +} + // PlanPanel is desktop's right panel (mock 2d): the Quest log and the // Regulars, segmented "Quest log · N | Regulars · N", each dinner with its // 48 px photo, name, difficulty and grip, and a link to swipe more Side @@ -63,6 +124,7 @@ export function PlanPanel() { const regulars = useRegulars() const [source, setSource] = useState('quest_log') const lists = { quest_log: questLog, regulars } + const keysHintId = useId() const label = (s: Source) => { const dinners = lists[s].data @@ -81,11 +143,18 @@ export function PlanPanel() { onValueChange={setSource} />

      {DRAG_HINT}

      + {(dinners) => (
        {dinners.map((dinner) => ( -
      • + {/* The row's name already says what the photo shows. */} -
      • + ))}
      )} @@ -109,9 +178,11 @@ export function PlanPanel() { } // QuestLogStrip is tablet's Quest log under the week's grid (mock 2c): each -// dinner a chip with its 28 px photo and name. +// dinner a chip with its 28 px photo and name, dragged as the panel's rows +// are. export function QuestLogStrip() { const questLog = useQuestLog() + const keysHintId = useId() const title = questLog.data === undefined ? sourceNames.quest_log : `${sourceNames.quest_log} · ${questLog.data.length}` return (
      @@ -119,16 +190,17 @@ export function QuestLogStrip() {

      {title}

      {DRAG_HINT}

      + {(dinners) => (
        {dinners.map((dinner) => ( -
      • + {dinner.name} -
      • + ))}
      )} diff --git a/web/src/plan/SwapSheet.tsx b/web/src/plan/SwapSheet.tsx index 0f12d09..ef4b0b2 100644 --- a/web/src/plan/SwapSheet.tsx +++ b/web/src/plan/SwapSheet.tsx @@ -9,7 +9,7 @@ import { Segmented } from '../ui/Segmented.tsx' import { Sheet } from '../ui/Sheet.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { Tag } from '../ui/Tag.tsx' -import type { PlanDay, Swap } from './api.ts' +import type { PlanDay, PlanDinner, Swap } from './api.ts' import { weekdayName } from './week.ts' import styles from './SwapSheet.module.css' @@ -23,6 +23,11 @@ export const emptyLists: Record = { regulars: 'You have no Regulars yet.', } +// planDinner is a listed dinner as it is planned on a day. +export function planDinner(dinner: QuestLogDinner): PlanDinner { + return { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total, method: dinner.method } +} + export type SwapSheetProps = { // The day being swapped. day: PlanDay @@ -88,17 +93,7 @@ export function SwapSheet({ day, planned, onSwap, onClose }: SwapSheetProps) { already ? undefined : () => - onSwap({ - date: day.date, - dinner: { - id: dinner.id, - name: dinner.name, - photo_id: dinner.photo_id, - minutes: dinner.minutes_total, - method: dinner.method, - }, - fromQuestLog: source === 'quest_log', - }) + onSwap({ date: day.date, dinner: planDinner(dinner), fromQuestLog: source === 'quest_log' }) } trailing={already && Already this week} /> diff --git a/web/src/plan/drag.module.css b/web/src/plan/drag.module.css new file mode 100644 index 0000000..1c33497 --- /dev/null +++ b/web/src/plan/drag.module.css @@ -0,0 +1,12 @@ +/* The drag's live region: read out, not shown. */ +.announcer { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} diff --git a/web/src/plan/drag.tsx b/web/src/plan/drag.tsx new file mode 100644 index 0000000..deee358 --- /dev/null +++ b/web/src/plan/drag.tsx @@ -0,0 +1,336 @@ +import { + createContext, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type PointerEvent, + type ReactNode, +} from 'react' +import type { PlanDay, PlanDinner, Swap } from './api.ts' +import { weekdayName } from './week.ts' +import styles from './drag.module.css' + +// Pickup is a dinner picked up from the plan panel or the Quest-log strip, +// and whether it is a Quest-log dinner. +export type Pickup = { dinner: PlanDinner; fromQuestLog: boolean } + +// Board is the week's grid as a drag drops onto it: its days, whether a +// swap of the week is pending, and how a dinner dropped on a day is swapped +// in. +export type Board = { days: PlanDay[]; pending: boolean; drop: (swap: Swap) => void } + +// Drag is the dinner being dragged, by pointer or keyboard, and the date of +// the day it would replace, if any. +export type Drag = { pickup: Pickup; keyboard: boolean; date: string | null } + +type PlanDrag = { + drag: Drag | null + board: Board | null + setBoard: (board: Board | null) => void + message: string + // start picks a dinner up, unless it cannot be dropped: there is no grid, + // a swap of the week is pending, or the dinner is already this week. It + // says whether it did. + start: (pickup: Pickup, keyboard: boolean) => boolean + // over moves the drag onto the day of date, or off every day (null). + over: (date: string | null) => void + // step moves a keyboard drag to the previous (-1) or next (1) day. + step: (by: -1 | 1) => void + // end drops the dinner on the day of date, or cancels the drag when date + // is null. It says whether the dinner was dropped. + end: (date: string | null) => boolean + cancel: () => void +} + +const PlanDragContext = createContext(null) + +function usePlanDrag(): PlanDrag { + const context = useContext(PlanDragContext) + if (context === null) throw new Error('usePlanDrag must be used inside PlanDragProvider') + return context +} + +// replaces says which dinner dropping on day replaces: "Friday: replaces +// Pizza night." +function replaces(day: PlanDay): string { + return `${weekdayName(day.date)}: replaces ${day.dinner.name}.` +} + +// PlanDragProvider holds the drag of a dinner from the plan panel or the +// Quest-log strip onto a day of the week's grid, which the grid registers as +// the Board. Each step of a drag is announced to screen readers. +export function PlanDragProvider({ children }: { children: ReactNode }) { + const [board, setBoard] = useState(null) + const [drag, setDrag] = useState(null) + const [message, setMessage] = useState('') + // The drag as last set, read by handlers that run before a render: a blur + // the drop's focus move causes must not cancel the drag it just ended. + const current = useRef(null) + + const value = useMemo((): PlanDrag => { + function set(next: Drag | null) { + current.current = next + setDrag(next) + } + function dayOf(date: string | null): PlanDay | undefined { + return board?.days.find((day) => day.date === date) + } + function cancel() { + const now = current.current + if (now === null) return + set(null) + setMessage(`Cancelled. ${now.pickup.dinner.name} was not swapped in.`) + } + return { + drag, + board, + setBoard, + message, + start(pickup, keyboard) { + const { name } = pickup.dinner + if (board === null) { + setMessage(`There is no plan this week to swap ${name} into.`) + return false + } + if (board.pending) { + setMessage(`A swap is still being saved. Pick ${name} up once it is done.`) + return false + } + if (board.days.some((day) => day.dinner.id === pickup.dinner.id)) { + setMessage(`${name} is already this week.`) + return false + } + // A keyboard drag starts on Monday; a pointer drag on no day. + const first = board.days[0]! + set({ pickup, keyboard, date: keyboard ? first.date : null }) + setMessage( + keyboard + ? `Picked up ${name}. ${replaces(first)} Use the arrow keys to choose a day, Enter to drop, Escape to cancel.` + : `Picked up ${name}.`, + ) + return true + }, + over(date) { + const now = current.current + if (now === null || now.date === date) return + set({ ...now, date }) + }, + step(by) { + const now = current.current + if (now === null || board === null) return + const at = board.days.findIndex((day) => day.date === now.date) + const next = board.days[Math.min(Math.max(at + by, 0), board.days.length - 1)]! + set({ ...now, date: next.date }) + setMessage(replaces(next)) + }, + end(date) { + const now = current.current + if (now === null) return false + const day = dayOf(date) + if (day === undefined || board === null) { + cancel() + return false + } + set(null) + if (board.pending) { + setMessage(`A swap is still being saved. ${now.pickup.dinner.name} was not swapped in.`) + return false + } + board.drop({ date: day.date, dinner: now.pickup.dinner, fromQuestLog: now.pickup.fromQuestLog }) + setMessage(`Dropped ${now.pickup.dinner.name} on ${weekdayName(day.date)}, replacing ${day.dinner.name}.`) + return true + }, + cancel, + } + }, [board, drag, message]) + + return {children} +} + +// DragAnnouncer is the polite live region a drag's steps are announced in. +export function DragAnnouncer() { + const { message } = usePlanDrag() + return ( +

      + {message} +

      + ) +} + +// DropBoard registers the week's grid as the Board while it is shown. +export function DropBoard({ days, pending, drop }: Board) { + const { setBoard } = usePlanDrag() + // drop is a new function on every render: the Board calls the latest. + const latestDrop = useRef(drop) + useLayoutEffect(() => { + latestDrop.current = drop + }) + useLayoutEffect(() => { + setBoard({ days, pending, drop: (swap) => latestDrop.current(swap) }) + return () => setBoard(null) + }, [setBoard, days, pending]) + return null +} + +// useDropTarget is the drag over the day of date, if it is over that day. +export function useDropTarget(date: string): Drag | null { + const { drag } = usePlanDrag() + return drag !== null && drag.date === date ? drag : null +} + +// DROP_DATE is the attribute naming the date of a day a dinner drops on. +export const DROP_DATE = 'data-drop-date' + +// dateAt is the date of the day under the point (x, y), or null when it is +// over no day. +function dateAt(x: number, y: number): string | null { + return document.elementFromPoint(x, y)?.closest(`[${DROP_DATE}]`)?.getAttribute(DROP_DATE) ?? null +} + +// A press becomes a drag once the pointer has moved this far, so a tap is +// 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 } + +// Float is where the dragged copy of a draggable is drawn, and how wide. +export type Float = { left: number; top: number; width: number } + +// 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 +// there, anywhere else cancels. With the keyboard Enter or Space picks it +// up onto Monday, the arrow keys choose the day, Enter drops it and +// Escape, or leaving it, cancels; a drop moves focus to the day's swap +// button, as a Quest-log dinner leaves the Quest log once planned. +export function useDraggable(pickup: Pickup) { + const plan = usePlanDrag() + const [float, setFloat] = useState(null) + const press = useRef(null) + const drag = plan.drag !== null && plan.drag.pickup.dinner.id === pickup.dinner.id ? plan.drag : null + const disabled = plan.drag === null && (plan.board === null || plan.board.pending || plan.board.days.some((day) => day.dinner.id === pickup.dinner.id)) + + // A draggable that goes mid-drag, as on crossing to mobile, cancels it; + // once the drag has ended, cancel does nothing. + const { cancel } = plan + const dragging = drag !== null + const latestCancel = useRef(cancel) + useLayoutEffect(() => { + latestCancel.current = cancel + }) + useEffect(() => { + if (!dragging) return + return () => latestCancel.current() + }, [dragging]) + + function release() { + press.current = null + setFloat(null) + } + + function onPointerDown(event: PointerEvent) { + if (press.current !== null || plan.drag !== null || !event.isPrimary || event.button !== 0) return + const rect = event.currentTarget.getBoundingClientRect() + event.currentTarget.setPointerCapture(event.pointerId) + press.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + dx: event.clientX - rect.left, + dy: event.clientY - rect.top, + width: rect.width, + dragging: false, + } + } + + function onPointerMove(event: PointerEvent) { + const held = press.current + if (held === null || held.pointerId !== event.pointerId) return + if (!held.dragging) { + if (Math.hypot(event.clientX - held.x, event.clientY - held.y) < DRAG_THRESHOLD) return + if (!plan.start(pickup, false)) { + press.current = null + return + } + held.dragging = true + } + setFloat({ left: event.clientX - held.dx, top: event.clientY - held.dy, width: held.width }) + plan.over(dateAt(event.clientX, event.clientY)) + } + + function onPointerUp(event: PointerEvent) { + const held = press.current + if (held === null || held.pointerId !== event.pointerId) return + release() + if (held.dragging) plan.end(dateAt(event.clientX, event.clientY)) + } + + // A cancel, or losing the capture before the release, cancels the drag. + // After a release the press is already over, so this ends only a drag + // whose capture was lost before it. + function onPointerCancel(event: PointerEvent) { + const held = press.current + if (held === null || held.pointerId !== event.pointerId) return + release() + if (held.dragging) plan.cancel() + } + + function onKeyDown(event: KeyboardEvent) { + const button = event.currentTarget + if (drag === null) { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + if (plan.drag === null) plan.start(pickup, true) + return + } + if (event.key === 'Escape') { + event.preventDefault() + release() + plan.cancel() + return + } + if (!drag.keyboard) return + // Space picked it up and does not drop it; it does not scroll the page. + if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') plan.step(-1) + else if (event.key === 'ArrowRight' || event.key === 'ArrowDown') plan.step(1) + else if (event.key === 'Enter') drop(button.ownerDocument, drag.date) + else if (event.key !== ' ') return + event.preventDefault() + } + + // drop ends a keyboard drag on the day of date and, once dropped, moves + // focus to that day's swap button. + function drop(document: Document, date: string | null) { + if (!plan.end(date)) return + const swapButton = document.querySelector(`[${DROP_DATE}="${date}"] button`) + if (swapButton === null) throw new Error(`The day ${date} dropped on has no swap button to focus.`) + swapButton.focus() + } + + // Leaving a keyboard drag cancels it. + function onBlur() { + if (drag !== null && drag.keyboard) plan.cancel() + } + + return { + drag, + float, + props: { + 'aria-disabled': disabled || undefined, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + onLostPointerCapture: onPointerCancel, + onKeyDown, + onBlur, + }, + } +} -- 2.55.0 From 621fdef42b19866d638b79f1f264b054f7607004 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 15:27:49 -0400 Subject: [PATCH 2/3] 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)) } -- 2.55.0 From 9e0c20ab5e65bfa3f3c27773ad641d225d18d958 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 15:28:02 -0400 Subject: [PATCH 3/3] Reflow the floating copy offset comment Co-Authored-By: Claude Opus 5.5 --- web/src/plan/drag.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/plan/drag.tsx b/web/src/plan/drag.tsx index 724da6e..16e1b35 100644 --- a/web/src/plan/drag.tsx +++ b/web/src/plan/drag.tsx @@ -197,8 +197,8 @@ const DRAG_THRESHOLD = 4 // 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. +// 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 -- 2.55.0