Accessible custom widgets
A widget you write is invisible to screen readers until it describes
itself. You do that by implementing interfaces from the
core package. The
platform bridges look for them; there is nothing to register.
This page builds a five-star rating control that screen readers announce as a slider: “Food, slider, 3 of 5 stars”.
Role and name
Every accessible widget implements
core.Accessible:
AccessibleRole()returns a role constant such ascore.RoleButton,core.RoleCheckboxorcore.RoleSlider. Pick the role whose behaviour matches your widget, so users know how to operate it.AccessibleLabel()returns the name.
If your widget embeds widget.BaseWidget, apps can also override the name
with SetAccessibilityLabel, as for standard widgets.
Value, state and actions
Optional interfaces add more:
| Interface | Methods | Use it for |
|---|---|---|
core.AccessibleValuer | AccessibleValue(), AccessibleRange() | A value to speak, and a numeric range for adjustable controls. Return 0, 0, 0 from AccessibleRange when there is no range. |
core.AccessibleStateful | AccessibleState() | Flags such as core.StateChecked, StateSelected or StateExpanded, combined with |. |
core.AccessibleActioner | AccessibleActions(), PerformAccessibleAction(name) | What a screen reader can do with the widget, such as core.ActionPress, ActionToggle, ActionIncrement. |
core.AccessibleDescriber | AccessibleDescription() | Extra help text, beyond the name. |
The rating widget uses a value and two actions:
var (
_ core.Accessible = (*Rating)(nil)
_ core.AccessibleValuer = (*Rating)(nil)
_ core.AccessibleActioner = (*Rating)(nil)
)
// AccessibleRole tells assistive technology what kind of control this is.
func (r *Rating) AccessibleRole() core.AccessibleRole { return core.RoleSlider }
// AccessibleLabel is the name a screen reader speaks.
func (r *Rating) AccessibleLabel() string { return r.Label }
// AccessibleValue is the value spoken after the name.
func (r *Rating) AccessibleValue() string { return fmt.Sprintf("%d of 5 stars", r.Value) }
// AccessibleRange gives the numeric range, so the value can be adjusted.
func (r *Rating) AccessibleRange() (min, max, current float64) {
return 0, 5, float64(r.Value)
}
// AccessibleActions lists what assistive technology can do with the widget.
func (r *Rating) AccessibleActions() []core.AccessibleAction {
return []core.AccessibleAction{
{Name: core.ActionIncrement, Description: "More stars"},
{Name: core.ActionDecrement, Description: "Fewer stars"},
}
}
// PerformAccessibleAction runs an action by name.
func (r *Rating) PerformAccessibleAction(name string) {
switch name {
case core.ActionIncrement:
r.set(r.Value + 1)
case core.ActionDecrement:
r.set(r.Value - 1)
}
}How the platforms use these:
- Standard actions map to the platform’s own commands where one exists.
On macOS and iOS, VoiceOver’s increment and decrement commands call
incrementanddecrement. On Windows, setting a slider’s value steps through them, so your widget keeps control of the step size. On macOS, VoiceOver’s press command runspress,toggle,selectordismiss. - Other actions, such as
renameordelete, appear in the screen reader’s extra actions menu on macOS, Linux and Android. Windows and iOS do not show them. - Disabled does not need reporting. If your widget implements
core.Disableable(it hasDisable,EnableandDisabledmethods), Bellui addsStateDisabledwhen it is disabled.
Children
For some roles, a screen reader treats the widget as one unit and ignores
the accessible widgets inside it. A slider, button, checkbox, switch, tab,
heading, link or image is announced once, not once for itself and again for
the label that draws its text. The rating widget draws its stars with a
widget.Label, and that label does not appear separately.
Containers and groups are different: their accessible children stay in the tree. If you build a composite widget from standard widgets, such as a search box with a button, give the outer widget a grouping role or no role at all, and let the inner widgets describe themselves.
Test it
core.SnapshotAccessible
returns what a screen reader would receive for one widget, so you can check
it in an ordinary Go test, without the build tag and without a screen
reader:
func TestRatingIsAccessible(t *testing.T) {
r := &Rating{Label: "Food", Value: 3}
node, ok := core.SnapshotAccessible(r)
if !ok {
t.Fatal("Rating does not implement core.Accessible")
}
if node.Role != core.RoleSlider || node.Label != "Food" || node.Value != "3 of 5 stars" {
t.Errorf("got role %v, label %q, value %q", node.Role, node.Label, node.Value)
}
}The snapshot applies the same rules as the platform bridges: a label set
with SetAccessibilityLabel wins over AccessibleLabel, and a disabled
widget gets StateDisabled.
core.WalkAccessibleTree
visits every accessible object under a root, if you want to check a whole
screen.
The rating widget is also operable from the keyboard. See Keyboard and focus.
The full program:
// A custom widget that screen readers can name, read and operate, and that
// works with the keyboard.
package main
import (
"fmt"
"strings"
"github.com/belluikit/bellui"
"github.com/belluikit/bellui/app"
"github.com/belluikit/bellui/container"
"github.com/belluikit/bellui/core"
"github.com/belluikit/bellui/event"
"github.com/belluikit/bellui/widget"
)
// Rating is a row of five stars. Tap a star, or focus the widget and use the
// arrow keys.
type Rating struct {
widget.BaseWidget
Label string
Value int // 0 to 5
focused bool
text *widget.Label
}
func NewRating(label string) *Rating {
r := &Rating{Label: label, text: widget.NewLabel("")}
r.ExtendBaseWidget(r)
return r
}
func (r *Rating) CreateRenderer() core.WidgetRenderer {
r.update()
return widget.NewSimpleRenderer(r.text)
}
func (r *Rating) update() {
stars := strings.Repeat("★", r.Value) + strings.Repeat("☆", 5-r.Value)
if r.focused {
stars = "[" + stars + "]" // a visible focus indicator
}
r.text.SetText(stars)
}
func (r *Rating) set(v int) {
r.Value = max(0, min(5, v))
r.update()
}
var (
_ core.Accessible = (*Rating)(nil)
_ core.AccessibleValuer = (*Rating)(nil)
_ core.AccessibleActioner = (*Rating)(nil)
)
// AccessibleRole tells assistive technology what kind of control this is.
func (r *Rating) AccessibleRole() core.AccessibleRole { return core.RoleSlider }
// AccessibleLabel is the name a screen reader speaks.
func (r *Rating) AccessibleLabel() string { return r.Label }
// AccessibleValue is the value spoken after the name.
func (r *Rating) AccessibleValue() string { return fmt.Sprintf("%d of 5 stars", r.Value) }
// AccessibleRange gives the numeric range, so the value can be adjusted.
func (r *Rating) AccessibleRange() (min, max, current float64) {
return 0, 5, float64(r.Value)
}
// AccessibleActions lists what assistive technology can do with the widget.
func (r *Rating) AccessibleActions() []core.AccessibleAction {
return []core.AccessibleAction{
{Name: core.ActionIncrement, Description: "More stars"},
{Name: core.ActionDecrement, Description: "Fewer stars"},
}
}
// PerformAccessibleAction runs an action by name.
func (r *Rating) PerformAccessibleAction(name string) {
switch name {
case core.ActionIncrement:
r.set(r.Value + 1)
case core.ActionDecrement:
r.set(r.Value - 1)
}
}
var _ event.Focusable = (*Rating)(nil)
func (r *Rating) FocusGained() { r.focused = true; r.update() }
func (r *Rating) FocusLost() { r.focused = false; r.update() }
func (r *Rating) TypedRune(rune) {}
// TypedKey gives keyboard users the same control as the pointer.
func (r *Rating) TypedKey(ev *event.KeyEvent) {
switch ev.Name {
case event.KeyRight, event.KeyUp:
r.set(r.Value + 1)
case event.KeyLeft, event.KeyDown:
r.set(r.Value - 1)
case event.KeyHome:
r.set(0)
}
}
// Tapped sets the rating from where the row was tapped.
func (r *Rating) Tapped(ev *event.PointEvent) {
if w := r.Size().Width; w > 0 {
r.set(int(ev.Position.X/w*5) + 1)
}
}
func main() {
a := app.New()
w := a.NewWindow("Rating")
w.SetContent(container.NewVBox(
widget.NewEntry(),
NewRating("Food"),
NewRating("Service"),
))
bellui.ShowAndRun(w)
}