Add accessible semantics
This recipe takes a small settings panel and makes it understandable to screen readers, keyboard users, and selector-driven tests.
The goal is not to add a second accessibility UI. The goal is to make the normal Fission widget tree expose the product meaning it already has.
Step 1: start from meaningful state and actions
Accessibility works best when the app already has explicit product actions.
use fission::prelude::*;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SettingsState {
pub email: String,
pub marketing_enabled: bool,
pub saving: bool,
}
impl GlobalState for SettingsState {}
#[derive(Action)]
pub struct EmailChanged(pub String);
#[derive(Action)]
pub struct ToggleMarketing;
#[derive(Action)]
pub struct SaveSettings;
#[fission_reducer(EmailChanged)]
fn email_changed(state: &mut SettingsState, action: EmailChanged) {
state.email = action.0;
}
#[fission_reducer(ToggleMarketing)]
fn toggle_marketing(state: &mut SettingsState) {
state.marketing_enabled = !state.marketing_enabled;
}
#[fission_reducer(SaveSettings)]
fn save_settings(state: &mut SettingsState) {
state.saving = true;
}
A screen reader activation should eventually dispatch SaveSettings, not a parallel accessibility-specific path.
Step 2: resolve labels from the current locale
Visible text can use translation keys directly. Semantic labels are strings, so resolve them from Env while converting the widget.
fn tr<S: GlobalState>(view: &ViewHandle<S>, key: &str, fallback: &str) -> String {
view.i18n()
.get(&view.env().locale, key)
.unwrap_or(fallback)
.to_string()
}
Use stable translation keys. Do not build labels by concatenating translated fragments.
Step 3: make the section a semantic region
Use SemanticsRegion when a subtree is meaningful as a region, panel, dialog, or custom control.
pub struct AccountSettings;
impl From<AccountSettings> for Widget {
fn from(_settings: AccountSettings) -> Widget {
let (ctx, view) = fission::build::current::<SettingsState>();
let state = view.state();
let email_label = tr(&view, "settings.email", "Email address");
let marketing_label = tr(&view, "settings.marketing", "Marketing emails");
let save_label = tr(&view, "settings.save", "Save settings");
let panel_label = tr(&view, "settings.account", "Account settings");
let email_changed = ctx.bind(EmailChanged(String::new()), reduce_with!(email_changed));
let toggle_marketing = ctx.bind(ToggleMarketing, reduce_with!(toggle_marketing));
let save_settings = ctx.bind(SaveSettings, reduce_with!(save_settings));
SemanticsRegion::new(
Column {
gap: Some(12.0),
children: widgets![
Text::new(panel_label.clone()).into(),
TextInput {
value: state.email.clone(),
label: Some(email_label.into()),
on_change: Some(email_changed),
..Default::default()
}
.semantics_identifier("settings.email")
.into(),
Checkbox {
checked: state.marketing_enabled,
label: Some(marketing_label),
on_toggle: Some(toggle_marketing),
..Default::default()
}
.semantics_identifier("settings.marketing")
.into(),
Button {
child: Some(Text::new(save_label).into()),
on_press: Some(save_settings),
disabled: state.saving,
..Default::default()
}
.semantics_identifier("settings.save")
.into(),
],
..Default::default()
}
)
.identifier("settings.account")
.label(panel_label)
.role(Role::Generic)
.into()
}
}
The region identifier gives tests and platform tooling a stable target for the panel. The individual controls expose stable identifiers for automation and clear labels for assistive technology.
Step 4: add explicit labels for visual-only controls
If a control uses only an icon or custom drawing, do not rely on pixels to communicate meaning.
let close_settings = ctx.bind(CloseSettings, reduce_with!(close_settings));
Button {
child: Some(Icon::new("x").into()),
on_press: Some(close_settings),
semantics: Some(Semantics {
role: Role::Button,
label: Some(tr(&view, "settings.close", "Close settings")),
identifier: Some("settings.close".into()),
focusable: true,
..Semantics::default()
}),
..Default::default()
}
.into()
Use this pattern for icon buttons, custom canvas controls, drag handles, toolbar buttons, disclosure affordances, and any status indicator that is not already represented by text.
Step 5: keep focus predictable
Most focusable controls should use normal pointer focus. Use FocusPolicy::PreserveCurrentOnPointer for toolbar or ribbon buttons that should not steal focus from an editor.
let toggle_bold = ctx.bind(ToggleBold, reduce_with!(toggle_bold));
Button {
child: Some(Text::new(TextContent::Key("editor.bold".into())).into()),
on_press: Some(toggle_bold),
focus_policy: FocusPolicy::PreserveCurrentOnPointer,
..Default::default()
}
.semantics_identifier("editor.bold")
.into()
This lets the button activate while the editor remains the focused text field.
Step 6: test with semantic selectors
Live tests should target product semantics instead of coordinates when the requirement is semantic behavior.
client.wait_for_visible("settings.email")?;
client.fill_text_semantic_identifier("settings.email", "[email protected]")?;
client.tap_semantic_identifier("settings.marketing")?;
client.tap_semantic_identifier("settings.save")?;
let tree = client.get_tree()?;
let save = tree
.nodes
.iter()
.find(|node| node.identifier.as_deref() == Some("settings.save"))
.expect("save button semantics");
assert_eq!(save.label.as_deref(), Some("Save settings"));
assert!(!save.disabled);
If a selector fails, inspect the returned candidates and visibility information. A good failure should tell you whether the target was missing, disabled, hidden, clipped, or ambiguous.
Step 7: run manual screen-reader QA
Before shipping a native app, run the app with the platform screen reader enabled.
Check at least these behaviors:
the screen title and major regions are discoverable;
each control announces a useful role and label;
checked, disabled, read-only, masked, and value states are announced correctly;
focus order matches visible order;
activating controls dispatches the same reducers as pointer input;
text fields can be focused, edited, selected, and submitted;
dialogs trap or guide focus appropriately;
errors and loading states are not communicated by color alone.