Event Calendar
A scheduling calendar for displaying events across month, week, and
agenda views. The month view is a day grid with event pills and a "+N more"
overflow; the week view is an hour-row time grid with overlap-split event blocks
and a current-time line; the agenda view is a chronological list grouped by day.
Navigation (previous / next / today) and the view switcher recompute the visible
window. Clicking an event emits an event-click event you handle in Livewire.
This is the scheduling grid — distinct from calendar,
which is a date-picker widget. It is token-themed with no third-party calendar
library.
Month View
The default view — a 7-column day grid. Events render as pills; days with more than three events show a "+N more" indicator. All-day events and day markers (see Day Markers below) appear as full-width labels at the top of the cell.
A pill carries its start time to the right of the title, formatted in your
application's locale — the same one the rest of the calendar is written in, and
overridable per calendar with the locale prop. The title truncates to make room, because a clipped time is
unreadable while a clipped title is still recognizable. An all-day event shows no
time — the cell it sits in is already the date.
There is no prop to turn it off, and that is deliberate: the pill's accessible
name has always ended with the time, so a screen-reader user heard "Sprint
planning, Monday June 8, 10:00 AM" where a sighted reader saw only "Sprint
planning". Making the visible time optional would let that gap be re-created. If
the time is not wanted at all, pass the events as allDay — a day cell already
says which day it is.
Week View
The time grid positions each event by its start and duration. Overlapping events split into side-by-side columns; a red line marks the current time on today's column. All-day events and day markers sit in a dedicated band above the hour grid (timed events have an hour position; all-day ones do not).
Agenda View
A compact chronological list — the best view on mobile and for a quick "what's next" glance.
Day Markers
dayMarkers is a day-level dimension, separate from timed events — use it
to mark holidays, working exception days, or notes. Each marker carries a date,
a label, an optional type, and an optional blocked flag, and renders across
all three views: a full-width band in month, a chip in the week all-day band, and
its own line in agenda.
A blocked day reads as unavailable — a muted surface with a diagonal hatch,
plus an "unavailable" suffix in its accessible name, so the state is never
conveyed by color or texture alone. WireKit renders the visual and semantic state
only; enforce any booking logic in your own application behind a blocked day.
Day-Marker Shape
| Key | Required | Description |
|---|---|---|
date |
yes | YYYY-MM-DD (parsed date-only — no timezone, so it never drifts a day) |
label |
yes | Display name (e.g. Christmas Day) |
type |
no | holiday (default) · working · note — drives the tint |
blocked |
no | true marks the day unavailable (muted surface + hatch + accessible state) |
Week Start
weekStartsOn sets the first column of the month and week grids — 0 for Sunday
through 6 for Saturday (1, Monday, is the default). Set it per instance or
globally in config/wirekit.php.
Livewire Integration
Pass events from the server and handle clicks (and view changes) in your component:
{{-- 1. Open a detail modal when an event is clicked --}}
<x-wirekit::event-calendar
:events="$this->events"
view="month"
x-on:event-click="$wire.openEvent($event.detail.id)"
x-on:view-change="$wire.set('view', $event.detail.view)"
/>
// 2. Shape events for the calendar (start/end are ISO 8601)
public function getEventsProperty(): array
{
return Appointment::query()
->whereBetween('starts_at', [now()->startOfMonth(), now()->endOfMonth()])
->get()
->map(fn ($a) => [
'id' => $a->id,
'title' => $a->title,
'start' => $a->starts_at->toIso8601String(),
'end' => $a->ends_at?->toIso8601String(),
'intent' => $a->status === 'confirmed' ? 'success' : 'warning',
])
->all();
}
// 3. Open the clicked event
public function openEvent(int $id): void
{
$this->editing = Appointment::find($id);
}
Event Shape
| Key | Required | Description |
|---|---|---|
id |
yes | Unique identifier (emitted on click) |
title |
yes | Event title |
start |
yes | ISO 8601 start datetime |
end |
no | ISO 8601 end (defaults to start + 1 hour) |
allDay |
no | true renders "All day" in agenda |
intent |
no | accent (default) · success · warning · danger · neutral |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
level |
int | 2 |
Heading level for the title, 1–6. The default is what it has always rendered — the calendar's period label — so nothing moves unless you set it. Set it to match the surrounding outline: a skipped level is what heading-order reports, and that rule sits in axe's best-practice tag rather than the WCAG tags most suites run, so it is invisible to an axe sweep and visible in Lighthouse. |
events |
array | [] |
Events (see shape above) |
dayMarkers |
array | [] |
Day-level holiday / working / note markers (see shape) |
view |
string | 'month' |
month · week · agenda |
date |
string|null | null |
ISO date the calendar opens on (default today) |
weekStartsOn |
int | 1 |
First column: 0 (Sunday) – 6 (Saturday); 1 (Monday) is the default |
locale |
string|null | null |
BCP-47 tag for every date and time the calendar prints — the month heading, the weekday columns, the hour gutter, the agenda day labels and every event's accessible name. null uses your application's locale, so the dates speak the language the rest of the page is written in. Set it when a calendar shows a schedule belonging to a different region than the page around it |
ariaLabel |
string | 'Calendar' |
Accessible name for the calendar as a whole |
weekLabel |
string|null | null |
Accessible name for the week time grid. Setting it also turns that grid into a screen-reader landmark — see Accessibility |
scope |
string|null | null |
Scoped personalization name |
The date the calendar is looking at
The Alpine scope exposes the focused date as focusedDate — a Date, the period the
calendar currently shows. It is what the previous/next controls move and what the title is
derived from, so a slot inside the component can read it:
<x-wirekit::event-calendar :events="$events">
<span x-text="focusedDate.getFullYear()"></span>
</x-wirekit::event-calendar>
It was called focus before v2.47.0
A scope property named after a browser global is resolved against window whenever the scope
is lost — so instead of an error naming this component, the expression quietly hits something
else. focus is one of those names; focusedDate is free of that collision. The old spelling
was undocumented, so this changes only markup that reached for it anyway.
Accessibility
-
Each event is a focusable button with a full accessible label ("Design review, Wednesday June 10, 2:00 PM"), so the schedule is operable without relying on the event's color.
-
The week time grid carries
tabindex="0"and a focus ring, so the scrolling grid is keyboard-reachable (WCAG 2.1.1) in every configuration. -
The week grid becomes a landmark only when you name it with
weekLabel. It getsrole="region"and that name, and shows up in a screen reader's landmark list. Without one it stays a plain focusable scroller — becauserole="region"plus a name is a landmark, and two calendars on one page would otherwise be two landmarks announcing the same thing, which is what axe reports aslandmark-unique. It is a separate prop fromariaLabelon purpose: that one names the calendar, and a region borrowing the widget's name would announce the part as the whole.<x-wirekit::event-calendar view="week" week-label="Sprint 42 schedule" :events="$events" /> -
The title is an
aria-live="polite"region, so navigating months / weeks is announced. -
Today carries
aria-current="date"in every view — the month cell, the week column header and the agenda day heading. The accent pill and heavier weight are the visual half; without the attribute, "this is today" would reach a screen reader only as a color, which is what WCAG 1.4.1 rules out. -
The view switcher is a
role="radiogroup"ofrole="radio"buttons witharia-checked+ rovingtabindex— arrow keys move and select, wrapping at the ends (the buttons own no tabpanels, so tab semantics would be a broken contract).
Keyboard Interaction
| Key | Action |
|---|---|
Tab |
Move through navigation, the view switcher, and event blocks |
Enter / Space |
Activate an event, navigate, or switch view |
← / → / ↑ / ↓ |
Move between Month, Week and Agenda while the view switcher has focus |
Escape |
Dismiss an open event tooltip |
The view switcher is a radio group, not a tab list: it takes ONE tab stop, and the arrow
keys move the selection inside it. That is why Tab steps past the whole switcher rather than
through its three buttons.
Design Tokens
| Element | Token |
|---|---|
| Today highlight | --color-wk-accent |
| Current-time line | --color-wk-danger |
| Grid borders | --color-wk-border |
| Out-of-month days | --color-wk-bg-subtle |
| Event (accent / success / warning / danger) | --color-wk-accent / --color-wk-success / --color-wk-warning / --color-wk-danger |
| Focus ring | --color-wk-ring |
Customization
Set the default view and week start via config/wirekit.php:
'components' => [
'event-calendar' => [
'view' => 'week',
'week-starts-on' => 0, // Sunday
],
],
Usage & Conventions
This build focuses on displaying a schedule. Drag-to-create / move / resize, recurrence (RRULE), the multi-resource view, and ICS export are planned as follow-ups.
Further Reading
- WAI-ARIA Authoring Practices — Radio Group — the pattern the view switcher implements. This line used to point at the Tabs pattern, which is a different keyboard model and not the one this component follows: a tab list changes what is displayed alongside its own panels, a radio group picks one of a set of values, and the switcher does the second.
- MDN:
Intl.DateTimeFormat