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, which opens the week showing that day and moves the focus there. 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).
Events That Span Days
An end on a later day than the start puts the event on every day it covers, in
all three views. In the week view it runs to the foot of its first day and starts at
the top of the next, so a night shift from 22:00 to 02:00 fills both ends of the
night. On every day after its first, the event shows no start time, and its
accessible name gives the whole span. For an all-day event, an end date is the
last day it covers.
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 long label wraps in every view rather than being cut,
so the whole name stays readable in a narrow calendar.
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), or a date object, such as a model attribute with a date cast, which is written as the day it names. A date in another form PHP reads, such as 31.12.2026 or a Unix timestamp, is written as its day too |
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) |
A marker goes into the page with these four keys and no others.
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/.
Selecting a day
Two opt-in props make the month grid answer "what happens on this day?":
selectable-daysturns each day number into a button. Pressing it dispatcheswirekit:event-calendar-day-selectwith the day asYYYY-MM-DDand the ids of its events, so your component can load or open whatever belongs to that day.day-detailalso lists the pressed day's events under the grid, and the+N morecontrol opens that list instead of switching to the week view. It impliesselectable-days.
No events on this day
The day number is the button rather than the whole cell, because the cell also holds the event
pills, which are buttons of their own. Each day button is named with the full date, and with
day-detail it reports whether its day is the one that is open. Pressing the open day again
closes the list without dispatching anything: closing is not a selection.
Filtering by category
Give each event a category and set filterable, and the calendar puts one toggle per category
above the views. Every category starts shown; pressing a toggle hides its events from every view,
so a hidden event takes no pill in the month grid, no +N more, and no column in the week grid.
Events without a category belong to no toggle and always stay visible.
The toggles follow the order in which the categories first appear in your events. A category is
its own field rather than the event's intent: the intent is the event's color, and two
categories may well share one.
Attendees
Give an event attendees and the agenda, and the day list from day-detail, shows who is
coming: up to three faces beside the title, then a count. A face is the attendee's avatar when
you pass one, and their initials otherwise.
The faces are decoration. A screen reader hears every attendee as part of the event's name — "Sprint planning, Monday, June 8, 10:00 AM, with Anna Berg, Ben Cole, Carla Diaz, Dan Evans, and Eva Fox" — joined the way the calendar's language joins a list. The month pills leave attendees out, because a pill has no room for them.
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);
}
{{-- 4. Or react to a day: the event carries its date and the ids of its events --}}
<x-wirekit::event-calendar
:events="$this->events"
selectable-days
x-on:wirekit:event-calendar-day-select="$wire.showDay($event.detail.date)"
/>
Event Shape
| Key | Required | Description |
|---|---|---|
id |
yes | Unique identifier (emitted on click). An integer id above Number.MAX_SAFE_INTEGER, such as a 64-bit id, is emitted as its digits, so it comes back exactly |
title |
yes | Event title |
start |
yes | ISO 8601 start datetime, or a YYYY-MM-DD date, which is read as that calendar day wherever the reader is. A date object works too: an all-day event's start is written as the day it names, any other as the instant it holds. A date in another form PHP reads, such as 2026-06-08 10:00 from a column without a cast, 31.12.2026 or a Unix timestamp, is handed to the browser in ISO 8601, a time without a zone as the wall-clock time it names. An event whose start is no date lies on no day and is left out, and text that cannot be read as a date is reported |
end |
no | ISO 8601 end (defaults to start + 1 hour). It may fall on a later day, see Events That Span Days; for an all-day event, a YYYY-MM-DD date is the last day it covers. A date object or a date in another form is written as start is, and an end that is no date counts as none |
allDay |
no | true renders "All day" in agenda |
intent |
no | accent (default) · success · warning · danger · neutral |
category |
no | A label to filter by when the calendar is filterable. Events without one are never filtered |
attendees |
no | Names, or ['name' => …, 'avatar' => …] entries. The agenda and the day list show up to three as faces with a count, and every name joins the row's accessible name |
The calendar writes its events into the page for its script, each with the keys above and no others, and an attendee with its name and avatar. An event or an attendee that is a model carries nothing else of it into the page.
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) |
selectableDays |
bool | false |
Month day numbers become buttons that dispatch wirekit:event-calendar-day-select with { date, eventIds } — see Selecting a day |
dayDetail |
bool | false |
Lists the pressed day's events under the month grid, and +N more opens that list. Implies selectableDays |
filterable |
bool | false |
One toggle per event category above the views — see Filtering by category. Shown only when the events carry two categories or more |
view |
string | 'month' |
month · week · agenda |
date |
string|DateTimeInterface|null | null |
ISO date the calendar opens on (default today). A YYYY-MM-DD date opens on that day in every time zone, a date object on its own day. A string that names no date is reported while you develop and opens on 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 shows — the month heading, the weekday columns, the hour gutter, the times and the agenda day labels. 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. In another language than the page's, what it shows carries that locale in lang, and the accessible name of every event and day stays in the page's language, as the words in it do |
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. The calendar renders no slot, so markup placed between its tags is dropped rather
than handed that scope. Read the date from your own script instead, through the calendar's id:
<x-wirekit::event-calendar id="team-calendar" :events="$events" />
// 1. Alpine hands back the calendar's scope for its root element.
const calendar = Alpine.$data(document.getElementById('team-calendar'));
// 2. The Date the calendar shows right now.
const year = calendar.focusedDate.getFullYear();
focus before v2.47.0A 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.
-
With
selectable-days, each day number is a button named with the full date ("Monday, June 8"), and withday-detailit carriesaria-pressedfor the day that is open. The list under the grid is a group named with that date, and its rows are the agenda's rows, with the same accessible names. -
The category toggles are buttons with
aria-pressedin a group named "Filter by category". Pressed means shown. After each toggle, a polite status announces how many events remain ("Showing 3 of 5 events"), so the effect of a press is heard as well as seen. -
Attendee faces are
aria-hidden. The row's accessible name already lists every attendee, so the faces are the sighted half of the same information rather than a second announcement. -
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). The checked view is tinted and carries an inner edge in the muted text color, at 3:1 or more against the tint in every preset, so it is not marked by color alone. -
Under forced colors, which paints no tint, the checked view, a shown category and the chosen day are framed in the system's selection color. A hidden category keeps the line through its name, and today its heavier weight.
Keyboard Interaction
| Key | Action |
|---|---|
Tab |
Move through navigation, the view switcher, the day numbers (with selectable-days), and event blocks |
Enter / Space |
Activate an event or a day, navigate, or switch view |
← / → / ↑ / ↓ |
Move between Month, Week and Agenda while the view switcher has focus |
Escape |
Dismiss an open event tooltip. A modal or a drawer around the calendar stays open |
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/:
'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. It is not the Tabs pattern, which is a different keyboard model: 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