Creating & Editing
Every app stores something different, so the form is yours. The scheduler provides the flow: it knows when the user picked a place for a new event, opens a panel there, and saves whatever your form builds through the same pipeline as a drag.
Three Ways In
Which one
| Way | Opens when |
|---|---|
createPanel | A drag over empty slots, Enter on a focused slot, or setting draft |
onSelectSlot and onEventClick | A click on a slot, a click on an event |
Neither | A drag creates "New event" at once |
<!-- 1. A panel inside the calendar, filled by your snippet -->
<Scheduler bind:events createPanel={form} />
<!-- 2. Your own dialog, opened from the two reports -->
<Scheduler bind:events creatable={false} detail={false}
onSelectSlot={(slot) => openDialog(slot)}
onEventClick={(event) => openDialog(event)} />
<!-- 3. Nothing: a drag creates "New event", to be renamed later -->
<Scheduler bind:events />A Create Panel
Pass createPanel and a panel slides in from the side of the calendar when the user marks out a new event: a drag
over empty slots, or Enter on a focused slot. The snippet receives the picked range and a create function. While createPanel is set the scheduler never creates an event on its own. The form below is built from sv5ui inputs
and is not part of the package. Click an existing event, then Open details, to edit it in the
same panel.
Oct 19 – 25, 2026
onMutate calls
- Create, edit or delete something.
<script lang="ts">
import { Scheduler, type CreatePanelSnippetProps, type EventInput } from '@sv5ui/scheduler';
import EventForm from './EventForm.svelte';
interface Notes { notes: string }
let events = $state<EventInput<Notes>[]>([/* ... */]);
</script>
<!-- Drag across empty slots, or press Enter on a focused slot, and the panel
opens with the picked range. create() goes through onMutate like a drag:
the id may be replaced by the server, a failure rolls back. -->
<Scheduler bind:events {calendars} createPanel={form} onMutate={save} />
{#snippet form({ start, end, allDay, close, create }: CreatePanelSnippetProps<Notes>)}
<EventForm {start} {end} {allDay} {calendars} onSubmit={create} onCancel={close} />
{/snippet}CreatePanelSnippetProps
| Field | Description |
|---|---|
start, end | The picked range, snapped to the grid; end exclusive |
allDay | Whole days were picked rather than a time range |
close() | Closes the panel without creating anything |
create(input) | Creates through the pipeline and closes. id is generated when omitted; onMutate may replace it |
Opening It From a Button
draft is the range the panel shows, or null when it is closed. It is bindable: set it to open the panel from a button of your own, as the
"Create event" button in the toolbar above does, and read it to know what is being planned. With creatable={false} the grid never opens the panel and draft becomes the only way in.
<script lang="ts">
import { now } from '@internationalized/date';
let draft = $state<SlotSelection | null>(null);
function nextHour() {
const start = now(zone).set({ minute: 0, second: 0, millisecond: 0 }).add({ hours: 1 });
draft = { start, end: start.add({ hours: 1 }), allDay: false };
}
</script>
<!-- draft is the range the panel shows; set it to open the panel from anywhere,
read it to know what is being planned. null closes it. -->
<Scheduler bind:events bind:draft createPanel={form} toolbarActions={actions} />
{#snippet actions()}
<Button label="Create event" icon="lucide:plus" onclick={nextHour} />
{/snippet}
<!-- creatable={false} with a createPanel: the grid never opens it; only draft does -->A Click Only Reports
A click on an empty slot does not open the panel: it reports one slot, or one day, through onSelectSlot. Opening a form on every stray click is the behaviour most apps end up switching off, so
it is not the default. If a click should open something, open it yourself from that
callback.
<!-- A click on an empty slot only reports it. It does not open the panel, so a
stray click does not throw a form at the user. -->
<Scheduler
bind:events
createPanel={form}
onSelectSlot={(slot) => console.log(slot.start.toString(), slot.end.toString(), slot.allDay)}
/>
<!-- To open your own dialog on a click instead -->
<Scheduler bind:events creatable={false} onSelectSlot={(slot) => (dialog = slot)} />
<Modal open={dialog !== null} title="New event">...</Modal>Your Own Dialog Instead
Leave createPanel out, turn the built-in details off, and listen to the two reports. Saving from a dialog is not
a gesture, so onMutate does not run: save to your API, then write into the array. Click a slot or an event below.
Oct 19 – 25, 2026
<script lang="ts">
let editing = $state<SchedulerEvent | SlotSelection | null>(null);
async function saveDialog(values: EventInput) {
// Not a gesture, so onMutate does not run: save, then update the array
const saved = await api.save(values);
const index = events.findIndex((event) => event.id === saved.id);
if (index === -1) events.push(saved);
else events[index] = saved;
editing = null;
}
</script>
<Scheduler
bind:events
creatable={false}
detail={false}
onSelectSlot={(slot) => (editing = slot)}
onEventClick={(event) => (editing = event)}
/>
<Modal open={editing !== null} onOpenChange={(open) => !open && (editing = null)}>
<!-- your form, seeded from editing -->
</Modal>Event Details
detail decides what a click on an event opens. popover is a card beside the event with the date, time, calendar, a delete button when the event can be
deleted, and a close button. slideover is a panel inside the calendar with the same content and the delete button in its footer. false opens nothing. Below compactBreakpoint a popover opens as a slide-over. onEventClick fires in every mode.
Oct 19 – 25, 2026
<!-- A card anchored to the event, with delete and close. The default. -->
<Scheduler bind:events detail="popover" />
<!-- A panel inside the calendar; the body is eventPanel when given -->
<Scheduler bind:events detail="slideover" />
<!-- Nothing opens; onEventClick still fires -->
<Scheduler bind:events detail={false} onEventClick={openMine} />
<!-- Below compactBreakpoint a popover opens as a slide-over anyway -->Adding to the Details
eventDetail adds content under the default details, in the popover and in the slide-over alike. The demo above
shows the notes and a button through it.
<!-- Adds to the default details, in the popover and the slide-over alike -->
<Scheduler bind:events eventDetail={extra} />
{#snippet extra({ event, close }: EventDetailSnippetProps<Meeting>)}
<p class="text-sm">{event.data?.location}</p>
<Button label="Join call" size="sm" onclick={() => { open(event.data?.link); close(); }} />
{/snippet}A Panel per Kind of Event
eventPanel replaces the body of the slide-over, keeping its title and close button. With detail="slideover" it opens on click; with detail="popover" the popover gains an Open details button that switches to it, which is what the first demo on
this page does.
<!-- Replaces the body of the slide-over. With detail="popover" the popover gains
an Open details button that switches to this panel. -->
<Scheduler bind:events detail="popover" eventPanel={panel} />
{#snippet panel({ event, close, remove, update, deletable }: EventPanelSnippetProps<Notes>)}
<EventForm
start={event.start}
end={event.end}
allDay={event.allDay ?? false}
initial={{ title: event.title, calendarId: event.calendarId, data: event.data }}
{calendars}
onSubmit={(changes) => { update(changes); close(); }}
onCancel={close}
/>
{#if deletable}
<Button label="Delete" color="error" variant="soft" onclick={remove} />
{/if}
{/snippet}EventPanelSnippetProps
| Field | Description |
|---|---|
event | The event, with ZonedDateTime dates and your data |
close() | Closes the panel |
remove() | Deletes through the pipeline and closes. Does nothing when deletable is false |
update(changes) | Merges the fields and sends an update mutation. Does nothing when deletable is false |
deletable | Whether the event may be changed, following editable on the event and the scheduler |
Editing an Existing Event
update(changes) from the panel merges the fields you pass into the event and sends an update mutation, so onMutate runs, a failure rolls back and the change is announced. A change to data alone is sent too. Occurrences of a series cannot be updated; change the rule on the series as Recurring Events shows.
// update(changes) merges the fields into the event and sends an 'update'
// mutation through the pipeline: onMutate runs, a failure rolls back, the
// change is announced. Fields left out keep their value. It does nothing
// when deletable is false, which follows editable on the event and the scheduler.
update({ title: 'Design review, extended', end: event.end.add({ minutes: 30 }) });
update({ data: { ...event.data, notes: 'Bring the mockups' } }); // payload only is fine
// An occurrence of a series is never editable; change the rule on the series instead.onEventClick
<!-- Fires on click or keyboard activation, whatever detail is, once per opening
click (not again for the click that closes a popover). An occurrence arrives
with seriesId set. -->
<Scheduler bind:events onEventClick={(event) => analytics.track('open', event.id)} />Props
The props of the three ways in. draft is bindable.
| Prop | Type | Default |
|---|---|---|
creatable | boolean | true |
editable | boolean | true |
detail | 'popover' | 'slideover' | false | 'popover' |
eventDetail | Snippet<[EventDetailSnippetProps<T>]> | - |
eventPanel | Snippet<[EventPanelSnippetProps<T>]> | - |
createPanel | Snippet<[CreatePanelSnippetProps<T>]> | - |
draft | SlotSelection | null | null |
onEventClick | (event: SchedulerEvent<T>) => void | - |
onSelectSlot | (selection: SlotSelection) => void | - |
Snippets
What the snippets on this page receive. Every type is exported from @sv5ui/scheduler.
| Type | Fields |
|---|---|
EventDetailSnippetProps | event, close |
EventPanelSnippetProps | event, close, remove, update, deletable |
CreatePanelSnippetProps | start, end, allDay, close, create |