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 {
|
.card {
|
||||||
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -531,6 +532,40 @@
|
|||||||
gap: 4px;
|
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
|
/* 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
|
keeps the 16 px footprint of its icon, reaching over the card's padding
|
||||||
and the tag. */
|
and the tag. */
|
||||||
|
|||||||
@@ -810,8 +810,8 @@ describe.each([
|
|||||||
const orzo = within(within(panel).getByRole('list', { name: 'Quest log' })).getByRole('listitem')
|
const orzo = within(within(panel).getByRole('list', { name: 'Quest log' })).getByRole('listitem')
|
||||||
expect(within(orzo).getByText('Lemon orzo').nextElementSibling).toHaveTextContent('Easy')
|
expect(within(orzo).getByText('Lemon orzo').nextElementSibling).toHaveTextContent('Easy')
|
||||||
expect(declaredStyle(within(orzo).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('48px')
|
expect(declaredStyle(within(orzo).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('48px')
|
||||||
// The 6-dot grip.
|
// The 6-dot grip, last in the row's button.
|
||||||
expect(orzo.lastElementChild!.children).toHaveLength(6)
|
expect(within(orzo).getByRole('button').lastElementChild!.children).toHaveLength(6)
|
||||||
|
|
||||||
fireEvent.click(within(panel).getByRole('radio', { name: 'Regulars · 2' }))
|
fireEvent.click(within(panel).getByRole('radio', { name: 'Regulars · 2' }))
|
||||||
const regulars = within(within(panel).getByRole('list', { name: 'Regulars' })).getAllByRole('listitem')
|
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:
|
// At tablet's narrow end seven usable columns do not fit beside the rail:
|
||||||
// the week keeps its columns usable and scrolls sideways.
|
// the week keeps its columns usable and scrolls sideways.
|
||||||
describe('PlanPage at 768 px', () => {
|
describe('PlanPage at 768 px', () => {
|
||||||
|
|||||||
+31
-10
@@ -28,6 +28,7 @@ import {
|
|||||||
type PlanStatus,
|
type PlanStatus,
|
||||||
type Swap,
|
type Swap,
|
||||||
} from './api.ts'
|
} from './api.ts'
|
||||||
|
import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx'
|
||||||
import { PlanPanel, QuestLogStrip } from './PlanPanel.tsx'
|
import { PlanPanel, QuestLogStrip } from './PlanPanel.tsx'
|
||||||
import { SwapSheet } from './SwapSheet.tsx'
|
import { SwapSheet } from './SwapSheet.tsx'
|
||||||
import {
|
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
|
// 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
|
// 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
|
// 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 }) {
|
function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) {
|
||||||
const { dinner } = day
|
const { dinner } = day
|
||||||
const time = cooking(dinner)
|
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 (
|
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">
|
<p className={styles.dayLabel} aria-hidden="true">
|
||||||
<span className={styles.dayName}>{weekdayKicker(day.date)}</span>
|
<span className={styles.dayName}>{weekdayKicker(day.date)}</span>
|
||||||
<span className={styles.dayNumber}>{dayOfMonth(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" />
|
<Icon icon={RefreshCw} size="inline" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
{target && (
|
||||||
|
<div className={styles.dropTarget}>
|
||||||
|
<p className={styles.dropTitle}>Drop to swap</p>
|
||||||
|
<p className={styles.dropReplaces}>Replaces {dinner.name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
@@ -316,6 +326,12 @@ function WeekBoard({
|
|||||||
setSwappingDate(null)
|
setSwappingDate(null)
|
||||||
swap.mutate(chosen)
|
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
|
// 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
|
// a time, even across leaving the week and coming back: the plan the
|
||||||
// server answers it with must not overwrite another still pending.
|
// server answers it with must not overwrite another still pending.
|
||||||
@@ -359,6 +375,7 @@ function WeekBoard({
|
|||||||
body =
|
body =
|
||||||
layout !== 'mobile' ? (
|
layout !== 'mobile' ? (
|
||||||
<>
|
<>
|
||||||
|
<DropBoard days={days} pending={swap.pending} drop={dropIn} />
|
||||||
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
|
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
|
||||||
{days.map((day) => (
|
{days.map((day) => (
|
||||||
<DayColumn
|
<DayColumn
|
||||||
@@ -545,7 +562,8 @@ function Unavailable({
|
|||||||
// Regulars in a right panel on desktop (2d). It opens on next week once this
|
// 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'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
|
// 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() {
|
export function PlanPage() {
|
||||||
const layout = useBreakpoint()
|
const layout = useBreakpoint()
|
||||||
const settings = useHouseholdSettings()
|
const settings = useHouseholdSettings()
|
||||||
@@ -563,12 +581,15 @@ export function PlanPage() {
|
|||||||
content = <WeekBoard key={week} weekStart={week} timezone={settings.data.timezone} layout={layout} onWeek={setWeek} />
|
content = <WeekBoard key={week} weekStart={week} timezone={settings.data.timezone} layout={layout} onWeek={setWeek} />
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.page} ${styles[`${layout}Page`]}`}>
|
<PlanDragProvider>
|
||||||
<div className={styles.board}>
|
<div className={`${styles.page} ${styles[`${layout}Page`]}`}>
|
||||||
{content}
|
<div className={styles.board}>
|
||||||
{layout === 'tablet' && <QuestLogStrip />}
|
{content}
|
||||||
|
{layout === 'tablet' && <QuestLogStrip />}
|
||||||
|
</div>
|
||||||
|
{layout === 'desktop' && <PlanPanel />}
|
||||||
|
{layout !== 'mobile' && <DragAnnouncer />}
|
||||||
</div>
|
</div>
|
||||||
{layout === 'desktop' && <PlanPanel />}
|
</PlanDragProvider>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The 48 px washed photo; Photo applies the washed filter. The placeholder's
|
/* The 48 px washed photo; Photo applies the washed filter. The placeholder's
|
||||||
@@ -169,3 +172,51 @@
|
|||||||
height: 28px;
|
height: 28px;
|
||||||
font-size: 0;
|
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 { 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 { Link } from 'react-router'
|
||||||
import { useRegulars } from '../dinners/api.ts'
|
import { useRegulars } from '../dinners/api.ts'
|
||||||
import { useQuestLog, type QuestLogDinner } from '../discover/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 { Photo } from '../ui/Photo.tsx'
|
||||||
import { Segmented } from '../ui/Segmented.tsx'
|
import { Segmented } from '../ui/Segmented.tsx'
|
||||||
import { Skeleton } from '../ui/Skeleton.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'
|
import styles from './PlanPanel.module.css'
|
||||||
|
|
||||||
// DRAG_HINT says how a dinner is swapped in from the panel or the strip.
|
// 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.'
|
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>
|
type List = ReturnType<typeof useQuestLog>
|
||||||
|
|
||||||
// ListBody is a list of the panel or the strip once it has loaded, or why it
|
// 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
|
// PlanPanel is desktop's right panel (mock 2d): the Quest log and the
|
||||||
// Regulars, segmented "Quest log · N | Regulars · N", each dinner with its
|
// 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
|
// 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 regulars = useRegulars()
|
||||||
const [source, setSource] = useState<Source>('quest_log')
|
const [source, setSource] = useState<Source>('quest_log')
|
||||||
const lists = { quest_log: questLog, regulars }
|
const lists = { quest_log: questLog, regulars }
|
||||||
|
const keysHintId = useId()
|
||||||
|
|
||||||
const label = (s: Source) => {
|
const label = (s: Source) => {
|
||||||
const dinners = lists[s].data
|
const dinners = lists[s].data
|
||||||
@@ -81,11 +143,18 @@ export function PlanPanel() {
|
|||||||
onValueChange={setSource}
|
onValueChange={setSource}
|
||||||
/>
|
/>
|
||||||
<p className={styles.hint}>{DRAG_HINT}</p>
|
<p className={styles.hint}>{DRAG_HINT}</p>
|
||||||
|
<KeysHint id={keysHintId} />
|
||||||
<ListBody source={source} list={lists[source]}>
|
<ListBody source={source} list={lists[source]}>
|
||||||
{(dinners) => (
|
{(dinners) => (
|
||||||
<ul aria-label={sourceNames[source]} className={styles.rows}>
|
<ul aria-label={sourceNames[source]} className={styles.rows}>
|
||||||
{dinners.map((dinner) => (
|
{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. */}
|
{/* The row's name already says what the photo shows. */}
|
||||||
<span aria-hidden="true">
|
<span aria-hidden="true">
|
||||||
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-pill)" sizes="48px" className={styles.rowPhoto} />
|
<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>}
|
{dinner.difficulty !== null && <span className={styles.rowMeta}>{DIFFICULTY_LABEL[dinner.difficulty]}</span>}
|
||||||
</span>
|
</span>
|
||||||
<Grip />
|
<Grip />
|
||||||
</li>
|
</Draggable>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
@@ -109,9 +178,11 @@ export function PlanPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// QuestLogStrip is tablet's Quest log under the week's grid (mock 2c): each
|
// 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() {
|
export function QuestLogStrip() {
|
||||||
const questLog = useQuestLog()
|
const questLog = useQuestLog()
|
||||||
|
const keysHintId = useId()
|
||||||
const title = questLog.data === undefined ? sourceNames.quest_log : `${sourceNames.quest_log} · ${questLog.data.length}`
|
const title = questLog.data === undefined ? sourceNames.quest_log : `${sourceNames.quest_log} · ${questLog.data.length}`
|
||||||
return (
|
return (
|
||||||
<section aria-label={sourceNames.quest_log} className={styles.strip}>
|
<section aria-label={sourceNames.quest_log} className={styles.strip}>
|
||||||
@@ -119,16 +190,17 @@ export function QuestLogStrip() {
|
|||||||
<h2 className={styles.stripTitle}>{title}</h2>
|
<h2 className={styles.stripTitle}>{title}</h2>
|
||||||
<p className={styles.stripHint}>{DRAG_HINT}</p>
|
<p className={styles.stripHint}>{DRAG_HINT}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<KeysHint id={keysHintId} />
|
||||||
<ListBody source="quest_log" list={questLog}>
|
<ListBody source="quest_log" list={questLog}>
|
||||||
{(dinners) => (
|
{(dinners) => (
|
||||||
<ul aria-label={`${sourceNames.quest_log} dinners`} className={styles.chips}>
|
<ul aria-label={`${sourceNames.quest_log} dinners`} className={styles.chips}>
|
||||||
{dinners.map((dinner) => (
|
{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">
|
<span aria-hidden="true">
|
||||||
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-pill)" sizes="28px" className={styles.chipPhoto} />
|
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-pill)" sizes="28px" className={styles.chipPhoto} />
|
||||||
</span>
|
</span>
|
||||||
{dinner.name}
|
{dinner.name}
|
||||||
</li>
|
</Draggable>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Segmented } from '../ui/Segmented.tsx'
|
|||||||
import { Sheet } from '../ui/Sheet.tsx'
|
import { Sheet } from '../ui/Sheet.tsx'
|
||||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||||
import { Tag } from '../ui/Tag.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 { weekdayName } from './week.ts'
|
||||||
import styles from './SwapSheet.module.css'
|
import styles from './SwapSheet.module.css'
|
||||||
|
|
||||||
@@ -23,6 +23,11 @@ export const emptyLists: Record<Source, string> = {
|
|||||||
regulars: 'You have no Regulars yet.',
|
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 = {
|
export type SwapSheetProps = {
|
||||||
// The day being swapped.
|
// The day being swapped.
|
||||||
day: PlanDay
|
day: PlanDay
|
||||||
@@ -88,17 +93,7 @@ export function SwapSheet({ day, planned, onSwap, onClose }: SwapSheetProps) {
|
|||||||
already
|
already
|
||||||
? undefined
|
? undefined
|
||||||
: () =>
|
: () =>
|
||||||
onSwap({
|
onSwap({ date: day.date, dinner: planDinner(dinner), fromQuestLog: source === 'quest_log' })
|
||||||
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',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
trailing={already && <Tag>Already this week</Tag>}
|
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