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 <noreply@anthropic.com>
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -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<typeof fetch>) {
|
||||
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<Response>((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<Response>((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<Response>((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<Response>(() => 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', () => {
|
||||
|
||||
+31
-10
@@ -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 (
|
||||
<li className={styles.column} aria-label={weekdayName(day.date)}>
|
||||
<li className={styles.column} aria-label={weekdayName(day.date)} {...{ [DROP_DATE]: day.date }}>
|
||||
<p className={styles.dayLabel} aria-hidden="true">
|
||||
<span className={styles.dayName}>{weekdayKicker(day.date)}</span>
|
||||
<span className={styles.dayNumber}>{dayOfMonth(day.date)}</span>
|
||||
@@ -165,6 +169,12 @@ function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone
|
||||
<Icon icon={RefreshCw} size="inline" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{target && (
|
||||
<div className={styles.dropTarget}>
|
||||
<p className={styles.dropTitle}>Drop to swap</p>
|
||||
<p className={styles.dropReplaces}>Replaces {dinner.name}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
@@ -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' ? (
|
||||
<>
|
||||
<DropBoard days={days} pending={swap.pending} drop={dropIn} />
|
||||
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
|
||||
{days.map((day) => (
|
||||
<DayColumn
|
||||
@@ -545,7 +562,8 @@ function Unavailable({
|
||||
// Regulars in a right panel on desktop (2d). It opens on next week once this
|
||||
// week's plan_draft time has come in the household's zone, otherwise on this
|
||||
// week. The board keeps its place in the tree across layouts, so crossing a
|
||||
// breakpoint keeps a draft being followed.
|
||||
// breakpoint keeps a draft being followed. From tablet up a dinner is
|
||||
// dragged from the strip or the panel onto a day of the grid to swap it in.
|
||||
export function PlanPage() {
|
||||
const layout = useBreakpoint()
|
||||
const settings = useHouseholdSettings()
|
||||
@@ -563,12 +581,15 @@ export function PlanPage() {
|
||||
content = <WeekBoard key={week} weekStart={week} timezone={settings.data.timezone} layout={layout} onWeek={setWeek} />
|
||||
}
|
||||
return (
|
||||
<div className={`${styles.page} ${styles[`${layout}Page`]}`}>
|
||||
<div className={styles.board}>
|
||||
{content}
|
||||
{layout === 'tablet' && <QuestLogStrip />}
|
||||
<PlanDragProvider>
|
||||
<div className={`${styles.page} ${styles[`${layout}Page`]}`}>
|
||||
<div className={styles.board}>
|
||||
{content}
|
||||
{layout === 'tablet' && <QuestLogStrip />}
|
||||
</div>
|
||||
{layout === 'desktop' && <PlanPanel />}
|
||||
{layout !== 'mobile' && <DragAnnouncer />}
|
||||
</div>
|
||||
{layout === 'desktop' && <PlanPanel />}
|
||||
</div>
|
||||
</PlanDragProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<typeof useQuestLog>
|
||||
|
||||
// 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 (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className={[className, styles.draggable, drag !== null && styles.ghost].filter(Boolean).join(' ')}
|
||||
aria-describedby={keysHintId}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{float !== null && <Floating float={float} className={className}>{children}</Floating>}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// 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(
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-testid="drag-float"
|
||||
className={[className, styles.floating].filter(Boolean).join(' ')}
|
||||
style={{ left: float.left, top: float.top, width: float.width }}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
// KeysHint is the keyboard's way to swap a dinner in, describing each
|
||||
// draggable.
|
||||
function KeysHint({ id }: { id: string }) {
|
||||
return (
|
||||
<p id={id} hidden>
|
||||
{KEYS_HINT}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<Source>('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}
|
||||
/>
|
||||
<p className={styles.hint}>{DRAG_HINT}</p>
|
||||
<KeysHint id={keysHintId} />
|
||||
<ListBody source={source} list={lists[source]}>
|
||||
{(dinners) => (
|
||||
<ul aria-label={sourceNames[source]} className={styles.rows}>
|
||||
{dinners.map((dinner) => (
|
||||
<li key={dinner.id} className={styles.row}>
|
||||
<Draggable
|
||||
key={dinner.id}
|
||||
dinner={dinner}
|
||||
fromQuestLog={source === 'quest_log'}
|
||||
className={styles.row}
|
||||
keysHintId={keysHintId}
|
||||
>
|
||||
{/* The row's name already says what the photo shows. */}
|
||||
<span aria-hidden="true">
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-pill)" sizes="48px" className={styles.rowPhoto} />
|
||||
@@ -95,7 +164,7 @@ export function PlanPanel() {
|
||||
{dinner.difficulty !== null && <span className={styles.rowMeta}>{DIFFICULTY_LABEL[dinner.difficulty]}</span>}
|
||||
</span>
|
||||
<Grip />
|
||||
</li>
|
||||
</Draggable>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
@@ -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 (
|
||||
<section aria-label={sourceNames.quest_log} className={styles.strip}>
|
||||
@@ -119,16 +190,17 @@ export function QuestLogStrip() {
|
||||
<h2 className={styles.stripTitle}>{title}</h2>
|
||||
<p className={styles.stripHint}>{DRAG_HINT}</p>
|
||||
</div>
|
||||
<KeysHint id={keysHintId} />
|
||||
<ListBody source="quest_log" list={questLog}>
|
||||
{(dinners) => (
|
||||
<ul aria-label={`${sourceNames.quest_log} dinners`} className={styles.chips}>
|
||||
{dinners.map((dinner) => (
|
||||
<li key={dinner.id} className={styles.chip}>
|
||||
<Draggable key={dinner.id} dinner={dinner} fromQuestLog className={styles.chip} keysHintId={keysHintId}>
|
||||
<span aria-hidden="true">
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-pill)" sizes="28px" className={styles.chipPhoto} />
|
||||
</span>
|
||||
{dinner.name}
|
||||
</li>
|
||||
</Draggable>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
@@ -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<Source, string> = {
|
||||
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 && <Tag>Already this week</Tag>}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<PlanDrag | null>(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<Board | null>(null)
|
||||
const [drag, setDrag] = useState<Drag | null>(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<Drag | null>(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 <PlanDragContext.Provider value={value}>{children}</PlanDragContext.Provider>
|
||||
}
|
||||
|
||||
// DragAnnouncer is the polite live region a drag's steps are announced in.
|
||||
export function DragAnnouncer() {
|
||||
const { message } = usePlanDrag()
|
||||
return (
|
||||
<p aria-live="polite" aria-atomic="true" data-testid="drag-announcer" className={styles.announcer}>
|
||||
{message}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<Float | null>(null)
|
||||
const press = useRef<Press | null>(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<HTMLElement>) {
|
||||
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<HTMLElement>) {
|
||||
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<HTMLElement>) {
|
||||
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<HTMLElement>) {
|
||||
const held = press.current
|
||||
if (held === null || held.pointerId !== event.pointerId) return
|
||||
release()
|
||||
if (held.dragging) plan.cancel()
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent<HTMLElement>) {
|
||||
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<HTMLElement>(`[${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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user