Skip to main content
WireKit
Copy for LLM

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.

Month view with events, overflow, and a day marker

No events in this range

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).

Week view with back-to-back, overlapping, all-day, and short events

No events in this range

Agenda View

A compact chronological list — the best view on mobile and for a quick "what's next" glance.

Agenda view with a day marker

No events in this range

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.

Holidays and blocked days

No events in this range

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.

Sunday-start month grid

No events in this range

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 gets role="region" and that name, and shows up in a screen reader's landmark list. Without one it stays a plain focusable scroller — because role="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 as landmark-unique. It is a separate prop from ariaLabel on 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" of role="radio" buttons with aria-checked + roving tabindex — 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

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store