The accessibility contract
What every calendar view must keep, rule by rule, as the development checker and runConformance check it.
This page is the contract the calendar's hooks implement and your markup keeps. The development
checker and runConformance link every finding to a rule below, by its name
(https://calendar.hapio.io/docs/a11y-contract#composite-tab-stop).
Accessibility explains the model in plain terms;
Customization shows markup on the hooks.
Every Hapio Calendar view, the registry block's and your own markup on the hooks, follows one
accessibility model. Two tools check it, and both link each finding to a rule on this page
(https://calendar.hapio.io/docs/a11y-contract#<rule>):
- The development contract checker (
a11yContract, default'warn'in development, stripped from production builds) runscheckContracton the calendar root after each commit and warns once per element and rule.checkContract(root, o?)is also exported from@hapio/calendar-react/testing. runConformance(render, o?)(@hapio/calendar-react/testing) mounts your UI in a real browser (Vitest browser mode), visits every view of its view switcher, and runs the suitesroles,names,focus,keyboard,selection(in the views where selection is on),grab(in the time and day grids with an event that Space grabs),drag(in the time grids with an event that a mouse drags),actions(in the views that render the event actions, and in the time and day grids with an editable event, which must render them) and, with an injected runner,axe(zero disabled rules). A suite that does not apply is reported as skipped:selectionwhen no view is selectable,grabanddragwhen no view has an editable event,actionswhen no view renders the actions or has an editable event. It rejects with oneErrorlisting every failure as- [suite] rule (view): message See https://calendar.hapio.io/docs/a11y-contract#rule.
import axe from 'axe-core'
import { runConformance } from '@hapio/calendar-react/testing'
it('my calendar conforms', async () => {
const report = await runConformance(() => <MyCalendar {...options} />, {
axe: (element) => axe.run(element),
})
// 'selection' when a view is selectable (the selection engine and `selectable`); 'grab',
// 'drag' and 'actions' when an event can be edited (the editing engine and `editable`).
expect(report.ran).toEqual([
'roles',
'names',
'axe',
'focus',
'keyboard',
'selection',
'grab',
'drag',
'actions',
])
})runConformance reads the DOM through roles, ARIA attributes and the getters' hooks
(data-slot, data-composite, data-composite-item, data-kind, data-date,
data-selectable), so spread every getter's props on the element it is for, and keep its id,
role, tabIndex and handlers.
Keys are dispatched as keydown (and, for Space and the edit keys, keyup) events on the focused
element; nothing is navigated (no Enter, PageUp or PageDown on the composites), no selection is
confirmed (Space starts one and Escape cancels it, so the app's onSelect is never called) and no
edit is dropped (Escape cancels every grab and every drag before its release, so the app's
onEventChange is never called), and no action that commits is chosen (the actions suite opens
the menu and closes it, and starts "Move to…" and cancels it). The drag suite dispatches
synthetic mouse PointerEvents and lets animation frames pass (the pointer engine handles moves
once per frame). The selection, grab, drag and actions suites read announcements from the live
region: render it with the default announcer (with 'auto' the browser's ariaNotify speaks
and the region stays empty). The reference markup is tests/byo-markup/byo-calendar.tsx in the
repository: a week, a month and a day on the kit hooks with a CSS module, with the slot cursor, the selection layer, the editing parts (mirror
segments, resize handles, the drag preview) and the event actions (the anchor, the trigger, a
plain menu of its own, the reschedule form in a plain <dialog>, external items in the mirror
layer, a draggable service) of @hapio/calendar-react/interaction, which CI runs through
runConformance read-only, selectable, editable and droppable, next to the registry block
(inside <CalendarSelection> when selectable, <CalendarEditing> when editable).
The rules, by the suite that reports them:
The contract checker reports the rules of the first section below in development (and
runConformance reports them too); the second section's rules are runConformance's own.
Rules of the contract checker
composite-tab-stop
Exactly one tabIndex={0} per roving composite. A composite is a container with
data-composite (it may name several: the day grid's "dates events"); its items carry
data-composite-item="<name>". Exactly one item of each composite has tabIndex 0, every other
item -1, so Tab enters the composite once and the arrow keys move inside it (WCAG 2.1.1, ARIA
APG "roving tabindex").
- Fix: spread the item getter's props and do not override
tabIndex(useCalendarEvent'sgetRootProps,useMoreLink'sgetTriggerProps,useDayCell(day).getCellProps()for the day cells,useTimeGrid'sgetNavLinkProps). The hooks move the stop with focus. The slot composite has one item, the slot cursor, always the stop (see slot-cursor). runConformancereports it underfocusbefore any key is pressed and checks the roving stop again after moving focus (events-keys,dates-keys,headers-keys).
role-nesting
Roles nest as ARIA requires. row sits in a rowgroup or grid, rowgroup in a grid,
gridcell, columnheader and rowheader in a row, listitem in a list (an <li> directly
in its <ul>/<ol>), and a row owns only cells. Wrappers between them have no role (or
presentation/none), so the accessibility tree skips them.
- Fix: give layout wrappers inside a row no role (the registry's all-day row positions its bars
against a role-less wrapper of the day cells). Never put a
list, abuttonor any other role directly inside a row: put it inside a cell. Events go inside their day's cell or lane.
aria-reference
Every id in aria-labelledby, aria-describedby and aria-controls exists in the document.
A reference to a missing id names or describes nothing.
- Fix: render the elements the getters refer to: the toolbar title (
getTitleProps, which the grid'saria-labelledbypoints at), the descriptions container ofuseDescriptions()with one element per item (each event'saria-describedby), the popover heading (getHeadingProps).
live-region
The live region is mounted and displayed. Navigation, view changes and (later) selections
are announced through the element of useAnnouncer().getLiveRegionProps()
(aria-live="polite" aria-atomic="true"). It must be inside the calendar root, always rendered,
and hidden visually only (sr-only), never with display: none or hidden, which make screen
readers ignore it.
chronological-order
Events are in the DOM in the view's chronological order. Screen readers browse the DOM, and
the events composite's ↑/↓ follow the model's order (view.focusOrder): the all-day row lane by
lane and level by level, then each lane's events, then its "+N more"; in a day grid week by week,
cell by cell, by level, with the cell's "+N more" last. The two orders must agree.
- Fix: render
segmentsFor(key),allDaySegmentsFor(key)andmoreFor(key)in the order they return, inside the lanes and cells in model order. Sort nothing yourself; position events with the geometry variables instead of reordering them. - The checker compares with
view.focusOrder.runConformance(suitekeyboard) walks the composite with ArrowDown and compares that sequence with the DOM.
duplicate-id
No id appears twice in the document. Duplicate ids break every aria-* reference to them.
Event copies in the "+N more" popover get their own ids (context: 'popover').
- Fix: keep the getters' ids; pass
{ context: 'popover' }touseCalendarEventfor events rendered inside the popover; give two calendars on one page their own roots (eachuseCalendarhas its own id prefix).
time-surface-padding
A time-grid lane (data-slot="calendar-lane") has no padding-block. Events and the now
indicator are placed in percent of the lane (--event-top, --event-height,
--calendar-now-top); block padding shifts them off the time axis.
- Fix: pad the content inside an event, never the lane.
slot-cursor
A time grid with a slot composite has exactly one slot cursor, and its name says which slot it
is on. When a time grid's dates respond (selectable, or onDateClick, with the selection
engine: useTimeGrid().interactions.slots), getGridProps() names a slot composite
(data-composite="slots events"). There is no element per slot (slot lines are CSS): the
composite's one focusable element is the slot cursor of useSlotCursor
(@hapio/calendar-react/interaction), a role="button" that stands on one slot at a time and is
re-keyed per slot, so screen readers announce every move as a new element taking focus. The
checker requires:
- exactly one cursor (
data-slot="calendar-slot-cursor") in each grid whosedata-compositenamesslots, and none anywhere else: read-only time grids, day grids and lists have none; role="button",tabIndex={0}(the composite's only item is always its tab stop) anddata-composite-item="slots";- a place in a lane (
getLaneProps, withdata-kind="slot") or an all-day cell (getAllDayCellProps, withdata-kind="all-day"), after their events; not insidearia-hiddencontent such as the selection layer, and notdisplay: noneorvisibility: hidden, which would keep focus out; - a name from its
aria-label(anaria-labelledbywould replace it) that starts with the slot's day as its column header names it, goes on with its time (or "All day") and, in a selectable grid (one withdata-selectable, whichgetGridProps()sets whenselectable), ends with its availability, joined by ", ": "Monday, October 19, 2026, 09:00 to 09:30, available", "…, 09:30 to 10:00, selected, available", "…, 12:00 to 12:30, unavailable, Lunch break". An editable or droppable grid is notaria-readonlyeither, sodata-selectableis what says a grid takes selections. Every time in a repeated (fall-back) hour names its UTC offset, so the two occurrences read apart ("Sunday, October 25, 2026, 02:00 (UTC+1) to 02:30 (UTC+1), available"); - in a selectable grid, a description (
aria-describedby): the selection hint ("Press Space to start a selection", the first item ofuseDescriptions()), except while a selection is in progress (data-selecting), when Space confirms and the hint would mislead (day cells follow the same rule on the focused cell); - a new element for every slot. The cursor's
idnames its slot, so the checker remembers theidof each cursor element and reports one whoseidchanged between two checks: React reused it for another slot, and moving to it is a name change on a focused element, which screen readers do not reliably speak; - while placing: "Move to…" in an event's actions
menu, or an external item picked up, starts placing, and the engine writes
data-placingon the calendar root until it ends. Keyboard users choose the place with the slot cursor (Enter or Space places), so the cursor shows in every time grid of that root, also one whoseinteractions.slotsisfalse(a read-only grid, whatever itsdata-compositesays): each has exactly one cursor, withdata-placing, whose name ends with the placed item's verdict ("…, 09:00 to 09:30, available", "…, unavailable, overlaps another event") in every grid, selectable or not. No selection hint is required then (Space places). Day grids keep their day cells, the dates composite whose one tab stop composite-tab-stop checks: placing there targets the focused day cell. A cursor carriesdata-placingonly while its calendar root does.
import { useSlotCursor } from '@hapio/calendar-react/interaction'
function SlotCursor({ laneKey, surface }: { laneKey: string; surface?: 'time' | 'all-day' }) {
const slot = useSlotCursor({ laneKey, surface })
return slot.active ? <div key={slot.cursor.key} {...slot.getCursorProps()} /> : null
}- Fix: render
<SlotCursor laneKey={lane.key} />in every lane and<SlotCursor laneKey={lane.key} surface="all-day" />in every all-day cell, after the events; spreadgetCursorProps()on adivkeyed bycursor.keyand give it no name, role ortabIndexof your own. Hide it visually until it is focused or a selection is in progress (data-selecting), never withdisplay: none. The same<SlotCursor>serves placing: while it lastsuseSlotCursoris active in every time grid, read-only ones included (slot.placing), so keep it in every lane and all-day cell whatever the grid's interactions, and draw the placing ring ondata-placing. - The development checker runs after each commit of the model, and moving the cursor alone
commits none, so it sees a cursor that was not re-keyed at the next selection, navigation or
event load;
checkContractcalled after a move (asrunConformancedoes) sees it at once.
selection-state
The selection is exposed where it is drawn, and drawn where it is exposed. In a
selectable day grid every day cell says whether it is selected (aria-selected); a time grid says
it through the slot cursor's name ("selected"); the selection layer
(useInteractionMirror().getSelectionProps(), aria-hidden) draws it. They must agree, so screen
reader users and sighted users get the same selection (WCAG 1.3.1, 4.1.2). The checker requires:
-
inside a grid,
aria-selectedonly on day cells (useDayCell(day).getCellProps(), the dates composite), as"true"or"false". Rows, lanes, all-day cells, events and the slot cursor never carry it: a lane or an all-day cell is not selected as a whole (the cursor's name says "selected").aria-selectedoutside the grids, such as tabs in your own toolbar, is not checked; -
aria-multiselectable="true"only on a day grid; -
a day grid with selection state (
aria-multiselectable="true", anyaria-selectedcell, or a drawn selection) isaria-multiselectable="true"(a selection covers several days), is notaria-readonly="true", and every one of its day cells carriesaria-selected("true" inside the selection, else "false").getGridProps()anduseDayCelldo all of it whenselectable; a read-only day grid has none of it; -
the selected day cells are one run in DOM order: a selection is one range of days, so a cell outside it that still says "true" did not re-render;
-
a selection piece (
data-slot="calendar-selection") inside a day cell is in a selected one (a row piece starts in the first selected day of its week row), and every week row with selected days draws a piece; -
a selected slot cursor (
data-selected) has a selection piece in its grid. -
Fix: spread
getGridProps()anduseDayCell(day).getCellProps()unchanged, and render the selection layer before the events:useInteractionMirror(day.key)in every day cell,useInteractionMirror(lane.key)in every lane anduseInteractionMirror(lane.key, { surface: 'all-day' })in every all-day cell, each drawinggetSelectionProps()while itsselectionis notnull.
edit-parts
The editing parts stay out of the accessibility tree and the tab order, sit where the engines
look for them, and agree with the edit in progress.
With an editing engine (editing(); the registry's <CalendarEditing>), an event that may be
resized shows resize handles, an edit in progress draws mirrors (the event as it would be, where it
would land) and, while a pointer or a finger moves an event, a drag preview follows it. They are
pointer and visual aids only: keyboard users move and resize the focused event with Space and the
arrow keys (the grab hint in its description says so), and the live region says where it would
go. The checker requires:
- hidden and never focusable: every mirror (
data-slot="calendar-mirror"), the drag preview (calendar-drag-preview) and every resize handle (calendar-resize-handle) isaria-hidden="true"(or insidearia-hiddencontent) and neither is nor holds anything focusable: notabIndex, link, button, form field or editable content, unless it isinertor disabled. None is a tab stop, so a view keeps at most three (tab-stops); - handles on the event they resize: a resize handle sits inside its event element
(
useCalendarEvent().getRootProps(),data-slot="calendar-event"), which has adata-key(the pointer engine finds the segment by it; the copies in the "+N more" popover have none and are never resized), carriesdata-edge="start"or"end", at most one per edge, and hastouch-action: none(a touch on it resizes the event instead of scrolling the page); - handles only where a resize can be allowed: never on an event whose change is pending
(
data-pending) or being reverted (data-reverting), on a list event, or on a timed event in a row (day grids move timed events by whole days); the start handle only on the segment that holds the event's start (data-start), the end handle only on the one that holds its end (data-end).useCalendarEvent().editssays exactly where: it also knows the engine, the event'seditableflags and whether it is a recurring occurrence, which the markup does not show; - one drag preview, inside the root: a calendar root holds at most one, and none is outside
every calendar root. The engines look for it inside the root, and its
position: fixedplaces it over the page from there; a portal to the document's body hides it from them; - parts only during their edit: a mirror is drawn only while an event of its calendar shows
the edit (
data-draggingfordata-kind="move",data-resizingforresize-startandresize-end: the source stays in place, dimmed, while its mirror shows where it would land), and the drag preview only while one showsdata-dragging. Two mirrors have no edit state on an event: a placing mirror (data-kind="placing": the event "Move to…" places, drawn at the slot cursor or the day cell) shows only while its calendar root showsdata-placing(the event being placed itself shows nothing: focus is on the cursor), and an external mirror (data-external: a service dragged or picked up from a palette, drawn where it would land, or a drop's placeholder,data-pendingwhile the app saves anddata-revertingwhen it refuses) has no source event in the calendar at all, and its grid is notaria-readonly(a droppable grid takes items). Both arearia-hiddenand hold nothing focusable like every mirror;useInteractionMirror(key).segmentsdraws the first,.externalthe second (with the default content:renderEventnever receives an external item, whose event is synthetic); - described: an event with a resize handle,
data-dragging,data-resizingordata-touch-selectedhas anaria-describedbywith text. Its description ends with the grab hint ("Press Space to move", or "Press Space to resize" when it can only be resized), the keyboard's only way to learn that the event can be edited; - touch-selected: an event with
data-touch-selected(a long-press selected it) hastouch-action: none, so the next touch on it drags at once (select-then-drag); - focus stays on a grabbed event: while an event shows
data-draggingordata-resizingfrom the keyboard, focus is on it (on one of its segments) until it is dropped or cancelled. The checker takes an edit for the keyboard's when the attribute's value says so ("keyboard"), or, when it has none, for a move whose calendar root draws no drag preview: pointer and touch moves always draw one. A pointer or touch edit leaves focus where it was.
Focus after a change (not checked: the calendar does it). When the focused element goes with
a change (a drop on another day, a revert, a settle
without new data, or for a recurring occurrence a 'following' or 'all' preview the app
rejects or answers without new data, and an echo that gives the series' occurrences new keys, as
a server that expands the series again does), focus moves in the same commit to the edited
event's element where the model now draws it: its first segment (under its own key, or the key
the echo gave it), else the "+N more" trigger that hides it, else the events' tab stop, never the
page body. Keep each event element's id and data-key from getRootProps() and key it by
segment.key, so the calendar finds it.
Handles and WCAG 2.5.8 (Target Size, Minimum). A handle is a pointer target, and an event 30 minutes long has no room for two 24 by 24 px handles and a body to press, so handles may be smaller. They rely on the criterion's equivalent exception: resizing is also available through controls that meet it, the event's actions menu ("Longer", "Shorter", "Change time…", which is also the single-pointer path of WCAG 2.5.7, see event-actions) and, from the keyboard, Shift with the arrow keys. The checker does not measure handles. Make each as easy to hit as the event allows without covering its body, where a press moves the event: the event's full width in a time grid, its full height in a row.
function EventItem({ segment }: { segment: EventSegment }) {
const event = useCalendarEvent(segment)
return (
<button {...event.getRootProps()}>
{event.content.timeText} {event.content.title}
{event.edits.resizeStart ? <span {...event.getResizeHandleProps('start')} /> : null}
{event.edits.resizeEnd ? <span {...event.getResizeHandleProps('end')} /> : null}
</button>
)
}- Fix: render the handles only where
editsallows them, inside the event element, spreadinggetResizeHandleProps(edge)on aspanwith nothing focusable in it (an event withhrefis an<a>, see event-role). Draw the mirror segments ofuseInteractionMirror(key).segmentswithgetMirrorProps(segment)in every lane, all-day cell and day cell, and the element ofuseDragPreview()once, inside the root, only while it isactive(both from@hapio/calendar-react/interaction); give both the event's look without links or buttons (the registry'srenderEventreceivesisMirror: true). Keep a grabbed event's element in place: the same key and element (React must not remount it), not hidden or moved; dim it instead. - Without an editing engine
editsis allfalse, so a read-only or selection-only calendar draws none of these parts. The checker reads the DOM only, so it cannot see the engine. - The development checker runs after each commit of the model, and a drag or a keyboard step
commits none: it sees an edit's parts when something else commits meanwhile, and the pending
event at a drop.
checkContractcalled during an edit (asrunConformancedoes) sees them at once.
event-actions
Every event that can be moved or resized has its actions — a "More actions" button beside it
that opens a menu — in every calendar that renders them. WCAG 2.5.7 (Dragging Movements) asks for a way to do without dragging whatever a drag
does. The actions are that way: the trigger opens a menu ("Move to…", "Change time…", "Earlier",
"Later", "Previous day", "Next day", "Longer", "Shorter", "Change resource…") that a click, a tap
or the keyboard operates (Shift+F10, the ContextMenu key or a right click on the focused event
opens it). useEventActions(segment) (@hapio/calendar-react/interaction) gives the anchor, the
trigger, the menu's items and the reschedule form; the registry's <CalendarEditing> renders them
for every editable event. The checker requires:
- where it applies: the rule looks only at calendars that render the actions somewhere (an
anchor or a trigger inside the calendar root). A read-only or selection-only calendar has none.
An editable calendar without them may offer its own single-pointer alternative (its own form on
onEventClick, say), which the markup cannot show: check such a calendar yourself, against WCAG 2.5.7; - every editable event has its actions: in such a calendar, an event that shows it can be
moved or resized sits in an anchor. An event shows it by a resize handle,
data-dragging,data-resizing,data-touch-selected, or a description that ends with the grab hint ("Press Space to move" or "Press Space to resize"; with strings of your own the checker knows editable events by their handles and edit states only). An event whose change is pending (data-pending) or being reverted (data-reverting) has no actions. The copies in the "+N more" popover count too (they belong to the calendar whosedata-calendar-prefixstarts their ids): the actions are their only way to move, since they are never dragged; - the anchor (
data-slot="calendar-event-anchor",getAnchorProps(),display: contents) wraps one event element and one trigger. Its handlers hear the event's Shift+F10, ContextMenu key and context menu (React's events bubble to it), so the event element goes inside it; - the trigger (
data-slot="calendar-event-actions",getTriggerProps()) is abutton(orrole="button") named "More actions for Haircut · Anna" (itsaria-label), witharia-haspopup="menu"andtabIndex={-1}: keyboard users open the menu from the focused event, so the view keeps at most three tab stops. It is not disabled (render the event alone whereavailableisfalse), sits beside its event and after it (not inside it: a button inside a link or a button is nested interactive content, which screen readers flatten), and is neither inaria-hiddenorinertcontent nordisplay: noneorvisibility: hidden: screen-reader users find it in browse mode right after the event. Hide it visually instead (opacity) until the event or the trigger is hovered or focused, the event is touch-selected (data-touch-selected) or the menu is open (data-open); a hidden trigger takes no pointer (pointer-events: none), so in a dense week the triggers of narrow overlapping events do not cover their neighbours; the registry's shown trigger sits just above a time-grid event's top inline-end corner, outside the event, so it never covers what a pointer presses to drag the event. A hidden trigger positioned at its event may also give up its box (the registry's theme makes it a 1 px box in flow, unpositioned, without border, shadow or stacking, its icon not rendered: a dense week mounts a thousand, and as positioned, stacked boxes they made each frame of a drag re-layerize the page); it stays in the accessibility tree, right after its event. On a touchscreen a trigger positioned at its event takes taps only on a touch-selected event, but one in flow (a list row, a line of the "+N more" popover, whose events cannot be touch-selected) is shown and takes taps. A modal dialog hides the rest of the page while it is open (the reschedule form's), triggers included: the checker does not count that hiding against them (a dialog witharia-modal="true"or opened withshowModal()outside the trigger, or the page hiding of a primitive's modal, which Radix marksdata-aria-hiddenand Base UIdata-base-ui-inert). When the menu primitive setsaria-expanded, it agrees withdata-open; - the menu: at most one trigger of a calendar shows
data-open(the actions open for one event at a time). An open trigger has its menu in the document: the element itsaria-controlsnames, or arole="menu"whosearia-labelledbynames the trigger'sid(both bases'DropdownMenuContentdo both). The menu isrole="menu"and named (by the trigger). A menu that shows while its trigger has nodata-openis not controlled by the hook'sopenandonOpenChange, so the engine does not know it is open. A menu on its way out (data-stateofclosed,data-closed,data-ending-style,hidden,display: none) is not checked; - the items (
data-slot="calendar-event-action",getItemProps(action)) are menu items (role="menuitem") inside the menu. Disabled items stay listed (aria-disabled). TheironPointerUpprevents a release whose press did not start on one of the menu's items (or of another button than the first): a right click on an event opens the menu at the press on macOS and Linux, under the pointer, and a primitive that chooses on a release (Radix) would choose the item under it. Spread the getter's handlers (composed), never replace them; - the reschedule form (
form.getFormProps()and the field getters; not checked by the checker, which never opens it): each field's message is its description, and a problem of the whole value (the end before the start, a rule's reason) is the form's description and also the description of where Save puts focus on an invalid value: the field at fault (aria-invalid, for an end before the start the end's date or time), else Save itself. Render the form's message element (getMessageProps('form')) whileform.problemsis not empty.
import { useEventActions } from '@hapio/calendar-react/interaction'
function EventItem({ segment, context }: { segment: EventSegment; context?: 'view' | 'popover' }) {
const event = useCalendarEvent(segment, { context })
const actions = useEventActions(segment, { context })
const element = <button {...event.getRootProps()}>{event.content.title}</button>
if (!actions.available) return element
return (
<span {...actions.getAnchorProps()}>
{element}
<DropdownMenu open={actions.open} onOpenChange={(open) => actions.onOpenChange(open)}>
<DropdownMenuTrigger {...actions.getTriggerProps()} />
<DropdownMenuContent>
{actions.actions.map((action) => (
<DropdownMenuItem key={action.id} {...actions.getItemProps(action)}>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</span>
)
}- Fix: wrap every event element that
availableallows, in the view and in the "+N more" popover ({ context: 'popover' }), in the anchor with its trigger, as above. Spread the getters unchanged; a menu of your own is arole="menu"witharia-labelledbythe trigger'sid(give the trigger one) androle="menuitem"items, shown only whileopen, with Escape and the arrow keys by your own code. - The checker reads the DOM only: it cannot see the engine,
availableor which items the engine offers.runConformance'sactionssuite opens the menu with Shift+F10, checks its items and where focus goes, and starts placing (actions-menu, actions-placing); it also fails a view whose events can be edited but that renders no trigger (actions-trigger). The development checker runs after each commit of the model, and opening a menu commits none: it sees an open menu when something else commits meanwhile;checkContractcalled with the menu open (asrunConformancedoes) sees it at once.
Rules of runConformance
render
The UI renders a calendar that settles. runConformance needs the calendar root
(getRootProps(), data-slot="calendar-root"), a model that is not pending after three seconds,
and, with o.views, radios of those names in the view switcher (getViewOptionProps, named
"Week view", "Month view", …). Choosing a view must check its radio and render the view.
view-structure
A grid view renders one grid of rowgroups, rows and cells; the list view a list. A time grid
or day grid view (data-kind on the root) renders exactly one role="grid" (getGridProps) with
a header row of columnheaders (getColumnHeaderProps) and gridcells (lanes, all-day cells,
day cells); every row owns cells. In the list view every event is inside a list item
(ul > li (day) > ul > li > a|button).
event-role
Events are links or buttons; "+N more" is a button. An event with href is an <a href>
and any other event a <button> (useCalendarEvent().as says which), so Enter activates it and
it is announced as what it does. A div with role="link" has no href: modified clicks, the
context menu and "open in new tab" do not work. A "+N more" trigger is a <button> with
aria-haspopup="dialog" (useMoreLink().getTriggerProps()).
accessible-name
Everything interactive, every header and the grid has a name. Buttons, links and radios need
text (an aria-label only for icon-only buttons, such as the popover's close button). The grid
is named by the toolbar title. Column and row headers need text in the accessibility tree, not
only an aria-label (axe empty-table-header): the time axis header (getAxisHeaderProps) and
the axis row header (getAxisProps) render model.strings.time visually hidden; the "All day"
row header shows its text.
label-in-name
A visible text label is part of the accessible name (WCAG 2.5.3 Label in Name). An element
named with aria-label/aria-labelledby whose visible text is a word ("Week") must contain it in
its name ("Week view"). Symbols and icons ("×", "‹") are not labels. Prefer names from content:
the registry's nav links read their visible text, then the full date in visually hidden text
("Thu 10/22 Thursday, October 22, 2026").
cell-name
Grid cells take no aria-label or aria-labelledby. A cell holds events, "+N more" and the
week number; a name of its own leaves that visible text out (WCAG 2.5.3, axe
label-content-name-mismatch). Lanes and all-day cells are identified by their column header and
aria-colindex; day cells are named by their content (see day-cell-name).
day-cell-name
A day cell is named by its content, its date first. Day cells are the dates composite's
focusable items, so they need a name, and it must start with the day: render day.label
("Thursday, October 22, 2026, 3 events") visually hidden as the cell's first child; the day
number (getDayNumberProps) is aria-hidden, and the events follow. A cell without the label is
named by its first event, or by nothing.
event-description
Every event has a description. The getters set aria-describedby to the event's entry in
the descriptions container ("Thursday, October 22, 2026, 09:00 to 10:30, Chair 2"); the name
stays the visible content (time first). Render useDescriptions(): its container (hidden) and
one element per item with the item's id and text.
background-name
A background event with role="img" has a name. useBackgroundEvent makes a labelled
background event an image named by its label ("Open · Thu 22 Oct 10:00–17:00"); one without a
label is aria-hidden, and one with your own content (hasContent) leaves the semantics to that
content. Do not add role="img" without a label.
tab-stops
At most three tab stops per view: the header nav links (one roving stop), the dates (day
grids) or the slot cursor (time grids with a slot composite; one), and the events (one).
Counted inside the view's scroller (or the grid or the list). Every other focusable element of
the view takes tabIndex={-1}: day numbers and week numbers (pointer shortcuts, aria-hidden),
day-grid header nav links (Enter on the day cell is the keyboard path). A time grid with a slot
composite has the nav links, the slot cursor and the events (two stops without nav links), and
its scroller is tabIndex={-1}, since the cursor is always a stop; until an event has had focus,
the events' stop is the first event on or after the cursor's day, as in a day grid after the
dates' stop. A read-only time grid has no slot composite: without nav links and events its
scroller is the one stop, so keyboard users can still scroll it (getScrollerProps). Resize handles, mirrors and the drag preview
are never focusable, so they add no stop (see edit-parts).
more-popover
"+N more" opens a named dialog that takes focus. Activating the trigger opens an element with
role="dialog" (getContentProps()), named by its heading (getHeadingProps(); the visible
heading plus the full label, "Wednesday, October 28, 2026, 5 events"). Focus moves into it: the
close button (getCloseButtonProps(), named "Close") comes first and takes focus, also for
programmatic opens. Escape and the close button close it. The listed events use
useCalendarEvent(segment, { context: 'popover' }). With o.axe, axe runs on the open dialog
too.
focus-return
Closing the popover returns focus to its trigger, after Escape and after the close button
(WCAG 2.4.3 Focus Order). Both registry bases do it for trigger opens, and the hook does it for
programmatic opens and markup without a primitive, as long as the trigger keeps the id of
getTriggerProps() and nothing else moves focus on close (a popover that restores focus to the
day cell, the grid or the page body fails).
events-keys
The events composite's keys work: ↑/↓ move to the previous/next item in chronological
DOM order and reach every event and "+N more" of the view; Home/End go to the first/last; ←/→ go
to the first event of the previous/next day that has events (flipped in right-to-left text);
Escape in a day grid returns to the event's day cell, and in a time grid with a slot composite
to the slot cursor at the event's start, in the lane or all-day cell that holds the event;
the focused item becomes the composite's only tab stop. Enter is the element's own activation (a
link or a button). Keep the getter's onKeyDown and onFocus; a handler of yours that calls
preventDefault() cancels the hook's.
dates-keys
The dates composite's keys work (day grids): ←/→ move one day (flipped in RTL, across
rows), ↑/↓ one week, Home/End to the row's first and last day, and the focused cell becomes the
only tab stop. runConformance does not press Enter, PageUp or PageDown (they navigate); the
hooks' own tests and the keyboard persona's e2e specs cover them. Space, the arrows and Escape of
a selection are the selection-keys rule's.
headers-keys
The header nav links are one roving composite (time grids with navLinks): ←/→ move between
the day headers (flipped in RTL), Home/End to the first/last, and the focused link becomes the
only tab stop. Enter follows the link (not pressed by runConformance).
slots-keys
The slot cursor's keys work (time grids with a slot composite): ↓ or ↑ moves the cursor to another slot of its day and the other key brings it back; ← or
→ (flipped in RTL) to the same time on another day and back, when the grid has more than one day.
Every move must render a new element (the cursor keyed by cursor.key) that takes focus and
names the slot it stands on, and the cursor stays the composite's only tab stop. At an edge a key
stays (the last slot, the grid's first day), so the opposite key is tried first.
- Fix: render
<div key={slot.cursor.key} {...slot.getCursorProps()} />whileuseSlotCursor({ laneKey, surface }).active, in every lane and all-day cell (see slot-cursor); keep itsonKeyDown(a handler of yours that callspreventDefault()cancels the move). A cursor element React reuses for every slot changes its name in place, which screen readers do not reliably speak.
selection-keys
Space starts a selection, the arrow keys extend it and Escape cancels it. In every view where selection is on (a time grid with a slot
composite that is data-selectable, a day grid that is aria-multiselectable),
runConformance starts a selection at the composite's tab stop and checks:
- Space on the slot cursor gives it
data-selectinganddata-selected(its name says "selected"); on a day cell it selects that day alone (aria-selected="true"on it and on no other cell: the selection in progress replaces the committed one while it lasts), focus staying where it is; - an arrow key (↓, else ↑, ←, →: the first that moves; the day grid's forward arrow) moves the
selection's end: focus follows to the cursor's new element (or the next day cell), which is
still selecting and selected, the day cells of the range say
aria-selected="true", and the selection layer grows (its pieces'--selection-heightand--selection-width); - Escape cancels: focus stays on the cursor (no
data-selecting) or on the day cell, the day cells say what the committed selection says again, and the selection layer draws exactly what it drew before Space.
A one-day view whose cursor is in the all-day row moves it to the day's first slot first (a selection never leaves the all-day row). Nothing is confirmed: Enter would call the app.
- Fix: spread
getCursorProps()anduseDayCell(day).getCellProps()unchanged (theironKeyDowncarries the keys out through the store; a handler of yours that callspreventDefault()cancels it), and render the selection layer ofuseInteractionMirrorin every lane, all-day cell and day cell, sized by the custom properties ofgetSelectionProps()(see selection-state). - After each step the contract checker runs again, and anything it did not find before the
selection started is reported under
selectionwith its own rule (a selected cursor without a piece is selection-state, a cursor reused for another slot slot-cursor).
selection-announce
Every selection step is announced. After Space, after the arrow key and after Escape the live region's text must change ("Selecting from …", "Selecting …" after 150 ms of quiet, "Selection cancelled"; a refused range adds its reason). Screen-reader users hear the range they are building only there: the cursor's own name says one slot.
- Fix: render
useAnnouncer().messageinside the element ofgetLiveRegionProps(), always mounted inside the root and hidden visually only (see live-region). Withannouncer: 'auto'in a browser that hasariaNotifythe region stays empty: runrunConformancewith the default announcer.
grab-keys
Space grabs an editable event without a click or a scroll, an arrow key moves it, Escape
cancels. In every time grid and day grid,
runConformance looks for an event that Space grabs: it focuses the view's events (those with
resize handles first, then in DOM order, up to twenty) and presses Space on each until one's
keydown is prevented (the event hook prevents it only to grab). No such event: the view has
nothing to edit (no editing engine, or nothing editable) and the suite skips it, unless an event
shows resize handles, which only an editable event has. On the grabbed event it checks:
- Space grabs: its
keydownand itskeyupare both prevented (a real Space would otherwise click a<button>event on its keyup, or scroll the page from an<a>event), an element of the event shows the edit (data-dragging, ordata-resizing), and focus stays on the event; - an arrow key (↓, else ↑, the forward and backward arrows, then Shift with ↓ or ↑ for an event
that can only be resized: the first that moves it) is prevented and draws the event's mirror
(
data-slot="calendar-mirror"), focus staying on the event, which still shows the edit; - Escape cancels: no mirror and no
data-draggingordata-resizingremain, and focus is still on the event; - on the way, no click reaches the calendar and neither the page nor the view scrolls.
Enter is never pressed: it would drop the event and call the app's onEventChange.
- Fix: spread
useCalendarEvent().getRootProps()unchanged on the event element (itsonKeyDownandonKeyUpgrab, step and cancel through the store; a handler of yours that callspreventDefault()first cancels them), keep the element while it is grabbed (key it bysegment.key: an element React remounts loses focus), and draw the mirror segments ofuseInteractionMirror(key).segmentswithgetMirrorProps(segment)in every lane, all-day cell and day cell (see edit-parts). - After each step the contract checker runs again, and anything it did not find before the grab
is reported under
grabwith its own rule (a mirror that is notaria-hiddenis edit-parts).
grab-announce
Every keyboard edit step is announced. After Space, after the arrow key and after Escape the live region's text must change ("Grabbed …" with the keys, "Moving to …" or "Resizing to …" after 150 ms of quiet, "Change cancelled"). The event's name and description do not change during a grab (it has not moved yet), so the live region is the only place a screen-reader user hears where the event would go.
- Fix: render
useAnnouncer().messageinside the element ofgetLiveRegionProps(), always mounted inside the root (see live-region); runrunConformancewith the default announcer.
drag-pointer
A mouse drag of an event draws its mirror and the drag preview, and Escape cancels it before
the release. In every time grid, runConformance looks
for an event in a lane that a mouse drags: it scrolls the event to the middle of the view (away
from the auto-scroll edges), presses it with a synthetic mouse pointerdown and moves 6 px (past
the 5 px threshold). An event that then shows no data-dragging cannot be moved; if it shows
resize handles, a drag of 6 px on its handle must start a resize (data-resizing), else the
pointer engine never got the press. On the first event that drags it checks:
-
focus did not move (a pointer drag moves no focus);
-
moving on in small steps, down its lane and else up, draws its mirror (one snap away);
-
with the mirror, one drag preview (
data-slot="calendar-drag-preview") is inside the root and the pointer engine placed it (itstransform); -
Escape (before the release) removes the mirror, the preview and
data-dragging, and the release after it drops nothing (nodata-pendingappears). -
Fix: spread
useCalendar().getRootProps()on the root (its ref is how the engines find it) anduseCalendarEvent().getRootProps()on the event (the engine presses events by theirdata-keyand handles bygetResizeHandleProps()'data-slotanddata-edge); render the element ofuseDragPreview()once, inside the root, while it isactive(a portal hides it from the engine), withgetPreviewProps()unchanged (never set itstransform: the engine writes it); draw the mirror segments as for grab-keys. -
After each step the contract checker runs again, and anything new is reported under
dragwith its own rule (a preview outside every root is edit-parts).
drag-announce
A cancelled pointer drag is announced: after Escape during a mouse drag the live region's text changes ("Change cancelled"). A pointer drag announces nothing else while it stays on allowed places (a place the rules refuse says "unavailable, …", at most twice a second), so the suite checks only the cancel.
- Fix: as for grab-announce.
actions-trigger
A view whose events can be edited renders their actions (WCAG 2.5.7). In a time or day grid that renders no actions trigger, runConformance looks
for an editable event: one with resize handles, else one that Space grabs (the grab is cancelled
at once). If it finds one, the view fails: every move and resize the editing engine offers by
dragging needs its single-pointer alternative, and the actions are it. The contract checker
cannot tell (an app may offer its own alternative, which the markup does not show: see
event-actions); runConformance asks for it unless you leave actions out of
o.suites, which is the way to say that your app offers its own alternative, checked against
WCAG 2.5.7 yourself.
- Fix: render
useEventActions(segment)'s anchor, trigger and menu around every event element whereavailable(the registry's<CalendarEditing>does), in the view and in the "+N more" popover (see event-actions).
actions-menu
Shift+F10 on an event opens its actions menu, focus on its first item; Escape closes it and
focus returns to the event. In every view that renders
an actions trigger, runConformance takes the first event with one (in a time grid the first in
a lane), focuses it and presses Shift+F10. It checks:
- the key's
keydownis prevented (the browser would open its own context menu); - a
role="menu"shows (the one the trigger controls or that names the trigger in itsaria-labelledby; else the shown menu of action items), named (by the trigger: "More actions for …"), whose action items (data-slot="calendar-event-action") aremenuitems, at least one; - focus moves to the first enabled item (the hook moves it there once the menu renders; a primitive that opened the menu from its own trigger may have moved it already);
- Escape closes it (the menu goes and the trigger loses
data-open) and focus is back on the event, the same element.
Nothing is chosen but "Move to…" (actions-placing), which is cancelled.
- Fix: control the menu with
openandonOpenChangeofuseEventActions(the app'sDropdownMenu; a menu of your own is arole="menu"labelled by the trigger, rendered whileopen, closing on Escape by callingonOpenChange(false)), spreadgetItemProps(action)on each item (its ref lets the hook focus it) andgetAnchorProps()on the wrapper of the event element (itsonKeyDownhears Shift+F10). Turn a primitive's own focus restore off (RadixonCloseAutoFocus={(e) => e.preventDefault()}, Base UIfinalFocus={false}): the hook returns focus to the event, and when the event is gone (new data without it, a navigation) with its menu or form open, to the calendar (the events' tab stop, else the slots', the dates', the header links', else the scroller). The "+N more" popover's own close focus goes off the same way (useMoreLinkreturns focus to "+N more" itself): after its exit animation it would take focus from the day cell where "Move to…" on one of its events started placing. - After each step the contract checker runs again, and anything new is reported under
actionswith its own rule (event-actions for the menu's markup).
actions-placing
"Move to…" starts placing with focus on the slot cursor or a day cell; an arrow key moves it
and draws the event's mirror; Escape cancels with focus back on the event. In every time and
day grid whose first
available event's menu has "Move to…", runConformance opens the menu again with Shift+F10,
focuses "Move to…" and presses Enter (where the item's keydown is not prevented, a native
button's Enter is its click: the suite clicks it). It checks:
- the calendar root shows
data-placingand focus is on the slot cursor (data-composite-item="slots", in a time grid, also one that takes no selection: the cursor shows while placing) or on a day cell (data-composite-item="dates", in a day grid), and the menu closed; - an arrow key (↓, ↑, then the forward and backward arrows in a time grid; the forward and
backward arrows, then ↓ and ↑ in a day grid: the first that moves) moves focus to a new cursor
element or another day cell, and the event's mirror is drawn
(
data-slot="calendar-mirror"); - Escape cancels: no
data-placing, no mirror, focus back on the event; - nothing was committed (no
data-pendingappeared).
Placing never starts in a list ("Move to…" is not in its menu) or for an event that cannot move.
- Fix: keep
useSlotCursor()'s element in every lane and all-day cell (whatever the grid's interactions:slot.activewhile placing) keyed by its slot,useDayCell(day).getCellProps()on the day cells, and the mirror segments ofuseInteractionMirror(key).segmentswithgetMirrorProps(segment)in every lane, all-day cell and day cell (see edit-parts); spreaduseCalendar().getRootProps()on the root (the engine hears Enter, Space and Escape there, in the capture phase, while placing).
actions-announce
Placing is announced. After "Move to…" the live region says "Moving Haircut · Ben. Choose a time with the arrow keys and press Enter, or click a time. Press Escape to cancel.", and after Escape "Change cancelled". Each step in between is the cursor's own name (its new element, ending in "available" or "unavailable, …"), so it is not waited for; opening and closing the menu announce nothing.
- Fix: as for grab-announce.
axe
axe finds no violation, with zero disabled rules. runConformance calls the runner
you inject ((element) => axe.run(element)) on the calendar root in every view and on each
"+N more" dialog it opens, and lists each violation by its axe rule id (for example
label-content-name-mismatch, target-size, color-contrast); axe's own help for the id
explains it. expectNoAxeViolations(container, axe) runs the same check on any element. The
library depends on neither axe-core nor a test runner.