diff --git a/crates/keymap/src/ui/keyboard.rs b/crates/keymap/src/ui/keyboard.rs index 0d0bc2a5..f01ed522 100644 --- a/crates/keymap/src/ui/keyboard.rs +++ b/crates/keymap/src/ui/keyboard.rs @@ -559,7 +559,7 @@ impl Draw<'_, '_> { } let a11y = if names.is_empty() { spoken } else { fill(self.ui("{chord}: {label}"), &[("chord", &spoken), ("label", &names.join(", "))]) }; let selected = self.state.keyboard.key == Some(key); - let resp = ui.interact(r, self.base.with(("key", key.name())), Sense::CLICK); + let resp = ui.interact(r, self.base.with(("key", key.name())), Sense::click()); resp.widget_info(|| WidgetInfo::selected(WidgetType::Button, true, selected, &a11y)); let description = match class(holders) { Class::Free => self.ui("Not assigned"), @@ -572,6 +572,8 @@ impl Draw<'_, '_> { let v = ui.visuals(); if selected { ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(2.0, v.strong_text_color()), StrokeKind::Outside); + } else if resp.has_focus() { + ui.painter().rect_stroke(r, CornerRadius::same(3), v.selection.stroke, StrokeKind::Outside); } else if resp.hovered() { ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.5, ink), StrokeKind::Inside); } diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 155ee2e9..57b32420 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -12,6 +12,7 @@ use craft_ui::buttons::{IconButton, IconButtonStyle}; use craft_ui::layout::{ResizeHandle, SplitAxis, SplitSize, split_rect}; use craft_ui::tabs::{self, Tab}; use egui::Galley; +use egui::accesskit::Live; use egui::text::{LayoutJob, TextFormat, TextWrapping}; use egui::{ Align, Button, Color32, ComboBox, EventFilter, Id, Key as EguiKey, Label, Layout, Modifiers, Painter, Rect, Response, RichText, ScrollArea, @@ -686,13 +687,24 @@ impl Draw<'_, '_> { action = self.rows(ui, range, shortcut_w, has_focus); }); }); + // Screen readers follow the selected row while the table has focus. + let row_id = position.filter(|p| shown.contains(p)).and_then(|p| match self.state.lines_cached().get(p) { + Some(Line::Row(r)) => Some(self.base.with(("row", &r.key))), + _ => None, + }); + if let Some(id) = row_id { + ui.ctx().accesskit_node_builder(table_id, |node| node.set_active_descendant(id.accesskit_id())); + } self.state.visible = shown; if has_focus { ui.painter().rect_stroke(rect, 2.0, ui.visuals().selection.stroke, StrokeKind::Inside); } if let Some(a) = action { - focus.request_focus(); + // A group header is a Tab stop of its own and keeps focus. + if !matches!(a, RowAction::Toggle(_)) { + focus.request_focus(); + } match a { RowAction::Select(c) => self.state.select_row(&c), RowAction::Record(c, old, chip) => { @@ -788,9 +800,14 @@ impl Draw<'_, '_> { action } + /// A group's header; a Tab stop, toggled with Enter or Space. fn group_header(&self, ui: &mut Ui, rect: Rect, name: &str, collapsed: bool) -> Option { - let resp = ui.interact(rect, self.base.with(("group", name)), Sense::CLICK); - resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, name)); + let resp = ui.interact(rect, self.base.with(("group", name)), Sense::click()); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::CollapsingHeader, true, name)); + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_expanded(!collapsed)); + if resp.has_focus() { + ui.painter().rect_stroke(rect, 2.0, ui.visuals().selection.stroke, StrokeKind::Inside); + } let v = ui.visuals(); let color = v.strong_text_color(); let m = self.mirror(rect); @@ -1254,7 +1271,8 @@ impl Draw<'_, '_> { fn conflict_bar(&mut self, ui: &mut Ui) { let Some(c) = self.state.conflict.clone() else { return }; let text = self.vis_chord(&c.text, &self.chord(&c.binding.trigger)); - ui.add(Label::new(RichText::new(text).color(self.style.warning)).wrap()); + let r = ui.add(Label::new(RichText::new(text).color(self.style.warning)).wrap()); + ui.ctx().accesskit_node_builder(r.id, |node| node.set_live(Live::Polite)); let mut act = 0; ui.horizontal_wrapped(|ui| { if self.button(ui, true, &self.ui("Take It"), "shortcuts.conflict.take") { @@ -1289,7 +1307,8 @@ impl Draw<'_, '_> { let text = self.vis_chord(&m.text, m.chord.as_deref().unwrap_or_default()); let text = if m.refused { self.warned(text) } else { text }; let color = if m.refused { self.style.warning } else { ui.visuals().text_color() }; - ui.add(Label::new(RichText::new(text).color(color)).wrap()); + let r = ui.add(Label::new(RichText::new(text).color(color)).wrap()); + ui.ctx().accesskit_node_builder(r.id, |node| node.set_live(Live::Polite)); if m.goto.is_some() && self.button(ui, true, &self.ui("Go to Conflict"), "shortcuts.message.goto") { act = 1; } diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 64500ac2..702ab84f 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -114,8 +114,9 @@ fn has(h: &Harness<'_, Fx>, label: &str) -> bool { h.query_by_label(label).is_some() } +/// The table itself has focus (a screen reader sees its selected row, the active descendant). fn table_focused(h: &Harness<'_, Fx>) -> bool { - h.get_by_label("Commands").is_focused() + h.get_by_label("Commands").accesskit_node().is_focused_in_tree() } fn chord(text: &str, platform: Platform) -> String { @@ -359,7 +360,8 @@ fn search(h: &mut Harness<'_, Fx>, text: &str) { fn choose_area(h: &mut Harness<'_, Fx>, area: &str) { h.get_by_role_and_label(Role::ComboBox, "Show:").click(); h.run(); - h.get_by_role_and_label(Role::Button, area).click(); + // The popup's item, after a group header of the same name. + h.get_all_by_role_and_label(Role::Button, area).last().unwrap_or_else(|| panic!("{area}")).click(); h.run(); } @@ -409,7 +411,7 @@ fn areas_follow_paths_kind_and_scope() { for area in ["All Commands", "File Menu", "Object Menu", "Tools", "Timeline Panel", "Other"] { assert!(h.get_all_by_label(area).count() >= 1, "{area}"); } - h.get_by_role_and_label(Role::Button, "Tools").click(); + h.get_all_by_role_and_label(Role::Button, "Tools").last().expect("the Tools item").click(); h.run(); assert!(has(&h, "Marquee Tool: M") && has(&h, "Elliptical Marquee Tool: M")); assert!(!has(&h, "Save: ⌘S")); @@ -463,6 +465,31 @@ fn table_keys_move_record_and_delete() { assert_eq!(h.state().bindings("file.saveAs"), ["F7"]); } +#[test] +fn group_headers_take_focus_and_toggle_with_enter() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + assert!(tab_to(&mut h, "File", 3)); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(!has(&h, "Export…: ⌘E"), "File is collapsed"); + assert!(h.get_by_label("File").is_focused()); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(has(&h, "Export…: ⌘E")); +} + +#[test] +fn table_names_the_selected_row_and_messages_are_live() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + let row = h.get_by_role_and_label(Role::Button, "Save").accesskit_node().id(); + assert_eq!(h.get_by_label("Commands").accesskit_node().active_descendant().map(|n| n.id()), Some(row)); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert_eq!(h.get_by_label("⚠ ⌘S is used by Save").accesskit_node().live(), egui::accesskit::Live::Polite); +} + #[test] fn small_window_keeps_ok_and_long_labels_truncate() { let long = "A very long command label ".repeat(12);