Atrium // Modules

Calendar

An endless, zoomable week-row calendar as a dependency-free custom element.

The Calendar module provides calendar-view, an endless, zoomable calendar shipped as a dependency-free custom element. Every row is a week. Zooming goes continuously from about 40 weeks (a year) down to a single week (a day planner), and rows get an all-day band and a time area once they are tall enough.

The calendar only shows the events it is given and reports what the user wants to change through DOM events. It never changes events itself, so the host application stays the single source of truth.

Example

Drag on empty space to create an event, drag an event or its edges to move or resize it, and use Ctrl/Cmd + wheel or pinch to zoom.

/* @jsxImportSource vue */
import type { CalendarEvent, CalendarEventMap, CalendarView } from "@atrium-ui/calendar";
import { defineComponent, ref, shallowRef } from "vue";

type View = CalendarEventMap["view-change"];

const calendars = [
  { id: "work", name: "Work", color: "#5b8def" },
  { id: "personal", name: "Personal", color: "#3ccf7e" },
];

const today = new Date();
const at = (days: number, hours: number) =>
  new Date(today.getFullYear(), today.getMonth(), today.getDate() + days, hours);

const initialEvents: CalendarEvent[] = [
  { id: "1", calendarId: "work", title: "Standup", start: at(0, 9), end: at(0, 10), allDay: false },
  { id: "2", calendarId: "work", title: "Design review", start: at(1, 14), end: at(1, 16), allDay: false },
  { id: "3", calendarId: "personal", title: "Climbing", start: at(2, 18), end: at(2, 20), allDay: false },
  { id: "4", calendarId: "personal", title: "Vacation", start: at(7, 0), end: at(12, 0), allDay: true },
];

async function setup(calendar: CalendarView | null, onView: (view: View) => void) {
  if (!calendar) return;

  // The element extends HTMLElement, so it can only be loaded in the browser.
  await import("@atrium-ui/calendar");
  if (calendar.calendars.length) return;

  calendar.addEventListener("view-change", (e) => onView((e as CustomEvent<View>).detail));
  calendar.calendars = calendars;
  calendar.activeCalendar = "work";
  calendar.events = initialEvents;

  calendar.addEventListener("event-create", (e) => {
    const { start, end, allDay, calendarId } = (e as CustomEvent<CalendarEventMap["event-create"]>).detail;
    const id = crypto.randomUUID();
    calendar.events = [...calendar.events, { id, calendarId, title: "New event", start, end, allDay }];
  });

  calendar.addEventListener("event-change", (e) => {
    const { id, changes } = (e as CustomEvent<CalendarEventMap["event-change"]>).detail;
    calendar.events = calendar.events.map((event) => (event.id === id ? { ...event, ...changes } : event));
  });

  calendar.addEventListener("events-delete", (e) => {
    const { ids } = (e as CustomEvent<CalendarEventMap["events-delete"]>).detail;
    calendar.events = calendar.events.filter((event) => !ids.includes(event.id));
  });
}

const monthTitle = (view: View | null) =>
  view ? view.date.toLocaleDateString(undefined, { month: "long", year: "numeric" }) : "";

/** The calendar has no toolbar of its own, the host builds it from the element's methods and `view-change`. */
export const CalendarExample = defineComponent(() => {
  const calendar = shallowRef<CalendarView | null>(null);
  const view = ref<View | null>(null);

  const mount = (el: unknown) => {
    calendar.value = el as CalendarView | null;
    void setup(calendar.value, (next) => {
      view.value = next;
    });
  };

  return () => (
    <div class="w-full overflow-hidden rounded-xl border border-zinc-200 text-zinc-900 dark:border-zinc-800 dark:text-zinc-100">
      <div class="flex flex-wrap items-center gap-2 border-zinc-200 border-b bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900">
        <button
          type="button"
          aria-label="Back"
          disabled={!view.value?.canBack}
          class="h-8 w-8 rounded-md border border-zinc-200 bg-white disabled:opacity-40 dark:border-zinc-700 dark:bg-zinc-800"
          onClick={() => calendar.value?.back()}
        >
          ‹
        </button>
        <button
          type="button"
          aria-label="Forward"
          disabled={!view.value?.canForward}
          class="h-8 w-8 rounded-md border border-zinc-200 bg-white disabled:opacity-40 dark:border-zinc-700 dark:bg-zinc-800"
          onClick={() => calendar.value?.forward()}
        >
          ›
        </button>
        <button
          type="button"
          class="h-8 rounded-md border border-zinc-200 bg-white px-3 text-sm dark:border-zinc-700 dark:bg-zinc-800"
          onClick={() => calendar.value?.goToday()}
        >
          Today
        </button>
        <span class="flex-1 truncate text-center font-semibold">{monthTitle(view.value)}</span>
        <input
          type="search"
          placeholder="Filter events…"
          aria-label="Filter events"
          class="h-8 w-40 rounded-md border border-zinc-200 bg-white px-3 text-sm placeholder:text-zinc-400 dark:border-zinc-700 dark:bg-zinc-800 dark:placeholder:text-zinc-500"
          onInput={(e: Event) => {
            if (calendar.value) calendar.value.filter = (e.target as HTMLInputElement).value;
          }}
        />
      </div>
      <calendar-view ref={mount} storage-key="atrium-docs-calendar" theme="auto" class="block h-[600px] w-full" />
    </div>
  );
});

Single week

Set minDate and maxDate to limit the calendar to a fixed range, like the schedule of a conference. Zoom is capped to the range, so the week fills the whole view, and the weekend outside of it can't be used. Locking the calendar makes the schedule read-only.

/* @jsxImportSource vue */
import type { CalendarEvent, CalendarView } from "@atrium-ui/calendar";
import { defineComponent } from "vue";

const today = new Date();
const monday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - ((today.getDay() + 6) % 7));
const at = (days: number, hours: number) =>
  new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + days, hours);

const events: CalendarEvent[] = [
  { id: "1", calendarId: "conference", title: "Conference", start: at(0, 0), end: at(5, 0), allDay: true },
  { id: "2", calendarId: "conference", title: "Registration", start: at(0, 8), end: at(0, 10), allDay: false },
  { id: "3", calendarId: "conference", title: "Keynote", start: at(0, 10), end: at(0, 12), allDay: false },
  { id: "4", calendarId: "conference", title: "Workshops", start: at(1, 9), end: at(1, 17), allDay: false },
  { id: "5", calendarId: "conference", title: "Talks", start: at(2, 9), end: at(2, 13), allDay: false },
  { id: "6", calendarId: "conference", title: "Speaker dinner", start: at(2, 19), end: at(2, 22), allDay: false },
  { id: "7", calendarId: "conference", title: "Talks", start: at(3, 9), end: at(3, 16), allDay: false },
  { id: "8", calendarId: "conference", title: "Closing", start: at(4, 10), end: at(4, 12), allDay: false },
];

async function setup(calendar: CalendarView | null) {
  if (!calendar) return;

  // The element extends HTMLElement, so it can only be loaded in the browser.
  await import("@atrium-ui/calendar");
  if (calendar.calendars.length) return;

  calendar.firstDayOfWeek = 1;
  calendar.minDate = at(0, 0);
  calendar.maxDate = at(4, 0);
  calendar.calendars = [{ id: "conference", name: "Conference", color: "#5b8def" }];
  calendar.lockedCalendars = ["conference"];
  calendar.events = events;
}

/** A read-only schedule limited to the work days of a single week through `minDate` and `maxDate`. */
export const WeekExample = defineComponent(() => {
  return () => (
    <div class="w-full overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-800">
      <calendar-view
        ref={(el: unknown) => void setup(el as CalendarView | null)}
        storage-key="atrium-docs-calendar-week"
        theme="auto"
        class="block h-[600px] w-full"
      />
    </div>
  );
});

Guidelines

  • Give it a size. The element fills its box, so set an explicit height on calendar-view or its container.
  • Own the data. Listen to event-create, event-change and events-delete, apply the change in your store, and assign a new events array.
  • Register every calendar. Each event's calendarId must exist in calendars. Use lockedCalendars for read-only sources like holidays.
  • Build the toolbar. The calendar has no navigation of its own. Wire buttons to goToday(), goMonth(), back() and forward(), a search field to filter, and show the month and back/forward state from view-change, as the example does.
  • Set the locale. Use the locale attribute or property so all generated text and the first day of the week match your users.
<calendar-view storage-key="my-calendar" theme="auto" locale="de-DE"></calendar-view>
<script type="module">
  import "@atrium-ui/calendar";

  const calendar = document.querySelector("calendar-view");
  calendar.calendars = [{ id: "work", name: "Work", color: "#5b8def" }];
  calendar.activeCalendar = "work";
  calendar.events = [
    { id: "1", calendarId: "work", title: "Review", start, end, allDay: false },
  ];
  calendar.addEventListener("event-change", (e) => save(e.detail.id, e.detail.changes));
</script>

Properties

Property
eventsCalendarEvent[] to display
calendars{ id, name, color }[], every event's calendarId must be in here
activeCalendarwhere new, duplicated and imported events go
hiddenCalendars, lockedCalendarsids; locked calendars are read-only
userEmailrecognises the user's invitations
localelanguage and region of all generated text
firstDayOfWeek0 = Sunday, null = from the region
themedark, light, solarized, high-contrast, auto, or a custom name
stringspartial overrides of the UI text
minDate, maxDateDate or null; limits the calendar to these days, e.g. a single week. Zoom is capped to the range and days outside it in the first and last week can't be used
expandThresholdrow height in px from which rows get an all-day band and a time area (90)
filteronly events whose title contains this text are shown, the weeks in between collapse
layersdrawing layers
selectionselected event ids

Attribute: storage-key (where the view is remembered, default calendar-view).

Methods

goTo(date), goToday(), goMonth(), back(), forward(), setLayer(id, on), cancelInteraction(), appendSummary(id, chunk), finishSummary(id), failSummary(id), refreshTheme().

Events

All events are CustomEvents that bubble out of the shadow root.

Eventdetail
event-create{ start, end, allDay, calendarId, duplicateOf? }
event-change{ id, changes: { start?, end?, title?, description?, reminders?, response? } }
events-delete{ ids }, all selected events on Delete/Backspace; confirming and checking permissions is up to the host
calendar-import{ data, fileName, calendarId }, data is already checked to look like iCalendar
selection-change{ ids }
event-click{ id }
range-select{ from, to, columns, minutes }
refresh-request{}
summary-request / summary-cancel{ id, text } / { id }
view-change{ date, zoomLevel, canBack, canForward }, for a host toolbar; date is in the month shown
notify{ text }, feedback for the user like "3 events copied" or a failed import, to show in a toast or status bar

Styling

Everything is themed through CSS variables on the element, and every piece of the UI is exposed as a ::part. The canvas reads the same variables, so overrides apply to the drawn parts too.

calendar-view {
  --cal-accent: hotpink;
  --cal-font: "Inter", sans-serif;
  --cal-event-radius: 0.125rem;
}
calendar-view::part(weekdays) {
  text-transform: none;
}

Changes to the theme attribute, the system colour scheme, and class/style/data-theme on <html> or the element are picked up automatically. Other host-side theme changes, like a stylesheet swap, need a call to refreshTheme().

The sidebar slot holds anything you want next to the calendar.

Layers

The calendar is drawn in layers. Custom layers can be added next to the default ones, and toggleable layers are switched with setLayer(id, on). Use layer.label(calendar.strings) to name them in a host menu.

import { defaultLayers, type Layer } from "@atrium-ui/calendar";

const holidays: Layer = {
  id: "holidays",
  toggleable: true,
  label: () => "Holidays",
  draw({ ctx, scene, theme, model }) {
    /* draw inside the day columns */
  },
};
calendar.layers = [...defaultLayers, holidays];

Accessibility

  • All interactions have keyboard shortcuts: T jumps to today, Esc cancels or clears the selection, Delete removes the selection, Ctrl/Cmd + C copies it as iCalendar and Ctrl/Cmd + Shift + R requests a refresh.
  • The high-contrast theme is available for users who need stronger contrast, and all colours can be overridden through CSS variables.
  • The event panel controls are labelled, and toggles expose their state with aria-pressed. All UI text can be translated through strings.
  • The calendar has no toolbar of its own. Give the host's navigation buttons accessible labels and reflect canBack/canForward from view-change as disabled.
  • Touch input is supported: one finger scrolls, a long press then drag creates, moves or resizes, and two fingers zoom and pan.
  • The time grid is drawn on a canvas. Offer an alternative list of upcoming events where users depend on a screen reader.
InputAction
Wheel, trackpad, touchscroll; middle-mouse drag pans
Ctrl/Cmd + wheel, pinchzoom around the pointer
Drag in the gutter, Ctrl/Cmd + Alt + vertical dragzoom
Drag on empty spacecreate; Shift freezes the length, Esc cancels
Drag an event / its edgesmove / resize; Alt or Cmd while dragging duplicates; dragging out of the browser exports an .ics
Ctrl/Cmd + drag on empty spacebox select; add Shift to add to the selection
Alt helddraw over existing events

References