Hapio Calendar

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) runs checkContract on 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 suites roles, 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: selection when no view is selectable, grab and drag when no view has an editable event, actions when no view renders the actions or has an editable event. It rejects with one Error listing 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:

SuiteRules
(any)render
rolesrole-nesting, aria-reference, live-region, duplicate-id, time-surface-padding, slot-cursor, selection-state, edit-parts, event-actions, view-structure, event-role
namesaccessible-name, label-in-name, cell-name, day-cell-name, event-description, background-name, more-popover (its name)
focuscomposite-tab-stop, tab-stops, more-popover, focus-return
keyboardevents-keys, dates-keys, headers-keys, slots-keys, chronological-order
selectionselection-keys, selection-announce, and any rule of the contract checker that a selection step breaks (selection-state, slot-cursor, …)
grabgrab-keys, grab-announce, and any rule of the contract checker that a grab, a step or its cancel breaks (edit-parts, …)
dragdrag-pointer, drag-announce, and any rule of the contract checker that a drag or its cancel breaks (edit-parts, …)
actionsactions-trigger, actions-menu, actions-placing, actions-announce, and any rule of the contract checker that opening the menu, placing or its cancel breaks (event-actions, slot-cursor, edit-parts, …)
axeaxe

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's getRootProps, useMoreLink's getTriggerProps, useDayCell(day).getCellProps() for the day cells, useTimeGrid's getNavLinkProps). The hooks move the stop with focus. The slot composite has one item, the slot cursor, always the stop (see slot-cursor).
  • runConformance reports it under focus before 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, a button or 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's aria-labelledby points at), the descriptions container of useDescriptions() with one element per item (each event's aria-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) and moreFor(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 (suite keyboard) 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' } to useCalendarEvent for events rendered inside the popover; give two calendars on one page their own roots (each useCalendar has 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 whose data-composite names slots, 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) and data-composite-item="slots";
  • a place in a lane (getLaneProps, with data-kind="slot") or an all-day cell (getAllDayCellProps, with data-kind="all-day"), after their events; not inside aria-hidden content such as the selection layer, and not display: none or visibility: hidden, which would keep focus out;
  • a name from its aria-label (an aria-labelledby would 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 with data-selectable, which getGridProps() sets when selectable), 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 not aria-readonly either, so data-selectable is 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 of useDescriptions()), 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 id names its slot, so the checker remembers the id of each cursor element and reports one whose id changed 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-placing on 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 whose interactions.slots is false (a read-only grid, whatever its data-composite says): each has exactly one cursor, with data-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 carries data-placing only 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; spread getCursorProps() on a div keyed by cursor.key and give it no name, role or tabIndex of your own. Hide it visually until it is focused or a selection is in progress (data-selecting), never with display: none. The same <SlotCursor> serves placing: while it lasts useSlotCursor is 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 on data-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; checkContract called after a move (as runConformance does) 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-selected only 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-selected outside 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", any aria-selected cell, or a drawn selection) is aria-multiselectable="true" (a selection covers several days), is not aria-readonly="true", and every one of its day cells carries aria-selected ("true" inside the selection, else "false"). getGridProps() and useDayCell do all of it when selectable; 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() and useDayCell(day).getCellProps() unchanged, and render the selection layer before the events: useInteractionMirror(day.key) in every day cell, useInteractionMirror(lane.key) in every lane and useInteractionMirror(lane.key, { surface: 'all-day' }) in every all-day cell, each drawing getSelectionProps() while its selection is not null.

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) is aria-hidden="true" (or inside aria-hidden content) and neither is nor holds anything focusable: no tabIndex, link, button, form field or editable content, unless it is inert or 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 a data-key (the pointer engine finds the segment by it; the copies in the "+N more" popover have none and are never resized), carries data-edge="start" or "end", at most one per edge, and has touch-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().edits says exactly where: it also knows the engine, the event's editable flags 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: fixed places 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-dragging for data-kind="move", data-resizing for resize-start and resize-end: the source stays in place, dimmed, while its mirror shows where it would land), and the drag preview only while one shows data-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 shows data-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-pending while the app saves and data-reverting when it refuses) has no source event in the calendar at all, and its grid is not aria-readonly (a droppable grid takes items). Both are aria-hidden and hold nothing focusable like every mirror; useInteractionMirror(key).segments draws the first, .external the second (with the default content: renderEvent never receives an external item, whose event is synthetic);
  • described: an event with a resize handle, data-dragging, data-resizing or data-touch-selected has an aria-describedby with 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) has touch-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-dragging or data-resizing from 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 edits allows them, inside the event element, spreading getResizeHandleProps(edge) on a span with nothing focusable in it (an event with href is an <a>, see event-role). Draw the mirror segments of useInteractionMirror(key).segments with getMirrorProps(segment) in every lane, all-day cell and day cell, and the element of useDragPreview() once, inside the root, only while it is active (both from @hapio/calendar-react/interaction); give both the event's look without links or buttons (the registry's renderEvent receives isMirror: 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 edits is all false, 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. checkContract called during an edit (as runConformance does) 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 whose data-calendar-prefix starts 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 a button (or role="button") named "More actions for Haircut · Anna" (its aria-label), with aria-haspopup="menu" and tabIndex={-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 where available is false), 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 in aria-hidden or inert content nor display: none or visibility: 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 with aria-modal="true" or opened with showModal() outside the trigger, or the page hiding of a primitive's modal, which Radix marks data-aria-hidden and Base UI data-base-ui-inert). When the menu primitive sets aria-expanded, it agrees with data-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 its aria-controls names, or a role="menu" whose aria-labelledby names the trigger's id (both bases' DropdownMenuContent do both). The menu is role="menu" and named (by the trigger). A menu that shows while its trigger has no data-open is not controlled by the hook's open and onOpenChange, so the engine does not know it is open. A menu on its way out (data-state of closed, 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). Their onPointerUp prevents 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')) while form.problems is 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 available allows, 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 a role="menu" with aria-labelledby the trigger's id (give the trigger one) and role="menuitem" items, shown only while open, with Escape and the arrow keys by your own code.
  • The checker reads the DOM only: it cannot see the engine, available or which items the engine offers. runConformance's actions suite 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; checkContract called with the menu open (as runConformance does) 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()} /> while useSlotCursor({ laneKey, surface }).active, in every lane and all-day cell (see slot-cursor); keep its onKeyDown (a handler of yours that calls preventDefault() 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-selecting and data-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-height and --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() and useDayCell(day).getCellProps() unchanged (their onKeyDown carries the keys out through the store; a handler of yours that calls preventDefault() cancels it), and render the selection layer of useInteractionMirror in every lane, all-day cell and day cell, sized by the custom properties of getSelectionProps() (see selection-state).
  • After each step the contract checker runs again, and anything it did not find before the selection started is reported under selection with 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().message inside the element of getLiveRegionProps(), always mounted inside the root and hidden visually only (see live-region). With announcer: 'auto' in a browser that has ariaNotify the region stays empty: run runConformance with 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 keydown and its keyup are 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, or data-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-dragging or data-resizing remain, 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 (its onKeyDown and onKeyUp grab, step and cancel through the store; a handler of yours that calls preventDefault() first cancels them), keep the element while it is grabbed (key it by segment.key: an element React remounts loses focus), and draw the mirror segments of useInteractionMirror(key).segments with getMirrorProps(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 grab with its own rule (a mirror that is not aria-hidden is 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().message inside the element of getLiveRegionProps(), always mounted inside the root (see live-region); run runConformance with 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 (its transform);

  • Escape (before the release) removes the mirror, the preview and data-dragging, and the release after it drops nothing (no data-pending appears).

  • Fix: spread useCalendar().getRootProps() on the root (its ref is how the engines find it) and useCalendarEvent().getRootProps() on the event (the engine presses events by their data-key and handles by getResizeHandleProps()' data-slot and data-edge); render the element of useDragPreview() once, inside the root, while it is active (a portal hides it from the engine), with getPreviewProps() unchanged (never set its transform: 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 drag with 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.

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 where available (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 keydown is prevented (the browser would open its own context menu);
  • a role="menu" shows (the one the trigger controls or that names the trigger in its aria-labelledby; else the shown menu of action items), named (by the trigger: "More actions for …"), whose action items (data-slot="calendar-event-action") are menuitems, 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 open and onOpenChange of useEventActions (the app's DropdownMenu; a menu of your own is a role="menu" labelled by the trigger, rendered while open, closing on Escape by calling onOpenChange(false)), spread getItemProps(action) on each item (its ref lets the hook focus it) and getAnchorProps() on the wrapper of the event element (its onKeyDown hears Shift+F10). Turn a primitive's own focus restore off (Radix onCloseAutoFocus={(e) => e.preventDefault()}, Base UI finalFocus={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 (useMoreLink returns 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 actions with 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-placing and 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-pending appeared).

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.active while placing) keyed by its slot, useDayCell(day).getCellProps() on the day cells, and the mirror segments of useInteractionMirror(key).segments with getMirrorProps(segment) in every lane, all-day cell and day cell (see edit-parts); spread useCalendar().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.

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.