Skip to main content

The calendar extension

mc-semantic-ui-ext-calendar brings a calendar: a month, a week or a day of events, with the buttons to move between them — and, like the chart extension, both halves of it: the node types (UiCalendar, UiCalendarEvent) and the painters, one in Java for the server and one in TypeScript for the browser, held to the same bytes.

What you get​

Node typescalendar (UiCalendar), calendar-event (UiCalendarEvent)
ViewsMONTH, WEEK, DAY
Moving aroundprevious / next / today and the view switch re-render the calendar from its own model — no server needed; with an onNavigate they fire it too, {date} and {view} filled in, so the server can send the new period's events
Pickinga click on a day or an hour fires onSelect with {date} and {hour} — through the browser bundle
Eventsall-day (may span days) or timed (sit in their hour); an accent color; onClick like any node
WordsEnglish, or labels from the model — labels(Locale) takes the names from java.time
Works without JavaScriptyes for looking and moving around, when rendered server-side; picking a day needs the bundle

Install​

With the asset registry

A Spring Boot host with the core's asset registry needs none of the browser wiring below: the jar declares its files, and installAll(renderer, bus) from /sui/assets.js installs it. See the asset registry.

<dependency>
<groupId>ai.mindconnect</groupId>
<artifactId>mc-semantic-ui-ext-calendar</artifactId>
<version>0.4.3</version>
</dependency>

Nothing else to configure. The module ships templates/sui/calendar.hbs plus the Handlebars helper it needs, contributed through SuiHelperContributor — found as a Spring bean or via ServiceLoader.

@GetMapping("/cal")
public UiPage calendar(@RequestParam(defaultValue = "MONTH") UiCalendar.View view,
@RequestParam Optional<LocalDate> date, Locale locale) {
LocalDate anchor = date.orElse(LocalDate.now());
var cal = UiCalendar.of("cal", view, anchor)
.labels(locale)
.hours(7, 19)
.onNavigate(UiTrigger.go("/cal?date={date}&view={view}"))
.onSelect(UiTrigger.api("POST", "/cal/pick?date={date}&hour={hour}"));
for (var e : events.between(anchor.minusMonths(1), anchor.plusMonths(1))) {
cal.event(UiCalendarEvent.timed(e.id(), e.title(), e.start(), e.end())
.onClick(UiTrigger.go("/events/" + e.id())));
}
return UiPage.of("/cal", cal);
}

of() sets today from the server's clock. It travels in the model on purpose: neither painter consults a clock or a locale, which is what keeps the server's bytes and the browser's identical.

How the calendar talks to the server​

Two triggers, two sets of placeholders filled into their URL:

TriggerFired byPlaceholders
onNavigateprevious, next, today, and the Day / Week / Month buttons{date} — the day to show, yyyy-MM-dd; {view} — DAY, WEEK or MONTH
onSelecta click on a month cell, an all-day cell, or an hour slot{date} — the day, yyyy-MM-dd; {hour} — the hour (0–23), or empty for a day; {time} — HH:00, or empty. Filled wherever the trigger carries them — its URL, or the nodes of an inline PATCH, so a pick can open a pre-filled "new event" dialog with no round trip

Navigation happens in the page first: the calendar keeps the model it was drawn from, and a button re-renders it for the new date and view from the events it already has — so a calendar with no onNavigate at all still moves. With one, each button is also a complete trigger link, filled in at render time; it fires after the re-render, and the server may answer with the events of that period as a REPLACE of the calendar node (or with nothing). Previous and next step by a day, a week or a month depending on the view, clamping the day of month where a month is shorter.

Adding an event from the page​

updateCalendar(id, node => …) in the browser bundle rewrites the model a rendered calendar was drawn from and draws it again — for a page that lets the user create an event without a round trip, say from a dialog that an onSelect inline patch opened, saved by an INVOKE handler:

bus.registerClientHandler("add-event", ctx => {
const p = ctx.payload; // the dialog form's values
updateCalendar("cal", n => ({ ...n, events: [...(n.events ?? []),
{ type: "calendar-event", id: crypto.randomUUID(), title: p.title, start: `${p.date}T${p.start}` }] }));
return { patches: [{ op: "REMOVE", targetId: "new-event-dlg" }], toasts: [] };
});

The nodes​

UiCalendar:

FieldTypeMeaning
idStringNode id — also the DOM id and the patch target.
viewDAY · WEEK · MONTHWhich view. Default MONTH.
dateStringThe day shown, or a day of the week or month shown, yyyy-MM-dd.
todayStringToday, yyyy-MM-dd — highlighted, and the target of the Today button.
selectedDateStringA day to highlight.
weekStartMONDAY · SUNDAYDefault MONDAY.
startHour, endHourIntegerThe hours the day and week views show. Default 0 to 24.
maxEventsPerDayIntegerChips a month cell shows before folding the rest into "+n more". Default 3.
labelsLabelsThe words shown; any left null is English.
eventsList<UiCalendarEvent>The events — all of them; the painter picks what falls into the view.
onNavigate, onSelectUiTriggerSee above. Both optional; without onNavigate the calendar still navigates, on its own.
extrasList<UiNode>Widgets of the page's own in the header, after the view switch — a "New event" button, a filter, a legend. Any node type; .extra(node) adds one.

UiCalendarEvent:

FieldTypeMeaning
id, titleStringId and what the chip shows.
startStringyyyy-MM-dd for an all-day event, yyyy-MM-ddTHH:mm for a timed one.
endStringSame form. Absent: one hour after the start, or the same day.
allDayBooleanForces all-day; absent, a start without a time means all-day.
colorStringAccent colour — a hex value, a name, rgb()/hsl() or var(--…); anything else is dropped.
onClick …UiTriggerThe node-level events every node carries.

A timed event lives on its start day; one that runs past midnight is cut there. An all-day event that spans days shows a chip on each of them.

Building one​

UiCalendar.of("cal", UiCalendar.View.WEEK, LocalDate.of(2026, 9, 21))
.labels(Locale.GERMAN)
.hours(7, 19)
.event(UiCalendarEvent.timed("e1", "Standup",
LocalDateTime.of(2026, 9, 21, 9, 0), LocalDateTime.of(2026, 9, 21, 9, 30)).color("#4f6bed"))
.event(UiCalendarEvent.allDay("e2", "Offsite", LocalDate.of(2026, 9, 22), LocalDate.of(2026, 9, 24)))
.onNavigate(UiTrigger.go("/cal?date={date}&view={view}"))
.onSelect(UiTrigger.api("POST", "/cal/pick?date={date}&hour={hour}"));

Styling​

Everything is built on the core's tokens, so every theme applies. Two knobs of the extension's own: --sui-calendar-accent, which an event's color sets inline and the chip's left edge shows; and --sui-calendar-hour-h, the height of one hour in the day and week views, which the event blocks are placed against (set it on .sui-calendar to zoom the day). The states are is-today, is-selected and is-outside on cells, is-allday on chips, is-active on the current view's button.

Both painters draw the same calendar​

The painter exists twice — CalendarPainter.java and calendar/extension.ts — and the two are held to byte-identical output by one fixture rendered in all three views through both. That is why the model carries today and the words: nothing is left to a clock or a locale on either side.

Limits​

Timed events that overlap are drawn on top of each other, not side by side. Dragging an event to another time is not part of this version; give an event an onClick that opens its detail instead.