Event dialog
StableThe create and edit form for an event, in a dialog, and the details popover that leads to it.
EventDialog asks for a title, a date, all day, a start and end in 15-minute steps (moving the start keeps the length) and the calendar, chosen by its pattern. It is controlled: a draft goes in as initial and a valid one comes back in onSave, and draftFromEvent and eventFromDraft convert to and from events. EventDetails is what an event’s popover shows: when, calendar, place, people, the linked task, Edit and Delete.
Open, edit, delete
import { useRef, useState } from 'react'import { sampleEvents } from '@rchursin/product/data/sample'import { draftFromEvent, EventBlock, EventDetails, EventDialog, eventFromDraft, HOUR_PX } from '@rchursin/product/calendar'import { Button, Popover, usePopover, useToast } from '@rchursin/product/ui'
/** “Weekly skill review” from the sample week */const sample = sampleEvents(new Date(2026, 9, 13)).find((e) => e.id === 'e11')!
/** * The calendar’s flow on one event: open it for its details, then Edit opens the form and * Delete removes it (with Undo). Focus goes back to the event after each step. */export default function EventDialogEdit() { const toast = useToast() const details = usePopover() const heading = useRef<HTMLHeadingElement>(null) const block = useRef<HTMLDivElement>(null) const undo = useRef<HTMLButtonElement>(null) const [event, setEvent] = useState(sample) const [editing, setEditing] = useState(false)Usage
import { draftFromEvent, EventDetails, EventDialog, eventFromDraft, type EventDraft } from '@rchursin/product/calendar'<EventDialog open={editing} mode="edit" initial={draftFromEvent(event)} onClose={() => setEditing(false)} onSave={(draft) => save(eventFromDraft(draft, event))}/>Examples
Create
An empty title shows an error. The end list only offers times after the start, with the length beside each.
import { useState } from 'react'import type { CalendarEvent } from '@rchursin/product/data/sample'import { AgendaList, EventDialog, eventFromDraft, type EventDraft } from '@rchursin/product/calendar'import { eventTime } from '@rchursin/product/calendar/events'import { Button, useToast } from '@rchursin/product/ui'
/** Where a new event starts: Friday morning, on the Focus calendar */const blank: EventDraft = { title: '', date: '2026-10-16', start: '10:00', end: '11:00', allDay: false, calendar: 'focus' }
/** * Create mode. Only the title is required; moving the start keeps the length. Ctrl+Enter (⌘Enter) * saves from any field. A click outside does nothing, so typing is never lost; Esc or Cancel closes. */export default function EventDialogCreate() { const toast = useToast() const [open, setOpen] = useState(false) const [created, setCreated] = useState<CalendarEvent[]>([]) /** The list starts at the earliest event you made and covers a year */ const first = created.reduce<Date | undefined>((min, e) => (!min || e.start < min ? e.start : min), undefined)
function save(draft: EventDraft) { const event = eventFromDraft(draft) setCreated((list) => [...list, event]) setOpen(false) toast({ title: 'Event created', description: `${event.title} · ${eventTime(event)}`, icon: 'calendar-check' }) }
return ( <div className="flex w-full max-w-sm flex-col gap-4"> <Button icon="plus" className="self-start" onClick={() => setOpen(true)}> New event </Button> <AgendaList events={created} from={first} days={366} className="-mx-3" empty={<p className="text-[13px] text-fg-3">Events you create show up here.</p>} /> <EventDialog open={open} mode="create" initial={blank} onClose={() => setOpen(false)} onSave={save} /> </div> )}API
EventDialog props
| Prop | Type | Default | Description |
|---|---|---|---|
openrequired | boolean | – | Shows the dialog. The form starts over from initial each time it opens. |
moderequired | 'create' | 'edit' | – | “New event” and Create event, or “Edit event” and Save changes. |
initialrequired | EventDraft | – | The form’s starting values. |
onCloserequired | () => void | – | Esc, Cancel or the close button. A click on the backdrop does nothing, so typing is never lost. |
onSaverequired | (draft: EventDraft) => void | – | Called with the draft once it has a title. |
EventDraft
| Prop | Type | Default | Description |
|---|---|---|---|
titlerequired | string | – | Required. |
daterequired | ISODate | – | “2026-10-16”. |
startrequired | TimeValue | – | “10:00”, 24-hour. |
endrequired | TimeValue | – | “11:00”. Ignored when allDay is on. |
allDayrequired | boolean | – | Hides the times. |
calendarrequired | 'team' | 'focus' | 'personal' | 'release' | – | Solid, hatched, outline or dashed. |
EventDetails props
| Prop | Type | Default | Description |
|---|---|---|---|
eventrequired | CalendarEvent | – | The event to describe. |
headingRef | RefObject<HTMLHeadingElement | null> | – | Give it to the Popover’s initialFocus, so the title takes focus when it opens. |
onEditrequired | () => void | – | The Edit button. |
onDeleterequired | () => void | – | The Delete button. |
onCloserequired | () => void | – | The close (×) button. |
taskHref | (taskId: string) => string | undefined | () => '#tasks' | Where the linked task goes, e.g. your task’s page. The default is the product kit’s own anchor; return undefined to show the task as text. |
Converters
| Prop | Type | Default | Description |
|---|---|---|---|
draftFromEvent | (e: CalendarEvent) => EventDraft | – | An event as form values. An all-day event gets a one-hour time range ready in case all day is turned off. |
eventFromDraft | (d: EventDraft, base?: CalendarEvent) => CalendarEvent | – | Form values as an event. With base it keeps the id, people and linked task, and a multi-day all-day event keeps its length. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| CtrlEnter | Save from any field. |
| Enter | In the title: save, the way any form submits. |
| Tab | Title, date, all day, starts, ends and the calendar, then Cancel and the save button. |
| Left arrowRight arrowUp arrowDown arrow | In Calendar: choose the previous or next calendar (they are radio buttons). |
| Esc | Close the dialog, or the details popover. Focus goes back. |
- A native modal dialog named “New event” or “Edit event”; focus starts in the title field.
- Saving without a title shows an error that suggests one (“Weekly skill review”), tied to the field by Field with aria-invalid and aria-describedby, and puts focus back in the field.
- Calendars are real radio buttons, visually hidden, in a fieldset with the legend “Calendar”. The chosen tile gets a tick and a heavier ring, not a colour, and a focus outline shows on the tile.
- EventDetails: the title is an <h2> that takes focus when the popover opens, so a screen reader starts there. The icons stand for hidden terms (Calendar, Where, People, Task), and with more than two people the full list follows for screen readers.
- The linked task in EventDetails is a link to taskHref(id) (the kit’s #tasks anchor by default), or plain text when taskHref returns undefined.
Guidelines
Do
- Put focus back yourself after Save or Delete: the button that opened the dialog was inside a popover that has since closed.
- Pass the popover’s heading ref to initialFocus, so the details start at the title.
Don’t
- Don’t let a click outside close a half-filled form; EventDialog turns that off for you.
- Don’t make people retype the end after moving the start; EventDialog keeps the length for them.
In use
Related
- CalendarThe whole calendar: week, day, month and agenda views, calendars told apart by pattern, and create, edit and delete.
- DialogA modal on the native <dialog> element: the page behind goes inert, Esc closes it, and focus goes back to what opened it.
- PopoverA panel beside its trigger, on the native popover API: never clipped, closed by Esc or a click outside, and it hands focus back.
- Date pickerA field that shows the chosen date and opens a month grid in a popover. Values are ISO date strings.
- Time selectA time of day from a list in 15-minute steps, in the locale’s hour cycle. End-time lists show each option’s length.
- Event blocksHow an event looks in each calendar view: a block in the week grid, a bar for all-day, a chip in a month cell.
Source
The component’s own files, exactly as they run on this site.
/** * The event form, in a dialog: title, date, all-day, start and end (15-minute steps; the end * keeps the length when the start moves) and the calendar, chosen by pattern. * ⌘/Ctrl + Enter saves from anywhere in the form. */import { useId, useRef, useState } from "react"import { cx } from "../../components/cx"import { calendars, type CalendarEvent } from "../data/sample"import { Icon } from "../icons/Icon"import { toISODate } from "../lib/dates"import { Button } from "../ui/button"import { Dialog } from "../ui/dialog"import { Field, Switch, TextInput } from "../ui/fields"import { dateAt, minutesOf, parseISODate, timeOf, timeValue, type ISODate,