Hapio Calendar

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.

components/bookings-agenda.browser.test.tsx
// 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: selection without a selectable view, grab and drag without an editable event, actions without the actions.
  • suites limits the run; leave actions out when your app offers its own alternative to dragging (and check that against WCAG 2.5.7 yourself). views names 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. root is the calendar's root element; rerender(ui) and unmount() do what they say.
  • expectNoAxeViolations(element, axe) rejects with every axe violation in element.
  • fixedClock(at) (from @hapio/calendar-react) and manualClock(at) (from @hapio/calendar-core/testing) make "now" a test's choice: pass one as now, and move a manual clock with advance(ms). Without them, Vitest's fake timers and Playwright's page.clock work too: the calendar reads Date.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:

components/week-grid.tsx
'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.