Skip to content
Accessible custom widgets

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 as core.RoleButton, core.RoleCheckbox or core.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:

InterfaceMethodsUse it for
core.AccessibleValuerAccessibleValue(), AccessibleRange()A value to speak, and a numeric range for adjustable controls. Return 0, 0, 0 from AccessibleRange when there is no range.
core.AccessibleStatefulAccessibleState()Flags such as core.StateChecked, StateSelected or StateExpanded, combined with |.
core.AccessibleActionerAccessibleActions(), PerformAccessibleAction(name)What a screen reader can do with the widget, such as core.ActionPress, ActionToggle, ActionIncrement.
core.AccessibleDescriberAccessibleDescription()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 increment and decrement. 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 runs press, toggle, select or dismiss.
  • Other actions, such as rename or delete, 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 has Disable, Enable and Disabled methods), Bellui adds StateDisabled when 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)
}