Repository navigation
DataTable: rows are unnamed to screen readers, with no table structure, header cells or keyboard focus #3647
Description
Description
On Windows, through AccessKit and UI Automation, DataTable exposes each visible row as an unnamed DataItem with nothing inside it. A screen reader announces "data item" for every row and can't read any cell, column name, or the row's position in the table.
What is exposed today:
- The row role and selected state, set in
state.rs:2199:.role(Role::Row).aria_selected(..). - A "Sort by …" button for each sortable column, set in
state.rs:1798.
Nothing else from the table reaches the accessibility tree.
Environment
- GPUI: gpui-pre 0.3.9
- GPUI Component: 0.8.0 (also
mainat 8b3631f) - Platform: Windows 11, UI Automation. I read the tree with UIA's control view (
TreeWalker.ControlViewWalker) and checked it with Accessibility Insights. I haven't tested VoiceOver on macOS.
Steps to Reproduce
cargo run -p gpui-component-story -- datatable- Inspect the window with Accessibility Insights for Windows or Narrator.
The DataTable story's tree (excerpt; the toolbar and menu bar above it are fine):
Button "Sort by Symbol"
Button "Sort by Price"
Button "Sort by Chg"
Button "Sort by Chg%"
DataItem ""
DataItem ""
DataItem ""
… 14 unnamed DataItems in total, one per visible row, none with children
Expected
A screen reader can identify the table and its column headers, read each row's cells, know which row is selected, and move through the rows by keyboard. That's what a native list view or data grid provides.
Actual
- No table container. No element has
Role::TableorRole::Grid, so there's no row or column count and assistive tools don't know the rows form a table. The rows'aria_selectedisn't reported either: they expose noSelectionItemPattern. Selection state normally needs a selectable container (grid, list) to be reported. - Rows have no name and no content. GPUI only puts text in the tree when it is a
Textwith an id (e.g. via thetext!macro) or anInteractiveText. Delegates usually return plain strings fromrender_td, and the cell containers have no role, so nothing inside a row reaches the tree. - Column headers aren't exposed. There are no
Role::ColumnHeadernodes and no column names. Only sortable columns appear, as "Sort by …" buttons, and those buttons aren't keyboard-focusable. - No keyboard focus in the tree. Tab moves focus into the table (the focus outline draws), but no node in the table is focusable or focused; UIA's focused element stays on the window. This looks like the same pattern as Input: focused frame handle has no accessibility node — Tab/click focus resolves to nothing (Role::TextInput not bound to frame_focus_handle) #3183 for
Input. - Expandable rows (grouped or tree-like data) have no way to report expanded/collapsed state unless the delegate adds it.
What a delegate can do today
render_tr returns the row element before the table adds Role::Row, so a delegate can already attach a label, expanded state and actions to rows. I verified this in an app with grouped rows (a process list):
fn render_tr(&mut self, row_ix: usize, _: &mut Window, cx: &mut Context<TableState<Self>>) -> Stateful<Div> {
let row = &self.rows[row_ix];
let table = cx.entity().downgrade();
let name = row.name.clone();
div()
.id(("row", row_ix))
.aria_label(row.accessible_label()) // e.g. "Microsoft Edge, 8 processes, CPU 0.5%, Memory 1021 MB"
.aria_expanded(row.expanded)
.on_a11y_action(Action::Expand, move |_, _, cx| {
let _ = table.update(cx, |table, cx| {
table.delegate_mut().toggle(&name);
cx.notify();
});
})
}- Names: named elements went from 8 of 29 to 29 of 29.
- Expand/collapse: group rows expose
ExpandCollapsePattern, and callingExpand()/Collapse()through UI Automation opens and closes the group (2 rows → 10 → 2).
A delegate can't add the table container, header cells, cell roles or focus, because the table renders those.
Suggested direction
I'm happy to implement this, in whatever order and shape you prefer:
- Container:
Role::Tableon the table root (orRole::Gridwhen rows or cells are selectable), witharia_row_count(rows_count)andaria_column_count(columns_count). The table is virtualized, so absolute counts and indices tell a screen reader where it is in the full list. - Header cells:
Role::ColumnHeaderwitharia_label(column.name)andaria_column_index. The sort button stays inside. - Body cells:
Role::Cellwitharia_row_index/aria_column_index, plus a way to name them. Either an optional delegate method such asfn cell_label(&self, row_ix, col_ix, cx) -> Option<SharedString>(defaultNone), or documenting thatrender_tdshould usetext!so cell text becomes aLabel. - Focus: expose the focused or selected row, e.g.
aria_active_descendanton the container, or make rows focusable.
Items 1–3 are self-contained and could be a first PR. Item 4 probably interacts with #3183.
Activity
table: Expose DataTable's structure and cells to assistive technology (…
Elitelord commentedon Oct 24, 2026
#3667 (merged) covers items 1–3:
Grid(or aTablewhen nothing is selectable), with row and column counts;ColumnHeaders named after their columns;Cells with row and column indices;TableDelegate::cell_labelgives a delegate a way to name its cells.In cell selection mode the cells are
GridCells, so cell selection now reaches screen readers on Windows.What's left:
accesskit_windows(0.34 and 0.35) only reportsSelectionItemPatternfor list, tree and tab items and forGridCell, with a// TODO: tables (#29)for rows. So row selection needs AccessKit's table support; gpui-kit already setsaria_selectedon rows.