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..329dcc2 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,364 @@ 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) 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, [clientX, clientY] = [160, 140]) { + hovered = over + 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 }) + } + + 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') + // 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') + 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('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() + 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..99eea60 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,52 @@ 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 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; + 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..8bf035d 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 +// below right of 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..16e1b35 --- /dev/null +++ b/web/src/plan/drag.tsx @@ -0,0 +1,353 @@ +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 + +// 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 +// 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, + width: rect.width, + height: rect.height, + 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(floatAt(held, event.clientX, event.clientY)) + 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, + }, + } +}