mirror of
https://github.com/emilk/egui.git
synced 2026-06-26 14:49:06 -04:00
Announce pressed state of selectable buttons to screen readers (#8130)
A `Button` used as a toggle (via `Button::selected(true)`, `Button::selectable(...)`, or `Ui::selectable_label`) now announces its pressed / not-pressed state to screen readers. Plain buttons that never call `.selected(...)` stay un-toggled, so their announcement doesn't change. `Checkbox` and the pre-existing selectable-label code path already did this; `Button` was the odd one out. No public API change: the field is private, `Button::selected(bool)` keeps its signature, and visuals are identical. Internally the field becomes `Option<bool>` so we can distinguish "plain button" from "toggle button currently off". Regression test added in `regression_tests.rs`, do let me know if some other file would be a better location. ### Note for manual testing `egui_demo_app` pulls in `eframe` with `default-features = false` and doesn't re-enable `accesskit`, so `cargo run -p egui_demo_app` publishes no AccessKit tree at all. To verify manually: `cargo run -p egui_demo_app --features accessibility_inspector` Happy to send a small follow-up PR enabling `accesskit` in the demo app's defaults if that's desirable, since that makes a11y work much easier to smoke-test locally. ### Use of AI This PR was drafted with Claude Code. I understand different projects have different policies regarding AI generated code. Do let me know if this is not acceptable here. Also happy to take any other feedback. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
080ce81c8b
commit
5a49d895bf
@@ -35,7 +35,7 @@ pub struct Button<'a> {
|
|||||||
frame_when_inactive: bool,
|
frame_when_inactive: bool,
|
||||||
min_size: Vec2,
|
min_size: Vec2,
|
||||||
corner_radius: Option<CornerRadius>,
|
corner_radius: Option<CornerRadius>,
|
||||||
selected: bool,
|
selected: Option<bool>,
|
||||||
image_tint_follows_text_color: bool,
|
image_tint_follows_text_color: bool,
|
||||||
limit_image_size: bool,
|
limit_image_size: bool,
|
||||||
classes: Classes,
|
classes: Classes,
|
||||||
@@ -54,7 +54,7 @@ impl<'a> Button<'a> {
|
|||||||
frame_when_inactive: true,
|
frame_when_inactive: true,
|
||||||
min_size: Vec2::ZERO,
|
min_size: Vec2::ZERO,
|
||||||
corner_radius: None,
|
corner_radius: None,
|
||||||
selected: false,
|
selected: None,
|
||||||
image_tint_follows_text_color: false,
|
image_tint_follows_text_color: false,
|
||||||
limit_image_size: false,
|
limit_image_size: false,
|
||||||
classes: Classes::default(),
|
classes: Classes::default(),
|
||||||
@@ -261,9 +261,14 @@ impl<'a> Button<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// If `true`, mark this button as "selected".
|
/// If `true`, mark this button as "selected".
|
||||||
|
///
|
||||||
|
/// Calling this method opts the button into toggle semantics and the
|
||||||
|
/// current pressed/not-pressed state will be reported to assistive
|
||||||
|
/// technologies (e.g. screen readers). Plain buttons that never call
|
||||||
|
/// `selected` are not announced as toggles.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn selected(mut self, selected: bool) -> Self {
|
pub fn selected(mut self, selected: bool) -> Self {
|
||||||
self.selected = selected;
|
self.selected = Some(selected);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +319,7 @@ impl<'a> Button<'a> {
|
|||||||
let response: Option<Response> = ui.ctx().read_response(id);
|
let response: Option<Response> = ui.ctx().read_response(id);
|
||||||
let state = response.map(|r| r.widget_state()).unwrap_or_default();
|
let state = response.map(|r| r.widget_state()).unwrap_or_default();
|
||||||
|
|
||||||
classes.add_class_if(SELECTED_CLASS, selected);
|
classes.add_class_if(SELECTED_CLASS, selected.unwrap_or(false));
|
||||||
|
|
||||||
let ButtonStyle { frame, text_style } = ui.style().button_style(&classes, state);
|
let ButtonStyle { frame, text_style } = ui.style().button_style(&classes, state);
|
||||||
|
|
||||||
@@ -376,12 +381,18 @@ impl<'a> Button<'a> {
|
|||||||
ui.ctx().set_cursor_icon(cursor);
|
ui.ctx().set_cursor_icon(cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
response.response.widget_info(|| {
|
response.response.widget_info(|| match (selected, &text) {
|
||||||
if let Some(text) = &text {
|
(Some(selected), Some(text)) => {
|
||||||
WidgetInfo::labeled(WidgetType::Button, ui.is_enabled(), text)
|
WidgetInfo::selected(WidgetType::Button, ui.is_enabled(), selected, text)
|
||||||
} else {
|
|
||||||
WidgetInfo::new(WidgetType::Button)
|
|
||||||
}
|
}
|
||||||
|
(Some(selected), None) => {
|
||||||
|
let mut info = WidgetInfo::new(WidgetType::Button);
|
||||||
|
info.enabled = ui.is_enabled();
|
||||||
|
info.selected = Some(selected);
|
||||||
|
info
|
||||||
|
}
|
||||||
|
(None, Some(text)) => WidgetInfo::labeled(WidgetType::Button, ui.is_enabled(), text),
|
||||||
|
(None, None) => WidgetInfo::new(WidgetType::Button),
|
||||||
});
|
});
|
||||||
|
|
||||||
response
|
response
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ use egui::epaint::Shape;
|
|||||||
use egui::style::ScrollAnimation;
|
use egui::style::ScrollAnimation;
|
||||||
use egui::text::{LayoutJob, TextWrapping};
|
use egui::text::{LayoutJob, TextWrapping};
|
||||||
use egui::{
|
use egui::{
|
||||||
Align, Color32, FontFamily, FontId, Image, Label, Layout, RichText, Sense, TextBuffer,
|
Align, Button, Color32, FontFamily, FontId, Image, Label, Layout, RichText, Sense, TextBuffer,
|
||||||
TextFormat, TextWrapMode, Ui, include_image, vec2,
|
TextFormat, TextWrapMode, Ui, include_image, vec2,
|
||||||
};
|
};
|
||||||
use egui_kittest::Harness;
|
use egui_kittest::Harness;
|
||||||
use egui_kittest::kittest::Queryable as _;
|
use egui_kittest::kittest::{NodeT as _, Queryable as _};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn image_button_should_have_alt_text() {
|
fn image_button_should_have_alt_text() {
|
||||||
@@ -23,6 +23,31 @@ fn image_button_should_have_alt_text() {
|
|||||||
harness.get_by_label("Egui");
|
harness.get_by_label("Egui");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn button_selected_should_announce_toggled_state() {
|
||||||
|
use egui::accesskit::Toggled;
|
||||||
|
|
||||||
|
let harness = Harness::new_ui(|ui| {
|
||||||
|
ui.add(Button::new("Plain"));
|
||||||
|
ui.add(Button::new("Off").selected(false));
|
||||||
|
ui.add(Button::new("On").selected(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
harness.get_by_label("Plain").accesskit_node().toggled(),
|
||||||
|
None,
|
||||||
|
"a plain Button must not be announced as a toggle",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
harness.get_by_label("Off").accesskit_node().toggled(),
|
||||||
|
Some(Toggled::False),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
harness.get_by_label("On").accesskit_node().toggled(),
|
||||||
|
Some(Toggled::True),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hovering_should_preserve_text_format() {
|
fn hovering_should_preserve_text_format() {
|
||||||
let mut harness = Harness::builder().with_size((200.0, 70.0)).build_ui(|ui| {
|
let mut harness = Harness::builder().with_size((200.0, 70.0)).build_ui(|ui| {
|
||||||
|
|||||||
Reference in New Issue
Block a user