Accessibility¶
Fresco has no accessibility layer of its own. Start with semantic HTML, which already provides names, roles, keyboard activation, and focus behaviour. Then derive ARIA state from the same subscriptions that drive the UI, and test it.
Use semantic elements¶
A button is focusable, keyboard-activatable, and announced as a button. A
clickable div is not.
;; Don't do this: only a pointer user can reliably operate it.
[:div.delete {:on-click [:todo/delete id]} "Delete"]
;; Do this: focus, Enter, Space, and the role come from the browser.
[:button.delete {:on-click [:todo/delete id]} "Delete"]
The same applies across the page:
- use
:afor navigation;h/route-linkreturns a real anchor; - pair labels and controls with a wrapping
:labelor matching:forand:id; - use
:ul/:lifor lists and:tablefor tabular data; - keep a meaningful heading outline, normally with one page
:h1; - use
:main,:nav, and:asidelandmarks.
Use ARIA roles when no native element fits the widget, such as a listbox or a tab strip, and then implement its full keyboard and state behaviour as well as the role. The dropdown in Overlays and focus is an example.
Give controls an accessible name¶
Visible text usually supplies the name automatically. Add an explicit name for icon-only controls or when the visible label lives elsewhere:
[:button
{:aria-label "Delete"
:on-click [:todo/delete id]}
"×"]
[:div
[:label {:for (str "todo-title-" id)} "Title"]
[:input
{:id (str "todo-title-" id)
:value (:title (h/sub [:todo/by-id id]))
:on-input [:todo/rename id ::h/value]}]]
Ids used by :for, :aria-labelledby, :aria-describedby, and
:aria-activedescendant must be unique on the page. Build them from the same
stable key used for the application state, here the todo id
(Ephemeral state).
:aria-* and :data-* attributes pass through as written on native elements
and declared hosts.
Derive ARIA state from the real state¶
Do not keep a separate accessibility copy of expanded, selected, busy, or invalid state. Read the value once and use it for both behaviour and ARIA:
(h/defview filter-toggle [_]
(let [open? (h/sub [:todo.ui/filter-menu-open?])]
[:button
{:aria-haspopup "menu"
:aria-expanded open?
:on-click [:todo.ui/toggle-filter-menu]}
"Show"]))
Other pages use the same pattern:
- the dropdown in Overlays and focus sets
:aria-expanded,:aria-activedescendant(the keyboard's current position), and:aria-selected(the chosen value); - the active route link sets
:aria-current "page"; - a resource-backed suggestion list sets
:aria-busy; - an exiting presence node sets
:inertand:aria-hidden.
Focus is the exception: it stays browser state. Move focus when you need to and let the browser or overlay restore it. Do not copy the focused element into app-db.
Pair validation messages with fields¶
(h/defview title-field [{:keys [id]}]
(let [error (h/sub [:todo.ui/title-error id])
error-id (str "todo-title-error-" id)]
[:div
[:input
{:aria-label "Todo title"
:value (:title (h/sub [:todo/by-id id]))
:on-input [:todo/rename id ::h/value]
:aria-invalid (some? error)
:aria-describedby (when error error-id)}]
(when error
[:p {:id error-id :role "alert"} error])]))
:role "alert" announces the message when it appears, and
:aria-describedby links it to the field.
Keyboard and focus ownership¶
Native controls already handle their own keys. For additional keys, put a
keyboard map at :on-key-down or
:on-key-up; Fresco does not dispatch from it during IME composition.
Where focus moves, and which page covers it:
| Moment | Behaviour | Covered in |
|---|---|---|
| Route change | Focus a keyed main region with :tab-index -1 and preventScroll |
Routing and navigation |
| Overlay open and close | Put the control that should receive focus first in tree order, use the platform trap, restore the opener | Overlays and focus |
| Menu or listbox navigation | Keep DOM focus on the trigger and move :aria-activedescendant from a combobox that :aria-controls the list, leaving :aria-selected on the committed value |
Overlays and focus |
| Exit animation | Add :inert and :aria-hidden during unmounting |
Motion and presence |
| Virtualised collection | Decide how keyboard users reach items that do not exist in the DOM and verify it in a browser | Lists and collections |
Test attributes as data¶
Names, roles, and ARIA state are ordinary attributes in the tree ht/tree
returns, so a plain unit test can check them:
(ns app.views-test
(:require [clojure.test :refer [deftest is]]
[re-frame.fresco.test :as ht]
[app.views :as views]))
(defn toggle-attrs [open?]
(let [tree (ht/tree [views/filter-toggle {}]
{:subs {[:todo.ui/filter-menu-open?] open?}})]
(ht/attrs (ht/find tree #(= :button (:tag %))))))
(deftest toggle-announces-its-state
(is (= "menu" (:aria-haspopup (toggle-attrs true))))
(is (true? (:aria-expanded (toggle-attrs true))))
;; the attribute follows the state
(is (false? (:aria-expanded (toggle-attrs false)))))
Checking both states shows that the attribute really follows the subscription.
Given a tree from ht/tree, (is (= [] (ht/unnamed-controls tree))) fails
on any button, link or field with no accessible name, and names the nodes. ht/role and
(ht/accessible-name tree node) return one node's role and name, computed from
:aria-labelledby, :aria-label, <label>s and text. They check the markup,
not a screen reader.
Tests on data cannot check real focus, Tab order, modal trapping, keyboard movement in virtualised lists, or screen-reader behaviour. Test those in real browsers (Testing). Run an automated axe check on the mounted screen as a baseline, then script the keyboard walk for important flows. Axe finds missing names and broken label pairings; it cannot tell whether the Tab order makes sense.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
| Control cannot be reached with Tab | A div or span is acting as a control |
Use a native button, a, or input; add tab-index to a generic element only when nothing native fits |
| Icon button is unnamed or announced only as “×” | Its glyph is the only name | Add :aria-label to the button |
| Input has the wrong or no label | :for and :id do not match, or ids are reused across instances |
Generate stable per-instance ids |
| Screen reader's expanded state never changes | :aria-expanded is a literal or a separate stale value |
Derive it from the same subscription that controls the panel |
| Validation message appears without announcement | The message is not a live alert and the field is not linked to it | Add :role "alert", :aria-invalid, and :aria-describedby |
| Focus does not move after route navigation | The new main region is not keyed or programmatically focusable | Use the route focus recipe |
| Fading item still accepts focus and clicks | Exit appearance changed without disabling interaction | Add :inert and :aria-hidden in the unmounting override |
| Modal traps focus but the background remains interactive | A non-modal or hand-written dialog is being used | Use overlay/modal, which calls showModal |
| Axe passes but keyboard users still get lost | Automated rules cannot evaluate the intended traversal order | Script the Tab, arrow-key, Escape, and focus-return flows in browser tests |
When not to add ARIA or state¶
- Do not repeat native semantics.
role="button"on abutton, or anaria-labelthat contradicts visible text, makes the result worse. - Do not mirror hover or focus into app-db solely for announcements. The browser already owns those facts.
- Do not build a general announcer service before a real use case requires it.
A state-driven
role="alert"region covers occasional live messages.