Skip to content
Theme

Accessibility

A Limn window publishes itself to the platform’s own accessibility API: UI Automation on Windows, NSAccessibility on macOS and AT-SPI2 on Linux. Screen readers on Windows, VoiceOver and Orca read it the way they read a native application, and your program contains no platform code for that: the backend opens the bridge the first time anything asks, and there is nothing to install or register.

Every widget describes itself. A button is a button, a check box says whether it is checked, a slider says its value and its range, a text field says what it holds and where the caret is, a list says its true row count and which rows are selected, a dialog says it is modal and what it blocks. Focus moves are published as they happen, and inside a list, a table, a tree, a menu or a date field the cursor is published too, so a reader follows the arrow keys and not only the Tab key.

What each reader makes of that is its own. The three describe the same row in different shapes and disagree about what to say unprompted; What readers say lists the differences worth designing around.

The names are the same I18nStrings the interface draws, so a screen reader speaks your application in the language it is displayed in. The word for each role — “button”, “check box”, “slider” — is the toolkit’s own, in every language it ships, because two of the three platforms will not supply one for a process that is not theirs.

This is the form from the forms guide, and beneath it what a screen reader is told about it. Both were produced by the same build that rendered this page.

The form, in the light palette.The form, in the light palette.
The form, in the light palette.
1 window "Limn UI: showcase" (explicit) window non-modal normal
2 heading "Create your account"
3 label "Full name" | label for #4 "Full name"
4 text field "Full name" (label) desc "12 / 40" [focusable, editable, required] text="Ada Lovelace" caret=12 actions: show menu, scroll into view, focus | labelled by #3 "Full name" | described by #5 "12 / 40"
5 label "12 / 40"
6 label "Email" | label for #7 "Email"
7 text field "Email" (label) desc "Enter an address like ada@example.com" [focusable, editable, invalid, required] text="ada@example" caret=11 actions: show menu, scroll into view, focus | labelled by #6 "Email" | described by #8 "Enter an address like ada@example.com"
8 label "Enter an address like ada@example.com"
9 label "Password" | label for #10 "Password"
10 password field "Password" (label) desc "At least 12 characters" [focusable, editable, password] text="•••••••••••••••••••••" caret=21 actions: show menu, scroll into view, focus | labelled by #9 "Password" | described by #11 "At least 12 characters"
11 label "At least 12 characters"
12 label "Plan" | label for #13 "Plan"
13 combo box "Plan" (label) [focusable, expandable, has-popup] value=1 [0..2 step 1] "Team" collapsed actions: expand, scroll into view, focus | labelled by #12 "Plan"
14 switch "Email me release notes" [focusable, checked] toggle=on actions: toggle, scroll into view, focus
15 separator "" [horizontal]
16 button "Cancel" [focusable] actions: press, scroll into view, focus
17 button "Create account" [focusable] actions: press, scroll into view, focus

Each line is one node in reading order: its role, its name in quotes and where the name came from, the states a reader would mention, the value, and the verbs it offers.

A caption above a field is a layout fact. It names nothing, because proximity is an accident of layout and a tree built from it says confident wrong things. A form’s field is a Field, whose caption names its control:

form.add(new Field(new I18nString("account.email", "Email"), email));

The form above is built that way: a screen reader says “Email, text field” and not “text field”. The caption is an I18nString, like every text a component takes, and the name is read at publish: when the language changes, the caption and the name it gives change together. A caption that is not a form’s, a heading over a row of choices or a word under a picture, names its control with one call:

public static Label caption(I18nString caption, Widget<?> control) {
return new Label(caption).setLabelFor(control);
}

A control with no caption takes a name directly:

Slider volume = new Slider(0, 100);
volume.setAccessibleName(new I18nString("player.volume", "Volume"));
ScrollView chapters = new ScrollView(list);
chapters.setAccessibleName(new I18nString("library.chapters", "Chapters"));

An icon-only button that has a tooltip is already named by it. A search field is named by its placeholder. A dialog is named by its title, a tab by its label, a list row by what the row draws or, for rows the list has not built, by what setItemName answers for the item.

Some things have a name and want a description as well, which a reader speaks after the name when asked for more: a picture, a 3D viewport, a control whose purpose the name alone does not carry.

ImageView logo = new ImageView(BLANK_PICTURE).setFit(ImageView.Fit.CONTAIN)
.setPreferredSize(96, 64);
logo.setAccessibleName(new I18nString("about.logo", "Limn logo"));
logo.setAccessibleDescription(new I18nString("about.logoDescription",
"A monogram on a rounded square"));
page.add(logo);
Label caption = new Label("Illustration repeated by the caption beside it");
ImageView decorative = new ImageView(BLANK_PICTURE).setPreferredSize(48, 48);
// A picture the text beside it already describes is decoration, and is left out.
decorative.setAccessibleIgnored(true);

The line under a field is a description too, because a sentence that merely sits under a field explains nothing to a reader that cannot see where it sits. A Field binds its line, the helper text or the message, as its control’s description, which is why the transcript shows the email as described by its message: a reader hears “Email, text field, invalid, enter an address like…” and not an invalid field with the reason somewhere else on the screen. A message of your own, outside a field, is bound the same way:

message.setDescriptionFor(email);

A field’s two states reach every system in its own words: required is UI Automation’s IsRequiredForForm on Windows, AXRequired on macOS and the REQUIRED state on Linux; invalid, which is a field’s ERROR state, is IsDataValidForForm false on Windows and INVALID_ENTRY on Linux. macOS has no attribute of its own for an invalid value, so there the message in the description says what is wrong. The asterisk after a required field’s caption is drawn and never read: the state says it. A caption, a description or a state given to a date picker lands on the field inside it, where the keyboard is.

An access letter, the & before a letter in a caption, a button’s or a menu item’s text, is published as the control’s access key, “Alt+N”, as a native control’s is: UI Automation’s AccessKey on Windows, the key binding on Linux, and nothing on macOS, which has no access letters (Access letters).

A picture that the text beside it already describes is decoration. Mark it ignored, and it leaves the tree entirely; leave it unmarked and it is published as an image with no name, on purpose, so that a check like the one below finds it and the decision is yours.

A dialog is published as a dialog with its title as its name, and while a modal one is open nothing behind it is operable: every control the modal blocks is published disabled, and the reader is told which window is the modal. A native dialog blocks its owner the same way.

A popup — a combo box’s list, a context menu, a menu bar’s menu — is described where it actually lives: a translucent window of its own where the platform has one, and inside the scene where it does not. A combo’s list in a window of its own is not a tree of its own as far as the reader is concerned: it is published under the field that opened it, and the cursor crosses into its items, so arrowing through the list is read item by item without leaving the field. A menu is different, because its window takes the keyboard while it is open: it is a tree of its own, as a native menu is, and the reader follows the focus into it and back out.

A date picker’s calendar crosses the same way. It is published under the field, as a combo’s list is, so no second window is announced; a reader follows the cursor through the grid and the month chooser, and is back on the field’s day segment when it closes. The keyboard stays with the field throughout; a pointer click in the calendar’s window makes it the key window, and the picker hands every key that reaches it on to the field, so the keys act on what was clicked: the grid, a header arrow or the time row. Closing the popup gives the field’s window the keyboard back. Where the platform has no second window, as in a Wayland session, the picker draws in the scene instead, and there is nothing to cross.

A list publishes its true row count and the rows it has realized, which is what makes a million-row list cost what twenty do under a screen reader as well as on screen. The row the keyboard is in stays realized even when a scroll takes it off screen, so a reader standing on it is not dropped to the window, and a verb addressed to it is performed where it stands. A list’s rows carry SELECT; a tree’s and a table’s carry their own set by state — a tree’s row: select, add to or remove from the selection, expand or collapse, move the cursor; a table’s the same set without expand or collapse, which only a tree row has — so a reader addresses a particular row rather than the container.

A card is read by what it is. One with a title is a group named by that title, the title a heading, and its parts are read inside it in order; one with no title adds nothing, so a chart in a card is heard as the chart. A card that is clicked (onActivate) is a button named by its title and described by its subtitle, and what else it shows is not read, because a reader presses a button and does not walk into it: say what the listener needs from the content with setAccessibleDescription on the card, which lands on that button, as setAccessibleName and a caption bound to the card do. With a footer, the button and the footer’s actions are held in a group named by the title, so a reader that reaches an action by Tab is told whose action it is.

A control that is merely scrolled out of view keeps everything it offers: the scene reveals it and performs. A control nobody can see — in an unselected tab, in a collapsed panel, behind a modal — publishes no verb at all, which is how a reader is told “not now” instead of being told yes and hearing nothing happen.

A Badge is static text, named by its word or its count as shown; name a count by its sentence with setAccessibleName("3 unread"). An Avatar is an image named by the person; beside the name it repeats, take it out with setAccessibleIgnored(true). An AvatarGroup is one group, named by you and described by every member’s name. A list, a table or a tree with no rows is described by its empty message, and its empty content’s buttons come after it; while it loads it is busy, and the start and the end of the load are said. A Popover is a named group, with Escape as its cancel, and the button attached to it says it opens a popup and whether it is open.

Four rules hold for any published tree, whatever is in it: no node has an unknown role, no focusable node is without a name, no two nodes share one identity, and no node shows outside what should clip it. limn-test has them as AccessibleInvariants.violations(…), the same check the toolkit holds every one of its own components to in both palettes, and the Testing guide shows the harness that hands a widget’s tree to it.

Rules find what can be named. Reading the tree aloud finds the rest: a field named after its own value, a caption that captions nothing. AccessibleTrees.describe(…) writes a tree as text, one line per node with its role, name and states, which is short enough to read aloud, like the transcript above.

A screen reader on Windows, VoiceOver on macOS and Orca on Linux have each been driven over every component on this site, a keystroke at a time; below, “the Windows reader” is the first of them. They agree on the essentials: a cursor move is spoken, a value change is spoken as the new value alone, and moving onto a control says its name, its role and its value. The differences below are the reader’s and not the toolkit’s. The toolkit publishes the same facts on all three platforms, so a screen should not depend on any one of them.

  • A row. The Windows reader speaks the level, the name, the state and the position, and in a grid the column. Orca speaks the name and the level, and reads a table row out as its cells in order. VoiceOver speaks the row, its state and how many rows it holds, and in a table the cell and, as the cursor moves along a row, its column.
  • A position in a set. Published everywhere. The Windows reader speaks it unasked. Orca speaks it only when the user has turned on its own position option, which is off by default. VoiceOver never speaks it. Where a row’s place carries meaning, draw it as well.
  • A selection change. VoiceOver says a row was added to or removed from the selection and how many rows are selected, none and all included. Orca says a row was selected or unselected, and that all items were selected. The Windows reader says a list’s row is not selected when it leaves, and nothing at a select-all or when Space takes a table’s row out from one of its cells, as in Windows’ own lists and grids. Where the change is the point of the screen, say it in the interface.
  • A sort. The Windows reader and Orca speak the direction when a header sorts; VoiceOver is given it and does not read it out.
  • A busy state. Published, and spoken by none of them, which is why a tree announces its loads in words (Lists and scrolling has the three sentences).
  • Something out of reach. A refused day or an unavailable action is said in each reader’s own words. What the toolkit guarantees is the stop and the withheld verb; do not copy one reader’s phrasing into your interface expecting it to match.

Four things follow the platform mappings but have not yet been checked with a reader: moving the caret in a text field or reading it by character, a Japanese-calendar era segment, a reader’s own verbs on a tree row (expanding, collapsing or selecting one through the accessibility action rather than the keyboard), and, under Orca on X11, a combo’s list or a date picker’s calendar opened in a window of its own.

scene.announce(text, Accessible.Politeness.POLITE) has the reader say a sentence that no focus move would: a search’s result count, a save that finished. POLITE waits for the reader to finish what it is saying, and ASSERTIVE interrupts; on macOS both are posted at VoiceOver’s high priority, because at a lower one VoiceOver holds the sentence back behind its own hint. Announce what changed, once, in the user’s language; a sentence repeated on every frame of a change is noise a reader cannot skip.

A popup of your own that belongs to a control, the way a combo’s list belongs to its field, opens with scene.pushPopup(layer) rather than pushOverlay: the page beneath it stays enabled for a reader, as a native drop-down leaves its field enabled, where a dialog disables what is behind it. A popup of your own drawn in a window of its own calls scene.graftPopup(popupScene) before its scene is bound, with the popup’s root naming its opener through setInheritanceHost: the reader then reads it under the control that opened it and never leaves the main window. Graft only a popup whose keys go on to that control, as the combo’s list and the calendar hand theirs; a menu, whose window takes the keyboard for itself, keeps a tree of its own.

Text is read and set whole. A reader can hear a field’s contents and its caret, move the caret and set the text; review by character and braille cursor routing inside a field are not supported, because no platform is given the rectangle of a range. A list publishes the rows it has realized rather than all of them, so a reader’s own list navigation stops at what is on screen. Of the system’s accessibility settings, reduced motion and increased contrast reach the toolkit: animations stop by themselves while motion is reduced, and every window paints with a contrast palette while more contrast is asked for, a Windows contrast theme’s own colours included (More contrast). Every component’s edge and every state it shows clears 3:1 against what is next to it in every shipped palette, as WCAG 2.1 1.4.11 asks. A system text scale does not reach it.

Your application has a name on Linux, where the desktop lists applications and not windows. Backend#setApplicationName sets it; without a call it is the first window’s title, and a later window never renames it.