Drag dinners onto days, by pointer or keyboard (Feature 14)
CI / ci (push) Successful in 3m26s
CI / ci (pull_request) Successful in 3m19s

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:
2026-10-07 15:03:57 -04:00
co-authored by Claude Opus 5.5
parent dba4b93280
commit 3d6c1109c8
8 changed files with 891 additions and 33 deletions
+35
View File
@@ -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. */
+338 -2
View File
@@ -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
View File
@@ -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>
) )
} }
+51
View File
@@ -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;
}
+81 -9
View File
@@ -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>
)} )}
+7 -12
View File
@@ -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>}
/> />
+12
View File
@@ -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;
}
+336
View File
@@ -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,
},
}
}