collective

Browse / github.com/storytold/gridcraft / patch

Expose the active grid cell to screen readers

Expose the active grid cell to screen readers The grid is painted, so assistive tech saw one unlabeled node. This gives the grid node a Cell role carrying the active cell's address (label), shown text (value) and formula (description, omitted when the formula is hidden by protection). Smallest slice of the screen-reader tree; the ribbon controls already carry labels. Tests: new `grid_screen_reader` test reads the node back through AccessKit. Ran on Windows (GNU toolchain) only: passes. `cargo fmt --check` clean. Not run: the full suite (CI), macOS/Linux, and a real screen reader (NVDA or VoiceOver) — none was used. <!-- This is an auto-

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #2758, open)
Base
89ac453efa7ea03a3ab0b0d24fd85ca51172f5cc
Size
+73 −1 in 4 files
Review
pending
Shared
2026-10-28
Id
067d54014a0621a2ccc3ca23

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/formula_bar.rs crates/ui-egui/src/grid/show.rs crates/ui-egui/tests/it/grid_screen_reader.rs crates/ui-egui/tests/it/main.rs

diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs--- a/crates/ui-egui/src/formula_bar.rs+++ b/crates/ui-egui/src/formula_bar.rs@@ -156,7 +156,7 @@ pub(crate) fn active_spill_anchor(app: &SheetApp) -> Option<gridcraft_engine::co     sh.spill_anchor(at) } -fn active_input(app: &SheetApp) -> String {+pub(crate) fn active_input(app: &SheetApp) -> String {     let Some(d) = app.session.active() else { return String::new() };     let Some(sh) = d.wb.active() else { return String::new() };     let a = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active);diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs--- a/crates/ui-egui/src/grid/show.rs+++ b/crates/ui-egui/src/grid/show.rs@@ -262,6 +262,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {         interact(app, ui, &resp, &geo, sh, &wb);     }     update_cursor(app, ui, &resp, &geo, sh);+    expose_active_cell(app, ui, resp.id, &wb, sh);     crate::text_box::show(app, ui, &geo, sh);     if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) {         in_cell_editor(app, ui, &geo, sh, &wb);@@ -733,6 +734,27 @@ fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) {     p.rect_stroke(pr, 0.0, Stroke::new(3.0, blue), StrokeKind::Outside); } +/// The grid is painted, so a screen reader sees one blank node. Give it the active cell: address as+/// the label, shown text as the value, and the formula (when it isn't hidden) as the description.+fn expose_active_cell(app: &SheetApp, ui: &egui::Ui, id: egui::Id, wb: &Workbook, sh: &Sheet) {+    let at = app.session.active().map(|d| d.selection.active).unwrap_or_default();+    let at = sh.merge_at(at).map(|m| m.start).unwrap_or(at);+    let input = crate::formula_bar::active_input(app);+    let picture = sh.cell_pictures.contains_key(&at);+    // A picture cell's text value is its error fallback; the picture's alt text is what it shows.+    let shown = if picture { input.clone() } else { gridcraft_engine::display::cell_text(wb, sh, at) };+    let address = at.to_string();+    let has_formula = !picture && !input.is_empty() && sh.cell(sh.spill_anchor(at).unwrap_or(at)).is_some_and(|c| c.formula.is_some());+    ui.ctx().accesskit_node_builder(id, |node| {+        node.set_role(egui::accesskit::Role::Cell);+        node.set_label(address);+        node.set_value(shown);+        if has_formula {+            node.set_description(input);+        }+    });+}+ #[cfg(test)] mod trace_arrow_tests {     use super::*;diff --git a/crates/ui-egui/tests/it/grid_screen_reader.rs b/crates/ui-egui/tests/it/grid_screen_reader.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/grid_screen_reader.rs@@ -0,0 +1,49 @@+//! The painted grid exposes the active cell to assistive tech: address, value and formula (gap 18).++use egui::accesskit::Role;+use egui_kittest::kittest::{NodeT as _, Queryable as _};+use gridcraft_engine::Session;+use gridcraft_ui_egui::SheetApp;+use serde_json::json;++#[test]+fn active_cell_is_announced_with_address_value_and_formula() {+    let mut s = Session::new();+    s.new_workbook();+    s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();+    s.execute("cell.set", json!({"cell": "B2", "input": "=A1*3"})).unwrap();+    s.execute("selection.set", json!({"range": "B2"})).unwrap();+    let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(+        |ui, app: &mut SheetApp| {+            app.logic(ui.ctx());+            app.ui(ui);+        },+        crate::common::app(s),+    );+    h.run_steps(4);+    let cell = h.get_by_role_and_label(Role::Cell, "B2");+    let node = cell.accesskit_node();+    assert_eq!(node.value().as_deref(), Some("6"));+    assert_eq!(node.data().description(), Some("=A1*3"));+}++#[test]+fn array_formula_is_described_in_braces() {+    let mut s = Session::new();+    s.new_workbook();+    s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();+    s.execute("cell.set", json!({"cell": "A2", "input": "4"})).unwrap();+    s.execute("selection.set", json!({"range": "B1:B2"})).unwrap();+    s.execute("cell.set", json!({"input": "=A1:A2*2", "array": true})).unwrap();+    s.execute("selection.set", json!({"range": "B1"})).unwrap();+    let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(+        |ui, app: &mut SheetApp| {+            app.logic(ui.ctx());+            app.ui(ui);+        },+        crate::common::app(s),+    );+    h.run_steps(4);+    let node = h.get_by_role_and_label(Role::Cell, "B1");+    assert_eq!(node.accesskit_node().data().description(), Some("{=A1:A2*2}"));+}diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs--- a/crates/ui-egui/tests/it/main.rs+++ b/crates/ui-egui/tests/it/main.rs@@ -47,6 +47,7 @@ mod formula_point_headers; mod formula_popup; mod freeze_handles; mod go_to_dialog;+mod grid_screen_reader; mod header_autofit; mod header_column_drag; mod host_chrome;