#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
}