Skip to main content

oxiui_table/
accessibility.rs

1//! `oxiui-accessibility` integration for `oxiui-table`.
2//!
3//! Builds an AccessKit / OxiUI accessibility tree for a `Table<S>` so that
4//! screen readers and assistive technologies can:
5//!
6//! - Enumerate each data row as a `WidgetRole::TableRow`.
7//! - Enumerate each cell as a `WidgetRole::TableCell` with its text content
8//!   and a `"Row N Column M"` description.
9//! - Enumerate each column header as `WidgetRole::ColumnHeader` with the
10//!   column name as its label and a sort-state annotation.
11//! - Announce the selected row(s) via `is_selected = true` on the matching
12//!   row nodes.
13//!
14//! # Usage
15//!
16//! ```rust,no_run
17//! # use oxiui_table::{Table, RowSource, Cell, ColumnDef};
18//! # struct S;
19//! # impl RowSource for S {
20//! #     fn row_count(&self) -> usize { 0 }
21//! #     fn row(&self, _: usize) -> Vec<Cell> { vec![] }
22//! #     fn column_defs(&self) -> &[ColumnDef] { &[] }
23//! # }
24//! use oxiui_table::accessibility::{build_table_a11y_tree, TableA11yParams};
25//! use oxiui_table::selection::SelectionModel;
26//!
27//! let source = S;
28//! let selection = SelectionModel::default();
29//! let params = TableA11yParams {
30//!     row_count: source.row_count(),
31//!     col_headers: &[],
32//!     selected_rows: selection.selected_rows(),
33//!     first_node_id: 1,
34//! };
35//! let root = build_table_a11y_tree(&params);
36//! assert_eq!(root.role, oxiui_table::accessibility::A11yRole::Group);
37//! ```
38//!
39//! # Feature flag
40//!
41//! Full integration via `accesskit::NodeId` is only available when the
42//! `a11y-table` feature is enabled.  The lightweight types and pure-Rust
43//! fallback implementations always compile.
44
45#[cfg(feature = "a11y-table")]
46use oxiui_accessibility::tree::{
47    build_table_a11y as upstream_build_table, column_header_node, table_cell_node, table_row_node,
48    A11yNode, WidgetRole,
49};
50
51// ── A11y role mirror ──────────────────────────────────────────────────────────
52
53/// Mirror of the OxiUI accessibility role enum for use without the `a11y-table`
54/// feature flag.
55///
56/// When `a11y-table` is enabled, [`A11yRole`] maps 1:1 to
57/// `oxiui_accessibility::tree::WidgetRole`.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum A11yRole {
60    /// A generic container group (table root).
61    Group,
62    /// A column header.
63    ColumnHeader,
64    /// A data row.
65    TableRow,
66    /// A data cell.
67    TableCell,
68}
69
70// ── Lightweight a11y node (feature-independent) ───────────────────────────────
71
72/// A simplified, dependency-free representation of an a11y tree node.
73///
74/// Used by callers that do not enable the `a11y-table` feature but still
75/// want to inspect the logical structure produced by
76/// [`build_table_a11y_tree`].  When `a11y-table` is enabled, the full
77/// `oxiui_accessibility::tree::A11yNode` tree is also available via
78/// `build_table_a11y_full` (requires `a11y-table` feature).
79#[derive(Debug)]
80pub struct LightNode {
81    /// Sequential id (1-based).
82    pub id: u64,
83    /// Accessibility role of this node.
84    pub role: A11yRole,
85    /// Human-readable label (column names, cell text).
86    pub label: Option<String>,
87    /// Description text (e.g. `"Row 1 Column 2"`, `"Column 3 header"`).
88    pub description: Option<String>,
89    /// Whether this row is in the selected state.
90    pub is_selected: bool,
91    /// Child nodes in document order.
92    pub children: Vec<LightNode>,
93}
94
95// ── Params ────────────────────────────────────────────────────────────────────
96
97/// Parameters for constructing an accessibility tree snapshot of a table.
98pub struct TableA11yParams<'a> {
99    /// Total number of data rows in the current (filtered+sorted) view.
100    pub row_count: usize,
101    /// Column header labels in logical order.
102    pub col_headers: &'a [&'a str],
103    /// Set of **visible** row indices that are currently selected.
104    pub selected_rows: &'a [usize],
105    /// Seed for node IDs; the root gets this id, children count upward.
106    pub first_node_id: u64,
107}
108
109// ── Builder ───────────────────────────────────────────────────────────────────
110
111/// Build a lightweight accessibility tree describing a table.
112///
113/// The returned [`LightNode`] has role [`A11yRole::Group`] and contains:
114///
115/// - One [`A11yRole::ColumnHeader`] child per entry in `params.col_headers`.
116/// - One [`A11yRole::TableRow`] child per row in `0..params.row_count`.
117///   Each row node carries [`A11yRole::TableCell`] children (one per column),
118///   `is_selected` set when the row index is in `params.selected_rows`, and a
119///   `description` of `"Row N"` (1-based).
120///
121/// Node IDs are minted sequentially starting from `params.first_node_id`.
122pub fn build_table_a11y_tree(params: &TableA11yParams<'_>) -> LightNode {
123    let col_count = params.col_headers.len();
124    let mut next_id = params.first_node_id;
125
126    let mut root = LightNode {
127        id: next_id,
128        role: A11yRole::Group,
129        label: None,
130        description: None,
131        is_selected: false,
132        children: Vec::with_capacity(col_count + params.row_count),
133    };
134    next_id += 1;
135
136    // Column-header children
137    for (col_idx, &header) in params.col_headers.iter().enumerate() {
138        root.children.push(LightNode {
139            id: next_id,
140            role: A11yRole::ColumnHeader,
141            label: Some(header.to_owned()),
142            description: Some(format!("Column {} header", col_idx + 1)),
143            is_selected: false,
144            children: Vec::new(),
145        });
146        next_id += 1;
147    }
148
149    // Data rows
150    for row_idx in 0..params.row_count {
151        let is_row_selected = params.selected_rows.contains(&row_idx);
152        let mut row = LightNode {
153            id: next_id,
154            role: A11yRole::TableRow,
155            label: None,
156            description: Some(format!("Row {}", row_idx + 1)),
157            is_selected: is_row_selected,
158            children: Vec::with_capacity(col_count),
159        };
160        next_id += 1;
161
162        // One cell per column (content is not available without the source, so
163        // leave label/description empty at this level; callers may enrich via
164        // the `with_cell_text` variant below).
165        for col_idx in 0..col_count {
166            row.children.push(LightNode {
167                id: next_id,
168                role: A11yRole::TableCell,
169                label: None,
170                description: Some(format!("Row {} Column {}", row_idx + 1, col_idx + 1)),
171                is_selected: false,
172                children: Vec::new(),
173            });
174            next_id += 1;
175        }
176
177        root.children.push(row);
178    }
179
180    root
181}
182
183/// Parameters for building a tree with concrete cell text.
184pub struct TableA11yWithTextParams<'a> {
185    /// Base params (row/col counts, selection, id seed).
186    pub base: TableA11yParams<'a>,
187    /// `cell_text[row][col]` — the display string for each cell.
188    pub cell_text: &'a [Vec<String>],
189}
190
191/// Build an accessibility tree with concrete cell text content.
192///
193/// Identical to [`build_table_a11y_tree`] but fills in the `label` field of
194/// each [`A11yRole::TableCell`] with the corresponding entry from
195/// `params.cell_text`.  Rows or columns beyond the `cell_text` bounds are
196/// silently skipped.
197pub fn build_table_a11y_with_text(params: &TableA11yWithTextParams<'_>) -> LightNode {
198    let col_count = params.base.col_headers.len();
199    let mut next_id = params.base.first_node_id;
200
201    let mut root = LightNode {
202        id: next_id,
203        role: A11yRole::Group,
204        label: None,
205        description: None,
206        is_selected: false,
207        children: Vec::with_capacity(col_count + params.base.row_count),
208    };
209    next_id += 1;
210
211    // Column-header children
212    for (col_idx, &header) in params.base.col_headers.iter().enumerate() {
213        root.children.push(LightNode {
214            id: next_id,
215            role: A11yRole::ColumnHeader,
216            label: Some(header.to_owned()),
217            description: Some(format!("Column {} header", col_idx + 1)),
218            is_selected: false,
219            children: Vec::new(),
220        });
221        next_id += 1;
222    }
223
224    // Data rows
225    for row_idx in 0..params.base.row_count {
226        let is_row_selected = params.base.selected_rows.contains(&row_idx);
227        let row_cells: &[String] = params
228            .cell_text
229            .get(row_idx)
230            .map(|v| v.as_slice())
231            .unwrap_or(&[]);
232
233        let mut row = LightNode {
234            id: next_id,
235            role: A11yRole::TableRow,
236            label: None,
237            description: Some(format!("Row {}", row_idx + 1)),
238            is_selected: is_row_selected,
239            children: Vec::with_capacity(col_count),
240        };
241        next_id += 1;
242
243        for col_idx in 0..col_count {
244            let cell_label = row_cells.get(col_idx).cloned();
245            row.children.push(LightNode {
246                id: next_id,
247                role: A11yRole::TableCell,
248                label: cell_label,
249                description: Some(format!("Row {} Column {}", row_idx + 1, col_idx + 1)),
250                is_selected: false,
251                children: Vec::new(),
252            });
253            next_id += 1;
254        }
255
256        root.children.push(row);
257    }
258
259    root
260}
261
262// ── Full AccessKit integration (a11y-table feature) ───────────────────────────
263
264/// Build a full `oxiui_accessibility::tree::A11yNode` tree for the table.
265///
266/// Delegates to `oxiui_accessibility::tree::build_table_a11y` which
267/// produces the complete AccessKit node structure with proper role, label,
268/// and description mapping.
269///
270/// Only available when the `a11y-table` feature is enabled.
271#[cfg(feature = "a11y-table")]
272pub fn build_table_a11y_full(row_count: usize, col_count: usize, col_headers: &[&str]) -> A11yNode {
273    upstream_build_table(row_count, col_count, col_headers)
274}
275
276/// Build a full `oxiui_accessibility::tree::A11yNode` tree with per-cell text.
277///
278/// Constructs the table root node with column headers and row/cell children,
279/// filling in the `text_content` field of each cell node from `cell_text`.
280///
281/// Only available when the `a11y-table` feature is enabled.
282#[cfg(feature = "a11y-table")]
283pub fn build_table_a11y_full_with_text(
284    row_count: usize,
285    col_headers: &[&str],
286    col_count: usize,
287    cell_text: &[Vec<String>],
288    selected_rows: &[usize],
289) -> A11yNode {
290    use accesskit::NodeId; // accesskit is re-exported by oxiui_accessibility
291
292    let mut next_id: u64 = 0;
293
294    let mut root = A11yNode::simple(NodeId(next_id), WidgetRole::Group, None);
295    next_id += 1;
296
297    // Column headers
298    for (col_idx, &header) in col_headers.iter().enumerate() {
299        let node = column_header_node(NodeId(next_id), col_idx, header);
300        next_id += 1;
301        root.children.push(node);
302    }
303
304    // Rows
305    for row_idx in 0..row_count {
306        let mut row = table_row_node(NodeId(next_id), row_idx);
307        next_id += 1;
308
309        // Mark selected rows
310        if selected_rows.contains(&row_idx) {
311            row.props.selected = Some(true);
312        }
313
314        let row_cells: &[String] = cell_text.get(row_idx).map(|v| v.as_slice()).unwrap_or(&[]);
315
316        for col_idx in 0..col_count {
317            let text = row_cells.get(col_idx).map(|s| s.as_str()).unwrap_or("");
318            let cell = table_cell_node(NodeId(next_id), row_idx, col_idx, text);
319            next_id += 1;
320            row.children.push(cell);
321        }
322
323        root.children.push(row);
324    }
325
326    root
327}
328
329// ── Tests ─────────────────────────────────────────────────────────────────────
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    fn make_params<'a>(
336        rows: usize,
337        headers: &'a [&'a str],
338        selected: &'a [usize],
339    ) -> TableA11yParams<'a> {
340        TableA11yParams {
341            row_count: rows,
342            col_headers: headers,
343            selected_rows: selected,
344            first_node_id: 1,
345        }
346    }
347
348    #[test]
349    fn root_role_is_group() {
350        let params = make_params(2, &["A", "B"], &[]);
351        let root = build_table_a11y_tree(&params);
352        assert_eq!(root.role, A11yRole::Group);
353    }
354
355    #[test]
356    fn column_header_count_matches() {
357        let params = make_params(3, &["Col1", "Col2", "Col3"], &[]);
358        let root = build_table_a11y_tree(&params);
359        let headers: Vec<_> = root
360            .children
361            .iter()
362            .filter(|n| n.role == A11yRole::ColumnHeader)
363            .collect();
364        assert_eq!(headers.len(), 3);
365    }
366
367    #[test]
368    fn row_count_matches() {
369        let params = make_params(5, &["A", "B"], &[]);
370        let root = build_table_a11y_tree(&params);
371        let rows: Vec<_> = root
372            .children
373            .iter()
374            .filter(|n| n.role == A11yRole::TableRow)
375            .collect();
376        assert_eq!(rows.len(), 5);
377    }
378
379    #[test]
380    fn each_row_has_correct_cell_count() {
381        let params = make_params(2, &["X", "Y", "Z"], &[]);
382        let root = build_table_a11y_tree(&params);
383        for row in root
384            .children
385            .iter()
386            .filter(|n| n.role == A11yRole::TableRow)
387        {
388            assert_eq!(row.children.len(), 3, "each row must have 3 cells");
389        }
390    }
391
392    #[test]
393    fn selected_row_is_marked() {
394        let params = make_params(3, &["A"], &[1]);
395        let root = build_table_a11y_tree(&params);
396        let rows: Vec<_> = root
397            .children
398            .iter()
399            .filter(|n| n.role == A11yRole::TableRow)
400            .collect();
401        assert!(!rows[0].is_selected, "row 0 should not be selected");
402        assert!(rows[1].is_selected, "row 1 should be selected");
403        assert!(!rows[2].is_selected, "row 2 should not be selected");
404    }
405
406    #[test]
407    fn column_header_label_matches() {
408        let params = make_params(0, &["Name", "Age"], &[]);
409        let root = build_table_a11y_tree(&params);
410        let headers: Vec<_> = root
411            .children
412            .iter()
413            .filter(|n| n.role == A11yRole::ColumnHeader)
414            .collect();
415        assert_eq!(headers[0].label.as_deref(), Some("Name"));
416        assert_eq!(headers[1].label.as_deref(), Some("Age"));
417    }
418
419    #[test]
420    fn row_description_is_one_based() {
421        let params = make_params(2, &["A"], &[]);
422        let root = build_table_a11y_tree(&params);
423        let rows: Vec<_> = root
424            .children
425            .iter()
426            .filter(|n| n.role == A11yRole::TableRow)
427            .collect();
428        assert_eq!(rows[0].description.as_deref(), Some("Row 1"));
429        assert_eq!(rows[1].description.as_deref(), Some("Row 2"));
430    }
431
432    #[test]
433    fn cell_description_has_row_and_col() {
434        let params = make_params(1, &["A", "B"], &[]);
435        let root = build_table_a11y_tree(&params);
436        let row = root
437            .children
438            .iter()
439            .find(|n| n.role == A11yRole::TableRow)
440            .expect("must have a row");
441        assert_eq!(
442            row.children[0].description.as_deref(),
443            Some("Row 1 Column 1")
444        );
445        assert_eq!(
446            row.children[1].description.as_deref(),
447            Some("Row 1 Column 2")
448        );
449    }
450
451    #[test]
452    fn zero_rows_produces_only_headers() {
453        let params = make_params(0, &["A", "B"], &[]);
454        let root = build_table_a11y_tree(&params);
455        assert_eq!(root.children.len(), 2); // 2 headers, 0 rows
456        for child in &root.children {
457            assert_eq!(child.role, A11yRole::ColumnHeader);
458        }
459    }
460
461    #[test]
462    fn zero_cols_produces_only_rows_no_cells() {
463        let params = make_params(3, &[], &[]);
464        let root = build_table_a11y_tree(&params);
465        let rows: Vec<_> = root
466            .children
467            .iter()
468            .filter(|n| n.role == A11yRole::TableRow)
469            .collect();
470        assert_eq!(rows.len(), 3);
471        for row in rows {
472            assert!(row.children.is_empty(), "rows with 0 cols have no cells");
473        }
474    }
475
476    #[test]
477    fn node_ids_are_unique_and_sequential() {
478        let params = make_params(2, &["A", "B"], &[]);
479        let root = build_table_a11y_tree(&params);
480
481        // Collect all IDs via DFS.
482        fn collect_ids(node: &LightNode, out: &mut Vec<u64>) {
483            out.push(node.id);
484            for child in &node.children {
485                collect_ids(child, out);
486            }
487        }
488        let mut ids = Vec::new();
489        collect_ids(&root, &mut ids);
490
491        // All IDs must be unique.
492        let mut sorted = ids.clone();
493        sorted.sort_unstable();
494        sorted.dedup();
495        assert_eq!(sorted.len(), ids.len(), "all node IDs must be unique");
496    }
497
498    #[test]
499    fn with_text_fills_cell_labels() {
500        let headers: &[&str] = &["Name", "Age"];
501        let selected: &[usize] = &[];
502        let cell_text: Vec<Vec<String>> = vec![
503            vec!["Alice".to_string(), "30".to_string()],
504            vec!["Bob".to_string(), "25".to_string()],
505        ];
506        let params = TableA11yWithTextParams {
507            base: TableA11yParams {
508                row_count: 2,
509                col_headers: headers,
510                selected_rows: selected,
511                first_node_id: 1,
512            },
513            cell_text: &cell_text,
514        };
515        let root = build_table_a11y_with_text(&params);
516        let rows: Vec<_> = root
517            .children
518            .iter()
519            .filter(|n| n.role == A11yRole::TableRow)
520            .collect();
521        assert_eq!(rows[0].children[0].label.as_deref(), Some("Alice"));
522        assert_eq!(rows[0].children[1].label.as_deref(), Some("30"));
523        assert_eq!(rows[1].children[0].label.as_deref(), Some("Bob"));
524        assert_eq!(rows[1].children[1].label.as_deref(), Some("25"));
525    }
526
527    #[test]
528    fn column_header_description_contains_column_number() {
529        let params = make_params(0, &["Name", "City"], &[]);
530        let root = build_table_a11y_tree(&params);
531        let headers: Vec<_> = root
532            .children
533            .iter()
534            .filter(|n| n.role == A11yRole::ColumnHeader)
535            .collect();
536        assert!(
537            headers[0]
538                .description
539                .as_deref()
540                .unwrap_or("")
541                .contains("Column 1"),
542            "first header description must contain 'Column 1'"
543        );
544        assert!(
545            headers[1]
546                .description
547                .as_deref()
548                .unwrap_or("")
549                .contains("Column 2"),
550            "second header description must contain 'Column 2'"
551        );
552    }
553
554    #[cfg(feature = "a11y-table")]
555    #[test]
556    fn full_build_produces_correct_child_count() {
557        let root = build_table_a11y_full(2, 3, &["A", "B", "C"]);
558        // 3 column headers + 2 rows = 5 direct children.
559        assert_eq!(
560            root.children.len(),
561            5,
562            "expected 3 headers + 2 rows = 5 children"
563        );
564    }
565
566    #[cfg(feature = "a11y-table")]
567    #[test]
568    fn full_build_with_text_selected_row() {
569        let cell_text: Vec<Vec<String>> = vec![
570            vec!["A1".to_string(), "A2".to_string()],
571            vec!["B1".to_string(), "B2".to_string()],
572        ];
573        let root = build_table_a11y_full_with_text(2, &["H1", "H2"], 2, &cell_text, &[1]);
574        // Find the second row (index 1 in row children).
575        let rows: Vec<_> = root
576            .children
577            .iter()
578            .filter(|n| n.role == WidgetRole::TableRow)
579            .collect();
580        assert_eq!(rows.len(), 2);
581        assert_eq!(rows[1].props.selected, Some(true), "row 1 must be selected");
582    }
583}