Scheduler
Scheduler is a calendar with day, week, work-week, month, and year views
and full event create, edit, and delete, this library's first
Dependencies
When to use
Reach for scheduler whenever someone needs to see and manage events against a calendar, such as a booking system, a team's shared agenda, or a personal planner. It's a full feature, not a single field: it owns its own navigation (previous, next, and today, across five view granularities) and its own create, edit, and delete flow, rather than being a display-only grid you'd wire interactions around yourself.
It is not form-associated, since there's no single value a calendar of many events could sensibly represent. Instead it's a controlled display component: pass its events in, and listen for its create, update, and delete events to keep your own copy of that array in sync (see "Install and usage" below). Scheduler never mutates the array you pass it.
Clicking an event never jumps straight into editing it. It opens a read-only detail popover first, listing the title, full date and time (or "All day" for an all-day event), location, description, notes, and priority, each only shown if actually set, along with its own Edit and Delete buttons. Only Edit actually opens the create and edit form. This keeps a stray click from accidentally starting an edit, and gives a fast "what's this?" glance that doesn't need the heavier form at all. See "Event details in the popover" under Examples for every field combination shown side by side.
Five views are available, switched via the header's own segmented control:
- Day: a single-column hour grid for one day.
- Week (the default): a seven-column hour grid for a full week.
- Work week: the same hour grid with weekend columns dropped entirely.
- Month: a full six-week grid, showing as many events as actually fit each day's cell before collapsing the rest behind a "+N more" affordance.
- Year: twelve mini month grids, with a plain dot marking days that have events.
An all-day or multi-day event, one lasting a full day or spanning several calendar days, never appears inside the timed hour grid. It renders instead as a bar in a dedicated all-day row above it, spanning every day column it covers. See "All-day and multi-day events" under Examples.
Install & usage
npm install @eunomia/elements
import "@eunomia/elements/scheduler.js";
<eun-scheduler id="scheduler"></eun-scheduler>
<script>
const scheduler = document.querySelector("#scheduler");
let events = [
{
id: "1",
title: "Team stand-up",
color: "blue",
start: new Date(2026, 2, 16, 9, 0),
end: new Date(2026, 2, 16, 9, 30),
},
];
scheduler.events = events;
scheduler.addEventListener("euneventcreate", (event) => {
events = [...events, event.event]; // event.event already has a generated id
scheduler.events = events;
});
scheduler.addEventListener("euneventupdate", (event) => {
events = events.map((e) => (e.id === event.event.id ? event.event : e));
scheduler.events = events;
});
scheduler.addEventListener("euneventdelete", (event) => {
events = events.filter((e) => e.id !== event.eventId);
scheduler.events = events;
});
</script>
Importing scheduler.js also registers eun-scheduler-event and
eun-scheduler-event-editor, since it renders both internally, alongside
the dependencies listed above, which you'll want to import explicitly if
you register components individually rather than through a barrel that
already does it.
Scheduler delegates the calendar grid itself (view rendering,
swipe-to-navigate, the mobile day-selector strip) entirely to
Guidance
- Listen for
eunperiodchangeto lazy-load events for the newly visible range instead of loading every event up front: it fires on every nav/swipe/view-switch change with the exactrangeStart/rangeEndnow visible - Keep
eventsas the single source of truth on your side, reassigned (not mutated) from the three CRUD listeners:eun-scheduleris deliberately uncontrolled beyond that, the same "controlled array" patterneun-scheduler-event-editordocuments on its own - Set
readonlyfor a view-only agenda (a public event calendar, a read-only shift schedule) rather than hiding the create/edit affordances with CSS: the detail popover still opens on click, so consultation stays available, it just hides its own Edit/Delete buttons
- Expecting per-slot keyboard focus in the day/week/work-week grid: empty-slot creation there is pointer-only by design (see the Accessibility tab), so keyboard users create events through the header's "New event" button
- Recurring events: each occurrence needs its own entry in
events, since there's no recurrence rule concept - Relying on the month view's "+N more" to show a full day's agenda inline: it switches to
dayview for that date instead of opening a second floating list, by design (one editing surface, one navigation model)
Live testing
Properties
Scheduler <eun-scheduler>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| event-variant | 'default' | 'transparent' | 'ghost' | 'default' | The visual variant applied to day, week, and work week event chips. Month view is unaffected, always picking ghost, or default for multi-day bars, itself |
| view | 'day' | 'week' | 'work-week' | 'month' | 'year' | 'week' | The active calendar view |
| week-starts-on | 0 | 1 | 1 | Which weekday a week starts on |
| day-start-hour | number | 0 | The first visible hour row in day, week, and work week |
| day-end-hour | number | 24 | The last visible hour row, exclusive, in day, week, and work week |
| slot-duration | number | 30 | The grid's row granularity, in minutes, also the default duration for a newly created event |
| readonly | boolean | false | Disables creating, editing, and deleting. View only |
| no-animation | boolean | false | Disables the mobile swipe slide animation and the drawer or modal's own transitions |
| today-label | string | 'Today' | The label of the jump-to-today button |
| new-event-label | string | 'New event' | The label of the create-a-new-event button |
| aria-label-previous | string | 'Previous period' | The accessible label of the previous-period button |
| aria-label-next | string | 'Next period' | The accessible label of the next-period button |
| view-day-label | string | 'Day' | The label of the day view switcher option |
| view-week-label | string | 'Week' | The label of the week view switcher option |
| view-work-week-label | string | 'Work week' | The label of the work week view switcher option |
| view-month-label | string | 'Month' | The label of the month view switcher option |
| view-year-label | string | 'Year' | The label of the year view switcher option |
| apply-label | string | "Apply" | |
| cancel-label | string | "Cancel" | |
| delete-label | string | "Delete" | |
| edit-label | string | "Edit" | Label of the read-only event popover's "switch to editing" button. |
| close-label | string | "Close" | Accessible label of the read-only event popover's close (✕) button. |
| more-events-label | string | '+{count} more' | The template for the month view's overflow affordance, with the placeholder replaced by the hidden event count |
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 |
|---|---|---|---|
| events | Array<EunomiaSchedulerEvent> | [] | The events to display. Never mutated internally |
| displayedDate | Date | new Date() | The anchor date for the active view. Defaults to today, self-managed on navigation, swipe, or view switch, always overridable from outside |
| locale | string | — | The locale used throughout. Defaults to the runtime's own |
| dictionary | — | This instance's locale-resolved string dictionary — see the class doc for the resolution order. |
Events
| Name | Type | Description |
|---|---|---|
| eunperiodchange | PeriodChangeEvent | Fired whenever the visible period changes, from navigation, a swipe, a view switch, or an external view or displayedDate assignment |
| euneventcreate | EventCreateEvent | Fired when a new event is saved |
| euneventupdate | EventUpdateEvent | Fired when an existing event is saved, or a day, week, or work week chip is dragged to a new time or day |
| euneventdelete | EventDeleteEvent | Fired when an event is deleted |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --scheduler-border-color | Sets the border color throughout the shell and grids |
| --scheduler-background | Sets the background color |
| --scheduler-slot-height | Sets the pixel height per slotDuration row in day, week, and work week |
| --scheduler-gutter-width | Sets the width of the hour-label gutter |
| --scheduler-body-max-height | Sets the maximum height, with scroll, of the day, week, and work week grid body |
| --scheduler-month-cell-min-height | Sets the minimum height of month view cells |
| --scheduler-swipe-duration | Sets the duration of the mobile swipe slide animation |
The event chip's and event editor's own properties and events are
documented on their own pages. See
Day view
A single-column hour grid: the finest-grained view, best for a focused look at one day's schedule.
<eun-scheduler view="day"></eun-scheduler>
Week and work week
week (the default) renders all 7 days, while work-week drops
Saturday/Sunday entirely (a 5-column grid), matching eun-date-picker's
own work-week mode.
<eun-scheduler view="work-week"></eun-scheduler>
Month view
Each week shows as many event rows as actually fit its fixed cell height
(measured live, not a hardcoded count), so a taller
--scheduler-month-cell-height always shows more before falling back to a
"+N more" affordance. Today (below) is deliberately overbooked to show
that affordance in action. Clicking either the day number or "+N more"
switches to day view for that date.
A multi-day event (allDay, or just an end on a later calendar day than
start, the same rule the week view's all-day row uses) renders as a
single continuous bar spanning every day cell it covers, exactly like a
typical calendar app, rather than a separate chip repeated in each day it
happens to touch:
scheduler.events = [
{
id: "offsite",
title: "Company offsite",
color: "purple",
allDay: true,
start: new Date(2026, 2, 17), // Tuesday
end: new Date(2026, 2, 20), // Friday — spans 4 days as one bar
},
];
<eun-scheduler view="month"></eun-scheduler>
Year view
A 12-month overview, where a plain dot marks days with at least one
event, not color-coded, since that would be illegible at this scale.
Click a day to jump to day view, or a month's name to jump to month
view for it.
<eun-scheduler view="year"></eun-scheduler>
All-day and multi-day events
Set allDay (or just give end a later calendar day than start) and
the event renders as a bar in the all-day row above the hour grid instead
of inside it, spanning every visible day column it covers and stacked into
extra rows when several overlap.
scheduler.events = [
{
id: "conf",
title: "Company offsite",
color: "purple",
allDay: true,
start: new Date(2026, 2, 17), // Tuesday
end: new Date(2026, 2, 19), // Thursday — spans 3 days
},
];
Event colors
Six accent colors (the library's color, set per event in the data you
pass to events, not a property of eun-scheduler itself. See
Localizing labels
Every piece of visible text the header, footer, and popover render is its own overridable property: the "Today" and "New event" buttons, the previous/next arrows' accessible labels, each of the five view names, and the Apply/Cancel/Delete/Edit/Close actions across the drawer, modal, and popover. Override as many as your own locale needs; the ones left alone keep their English default.
<eun-scheduler
today-label="Aujourd'hui"
new-event-label="Nouvel évènement"
aria-label-previous="Période précédente"
aria-label-next="Période suivante"
view-day-label="Jour"
view-week-label="Semaine"
view-work-week-label="Semaine ouvrée"
view-month-label="Mois"
view-year-label="Année"
apply-label="Enregistrer"
cancel-label="Annuler"
delete-label="Supprimer"
edit-label="Modifier"
close-label="Fermer"
></eun-scheduler>
Click an event above and open its popover, or the "New event" button, to see the translated Edit/Delete/Apply/Cancel/Close labels alongside the header's own.
Read-only
readonly hides the "New event" button and the detail popover's Edit/
Delete buttons, for a public/view-only agenda. Clicking an event still
opens the read-only popover, there's just nothing in it to act on.
<eun-scheduler readonly></eun-scheduler>
Moving an event by drag
A timed event chip in day/week/work-week can be dragged (mouse/pen
only) to a different time and/or day, not just clicked open: grab it, a
floating ghost follows the pointer with a live-updating time label, and
dropping it fires the exact same euneventupdate a form save does (see
the demo at the top of this page, nothing extra to wire up if you're
already listening for it there, reassigning events the same way).
readonly disables it, same as the "New event" button and the popover's
Edit action. The mechanics themselves (the ghost, the snapping, the
cross-day hit-testing) live on eun-calendar. See
Event details in the popover
The read-only detail popover only ever shows the fields a given event
actually has set: nothing more, nothing padded out with placeholders.
Click each event below to see the difference: Team sync has just a
title and time, Client call adds a location, Design review sets
every optional field at once (location, description, notes, priority, and
guests, where the description and notes rows each get their own icon so
they're never mistaken for one another, and each guest's email shows a
status icon: a check for confirmed, an hourglass for pending, and a
cross for declined), and Public holiday/Company offsite are
allDay (a single day and a multi-day span, respectively), which
prefixes the date line with "All day" instead of a start–end time.
scheduler.events = [
{
id: "minimal",
title: "Team sync",
color: "blue",
start: new Date(2026, 2, 16, 9, 0),
end: new Date(2026, 2, 16, 9, 30),
},
{
id: "location-only",
title: "Client call",
color: "red",
location: "Zoom",
start: new Date(2026, 2, 16, 11, 0),
end: new Date(2026, 2, 16, 11, 30),
},
{
id: "full",
title: "Design review",
color: "purple",
location: "Room A",
description: "Walk through the new onboarding flow.",
notes: "Bring the latest Figma export.",
priority: "high",
guests: [
{ email: "amelie@example.com", status: "confirmed" },
{ email: "noah@example.com", status: "pending" },
],
start: new Date(2026, 2, 16, 14, 0),
end: new Date(2026, 2, 16, 15, 0),
},
{
id: "all-day",
title: "Public holiday",
color: "green",
allDay: true,
start: new Date(2026, 2, 17),
end: new Date(2026, 2, 17),
},
{
id: "multi-day",
title: "Company offsite",
color: "yellow",
allDay: true,
start: new Date(2026, 2, 18),
end: new Date(2026, 2, 20), // spans 3 days
},
];
Mobile layout
Below 768px: the desktop eun-toggle-group view switcher becomes a
eun-dropdown-button (5 spelled-out options don't fit a phone width), the
period label sits between the prev/next arrows, and editing switches from
the desktop eun-drawer side panel to a eun-modal with an explicit
Apply/Cancel(/Delete) footer, the same split eun-date-picker uses. The
calendar itself also gains touch swipe-to-navigate (prev/next period, with
the same slide-in replay animation eun-date-picker's own mobile swipe
uses), and horizontal touch panning is reserved for it, so a finger-drag
always changes the period rather than fighting a horizontal scrollbar.
The preview below is pinned to a real 375px-wide iframe so it always renders the mobile layout, right here, seeded with a couple of events. Tap "Team sync" to see the read-only detail popover, then its Edit button to open the modal editor. Tap "New event" to create one from scratch, or swipe the calendar left/right on a touch device.
Building your own scheduler
Everything above uses eun-scheduler as a whole. Its own calendar grid
(view rendering, swipe-to-navigate, the mobile day-selector strip) is
itself a standalone, independently usable layout primitive:
eun-calendareun-calendar yourself
(events/view/displayedDate down, euneventclick/eunslotclick/
eunperiodchange back up) around your own chrome, rather than forking the
whole feature, the same way
eun-scheduler-event-editor<button>s and a native <select> view switcher, not
eun-toggle-group/eun-dropdown-button) and a eun-modal that lets the
editor's own built-in Save/Cancel/Delete footer drive everything:
hide-actions goes on the modal instead (so its default Confirm/Cancel
pair doesn't double up with the editor's own), and the editor itself needs
no hide-actions at all, unlike eun-scheduler's own unify-the-footer
approach. No drawer, no popover, a simpler composition for a simpler
surface. Full code below.
<div id="custom-scheduler">
<header>
<eun-button id="custom-new" size="s" variant="primary"
>+ New event</eun-button
>
<button type="button" id="prev" aria-label="Previous week">‹</button>
<strong id="period-label"></strong>
<button type="button" id="next" aria-label="Next week">›</button>
<select id="view" aria-label="Calendar view">
<option value="day">Day</option>
<option value="week" selected>Week</option>
<option value="work-week">Work week</option>
<option value="month">Month</option>
</select>
</header>
<eun-calendar id="calendar" view="week"></eun-calendar>
<eun-button id="new-event" variant="primary">+ New event</eun-button>
</div>
<eun-modal id="event-modal" heading="Event" hide-actions>
<eun-scheduler-event-editor id="event-editor"></eun-scheduler-event-editor>
</eun-modal>
<script type="module">
import "@eunomia/elements/calendar.js";
import "@eunomia/elements/scheduler-event-editor.js";
import "@eunomia/elements/modal.js";
const calendar = document.querySelector("#calendar");
const labelEl = document.querySelector("#period-label");
const modal = document.querySelector("#event-modal");
const editor = document.querySelector("#event-editor");
let events = [
// your own event objects — see EunomiaSchedulerEvent's own shape
];
calendar.events = events;
function openEditor(event, initialStart) {
editor.event = event ?? null; // null => create mode
if (!event) {
editor.initialStart = initialStart;
editor.initialEnd = new Date(initialStart.getTime() + 60 * 60 * 1000);
}
modal.heading = event ? event.title : "New event";
modal.open = true;
}
// eun-calendar only reports intent — it never opens an editor itself.
calendar.addEventListener("euneventclick", (e) => openEditor(e.event));
calendar.addEventListener("eunslotclick", (e) => openEditor(null, e.start));
calendar.addEventListener("eunperiodchange", (e) => {
const { rangeStart, rangeEnd } = e;
labelEl.textContent = `${rangeStart.toLocaleDateString(undefined, { day: "numeric", month: "short" })} – ${rangeEnd.toLocaleDateString(undefined, { day: "numeric", month: "short" })}`;
});
// Re-triggers the calendar's own first eunperiodchange in case its
// initial render already happened before the listener above attached.
calendar.displayedDate = new Date(calendar.displayedDate.getTime());
// The editor dispatches these regardless of what wraps it — a drawer, a
// modal, or (like here) nothing at all beyond your own <div>.
editor.addEventListener("euneventcreate", (e) => {
events = [...events, e.event];
calendar.events = events;
modal.open = false;
});
editor.addEventListener("euneventupdate", (e) => {
events = events.map((existing) =>
existing.id === e.event.id ? e.event : existing,
);
calendar.events = events;
modal.open = false;
});
editor.addEventListener("euneventdelete", (e) => {
events = events.filter((existing) => existing.id !== e.eventId);
calendar.events = events;
modal.open = false;
});
document.querySelector("#prev").addEventListener("click", () => {
const next = new Date(calendar.displayedDate);
next.setDate(next.getDate() - 7);
calendar.displayedDate = next;
});
document.querySelector("#next").addEventListener("click", () => {
const next = new Date(calendar.displayedDate);
next.setDate(next.getDate() + 7);
calendar.displayedDate = next;
});
document.querySelector("#view").addEventListener("change", (e) => {
calendar.view = e.target.value;
});
document.querySelector("#new-event").addEventListener("click", () => {
const start = new Date();
start.setMinutes(0, 0, 0);
start.setHours(start.getHours() + 1);
openEditor(null, start);
});
</script>
<style>
#custom-scheduler header {
display: flex;
align-items: center;
justify-content: center; /* the "centered header" composition */
gap: 12px;
}
</style>
Reordering a calendars list
eun-scheduler/eun-calendar render events, not the "my calendars"
sidebar list a real scheduler is usually built alongside: that list is
just your own markup, composed the same way the custom header above is.
Being plain markup means it's also just a container and its direct
children as far as
createDragReorderSpace, then the arrow keys,
then Space again. Toggle its checkbox to show or hide its events on the
week view alongside it.
-
Work
-
Design reviews
-
Personal
-
1:1s
-
Clients
-
Releases
<ul id="calendars">
<li data-color="blue">
<button class="drag-handle" aria-label="Reorder Work">
<eun-icon name="drag_indicator"></eun-icon>
</button>
<span class="dot" style="background: var(--eun-color-blue-500);"></span>
<span>Work</span>
<eun-checkbox checked aria-label="Show Work events"></eun-checkbox>
</li>
<!-- ...more calendars... -->
</ul>
<eun-calendar id="calendar" view="week"></eun-calendar>
import { createDragReorder } from "@eunomia/elements";
const list = document.querySelector("#calendars");
createDragReorder(list, {
items: () => Array.from(list.children),
handle: ".drag-handle",
onReorder: ({ order }) => {
// Persist your own sidebar order, if it should survive a reload —
// `order` is the `<li>`s themselves, already in their new DOM order.
myCalendarOrder = order.map((li) => li.dataset.color);
},
});
Reordering a calendars list like this has nothing to do
with the calendar grid's own event dragging (moving an event to a
different day/time). That's a distinct problem, mapping a pointer
position to a calendar cell, and isn't what createDragReorder
does. See the drag-and-drop utility's own page for the boundary between
the two.
Keyboard interactions
| Key | Action |
|---|---|
Tab / Shift+Tab |
Moves focus through the header controls, the active view's cells/chips, and (once open) the popover/editor |
ArrowLeft/ArrowRight/ArrowUp/ArrowDown (month view) |
Moves the roving-tabindex focus by day/week |
Home / End (month view) |
Jumps focus to the start/end of the focused week |
PageUp / PageDown (month view) |
Changes the displayed month (Shift for a year instead) |
Enter / Space (nav/today) |
Moves the displayed period / jumps to today |
Enter / Space (view switcher) |
Changes the active view |
Enter / Space (an event chip) |
Opens the read-only detail popover for that event (consultation mode) |
Enter / Space (the popover's Edit button) |
Closes the popover and opens the editor, pre-filled with that event |
Enter / Space (the popover's close button) |
Closes the popover without acting on the event, same as Escape |
Enter / Space ("New event") |
Opens the editor in create mode, seeded at the next hour |
Escape (popover open) |
Closes the popover (native Popover API light-dismiss), focus returns to the chip that opened it |
Escape (editor open) |
Closes the drawer/modal, discarding unsaved changes (the drawer's built-in unsaved-changes guard confirms first if the form is dirty) |
Month view day cells follow the same roving-tabindex pattern
eun-date-picker's own day grid uses: ArrowLeft/ArrowRight/ArrowUp/
ArrowDown move focus by day/week, Home/End jump to the focused day's
week bounds, PageUp/PageDown (Shift for a year instead of a month)
change the displayed month, and Enter/Space activate the focused cell
(a native <button>, no custom handling needed for those two). Moving
focus past the currently displayed month's grid updates displayedDate to
bring the new month into view, same as eun-date-picker's own
PageUp/PageDown. Year view does not follow this pattern, see below.
Deliberately bounded: empty-slot creation in day/week/work-week
Creating an event by clicking a blank spot on the hour grid is pointer/
touch-only: there's no per-slot keyboard focus target. A full
roving-tabindex 2D grid over every 30-minute slot across up to 7 columns
would mean dozens of tab stops for one view, a worse keyboard experience
than the alternative: the header's "New event" button, always reachable
in one tab stop, opens the same editor seeded at a sensible default time.
Event chips themselves remain fully keyboard-focusable/operable (native
<button>s), since there are far fewer of them than there are slots.
Dragging a chip to move it is bounded the same way, and mouse/pen only
even among pointer types (a touch press on a chip scrolls/swipes instead
of dragging). A keyboard user reschedules an event through the editor
instead: Enter/Space on the chip opens the popover, its Edit button
opens the same editor a drag would have produced an equivalent
euneventupdate from, just via a fully keyboard-operable form.
Year view is similarly a coarse, primarily mouse/touch-driven overview (as
in most calendar apps) rather than a roving-tabindex surface. Its day
cells are still real, individually reachable <button>s, just without the
arrow-key roving month/day grids get.
Aria attributes and rules
- The period label is
role="status" aria-live="polite", so a screen reader announces the new period on every nav/swipe/view-switch change. It sits between the prev/next buttons visually, but that's a pure CSS ordering choice, and the buttons still have their own explicitaria-labels rather than relying on visual proximity to the label. - Below the mobile breakpoint, the view switcher becomes a
eun-dropdown-button(its ownrole="menu"/roving-focus keyboard pattern, see that component's own Accessibility tab) instead ofeun-toggle-group's roving-tabindex segmented control. Both expose the same 5 choices, just through a different, already-accessible control. - Month/year view day cells expose a full
aria-label(weekday, day, month, year) rather than just the bare day number. eun-scheduler-eventrenders as a native<button>with anaria-labelcombining the title and time (and end time, when known). See its ownAccessibility tab .- The detail popover is a native
[popover]element (noteun-tooltip, since that component is non-interactive by ARIA convention, the wrong fit for a panel holding focusable Edit/Delete/close buttons), positioned against the clicked chip the same wayeun-date-pickerpositions its own calendar panel. Its own light-dismiss (outside click,Escape) and return-focus behavior come from the native Popover API, not a custom reimplementation. - The editor's own dialog semantics (focus trap,
Escapehandling, labelled heading) come entirely fromeun-drawer/eun-modal: see their own Accessibility tabs for the full contract, and nothing about those is reimplemented here. - The header (navigation, view switcher, "New event") is
position: sticky, staying pinned to the top of whichever ancestor actually scrolls as the calendar body scrolls past it (a no-op if nothing outside the component scrolls). The day/week/work-week grid separately scrolls both horizontally and vertically within itself on narrow viewports, with its own header row and hour gutter each independently sticky, so day labels and hour labels both stay in view.