Event editor
Event editor is the create-and-edit form for a single scheduler event,
covering a title, description, a date (or a start and end date range in
all-day mode), start and end time, location, notes, priority, color, and
guests to share the event with, each rendered with an existing library
field component. It's an independent piece of the
Dependencies
When to use
Reach for event editor whenever you need the exact create-and-edit form scheduler itself uses for a single event, embedded in your own dialog instead of a drawer or modal, on a dedicated "new event" page, or anywhere else a standalone event form makes sense outside a full calendar.
Leaving the event unset (or null) puts the form in create mode, where it seeds from an initial start and end time, defaulting to the next hour and one hour after that. Passing an existing event switches it to edit mode instead, so every field seeds from it and a Delete button appears in the built-in footer. It never mutates the event itself, and reads or writes nothing beyond dispatching its events (see below), so the consumer decides what a save or delete actually does to their own data.
See
Install & usage
npm install @eunomia/elements
import "@eunomia/elements/scheduler-event-editor.js";
<eun-scheduler-event-editor id="editor"></eun-scheduler-event-editor>
<script>
const editor = document.querySelector("#editor");
editor.addEventListener("euneventcreate", (event) => {
console.log(event.event); // a full EunomiaSchedulerEvent, id included
});
editor.addEventListener("euneventupdate", (event) => {
console.log(event.event);
});
editor.addEventListener("euneventdelete", (event) => {
console.log(event.eventId);
});
</script>
Set hide-actions to suppress the built-in Save/Cancel/Delete footer and
drive the form from your own UI instead, via the public requestSave()/
requestDelete()/requestCancel() methods and the dirty/valid/
isEditing getters, with euneditorstatechange reporting each change of
dirty or valid,
exactly how eun-scheduler unifies the footer with its own drawer/modal
chrome rather than rendering two stacked footers:
const saved = editor.requestSave(); // false if invalid, turning on inline errors
if (saved) {
/* editor already dispatched euneventcreate/euneventupdate */
}
editor.requestDelete(); // dispatches euneventdelete, edit mode only
editor.requestCancel(); // resets the form and dispatches euneventcancel
// Keeps a custom Save button disabled until something changed.
editor.addEventListener("euneditorstatechange", (event) => {
saveButton.disabled = !event.dirty || !event.valid;
});
Live testing
Properties
SchedulerEventEditor <eun-scheduler-event-editor>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| locale | string | — | The locale used by the nested date and time fields. Defaults to the one set with setDefaultLocale, English otherwise |
| hide-actions | boolean | false | Hides the built-in save, cancel, and delete footer, for a host that drives the form itself |
| title-label | string | — | The label of the title field. Defaults to the localized string for the current locale (English: "Title") |
| description-label | string | — | The label of the description field. Defaults to the localized string for the current locale (English: "Description") |
| all-day-label | string | — | The label of the all day checkbox. Defaults to the localized string for the current locale (English: "All day") |
| date-label | string | — | The label of the date field. Defaults to the localized string for the current locale (English: "Date") |
| end-date-label | string | — | The label of the end date field. Defaults to the localized string for the current locale (English: "End date") |
| start-time-label | string | — | The label of the start time field. Defaults to the localized string for the current locale (English: "Start time") |
| end-time-label | string | — | The label of the end time field. Defaults to the localized string for the current locale (English: "End time") |
| location-label | string | — | The label of the location field. Defaults to the localized string for the current locale (English: "Location") |
| notes-label | string | — | The label of the notes field. Defaults to the localized string for the current locale (English: "Notes") |
| priority-label | string | — | The label of the priority control. Defaults to the localized string for the current locale (English: "Priority") |
| color-label | string | — | The label of the color swatch picker. Defaults to the localized string for the current locale (English: "Color") |
| required-label | string | undefined | — | Accessible name appended after titleLabel when the title field is left empty on submit (e.g. "Title is required"). Left unset along with titleLabel, the whole message is the localized one for the current locale (e.g. "Le titre est obligatoire"). |
| guests-label | string | — | The label of the guest-sharing field. Defaults to the localized string for the current locale (English: "Guests") |
| add-guest-label | string | — | The accessible label of the guest email input's add button. Defaults to the localized string for the current locale (English: "Add guest") |
| guest-error-label | string | — | The error shown under the guest email field when the entered address is malformed. Defaults to the localized string for the current locale (English: "Enter a valid email address") |
| range-error-label | string | — | The error shown under the time fields when the end isn't after the start. Defaults to the localized string for the current locale (English: "The end must be after the start") |
| remove-guest-label | string | — | A prefix combined with the guest's email to build each guest chip's delete button accessible name, such as "Remove alice@example.com". Defaults to the localized string for the current locale (English: "Remove") |
| save-label | string | — | The label of the built-in save button. Defaults to the localized string for the current locale (English: "Save") |
| cancel-label | string | Cancel | The label of the built-in cancel button. Resets the form to its last-seeded values and fires the cancel event. Defaults to the localized string for the current locale (English: "Cancel") |
| delete-label | string | — | The label of the built-in delete button. Only shown while editing an existing event. Defaults to the localized string for the current locale (English: "Delete") |
| delete-confirm-label | string | — | The question the built-in footer asks before deleting. Defaults to the localized string for the current locale (English: "Delete this event?") |
Import the exact TypeScript type behind any property above, see
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| event | EunomiaSchedulerEvent | null | — | The event being edited. Unset, or null, puts the form in create mode instead, seeded from initialStart and initialEnd |
| initialStart | Date | — | The start time to seed a new event with, in create mode. Defaults to the next hour |
| initialEnd | Date | — | The end time to seed a new event with, in create mode. Defaults to one hour after initialStart |
| colorLabels | Record<EunomiaSchedulerEventColor, string> | undefined | — | Accessible name for each color swatch button, keyed by EunomiaSchedulerEventColor. Falls back to the localized names for the current locale when unset. |
| dirty | boolean | — | Whether any field has changed since the last seed/save — wire into a host `eun-drawer`'s own `dirty` for its built-in unsaved-changes guard. |
| isEditing | boolean | — | Whether the form is currently editing an existing event (`event` is set) rather than creating a new one. |
| valid | boolean | — | Whether the form currently passes validation (non-empty title, a complete date/start/end, and an end strictly after start). |
| resolvedLocale | — | This instance's own `locale` when set, the shared default locale otherwise. Used for `Intl` formatting, so dates and numbers follow the same locale as dictionary. | |
| dictionary | — | This instance's locale-resolved string dictionary — see the class doc for the resolution order. |
Slots
| Name | Description |
|---|---|
| (default) | Unused. Present only so a wrapping drawer or modal can slot this element into their own default body slot |
Events
| Name | Type | Description |
|---|---|---|
| input | Event | |
| euneditorstatechange | EditorStateChangeEvent | Fired whenever dirty or valid changes value. Not fired for the initial render |
| eunguestinvite | GuestInviteEvent | Fired whenever a guest email is added, so a consumer can actually send the share invite |
| euneventcreate | EventCreateEvent | Fired on a valid save, in create mode |
| euneventupdate | EventUpdateEvent | Fired on a valid save, in edit mode, unless nothing changed |
| euneventdelete | EventDeleteEvent | Fired when the built-in delete button is activated |
| euneventcancel | EventCancelEvent | Fired when the built-in cancel button, or requestCancel, resets the form |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --scheduler-event-editor-gap | Sets the gap between fields |
Modes
Create mode
Leaving event unset renders a blank form (seeded from initialStart/
initialEnd, if set) with no Delete button.
<eun-scheduler-event-editor></eun-scheduler-event-editor>
Seeding a new event
initialStart and initialEnd seed a blank, create-mode form's date and
time fields, useful for prefilling from wherever the user triggered "new
event," such as a clicked time slot on a calendar. Both are Date
properties (attribute: false, like every date value elsewhere in this
library), so set them from JavaScript rather than as HTML attributes.
editor.initialStart = new Date(2026, 2, 16, 15, 30);
editor.initialEnd = new Date(2026, 2, 16, 16, 30);
Edit mode
Setting event seeds every field from it, and shows a Delete button.
The Cancel button resets the form to those values and fires
euneventcancel, for a host modal or drawer to close itself. Save stays
disabled until something changes, so an unchanged event is never sent
back, and a color given as a preset name keeps that name while its swatch
stays picked. Delete asks for a confirmation in the footer before firing
euneventdelete.
Every event has a start and an end date, so a timed event can span several days. An end at or before the start is refused with an inline error rather than moved to the next day, and changing the start date moves the end date along with it.
editor.event = {
id: "sample-1",
title: "Design review",
description: "Walk through the new onboarding flow.",
location: "Room A",
color: "purple",
priority: "high",
start: new Date(2026, 2, 16, 14, 0),
end: new Date(2026, 2, 16, 15, 0),
};
All-day mode
Checking "All day" hides the start/end time fields, leaving only the
start/end dates, with no time of day involved. Saving sets allDay: true and
start/end at the two dates' own day boundaries.
editor.event = {
id: "trip-1",
title: "Company offsite",
color: "purple",
allDay: true,
start: new Date(2026, 2, 17),
end: new Date(2026, 2, 19), // spans 3 days
};
Sharing with guests
Guests are entered as emails through the "Guests" field (Enter or the
add button), each starting out pending in event.guests, since the
editor never sends anything itself and only tracks the resulting
pending/confirmed/declined status. Adding one fires eunguestinvite
with the email (and the event's id, once editing an existing event): the
signal a consumer listens for to actually send the invite. Each guest
chip shows its status as an icon: check / hourglass / cross for
confirmed/pending/declined, in both the editor and the
editor.event = {
id: "sample-guests-1",
title: "Design review",
color: "purple",
start: new Date(2026, 2, 16, 14, 0),
end: new Date(2026, 2, 16, 15, 0),
guests: [
{ email: "amelie@example.com", status: "confirmed" },
{ email: "noah@example.com", status: "pending" },
{ email: "sofia@example.com", status: "declined" },
],
};
// Fired whenever a guest email is added from the field — send the actual
// invite from here (this library only tracks the resulting status).
editor.addEventListener("eunguestinvite", (event) => {
sendInvite(event.email, event.eventId); // your own API call
});
Customization
Without the built-in footer
For a host (like eun-scheduler itself) that drives Save/Cancel/Delete
from its own surrounding chrome instead.
<eun-scheduler-event-editor hide-actions></eun-scheduler-event-editor>
Localizing labels
Every visible label, down to the guest chip's delete button, is overridable, which is what lets the whole form read naturally in a language other than English without touching a single word of markup.
<eun-scheduler-event-editor
title-label="Titre"
description-label="Description"
all-day-label="Toute la journée"
date-label="Date"
end-date-label="Date de fin"
start-time-label="Heure de début"
end-time-label="Heure de fin"
location-label="Lieu"
notes-label="Notes"
priority-label="Priorité"
color-label="Couleur"
guests-label="Invités"
add-guest-label="Ajouter un invité"
remove-guest-label="Retirer"
save-label="Enregistrer"
cancel-label="Annuler"
delete-label="Supprimer"
></eun-scheduler-event-editor>
Custom event editor
You're not limited to eun-scheduler-event-editor's own fields or layout.
It never mutates event and only ever dispatches plain euneventcreate/
euneventupdate/euneventdelete events carrying an event/eventId
property: the exact same contract eun-calendar's own wiring in
eun-input/eun-date-picker/
eun-time-picker (just title, date, start/end time and a color, no
description/notes/priority/all-day support), and drops straight into that
same eun-calendar wiring in place of <eun-scheduler-event-editor>, no
other changes needed.
<div id="custom-editor">
<eun-input id="ce-title" label="Title" required hide-error></eun-input>
<span id="ce-title-error" role="alert" hidden>Title is required</span>
<eun-date-picker id="ce-date" label="Date"></eun-date-picker>
<div class="row">
<eun-time-picker id="ce-start" label="Start time"></eun-time-picker>
<eun-time-picker id="ce-end" label="End time"></eun-time-picker>
</div>
<label for="ce-color">Color</label>
<select id="ce-color">
<option value="blue">Blue</option>
<option value="red">Red</option>
<option value="green">Green</option>
<option value="purple">Purple</option>
<option value="yellow">Yellow</option>
<option value="pink">Pink</option>
</select>
<div class="actions">
<eun-button id="ce-delete" color="critical">Delete</eun-button>
<eun-button id="ce-cancel" color="secondary">Cancel</eun-button>
<eun-button id="ce-save" color="primary">Save</eun-button>
</div>
</div>
<script type="module">
import "@eunomia/elements/input.js";
import "@eunomia/elements/date-picker.js";
import "@eunomia/elements/time-picker.js";
import "@eunomia/elements/button.js";
const root = document.querySelector("#custom-editor");
const titleEl = root.querySelector("#ce-title");
const titleErrorEl = root.querySelector("#ce-title-error");
const dateEl = root.querySelector("#ce-date");
const startEl = root.querySelector("#ce-start");
const endEl = root.querySelector("#ce-end");
const colorEl = root.querySelector("#ce-color");
const deleteBtn = root.querySelector("#ce-delete");
let currentEvent = null; // null => create mode
function startOfDay(date) {
const start = new Date(date);
start.setHours(0, 0, 0, 0);
return start;
}
function nextHour() {
const date = new Date();
date.setMinutes(0, 0, 0);
date.setHours(date.getHours() + 1);
return date;
}
// Call this from your own click/select handlers, the same way
// `eun-scheduler-event-editor.event = ...` seeds it — `event` here is a
// `EunomiaSchedulerEvent`, `null` for create mode.
function seed(event) {
currentEvent = event;
titleEl.value = event?.title ?? "";
dateEl.value = event ? startOfDay(event.start) : startOfDay(new Date());
startEl.value = event?.start ?? nextHour();
endEl.value = event?.end ?? new Date(nextHour().getTime() + 60 * 60 * 1000);
colorEl.value = event?.color ?? "blue";
titleErrorEl.hidden = true;
deleteBtn.hidden = !event;
}
function requestSave() {
if (!titleEl.value.trim()) {
titleErrorEl.hidden = false;
return false;
}
const start = new Date(dateEl.value);
start.setHours(startEl.value.getHours(), startEl.value.getMinutes(), 0, 0);
const end = new Date(dateEl.value);
end.setHours(endEl.value.getHours(), endEl.value.getMinutes(), 0, 0);
const event = {
id: currentEvent?.id ?? crypto.randomUUID(),
title: titleEl.value.trim(),
color: colorEl.value,
start,
end,
};
// Matches `EventCreateEvent`/`EventUpdateEvent`'s own shape (an
// `event` property, not `detail`) so this drops into the exact same
// `euneventcreate`/`euneventupdate`/`euneventdelete` listeners
// "Building your own scheduler" wires up for `eun-scheduler-event-editor`.
const saveEvent = new Event(
currentEvent ? "euneventupdate" : "euneventcreate",
{ bubbles: true },
);
saveEvent.event = event;
root.dispatchEvent(saveEvent);
seed(event);
return true;
}
root.querySelector("#ce-save").addEventListener("click", requestSave);
root
.querySelector("#ce-cancel")
.addEventListener("click", () => seed(currentEvent));
deleteBtn.addEventListener("click", () => {
if (!currentEvent) {
return;
}
const deleteEvent = new Event("euneventdelete", { bubbles: true });
deleteEvent.eventId = currentEvent.id;
root.dispatchEvent(deleteEvent);
});
</script>
<style>
#custom-editor {
display: flex;
flex-direction: column;
gap: 12px;
}
#custom-editor .row {
display: flex;
gap: 12px;
}
#custom-editor .actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
#custom-editor .actions #ce-delete {
margin-right: auto;
}
</style>
Server-side rendering
The form is drawn inside the editor's shadow root, so a server-rendered
editor takes no space until the element upgrades, then pushes the content
below it. Link scheduler-ssr.css in the page's <head> to keep its room from the
first paint:
<link
rel="stylesheet"
href="node_modules/@eunomia/elements/dist/ssr/scheduler-ssr.css"
/>
A bundler that resolves package exports can reach it as
@eunomia/elements/scheduler-ssr.css. The same stylesheet covers
eun-calendar, eun-scheduler, eun-scheduler-event and
eun-scheduler-event-editor.
It reserves the height of the empty form, in the page's font, with or
without hide-actions. The fields show with the upgrade. An editor inside
a closed modal or drawer takes no room anyway.
Keyboard interactions
| Key | Action |
|---|---|
Tab / Shift+Tab |
Moves focus through the fields and footer buttons in document order |
ArrowLeft/ArrowRight |
Priority: moves and selects between options, wrapping. Color: only moves focus among swatches, browsing without picking |
Home / End |
Jumps to the first/last option in the focused group |
Enter / Space |
Activates the focused field/button/swatch/option, and is how a color actually gets picked |
Every text/date/time field, including the "All day" checkbox
(eun-checkbox) and the date/time fields it toggles between, is an
existing library component, so their keyboard behavior applies unchanged,
and each one's own Accessibility tab documents the full contract. The
color field is a real
eun-color-pickerpresets-inline mode, showing its declared presets directly as the
field with no trigger button or panel, while the priority picker is a
real eun-toggle-groupsingle mode, so their exact keyboard/ARIA contracts are documented
on their own Accessibility tabs instead of duplicated here, and this
page only covers how each is configured.
Aria attributes and rules
- The color field is
eun-color-picker's own preset swatch radiogroup: arole="radiogroup"ofrole="radio"swatch buttons, each witharia-checkedreflecting the current selection and anaria-labeltaken from its option's label text (e.g."Red"), and each its own real Tab stop (tabindex="0"), reachable one at a time byTabjust like by the arrow keys. Browsing with the arrow keys only moves focus, it never changesaria-checkedon its own, picking a color always takes an explicitEnter/Spaceor click. - The priority picker is a
eun-toggle-groupwithtype="stroke"andsingleset: exclusive selection (exactly one of the four always checked, defaulting tomedium) rendered as standalone bordered buttons instead of the color picker's swatches, each aeun-toggle-itemlabelled"Low"/"Medium"/"High"/"Critical".type="stroke"is purely visual: the underlying role/state/keyboard model is identical toeun-toggle-group's default presentation, see its own Accessibility tab. Each item'scolormapslowtosuccess,mediumto the unsetprimarydefault,hightowarning, andcriticaltocritical, a narrower, different mapping than the semantic-state token scale the read-only event chip/popover use elsewhere (that one also hasinfo, whicheun-button/eun-toggle-group's owncolordoesn't). - A required title left empty surfaces an inline
role="alert"message oncerequestSave()/the built-in Save button is attempted, not proactively while still typing, so a screen reader isn't interrupted before the user has even finished the field. - An end at or before the start surfaces its own inline
role="alert"message right away, since it only appears once both times are set.