Hapio Calendar

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-draggable

A palette of items

CalendarDraggable makes anything draggable; <CalendarExternalDrops>, nested inside <CalendarEditing> (or <CalendarSelection>), lets the calendars inside it take items:

components/service-scheduler.tsx
'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

  • droppable turns drops on; without <CalendarExternalDrops> around the calendar, development builds report that the external-drop engine is missing.
  • acceptExternal is a type predicate on the item's data (and its element): it decides which items the calendar takes and narrows onExternalDrop's drop.data to 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 no CalendarDraggable item.
  • The rules judge every place: validRange and rules.event, where allow receives kind: 'external' and event: null.
  • duration and allDay of the item shape the drop: a drop on a time slot lasts the item's duration (default defaultTimedDuration), 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:

components/photo-calendar.tsx
'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:

components/dnd-kit-scheduler.tsx
'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.