Eunomia v1.1.0
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

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 feature. It's built on the same date utilities date picker uses, and on two independent sub-elements documented on their own pages: the event chip shown on the calendar grid, and the event editor used to create and edit them.

Dependencies

eun-icon · for the nav chevrons eun-button · throughout the header and editor footers eun-toggle · for the desktop view switcher eun-dropdown-button · for the view switcher below the mobile breakpoint eun-drawer · for the desktop editing surface eun-modal · for the mobile editing fallback, below the mobile breakpoint
Overview API Examples Accessibility

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, and Delete asks for a confirmation, in the popover as in the editor's footer, before removing the event. Apply stays disabled until an edited event changes. 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 Calendar, which in turn renders the event chip for every event shown. Around that, scheduler renders the event editor for its create and edit form, plus the components listed above for its own chrome.

Guidance

  • Listen for eunperiodchange to 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 exact rangeStart/rangeEnd now visible, and visibleRange gives the initial range
  • Keep events as the single source of truth on your side, reassigned (not mutated) from the three CRUD listeners: eun-scheduler is deliberately uncontrolled beyond that, the same "controlled array" pattern eun-scheduler-event-editor documents on its own
  • Set readonly for 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 day view 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

NameTypeDefaultDescription
hide-now-linebooleanfalseHides the line marking the current time in day, week and work week views
event-appearance'default' | 'transparent' | 'ghost''default'The appearance 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-on0 | 11Which weekday a week starts on
day-start-hournumber0The first visible hour row in day, week, and work week
day-end-hournumber24The last visible hour row, exclusive, in day, week, and work week
slot-durationnumber30The grid's row granularity, in minutes, also the default duration for a newly created event
readonlybooleanfalseDisables creating, editing, and deleting. View only
no-animationbooleanfalseDisables the mobile swipe slide animation and the drawer or modal's own transitions
today-labelstring—The label of the jump-to-today button. Defaults to the localized string for the current locale (English: "Today")
new-event-labelstring—The label of the create-a-new-event button. Defaults to the localized string for the current locale (English: "New event")
previous-labelstringPrevious periodThe accessible label of the previous-period button. Defaults to the localized string for the current locale (English: "Previous period")
next-labelstringNext periodThe accessible label of the next-period button. Defaults to the localized string for the current locale (English: "Next period")
view-day-labelstring—The label of the day view switcher option. Defaults to the localized string for the current locale (English: "Day")
view-week-labelstring—The label of the week view switcher option. Defaults to the localized string for the current locale (English: "Week")
view-work-week-labelstring—The label of the work week view switcher option. Defaults to the localized string for the current locale (English: "Work week")
view-month-labelstring—The label of the month view switcher option. Defaults to the localized string for the current locale (English: "Month")
view-year-labelstring—The label of the year view switcher option. Defaults to the localized string for the current locale (English: "Year")
apply-labelstring—The label of the event editor's save button. Defaults to the localized string for the current locale (English: "Apply")
cancel-labelstring | undefinedCancel
delete-labelstring—The label of the delete button, in the event editor and the read-only event popover. Defaults to the localized string for the current locale (English: "Delete")
delete-confirm-labelstring—The question the read-only event popover asks before deleting. Defaults to the localized string for the current locale (English: "Delete this event?")
edit-labelstring | undefined—Label of the read-only event popover's "switch to editing" button. Falls back to the localized string for the current locale when unset.
close-labelstring | undefined—Accessible label of the read-only event popover's close (✕) button. Falls back to the localized string for the current locale when unset.
more-events-labelstring—The template for the month view's overflow affordance, with the placeholder replaced by the hidden event count. Defaults to the localized string for the current locale (English: "+{count} more")

Import the exact TypeScript type behind any property above, see Types.

Properties

JS-only — no matching HTML attribute, set these from a script or a template binding.

NameTypeDefaultDescription
eventsArray<EunomiaSchedulerEvent>[]The events to display. Never mutated internally
displayedDateDatenew Date()The anchor date for the active view. Defaults to today, self-managed on navigation, swipe, or view switch, always overridable from outside
visibleRangeDateRange—The range currently visible for view and displayedDate, the same one `eunperiodchange` reports. In `month` view, it covers the whole 6-week grid, leading and trailing days included.
localestring—The locale used throughout. Defaults to the one set with setDefaultLocale, English otherwise
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.

Events

NameTypeDescription
eunperiodchangePeriodChangeEventFired whenever the visible period changes, from navigation, a swipe, a view switch, or an external view or displayedDate assignment. Not fired for the initial render, read visibleRange instead
euneventcreateEventCreateEventFired when a new event is saved
euneventupdateEventUpdateEventFired when an existing event is saved, or a day, week, or work week chip is dragged to a new time or day
euneventdeleteEventDeleteEventFired when an event is deleted

Every event above follows the same naming convention, covered in Events.

CSS custom properties

NameDescription
--scheduler-border-colorSets the border color throughout the shell and grids
--scheduler-backgroundSets the background color
--scheduler-slot-heightSets the pixel height per slotDuration row in day, week, and work week
--scheduler-gutter-widthSets the width of the hour-label gutter
--scheduler-body-max-heightSets the maximum height, with scroll, of the day, week, and work week grid body and the year view, and of the month view when set (uncapped by default)
--scheduler-month-cell-min-heightSets the minimum height of month view cells
--scheduler-swipe-durationSets the duration of the mobile swipe slide animation
--scheduler-accent-colorSets the color marking today, the selected day and the focus ring. Defaults to the theme's primary color
--scheduler-focus-outline-colorSets the keyboard focus outline color, falling back to --scheduler-accent-color, or to each event's own color on an event
--scheduler-accent-text-colorSets the text color shown on the accent color, such as today's number in month view
--scheduler-now-line-colorSets the color of the line marking the current time. Defaults to the critical color

The event chip's and event editor's own properties and events are documented on their own pages. See Event: API and Event editor: API.

Views

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>

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.

Events

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 decorative token scale), each an event's color, set per event in the data you pass to events, not a property of eun-scheduler itself. See Event: Examples for every color/appearance combination shown side by side.

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
  },
];

Interaction

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 Calendar: Moving an event by drag for the full details, including why it's mouse/pen-only and how a keyboard user reschedules an event instead.

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>

Configuration

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, along with the popover's delete confirmation. 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"
  previous-label="Période précédente"
  next-label="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"
  delete-confirm-label="Supprimer cet événement ?"
  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.

Current time line

In day, week and work week views, a line marks the current time on today's column. --scheduler-now-line-color changes its color, and hide-now-line hides it.

<eun-scheduler hide-now-line></eun-scheduler>
eun-scheduler {
  --scheduler-now-line-color: var(--eun-color-primary-500);
}

Composition

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-calendar. Composing your own scheduler UI means driving eun-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 plugs into whatever surface you put around it. The example below builds a compact week calendar with its own centered header (built from plain <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.

+ New event
<div id="custom-scheduler">
  <header>
    <eun-button id="custom-new" size="s" color="primary"
      >+ New event</eun-button
    >
    <button type="button" id="prev" aria-label="Previous period">‹</button>
    <strong id="period-label"></strong>
    <button type="button" id="next" aria-label="Next period">›</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" color="primary">+ New event</eun-button>
</div>

<eun-modal
  closeable
  close-on-backdrop
  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));
  function renderLabel(start, end) {
    labelEl.textContent = `${start.toLocaleDateString(undefined, { day: "numeric", month: "short" })} – ${end.toLocaleDateString(undefined, { day: "numeric", month: "short" })}`;
  }
  renderLabel(calendar.visibleRange.start, calendar.visibleRange.end);
  calendar.addEventListener("eunperiodchange", (e) =>
    renderLabel(e.rangeStart, e.rangeEnd),
  );

  // 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;
  });
  editor.addEventListener("euneventcancel", () => {
    modal.open = false;
  });

  document
    .querySelector("#prev")
    .addEventListener("click", () => calendar.previous());
  document
    .querySelector("#next")
    .addEventListener("click", () => calendar.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 createDragReorder (the framework-agnostic drag-and-drop utility) is concerned, the same way it would attach to any other list. Drag a calendar by its handle to reorder it, or focus the handle and press Space, 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.

Custom colors

The custom properties that change colors, borders, shadows and corners, at a glance. The API tab lists every property of the component.

Variable Sets
--scheduler-border-color Sets the border color throughout the shell and grids
--scheduler-background Sets the background color
--scheduler-accent-color Sets the color marking today, the selected day and the focus ring, the theme's primary color by default
--scheduler-focus-outline-color Sets the keyboard focus outline color, falling back to --scheduler-accent-color, or to each event's own color on an event
--scheduler-accent-text-color Sets the text color shown on the accent color, such as today's number in month view
--scheduler-now-line-color Sets the color of the line marking the current time. Defaults to the critical color

Server-side rendering

The header and the calendar are drawn inside the scheduler's shadow root, from properties a script sets such as events, so a server-rendered scheduler 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 draws the scheduler's frame and reserves the height of its header and of the view set by view, on desktop and on mobile, readonly included, following --scheduler-body-max-height. The header, the grid and the events show with the upgrade.

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 explicit aria-labels rather than relying on visual proximity to the label.
  • Below the mobile breakpoint, the view switcher becomes a eun-dropdown-button (its own role="menu"/roving-focus keyboard pattern, see that component's own Accessibility tab) instead of eun-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-event renders as a native <button> with an aria-label combining the title and time (and end time, when known). See its own Accessibility tab.
  • The detail popover is a native [popover] element (not eun-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 way eun-date-picker positions 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, Escape handling, labelled heading) come entirely from eun-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 header and all-day rows and its hour gutter each independently sticky, so day labels, all-day events and hour labels all stay in view.