EmCalendar
A month calendar with day selection, an event layer, and the full APG date-grid keyboard map. The compound is yours to arrange — header, title, navigation and grid are each their own part.
Usage
EmCalendar owns two pieces of state and exposes both. v-model is the selected day as an ISO YYYY-MM-DD string; v-model:month is the visible-month cursor as a Date.
The selected day being a string rather than a Date is deliberate. Two Date objects for the same day are not equal, so a re-created value silently breaks identity checks and re-render guards; an ISO string compares by value and survives a round trip through JSON without a timezone shifting it a day.
Arrange the parts however the surface needs. Nothing is required except the root — a calendar with no header is valid, and so is one whose title sits above its navigation.
Anatomy
<script setup lang="ts">
import {
EmCalendar,
EmCalendarGrid,
EmCalendarHeader,
EmCalendarMini,
EmCalendarNext,
EmCalendarPrev,
EmCalendarTitle,
EmCalendarToday,
} from '@paper/emerald'
</script>
<template>
<EmCalendar>
<EmCalendarHeader>
<EmCalendarPrev />
<EmCalendarTitle />
<EmCalendarNext />
<EmCalendarToday />
</EmCalendarHeader>
<EmCalendarGrid />
<EmCalendarMini />
</EmCalendar>
</template>Composed on v0
This is the one pilot component that does not wrap a v0 compound, because v0 does not have a calendar yet.
Underneath EmCalendar is a createCalendar core — the cursor, the 42-cell matrix, the ISO arithmetic and the clamping — that lives inside Emerald as a private module and is not exported from any barrel. It is incubating here ahead of graduating to v0, where it will become a public composable; Emerald is its first consumer, and building it against a real design system first is how its API gets found before it is frozen.
That does not make the component v0-free. The core is built from v0 primitives, the parts talk to each other through v0’s createContext, EmCalendarTitle renders v0’s Atom so its heading level is a prop, and EmCalendarGrid reads useRtl — which is why the horizontal arrow keys swap direction in a right-to-left locale rather than moving the wrong way.
Localization is optional and pluggable: the component reads v0’s date plugin if one is installed, and falls back to Intl when it is not. Install useDate and the month names, weekday names and first-day-of-week follow the adapter.
Because the core is private, min, max and per-day disabling exist inside it but are not reachable from the component’s props. Those will surface when the composable graduates.
Examples
Props
| Prop | Type | Default | Description |
|---|---|---|---|
v-model | string | — | Selected day as ISO YYYY-MM-DD |
v-model:month | Date | current month | Visible-month cursor |
events | EmCalendarEvent[] | [] | Events to lay over the month |
firstDayOfWeek | number | date adapter’s value | 0 is Sunday |
disabled | boolean | false | Freezes selection and navigation |
id | string | — | Root element id |
namespace | string | 'emerald:calendar' | Context the parts resolve against |
EmCalendarEvent is { date: string | Date, title: string, time?: string, allDay?: boolean, tone?: EmCalendarTone }, where EmCalendarTone is 'neutral' | 'primary' | 'secondary' | 'info' | 'alert' | 'danger'.
Parts
Every part takes namespace, defaulting to 'emerald:calendar' — except EmCalendarHeader, which accepts the prop for symmetry but reads no context and does nothing with it.
| Part | Props | Notes |
|---|---|---|
EmCalendarGrid | overflow (number, default 2) | The APG date grid. Chips per cell before +N more |
EmCalendarMini | dots (number, default 3) | Compact month; role="group", no roving focus |
EmCalendarHeader | — | Layout only. Takes namespace but ignores it |
EmCalendarTitle | as (default 'h2'), live (boolean, default true) | Labels the grid; announces month changes |
EmCalendarPrev | label (default 'Previous month') | |
EmCalendarNext | label (default 'Next month') | |
EmCalendarToday | — | Returns to this month and selects today |
The root exposes its internal calendar context via defineExpose({ calendar }), giving imperative goto, first, last and the isFirst / isLast signals through a template ref. Note the extra hop — the context is exposed under a calendar key, so it is ref.value.calendar.goto(…), not ref.value.goto(…).
Treat all of it as provisional: that is the incubating core’s surface, not a stable API, and it is the part most likely to change when the composable graduates to v0.
Accessibility
EmCalendarGrid implements the WAI-ARIA APG date-grid pattern: role="grid" labelled by the title, role="row" weeks, and role="gridcell" days that are real buttons.
Keyboard
The grid is a single tab stop. One cell holds tabindex="0" — the focused day, else the selected one, else today, else the first day of the month — and the arrow keys move a roving focus from there.
| Key | Behavior |
|---|---|
| Arrow Left / Right | Previous / next day. Swapped under RTL, so the keys follow reading order |
| Arrow Up / Down | Same weekday, previous / next week |
| Home / End | First / last day of the focused week |
| Page Up / Page Down | Previous / next month |
| Shift + Page Up / Page Down | Previous / next year |
| Enter, Space | Select the focused day — native, since the cell is a <button> |
Moving past the edge of the visible month pages the calendar and keeps focus on the day it landed on, so arrowing down from the last week walks into the next month rather than stopping. Home and End deliberately stay inside the rendered week, per APG, rather than jumping to the month’s boundaries.
There is no Escape handling and no type-ahead; the grid is not a popup and owns no dismissal.
Announcements
EmCalendarTitle is an aria-live="polite" region by default, so paging the month announces the new one — without it, a reader navigating by Page Up has no feedback that anything moved.
Turn live off when a second title renders over the same state; two live regions announcing the same month change is worse than one. It is also why the mini and the grid should share one title rather than each carrying their own.
Cell naming
Each day is named by its full date, with the event count appended when there are any — “14 March 2026, 3 events”. Event chips are aria-hidden, so the titles are not read out; the count tells the reader there is something there and the day is what they act on.
Today carries aria-current="date", and the selected day aria-selected. Days outside the visible month are rendered but aria-disabled and unselectable, so the grid keeps its shape without offering dates the reader did not navigate to.
The mini variant
EmCalendarMini is role="group" with plain buttons — no grid semantics and no roving focus, by design, so it never competes with the real grid for the arrow keys. The cost is that every day is a tab stop; that is the trade you accept for a widget meant to be glanced at and clicked rather than operated by keyboard.