Testing
Check your calendar against the accessibility contract with runConformance, render it in tests, and keep markup of your own conformant.
@hapio/calendar-react/testing has the tools the calendar's own tests use. None of them depends
on a test runner or on axe-core: you pass them in.
runConformance
runConformance(render, options) mounts your calendar in a real browser, visits every view of its
view switcher and checks it against the accessibility contract: roles,
names, focus and tab stops, the keyboard maps, and where they apply selection, keyboard and pointer
editing and the event actions, all without committing anything. With an axe runner it also runs
axe, with no rule disabled.
// bookings-agenda.browser.test.tsx: Vitest in browser mode.
import { runConformance } from '@hapio/calendar-react/testing'
import axe from 'axe-core'
import { expect, it } from 'vitest'
import { BookingsAgenda } from '@/components/bookings-agenda'
import type { Booking } from '@/lib/bookings'
const BOOKINGS: Booking[] = [
{
id: 'b1',
title: 'Haircut · Anna',
startsAt: '2026-10-22T09:00:00+02:00',
endsAt: '2026-10-22T09:45:00+02:00',
staffId: null,
status: 'confirmed',
},
]
it('the agenda keeps the accessibility contract', async () => {
// Rejects with every failure, each linking its rule on /docs/a11y-contract.
const report = await runConformance(() => <BookingsAgenda bookings={BOOKINGS} />, {
axe: (element) => axe.run(element),
})
// A read-only agenda: the interaction suites have nothing to check.
expect(report.ran).toEqual(['roles', 'names', 'axe', 'focus', 'keyboard'])
})- Run it in Vitest's browser mode (or any runner with a real DOM and layout): names, visibility and positions come from the browser.
- It rejects with one error listing every failure,
- [suite] rule (view): message See https://calendar.hapio.io/docs/a11y-contract#rule, and resolves with the suites it ran and skipped. A suite that does not apply is skipped, never failed:selectionwithout a selectable view,grabanddragwithout an editable event,actionswithout the actions. suiteslimits the run; leaveactionsout when your app offers its own alternative to dragging (and check that against WCAG 2.5.7 yourself).viewsnames the views to visit ("Week view").- Render the live region with the default
announcer: the selection, grab, drag and actions suites read their announcements from it.
Run it against the registry block too, as configured in your app (inside <CalendarEditing>, with
your renderEvent and classNames), so a change to a copied file is checked before it ships.
The development checker
In development the calendar checks its own markup after every change of its model and warns once
per element and rule in the console, with a link to the rule (a11yContract: 'warn', the
default; 'throw' makes it an error, 'off' turns it off). Production builds leave it out
wherever the bundler replaces process.env.NODE_ENV. checkContract(root) runs the same checks
on demand and returns the violations, for a test of a single state.
Rendering in tests
renderCalendar(ui)mounts a calendar and resolves once it has settled: the store connected, the clock known, the model ready and a month's capacity measured.rootis the calendar's root element;rerender(ui)andunmount()do what they say.expectNoAxeViolations(element, axe)rejects with every axe violation inelement.fixedClock(at)(from@hapio/calendar-react) andmanualClock(at)(from@hapio/calendar-core/testing) make "now" a test's choice: pass one asnow, and move a manual clock withadvance(ms). Without them, Vitest's fake timers and Playwright'spage.clockwork too: the calendar readsDate.now()when it needs the time.
Markup of your own
When you build markup on the hooks, spread every getter's props on the element it is for, and keep
their id, role, tabIndex and handlers: runConformance reads the DOM through roles, ARIA
attributes and the getters' data-* attributes (data-slot, data-composite,
data-composite-item, data-kind, data-date, and data-selectable on a time grid that takes
selections: an editable or droppable grid is not aria-readonly either, so that alone does not
tell). The
contract's rules each say what a failure means and how to fix it.
Sticky headers
A grid whose header sticks to the top of its scroller can hide the element that has focus: the
browser scrolls a focused element into view at the scroller's edge, under the header. The
registry's grids keep everything focusable below the header clear of it; markup of your own must
do the same (WCAG 2.4.11, Focus Not Obscured). Measure the header's height into a variable and give
the focusable elements in the body that much scroll-margin-block-start:
'use client'
import {
useCalendarContext,
useCalendarEvent,
useTimeGrid,
type ColumnSegment,
} from '@hapio/calendar-react'
/**
* A ref for the sticky header: keeps its height in `--calendar-header-height` on the grid, and
* up to date when the header's text wraps.
*/
function measureStickyHeader(header: HTMLDivElement | null): (() => void) | undefined {
const grid = header?.parentElement
if (!header || !grid) return undefined
const measure = () => {
grid.style.setProperty('--calendar-header-height', `${header.getBoundingClientRect().height}px`)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(header)
return () => observer.disconnect()
}
/** Whatever takes focus below the header keeps clear of it when focus scrolls it into view. */
const BELOW_HEADER = '[&_[tabindex]]:[scroll-margin-block-start:var(--calendar-header-height,0px)]'
export function WeekGrid() {
const grid = useTimeGrid()
const { strings } = useCalendarContext().model
return (
<div {...grid.getScrollerProps({ className: 'h-[36rem] rounded-xl border' })}>
<div {...grid.getGridProps({ className: 'grid' })}>
<div
{...grid.getHeaderRowGroupProps({
ref: measureStickyHeader,
className: 'sticky top-0 z-10 bg-background',
})}
>
<div
{...grid.getHeaderRowProps(undefined, {
className: 'grid grid-cols-[3.5rem_var(--calendar-columns)]',
})}
>
<div {...grid.getAxisHeaderProps()}>
<span className="sr-only">{strings.time}</span>
</div>
{(grid.headerTiers.at(-1) ?? []).map((header) => (
<div key={header.key} {...grid.getColumnHeaderProps(header)}>
{header.text}
</div>
))}
</div>
</div>
<div {...grid.getBodyRowGroupProps({ className: BELOW_HEADER })}>
<div
{...grid.getBodyRowProps({
className: 'grid grid-cols-[3.5rem_var(--calendar-columns)]',
})}
>
<div {...grid.getAxisProps()}>
<span className="sr-only">{strings.time}</span>
{grid.slotLabels.map((label) => (
<div key={label.key} {...grid.getSlotLabelProps(label)}>
{label.text}
</div>
))}
</div>
{grid.lanes.map((lane) => (
<div
key={lane.key}
{...grid.getLaneProps(lane, {
className: 'h-[calc(var(--calendar-slots)*1.75rem)]',
})}
>
{grid.segmentsFor(lane.key).map((segment) => (
<EventChip key={segment.key} segment={segment} />
))}
</div>
))}
</div>
</div>
</div>
</div>
)
}
function EventChip({ segment }: { segment: ColumnSegment }) {
const event = useCalendarEvent(segment)
const props = event.getRootProps({
className:
'absolute start-(--event-inset-start) top-(--event-top) h-(--event-height) w-(--event-width)',
})
const content = `${event.content.timeText} ${event.content.title}`
return event.as === 'a' ? <a {...props}>{content}</a> : <button {...props}>{content}</button>
}Put the margin on the body's elements, not on the header's own links (focus must never scroll to
them), and not as scroll-padding on the scroller, which would scroll the grid when a header link
takes focus.