Hapio Calendar

Accessibility

How the calendar works with a keyboard and a screen reader, every key, every announcement, and what is checked automatically.

Accessibility lives in the hooks, so the registry's markup and markup of your own get the same roles, names, keys and announcements. The accessibility contract states what markup must keep, and two tools check it: a development checker and runConformance (Testing). axe runs on every example with no rule disabled.

The model

A view is a grid (time and day grids) or a list (the list view), named by the toolbar's title. Inside it, focus moves in roving composites: one tab stop each, the arrow keys move inside.

  • The events: every event and "+N more" of the view, in time order. ↑ and ↓ walk them in the order a screen reader reads them.
  • The dates: in a month, the day cells. In a week or day view that takes selections or date clicks, the slot cursor: one element that stands on one slot at a time and is a new element for every slot, so a screen reader announces each move.
  • The header links of a time grid with navLinks.

So a view has at most three tab stops, however many events it holds. The toolbar's buttons and its view switcher (a radio group: one tab stop, the arrow keys switch views) come before the view.

Keys

Events (any view):

KeyDoes
↑ / ↓the previous / next event or "+N more", in time order
← / →the first event of the previous / next day that has events
Home / Endthe first / last event of the view
PageUp / PageDownthe previous / next period; focus follows to its first event
Enterthe event's own action: its link, or onEventClick
Escapethe day cell (month), or the slot cursor (week, day) where the event starts
Spacegrabs the event to move it, when it can be edited (Editing)
Shift+F10, ContextMenuopens its actions menu (Event actions)

The slot cursor (week and day views) and day cells (month):

KeySlot cursorDay cell
↑ / ↓the previous / next slot (from the first slot ↑ goes to the all-day row)a week earlier / later
← / →the same time on the previous / next daythe previous / next day
Home / Endthe day's first / last slotthe week's first / last day
Ctrl+Home / Ctrl+Endthe grid's first / last slotthe grid's first / last day
PageUp / PageDownthe previous / next periodthe previous / next period
Spacestarts a selectionstarts a selection
Shift + a move keystarts a selection and extends itthe same
EnteronDateClick, else starts a selectionits nav link, else onDateClick
Escapeclears the selectionclears the selection

While selecting, the move keys extend the selection, Enter or Space selects, and Escape cancels. A grabbed event has its own keys, on Editing; placing ("Move to…", or an item picked up from outside) uses the slot cursor's and day cells' moves with Enter or Space to place and Escape to cancel.

In right-to-left layouts ← and → swap meaning everywhere, so they always move the way they point.

Names and descriptions

  • An event's name is its visible content, time first ("09:00 – 10:30 Haircut · Anna"). Its description gives what the screen does not: the full date and time and its resources ("Thursday, October 22, 2026, 09:00 to 10:30, Chair 2"), and, when it can be edited, how ("Press Space to move").
  • A day cell is named by its date and its number of events ("Thursday, October 22, 2026, 3 events"); the visible day number is hidden from screen readers.
  • The slot cursor names its slot and its verdict: "Monday, October 19, 2026, 09:00 to 09:30, available", or "unavailable" with the reason. In the hour that happens twice on a DST day, times carry their offset ("02:30 (UTC+1)").
  • A background event with a label is an image with that name; one without is hidden.
  • Toolbar buttons are named by what they do ("Previous Week"), the view radios by their view ("Week view").

A grid says aria-readonly="true" only when nothing in it can change: it takes no selection, none of its events can be moved or resized (an editing engine and an event in the view whose editable flags allow a change), and it takes no drops from outside (a calendar inside <CalendarExternalDrops> is not read-only). A month that takes selections is aria-multiselectable with aria-selected on every day cell; a week or day view that does carries data-selectable, which the contract checker and runConformance read (slot-cursor).

The calendar's root carries lang from its strings (strings.lang: 'en' for the built-in English, 'sv' for the Swedish pack), not from locale, and so do its popover, menus and dialogs, so a calendar in another language than the page's is read in its own, and one with Hebrew dates but English texts (locale="he-IL", no Hebrew strings) is read as English (Languages).

Announcements

A live region inside the calendar says what changed where focus does not show it:

WhenScreen readers hear
a navigationthe new period ("Week of 19–25 October 2026")
a selection starts, grows, ends"Selecting from …", "Selecting …", "Selected …", "Selection cancelled"
a selection the rules refuse"Can't select …" and the reason
an event is grabbed, moved, dropped"Grabbed … (the keys)", "Moving to …", "Moved … to …", "Change cancelled"
a change your app rejects"Change reverted"
"Move to…", an item picked up"Moving Haircut · Ben. Choose a time with the arrow keys and press Enter, or click a time."
a placing step onto a day (month)"available", or "unavailable" and the reason (a slot cursor's name says it in a week)
an item dropped from outside"Dropped … on …", or "Drop reverted"

Steps are announced after a short pause (150 ms), so holding an arrow key does not queue a sentence per slot. Refusals and reverts are assertive; the rest is polite. announcer: 'auto' speaks through the browser's ariaNotify where it exists, and commands.announce(message) lets your app use the same channel.

Focus

Focus is never lost to the page body: after a drop, a revert, a navigation or new data, it follows the event to where it is drawn, else to the closest thing that still exists ("+N more", the view's tab stop). Menus and dialogs return focus to the event that opened them. Focused elements are never hidden under a sticky header.

Pointers and touch

Every drag has a single-pointer alternative (Event actions), touch has a long-press with select-then-drag, and pointer gestures never steal focus. Resize handles are smaller than 24 by 24 px on short events: they rely on WCAG 2.5.8's equivalent exception, since the actions menu and the keyboard resize too.

Manual testing

Automated checks cannot hear a screen reader. The manual passes have not run yet: with NVDA and Firefox, JAWS and Chrome, and VoiceOver and Safari, the audit of the alternatives to dragging (WCAG 2.5.7), and on real iOS and Android devices. They run before the first public release, and their results will be published here.