Playground

Every feature in one place: all views, three merged event sources, a time-zone picker, variable week length, keyboard and swipe navigation, business hours and overlap rules for edits, a random schedule generator, and a headless toolbar beside the built-in one. This is the demo the repository's smoke tests exercise.

Playground.tsx
Playground.tsx
import {
  Calendar,
  DayGrid,
  Interaction,
  List,
  ResourceTimeGrid,
  ResourceTimeline,
  TimeGrid,
  useCalendar,
  useCalendarController,
  type BusinessHoursInput,
  type CalendarProps,
  type EventInput,
  type EventSourceInput,
  type FetchInfo,
  type ResourceInput,
  type SelectInfo,
  type CalEvent,
} from 'z-cal/react'
import { useCallback, useMemo, useState, type JSX } from 'react'
import { useScheme } from '../DemoFrame'
import { today, todayDate } from '../shared/dates'
import { EventPopover, placeUnder, type EventPopoverTarget } from '../shared/EventPopover'
import { TimezonePicker } from '../shared/TimezonePicker'
import { EventCreateDialog } from '../shared/EventCreateDialog'
import { createRandomEvents, createScheduleForRange, DEMO_CALENDARS } from '../shared/random-events'

const PLUGINS = [DayGrid, TimeGrid, List, ResourceTimeGrid, ResourceTimeline, Interaction]

const HEADER_TOOLBAR = {
  start: 'title',
  center: '',
  end: 'today prev,next dayGridMonth,timeGridWeek,timeGridDay,listWeek,resourceTimeGridWeek,resourceTimelineWeek',
}

// The core's defaults label every week-length view "week"; disambiguate the list and resource views.
const BUTTON_TEXT = {
  listWeek: 'list',
  resourceTimeGridWeek: 'resources',
  resourceTimelineWeek: 'timeline',
}

// Business hours presets; picking one also turns every constraint to 'businessHours'.
const BUSINESS_HOURS: Record<string, { label: string; hours: BusinessHoursInput[] }> = {
  office: { label: 'Weekdays 9:00–17:00', hours: [{ startTime: '09:00', endTime: '17:00' }] },
  lunch: {
    label: 'Weekdays 9:00–12:00 and 13:00–18:00',
    hours: [
      { startTime: '09:00', endTime: '12:00' },
      { startTime: '13:00', endTime: '18:00' },
    ],
  },
  shop: {
    label: 'Every day 10:00–20:00, Sundays to 16:00',
    hours: [
      { daysOfWeek: [1, 2, 3, 4, 5, 6], startTime: '10:00', endTime: '20:00' },
      { daysOfWeek: [0], startTime: '10:00', endTime: '16:00' },
    ],
  },
}

const RESOURCES: ResourceInput[] = [
  { id: 'a', title: 'Room A' },
  {
    id: 'b',
    title: 'Room B',
    children: [
      { id: 'b1', title: 'Desk 1' },
      { id: 'b2', title: 'Desk 2' },
    ],
  },
  { id: 'c', title: 'Room C', expanded: false, children: [{ id: 'c1', title: 'Desk 3' }] },
]

// Source 1: a static array, exposed as a synchronous source below so it merges with the other two
// (like vkurko/calendar, the `events` option is only consulted when there are no `eventSources`).
const STATIC_EVENTS: EventInput[] = [
  { id: 1, resourceId: 'a', start: today(0, 10), end: today(0, 11), title: 'Team sync' },
  { id: 2, resourceId: 'b1', start: todayDate(1), end: todayDate(4), title: 'Conference' },
  { id: 3, resourceId: 'b2', start: todayDate(-3), title: 'Deadline', color: 'oklch(65% 0.2 30)' },
  { id: 4, resourceId: 'b1', start: today(0, 13), end: today(0, 15), title: 'Workshop' },
  // Anchored to UTC so the timezone picker visibly moves it.
  {
    id: 'launch',
    resourceId: 'a',
    start: `${todayDate(0)}T12:00:00Z`,
    end: `${todayDate(0)}T13:00:00Z`,
    title: 'Global launch',
    color: 'oklch(55% 0.18 280)',
  },
]

// Source 2: an async function source that behaves like a server. The first request for a period takes
// ~300 ms; days already fetched are served from an in-memory cache, synchronously, so paging back to a
// period you have seen does not blank the view. The schedule itself is deterministic per date.
const scheduleCache = new Map<string, EventInput[]>()
const asyncSource: EventSourceInput = {
  events: (info: FetchInfo, success) => {
    const start = info.start.toPlainDate()
    const end = info.end.toPlainDate()
    const days: Temporal.PlainDate[] = []
    for (let day = start; Temporal.PlainDate.compare(day, end) < 0; day = day.add({ days: 1 }))
      days.push(day)
    const collect = (): EventInput[] =>
      days.flatMap((day) => scheduleCache.get(day.toString()) ?? [])
    const missing = days.filter((day) => !scheduleCache.has(day.toString()))
    if (missing.length === 0) {
      success(collect())
      return
    }
    setTimeout(() => {
      for (const day of missing)
        scheduleCache.set(day.toString(), createScheduleForRange(day, day.add({ days: 1 })))
      success(collect())
    }, 300)
  },
}

// Source 3: a JSON feed (static file under public/, fetched with ?start=&end=&timeZone=).
const EVENT_SOURCES: EventSourceInput[] = [
  { events: (_info, success) => success(STATIC_EVENTS) },
  asyncSource,
  { url: '/events.json' },
]

interface LogEntry {
  id: number
  line: string
}

let logCounter = 0

// Custom event content: the default markup (title, then time) rendered by React. Reusing the core
// class names keeps the built-in layout, including the one-line form for short timed events.
const eventContent: CalendarProps['eventContent'] = (info) => (
  <>
    <h4 className="cx-event-title">{String(info.event.title)}</h4>
    {info.timeText && <time className="cx-event-time">{info.timeText}</time>}
  </>
)

export function PlaygroundDemo(): JSX.Element {
  const scheme = useScheme()
  const [timeZone, setTimeZone] = useState('America/New_York')
  // Zero enables container-based sizing; 1–7 select a fixed duration. Seven keeps the week aligned;
  // anything shorter is a day-based duration anchored on the current date, so prev/next step by N.
  const [weekDays, setWeekDays] = useState(0)
  const views = useMemo<NonNullable<CalendarProps['views']>>(
    () => ({
      timeGridWeek: {
        duration: weekDays === 0 || weekDays === 7 ? { weeks: 1 } : { days: weekDays },
      },
    }),
    [weekDays],
  )
  const buttonText = useMemo(
    () => ({
      ...BUTTON_TEXT,
      timeGridWeek: weekDays === 0 || weekDays === 7 ? 'week' : `${weekDays} days`,
    }),
    [weekDays],
  )
  const [entries, setEntries] = useState<LogEntry[]>([])
  // Event details popover: plain app state driven by `eventClick`; the library only reports the click.
  const [popover, setPopover] = useState<EventPopoverTarget | null>(null)
  const [selection, setSelection] = useState<SelectInfo | null>(null)
  const [createdEvents, setCreatedEvents] = useState<EventInput[]>([])
  const [scrollToFirstEvent, setScrollToFirstEvent] = useState(false)
  const [swipeNavigation, setSwipeNavigation] = useState(false)
  const [keyboard, setKeyboard] = useState(true)
  const [fetchPadding, setFetchPadding] = useState(true)
  // Empty string means off; otherwise a key of BUSINESS_HOURS.
  const [businessHours, setBusinessHours] = useState('')
  const [noOverlap, setNoOverlap] = useState(false)
  // Room A keeps its own, earlier hours: resource hours replace the calendar's for that column or row.
  const resources = useMemo<ResourceInput[]>(
    () =>
      businessHours
        ? RESOURCES.map((room) =>
            room.id === 'a'
              ? { ...room, businessHours: { startTime: '07:00', endTime: '15:00' } }
              : room,
          )
        : RESOURCES,
    [businessHours],
  )
  const [randomCount, setRandomCount] = useState(50)
  const [randomEvents, setRandomEvents] = useState<EventInput[]>([])
  const eventSources = useMemo<EventSourceInput[]>(
    () => [
      ...EVENT_SOURCES,
      { events: (_info, success) => success(createdEvents) },
      { events: (_info, success) => success(randomEvents) },
    ],
    [createdEvents, randomEvents],
  )
  const closePopover = useCallback(() => setPopover(null), [])
  // The built-in hover preview would stack on top of the open popover for the same event.
  const openEventId = popover?.event.id
  const eventTooltip = useCallback(
    (info: { event: CalEvent }) => info.event.id !== openEventId,
    [openEventId],
  )
  const { ref, calendar } = useCalendar()
  // The clicked element is one segment of the event, and a move, resize, or navigation may re-create
  // it. Once the calendar has rendered, anchor the open popover to whatever renders the event now.
  const reanchorPopover = (id: string): void => {
    requestAnimationFrame(() =>
      setPopover((previous) => {
        if (!previous || previous.event.id !== id || !calendar) return previous
        const [anchor] = calendar.getEventElements(id)
        const event = calendar.getEventById(id)
        return anchor && event
          ? { event, anchor, placement: placeUnder(anchor.getBoundingClientRect()) }
          : null
      }),
    )
  }
  const controller = useCalendarController(calendar)

  const log = (line: string): void => {
    const entry = { id: ++logCounter, line }
    setEntries((prev) => [entry, ...prev].slice(0, 8))
  }

  const generateSchedule = (): void => {
    if (!calendar) return
    setPopover(null)
    setSelection(null)
    calendar.unselect().changeView('timeGridWeek').render()
    const view = calendar.getView()
    const first = view.currentStart.toPlainDate()
    const days = first.until(view.currentEnd.toPlainDate(), { largestUnit: 'days' }).days
    setRandomEvents(createRandomEvents(randomCount, first, days))
    log(`generated ${randomCount} events across four calendars`)
  }

  // Local edits remain authoritative when the demo's sources refetch.
  const persistLocalEvent = (event: CalEvent): void => {
    const update = (previous: EventInput[]): EventInput[] =>
      previous.some((input) => input.id === event.id)
        ? previous.map((input) =>
            input.id === event.id
              ? {
                  ...input,
                  start: event.start,
                  end: event.end,
                  allDay: event.allDay,
                  resourceId: event.resourceIds,
                  resourceIds: event.resourceIds,
                }
              : input,
          )
        : previous
    setCreatedEvents(update)
    setRandomEvents(update)
  }

  // Month view only: other views re-create their day cells, so the slot simply is not requested there.
  const dayCellContent: CalendarProps['dayCellContent'] =
    controller.view === 'dayGridMonth'
      ? (info) => (
          <span className="day-number" data-testid="day-number">
            {info.date.day}
          </span>
        )
      : undefined

  return (
    <>
      <section className="playground-panel" aria-label="Playground settings">
        <fieldset>
          <legend>Display</legend>
          <label className="field" htmlFor="tz-picker">
            <span>Time zone</span>
            <TimezonePicker value={timeZone} onChange={setTimeZone} />
          </label>
          <label className="field" htmlFor="days-picker">
            <span>Week length</span>
            <select
              id="days-picker"
              value={weekDays}
              onChange={(e) => setWeekDays(Number(e.currentTarget.value))}
            >
              <option value={0}>Auto</option>
              {[1, 2, 3, 4, 5, 6, 7].map((n) => (
                <option key={n} value={n}>
                  {n === 1 ? '1 day' : `${n} days`}
                </option>
              ))}
            </select>
          </label>
        </fieldset>
        <fieldset>
          <legend>Behavior</legend>
          <label className="toggle">
            <input
              type="checkbox"
              checked={keyboard}
              onChange={(event) => setKeyboard(event.currentTarget.checked)}
            />
            Keyboard navigation and shortcuts
          </label>
          <label className="toggle">
            <input
              type="checkbox"
              checked={swipeNavigation}
              onChange={(event) => setSwipeNavigation(event.currentTarget.checked)}
            />
            Swipe to change period
          </label>
          <label className="toggle">
            <input
              type="checkbox"
              checked={fetchPadding}
              onChange={(event) => setFetchPadding(event.currentTarget.checked)}
            />
            Prefetch adjacent periods
          </label>
          <label className="toggle">
            <input
              type="checkbox"
              checked={scrollToFirstEvent}
              onChange={(event) => setScrollToFirstEvent(event.currentTarget.checked)}
            />
            Scroll to first event
          </label>
          <label className="field" htmlFor="business-hours">
            <span>Business hours (shade and constrain edits)</span>
            <select
              id="business-hours"
              value={businessHours}
              onChange={(event) => setBusinessHours(event.currentTarget.value)}
            >
              <option value="">Off</option>
              {Object.entries(BUSINESS_HOURS).map(([key, preset]) => (
                <option key={key} value={key}>
                  {preset.label}
                </option>
              ))}
            </select>
          </label>
          <label className="toggle">
            <input
              type="checkbox"
              checked={noOverlap}
              onChange={(event) => setNoOverlap(event.currentTarget.checked)}
            />
            No overlapping edits or selections
          </label>
        </fieldset>
        <fieldset>
          <legend>Random schedule</legend>
          <div className="row">
            <select
              id="random-event-count"
              aria-label="Number of random events"
              value={randomCount}
              onChange={(event) => setRandomCount(Number(event.currentTarget.value))}
            >
              {[10, 25, 50, 100, 250, 500].map((count) => (
                <option key={count} value={count}>
                  {count} events
                </option>
              ))}
            </select>
            <button type="button" onClick={generateSchedule} disabled={!calendar}>
              {randomEvents.length ? 'Shuffle' : 'Generate'}
            </button>
            {randomEvents.length > 0 && (
              <button
                type="button"
                onClick={() => {
                  setRandomEvents([])
                  setPopover(null)
                  setSelection(null)
                  calendar?.unselect()
                }}
              >
                Clear
              </button>
            )}
          </div>
          <ul className="calendar-legend" aria-label="Generated calendars">
            {DEMO_CALENDARS.map((item) => (
              <li key={item.id}>
                <span style={{ backgroundColor: item.color }} aria-hidden="true" />
                {item.title}
              </li>
            ))}
          </ul>
          <p className="hint">
            {randomEvents.length
              ? `${randomEvents.length} overlapping events in the visible week.`
              : 'Fills the visible week with overlapping events across four calendars.'}
          </p>
        </fieldset>
      </section>
      <div className="status-row">
        <div className="headless-toolbar" data-testid="headless-toolbar">
          <button
            type="button"
            data-testid="headless-prev"
            aria-label="Previous date range"
            onClick={controller.prev}
          >
            ‹
          </button>
          <strong data-testid="headless-title">{controller.title}</strong>
          <button
            type="button"
            data-testid="headless-next"
            aria-label="Next date range"
            onClick={controller.next}
          >
            ›
          </button>
          <span className="hint">headless toolbar via useCalendarController</span>
        </div>
        <output className="status" aria-live="polite">
          <code data-testid="view">{controller.view}</code>
          <span className="loading-status" data-testid="loading">
            {controller.loading ? 'loading…' : ''}
          </span>
        </output>
      </div>
      <div className="card">
        <Calendar
          ref={ref}
          plugins={PLUGINS}
          view="dayGridMonth"
          colorScheme={scheme}
          headerToolbar={HEADER_TOOLBAR}
          buttonText={buttonText}
          views={views}
          responsiveWeek={weekDays === 0}
          timeZone={timeZone}
          nowIndicator
          scrollTime="08:00"
          scrollToFirstEvent={scrollToFirstEvent}
          swipeNavigation={swipeNavigation}
          keyboardNavigation={keyboard}
          keyboardShortcuts={keyboard}
          fetchPadding={fetchPadding}
          height="720px"
          editable
          selectable
          businessHours={businessHours ? BUSINESS_HOURS[businessHours]!.hours : false}
          dragConstraint={businessHours ? 'businessHours' : undefined}
          resizeConstraint={businessHours ? 'businessHours' : undefined}
          selectConstraint={businessHours ? 'businessHours' : undefined}
          eventOverlap={!noOverlap}
          selectOverlap={!noOverlap}
          resources={resources}
          eventSources={eventSources}
          eventContent={eventContent}
          eventTooltip={eventTooltip}
          dayCellContent={dayCellContent}
          eventClick={(info) => {
            log(`clicked event "${info.event.title}"`)
            const target = {
              event: info.event,
              anchor: info.el,
              placement: placeUnder(info.el.getBoundingClientRect()),
            }
            setPopover((previous) => (previous?.anchor === info.el ? null : target))
          }}
          eventDrop={(info) => {
            persistLocalEvent(info.event)
            log(`moved "${info.event.title}" to ${info.event.startStr}`)
            reanchorPopover(info.event.id)
          }}
          eventResize={(info) => {
            persistLocalEvent(info.event)
            log(`resized "${info.event.title}" to ${info.event.endStr}`)
            reanchorPopover(info.event.id)
          }}
          pointerDown={(info) => {
            // What was pressed, classified by the calendar before any click, drag, or selection.
            if (info.target === 'event') log(`pressed event "${info.event.title}"`)
            else if (info.target === 'date') log(`pressed ${info.dateStr}`)
            else log('pressed outside cells')
          }}
          select={(info) => {
            log(`selected ${info.startStr} → ${info.endStr}`)
            setPopover(null)
            setSelection(info)
          }}
          dateClick={(info) => log(`clicked ${info.dateStr}`)}
          datesSet={(info) => {
            log(`datesSet ${info.view.type} ${info.startStr.slice(0, 10)}`)
            // The view re-renders: follow the event if it is still shown, otherwise close.
            setPopover((previous) => {
              if (previous) reanchorPopover(previous.event.id)
              return previous
            })
            setSelection(null)
          }}
          resourceExpand={(info) =>
            log(
              `${info.resource.expanded ? 'expanded' : 'collapsed'} ${String(info.resource.title)}`,
            )
          }
        />
      </div>
      {selection && (
        <EventCreateDialog
          selection={selection}
          timeZone={timeZone}
          resourceTitle={selection.resource ? resourceTitle(selection.resource.id) : undefined}
          onCancel={() => {
            setSelection(null)
            calendar?.unselect().focus()
          }}
          onCreate={(title) => {
            const input: EventInput = {
              id: crypto.randomUUID(),
              title,
              start: selection.start,
              end: selection.end,
              allDay: selection.allDay,
              resourceIds: selection.resource ? [selection.resource.id] : [],
            }
            calendar?.addEvent(input)
            setCreatedEvents((previous) => [...previous, input])
            log(`created "${title}"`)
            setSelection(null)
            calendar?.unselect().focus()
          }}
        />
      )}
      <EventPopover
        target={popover}
        locale="en-US"
        resourceTitle={resourceTitle}
        onClose={closePopover}
        onDelete={(event) => {
          setCreatedEvents((previous) =>
            previous.some((input) => input.id === event.id)
              ? previous.filter((input) => input.id !== event.id)
              : previous,
          )
          setRandomEvents((previous) => previous.filter((input) => input.id !== event.id))
          calendar?.removeEventById(event.id)
          log(`deleted "${event.title}"`)
          setPopover(null)
        }}
      />
      <ul className="log" aria-live="polite">
        {entries.map((entry) => (
          <li key={entry.id}>{entry.line}</li>
        ))}
      </ul>
    </>
  )
}

/** Resource title lookup over the (nested) RESOURCES tree. */
function resourceTitle(id: string): string {
  const find = (list: ResourceInput[]): string | undefined => {
    for (const r of list) {
      if (String(r.id) === id) return String(r.title)
      const inChildren = r.children && find(r.children)
      if (inChildren) return inChildren
    }
    return undefined
  }
  return find(RESOURCES) ?? id
}