Skip to main content

Module _accessibility

Module _accessibility 

Source
Expand description

§Accessibility in GPUI

“Accessibility” refers to the ability of your application to be used by all users, regardless of disability status. There are many aspects, all important, including:

  • Ensuring sufficient text contrast.
  • Providing a mechanism to disable animations.
  • Providing a mechanism to increase text sizes.
  • etc.

This guide is focused on programmatic accessibility. This allows assistive technology, such as screen readers or Braille displays, to inspect and interact with your app.

GPUI integrates with AccessKit to provide programmatic accessibility features (referred to as simply “accessibility” for the rest of this guide).

A minimal example can be found in the examples/a11y directory.

§Background

Accessibility support is based on two key capabilities:

  • Exposing information about the current UI state to assistive technology.
  • Responding to actions requested by assistive technology.

For example, a screen reader might want to announce to the user that a new button has appeared. The user may then want to use a voice control program to press that button.

§IDs in GPUI - ElementId and GlobalElementId

In GPUI, each Element can have an id:

let div_with_id = div().id("my-id").child(text!("hello"));

// IDs are optional
let div_without_id = div().child(text!("hello"));

Elements with IDs are also assigned a GlobalElementId. This global ID is formed by composing all the non-None IDs of its ancestors. For example:

let inner = div().id("inner-id");
let middle = div().child(inner);  // no ID
let outer = div().id("outer-id").child(middle);

In this example, inners global ID is (roughly speaking) ["outer-id", "inner-id"].

Since middle doesn’t have an ID itself, it has no global ID.

GlobalElementIds should be unique per-frame. Duplicate global IDs in the same frame will likely cause bugs.

§IDs and accessibility

When GPUI renders a frame, it walks your UI tree, and finds nodes with global IDs, and informs assistive technology about this node.

In order for nodes to be reported, they must also have a non-None role. This is used to inform assistive technology what sort of node it is (button, label, table, etc.). You can use div().id(...).role() to set the role.

Nodes with the same global ID across frames are considered to be “the same” node. For example:

// The UI in frame 1
let frame_1 = div()
    .id("parent")
    .role(Role::Button)
    .child(
      div()
        .id("id-1")
        .role(Role::Label)
        .child(text!("hello"))
    );

// The UI on the next frame
let frame_2 = div()
    .id("parent")
    .role(Role::Button)
    .child(
      div()
        .id("id-2")  // <- different ID
        .role(Role::Label)
        .child(text!("hello"))
    );

Logically, the UI has not changed. But the screen reader has no way of knowing that both child divs are “the same”. So assistive technology will interpret this as one node being removed, and another node being added. This can be very disorienting for users, since announcements typically only happen when something has meaningfully changed.

In other words, by controlling the ID of an element, you can control whether a change to a UI element is considered meaningful. You can also control whether elements are reported to assistive technology at all by setting the role, since nodes with no role are not reported.

§IDs and text

Special care must be taken when dealing with text.

GPUI provides the text! macro, which wraps strings in the Text type, but automatically derives an ID. Usually, this is what you want. However, the way it generates its ID is subtle and perhaps surprising.

The ID of an invocation of the text! macro is derived from the location in the source code of that invocation. For example:

let a = text!("a");
let b = text!("b");

// Different source locations, different IDs
assert_ne!(a.id(), b.id());

// However:

fn make_text(s: &str) -> Text { text!(s) }

let a = make_text("a");
let b = make_text("b");

// Both `a` and `b` are produced by the same `text!` invocation, so the IDs
// are the same
assert_eq!(a.id(), b.id());

This can produce surprising behaviour. For example, this footgun:

let todos = vec!["eat lunch", "drink water", "go to gym"];
let todo_divs = todos.into_iter().map(|todo| {
    text!(todo)
});

div()
    .id("todo-list")
    .role(Role::Document)
    .children(todo_divs);  // ERROR: multiple nodes with the same global ID

Here, when we map the iterator, since we have only written text! once, there is only one ID. And since they have the same ancestors and the same ID, they will have the same global ID. In release builds, this will mean some nodes get silently dropped!

To fix this, you can set an ID:

let todos = vec!["eat lunch", "drink water", "go to gym"];
let todo_divs = todos.into_iter().enumerate().map(|(index, todo)| {
    text!(todo).with_id(index)  // OR `text(id = index, todo)`
});

div()
    .id("todo-list")
    .role(Role::Document)
    .children(todo_divs);

Another possible solution is to wrap the text! in another node that does have a unique global ID. For example:

let todos = vec!["eat lunch", "drink water", "go to gym"];
let todo_divs = todos.into_iter().enumerate().map(|(index, todo)| {
    div().id(index).child(text!(todo))
});

div()
    .id("todo-list")
    .role(Role::Document)
    .children(todo_divs);

Since the AccessKit NodeId is derived from the global ID, and the global ID takes into account the IDs of all ancestors, this works too.

Occasionally, you will need to create a Text element with no ID. You can achieve this with Text::new_inaccessible. If you are creating a custom UI component (e.g. a button), you may want this so that you can set a label property on a parent div without duplicating the text in the accessibility tree.

§Handling actions

Assistive technology can dispatch actions to the UI. While many users of assistive technology use traditional input devices (e.g. a keyboard), some use more specialized systems. For example, users with limited mobility may use voice control to interact with your app.

When a user dispatches an action, it is dispatched to a specific node. It is your responsibility to tell the UI elements how they should respond when a request comes in.

Note, these actions are totally unrelated to GPUI’s Action trait. AccessKit exposes accesskit::Action. In GPUI, this is re-exported as AccessibleAction.

To respond to an accessible action, use [div().on_a11y_action()][InteractiveElement::on_a11y_action]:

div()
    .id("my-slider")
    .role(Role::Slider)
    .on_a11y_action(AccessibleAction::Increment, |_extra, _window, _cx| {
        position += 1;
        cx.notify();
    })
    .child(my_cool_slider());

Note that some common actions are automatically registered. For example, .on_click() adds an AccessibleAction::Click handler that calls the click handler.

§Further reading

Designing high-quality accessible interfaces can be challenging, in the same way that designing high-quality traditional interfaces can be. The following pages have useful information:

Note that, while GPUI mimics web APIs, it doesn’t necessarily behave exactly as a web browser would with the same attributes.