Dragging in from outside
Drop or place items from a palette, files from the desktop, or another drag-and-drop library's items onto the calendar.
A calendar can take items from outside it: a palette of services, files dragged from the desktop, or the items of a drag-and-drop library you already use. Every way works with a single pointer and the keyboard too.
npx shadcn@latest add @hapio/calendar-external-draggableA palette of items
CalendarDraggable makes anything draggable; <CalendarExternalDrops>, nested inside
<CalendarEditing> (or <CalendarSelection>), lets the calendars inside it take items:
'use client'
import type { CalendarEventInput, EventRules, ExternalDrop } from '@hapio/calendar-react'
import { CalendarDraggable } from '@/components/hapio-calendar/calendar-draggable'
import { CalendarEditing } from '@/components/hapio-calendar/calendar-editing'
import { CalendarExternalDrops } from '@/components/hapio-calendar/calendar-external-drops'
import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'
import { createBooking } from '@/lib/bookings'
import { SERVICES, type Service } from '@/lib/services'
/** What this calendar takes: it narrows `drop.data` to `Service`. */
function isService(data: unknown): data is Service {
return typeof data === 'object' && data !== null && 'kind' in data && data.kind === 'service'
}
const RULES: { readonly event: EventRules<undefined> } = {
event: {
constraint: 'businessHours',
// `kind: 'external'` is a drop; a string is the reason users hear.
allow: (proposal) =>
proposal.kind === 'external' && proposal.next.allDay ? 'Drop it on a time' : true,
},
}
export function ServiceScheduler({
events,
onBooked,
}: {
events: readonly CalendarEventInput[]
onBooked: () => void
}) {
// The calendar shows a placeholder where the item landed until your events change.
const onExternalDrop = async ({ data, span }: ExternalDrop<Service>) => {
await createBooking({ serviceId: data.id, startsAt: span.startIso, endsAt: span.endIso })
onBooked() // refetch or add the booking: the new events replace the placeholder
}
return (
<CalendarEditing>
<CalendarExternalDrops>
<ul aria-label="Services" className="flex flex-wrap gap-2">
{SERVICES.map((service) => (
<li key={service.id}>
<CalendarDraggable
data={service}
label={`${service.name} · ${service.minutes} min`}
duration={{ minutes: service.minutes }}
/>
</li>
))}
</ul>
<HapioCalendar
events={events}
timeZone="Europe/Stockholm"
businessHours
rules={RULES}
droppable
acceptExternal={isService}
onExternalDrop={onExternalDrop}
className="h-[36rem]"
/>
</CalendarExternalDrops>
</CalendarEditing>
)
}How users bring an item in:
- Drag it: a mouse or pen drag past 5 px, or on touch a long-press (400 ms) and then a drag, carries it over the calendar, which draws it where it would land with its verdict; the release drops it. Escape cancels.
- Pick it up and place it: Space, Enter, a click, a tap or a screen reader's activation picks
the item up (
aria-pressed). Keyboard users land on the first calendar that takes it, at its slot cursor or day cell ("Picked up Haircut · 45 min. Choose a time with the arrow keys and press Enter, or click a time."); the arrow keys choose a place, each saying its verdict (the slot cursor's name in a week, an announcement in a month), and Enter drops it. A click or tap on a slot or day drops it too. Tab reaches the other calendars that take it; Escape, or activating the item again, puts it down.
The item's description tells keyboard users how ("Press Space to pick it up, then choose a time in
the calendar"). It lives outside every calendar, so pass a translation as hint
(hint={sv.pickUpHint} with the Swedish pack; see Languages).
Try it in the drag-in example, with two calendars and files.
What a calendar takes
droppableturns drops on; without<CalendarExternalDrops>around the calendar, development builds report that the external-drop engine is missing.acceptExternalis a type predicate on the item'sdata(and its element): it decides which items the calendar takes and narrowsonExternalDrop'sdrop.datato that type. It is called once when a drag starts or an item is picked up, not on every move. Without it, the calendar takes noCalendarDraggableitem.- The rules judge every place:
validRangeandrules.event, whereallowreceiveskind: 'external'andevent: null. durationandallDayof the item shape the drop: a drop on a time slot lasts the item'sduration(defaultdefaultTimedDuration), a drop on an all-day cell or a day is all-day.
onExternalDrop
onExternalDrop(drop) receives data (narrowed), span (where it landed), via ('pointer',
'touch', 'keyboard' or 'native'), element (the draggable, null for native drops),
nativeEvent (the DOM event that ended it: a drag's release, a native drag's drop, or the
click, tap or key that placed a picked-up item; null when there is none) and signal (aborted
when a newer drop or change supersedes it). Create the event in your data. Until
your events change, the calendar draws a placeholder where the item landed, striped while it
waits. As with changes, return nothing, true or a promise to accept it, and false, a throw or a
rejection to take the placeholder away again ("Drop reverted"). An accepted drop settles with the
next new events or after settleTimeoutMs.
Files from the desktop
nativeDrop takes native drags, such as files, with the same mirror, rules and placeholder:
'use client'
import type { CalendarEventInput, DateSpan } from '@hapio/calendar-react'
import { CalendarExternalDrops } from '@/components/hapio-calendar/calendar-external-drops'
import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'
interface Photos {
readonly kind: 'photos'
readonly files: readonly File[]
}
const PHOTO_DROP = {
// While the drag is over the calendar the browser shows only the items' kinds and types.
canAccept: (data: DataTransfer) =>
Array.from(data.items).some((item) => item.kind === 'file' && item.type.startsWith('image/')),
// At the drop: the drop's `data`, or `null` to refuse it.
accept: (data: DataTransfer): Photos | null => {
const files = Array.from(data.files).filter((file) => file.type.startsWith('image/'))
return files.length === 0 ? null : { kind: 'photos', files }
},
label: 'Photo shoot', // the mirror's and the placeholder's title
duration: { hours: 1 },
}
export function PhotoCalendar({
events,
upload,
}: {
events: readonly CalendarEventInput[]
upload: (files: readonly File[], span: DateSpan) => Promise<void>
}) {
return (
<CalendarExternalDrops>
<HapioCalendar
events={events}
timeZone="Europe/Stockholm"
droppable
nativeDrop={PHOTO_DROP}
onExternalDrop={(drop) => upload(drop.data.files, drop.span)}
className="h-[36rem]"
/>
</CalendarExternalDrops>
)
}While a native drag is over the calendar the browser hides its data, so canAccept sees only the
items' kinds and types (by default it accepts any drag that carries files). Give a keyboard and
single-pointer path to the same result too, such as a file input whose files become a
CalendarDraggable, as the drag-in example does.
Other drag-and-drop libraries
When another library owns the drag (dnd-kit, Pragmatic drag and drop), the calendar only needs to
say where a point lands: store.hitTest(clientX, clientY) returns the lane, the span of the slot
(a time grid) or the day (an all-day cell or a month day) under a viewport point, or null
outside the grid. It needs the external-drop engine, so compose the calendar with useCalendar
inside <CalendarExternalDrops>; the calendar need not be droppable:
'use client'
import { DndContext, useDraggable, useDroppable, type DragEndEvent } from '@dnd-kit/core'
import {
CalendarProvider,
useCalendar,
type CalendarEventInput,
type DateSpan,
} from '@hapio/calendar-react'
import type { ReactNode } from 'react'
import { CalendarDescriptions } from '@/components/hapio-calendar/calendar-descriptions'
import { CalendarExternalDrops } from '@/components/hapio-calendar/calendar-external-drops'
import { CalendarLiveRegion } from '@/components/hapio-calendar/calendar-live-region'
import { useCalendarSelectionParts } from '@/components/hapio-calendar/calendar-selection-context'
import { CalendarToolbar } from '@/components/hapio-calendar/calendar-toolbar'
import { CalendarView } from '@/components/hapio-calendar/calendar-view'
import { SERVICES, type Service } from '@/lib/services'
interface SchedulerProps {
readonly events: readonly CalendarEventInput[]
readonly book: (service: Service, span: DateSpan) => void
}
/** `hitTest` needs the external-drop engine, which `<CalendarExternalDrops>` provides. */
export function DndKitScheduler(props: SchedulerProps) {
return (
<CalendarExternalDrops>
<Scheduler {...props} />
</CalendarExternalDrops>
)
}
function Scheduler({ events, book }: SchedulerProps) {
const parts = useCalendarSelectionParts()
const calendar = useCalendar(
{ events, timeZone: 'Europe/Stockholm', interaction: parts?.engine },
{ selectionParts: parts !== null },
)
const onDragEnd = ({ active, over, activatorEvent, delta }: DragEndEvent) => {
const service = SERVICES.find((item) => item.id === active.id)
if (over?.id !== 'calendar' || service === undefined) return
if (!(activatorEvent instanceof PointerEvent)) return // the pointer sensor only, here
// Where the pointer let go, in viewport coordinates.
const x = activatorEvent.clientX + delta.x
const y = activatorEvent.clientY + delta.y
const hit = calendar.store.hitTest(x, y) // the slot or day under the point, or null
if (hit !== null) book(service, hit.span)
}
return (
<DndContext onDragEnd={onDragEnd} autoScroll={false}>
<ul aria-label="Services">
{SERVICES.map((service) => (
<li key={service.id}>
<ServiceChip service={service} />
</li>
))}
</ul>
<DropTarget>
<CalendarProvider value={calendar}>
<div {...calendar.getRootProps({ className: 'flex h-[36rem] flex-col gap-3' })}>
<CalendarToolbar />
<CalendarView />
<CalendarDescriptions />
<CalendarLiveRegion />
</div>
</CalendarProvider>
</DropTarget>
</DndContext>
)
}
function DropTarget({ children }: { children: ReactNode }) {
const { setNodeRef } = useDroppable({ id: 'calendar' })
return <div ref={setNodeRef}>{children}</div>
}
function ServiceChip({ service }: { service: Service }) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({ id: service.id })
const moved = transform === null ? undefined : `translate(${transform.x}px, ${transform.y}px)`
return (
<button
ref={setNodeRef}
type="button"
style={{ transform: moved }}
{...listeners}
{...attributes}
>
{service.name}
</button>
)
}Here your app commits the drop itself: no rules, no placeholder. dnd-kit's delta includes the
page's scrolling since the drag started, so its auto-scroll is off here; the
dnd-kit example also wires dnd-kit's keyboard sensor to move by the
calendar's slots.
Not in 1.0
- Items show no drag preview outside the calendars: the draggable itself shows
data-dragging, and each calendar draws the item's mirror where it would land. - Events cannot be dragged out of a calendar into another one or onto other targets.
- With several droppable calendars, a keyboard pick-up always lands on the first; Tab reaches the others.