[framework]Add semantics role to table rows. (#163337)

**1. framework side:**
This PR Create semantics node for rows in table's
`assembleSemanticsNode` function.

**2. web side:**
I tested on my mac, and i need to remove the 
`<flt-semantics-container>` between table and row, row and cell to
traverse inside table, removing those transfom intermediate containers
on web will be a bit of hassle and will be in another separate PR.

For example this code can only announce table but can’t get into cells.
```
<flt-semantics id="flt-semantic-node-4" role="table" style="position: absolute; overflow: visible; width: 751px; height: 56px; transform-origin: 0px 0px 0px; transform: matrix(1, 0, 0, 1, 0, 56); pointer-events: none; z-index: 1;">
    <flt-semantics-container style="position: absolute; pointer-events: none; top: 0px; left: 0px;">
        <flt-semantics id="flt-semantic-node-6" role="row" style="position: absolute; overflow: visible; width: 751px; height: 56px; top: 0px; left: 0px; pointer-events: none;">
            <flt-semantics-container style="position: absolute; pointer-events: none; top: 0px; left: 0px;">
                <flt-semantics id="flt-semantic-node-5" role="columnheader" aria-label="Name" style="position: absolute; overflow: visible; width: 751px; height: 56px; top: 0px; left: 0px; pointer-events: all;"></flt-semantics>
            </flt-semantics-container>
        </flt-semantics>
    </flt-semantics-container>
</flt-semantics>
```
If I removed the in between `</flt-semantics-container>`, the code come 
```
<flt-semantics id="flt-semantic-node-4" role="table" style="position: absolute; overflow: visible; width: 751px; height: 56px; transform-origin: 0px 0px 0px; transform: matrix(1, 0, 0, 1, 0, 56); pointer-events: none; z-index: 1;">
    <flt-semantics id="flt-semantic-node-6" role="row" style="position: absolute; overflow: visible; width: 751px; height: 56px; top: 0px; left: 0px; pointer-events: none;">
        <flt-semantics id="flt-semantic-node-5" role="columnheader" aria-label="Name" style="position: absolute; overflow: visible; width: 751px; height: 56px; top: 0px; left: 0px; pointer-events: all;"></flt-semantics>
    </flt-semantics>
</flt-semantics>
```
And I can get into table cells.


**3. Other aria-attributes:** 

[aria-colcount](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-colcount) ,[aria-rowcount](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-rowcount) 
[aria-colindex](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-colindex) 
[aria-rowindex](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-rowindex) 
* theres attributes are only needed if some rows and columns are hidden
in the Dom tree. havn't added them yet

aria-rowspan , aria-colspan : 
*we currently don't support row span and col span in our widgets.
related issue: https://github.com/flutter/flutter/issues/21594

 related: https://github.com/flutter/flutter/pull/162339
issue: https://github.com/flutter/flutter/issues/45205

## Pre-launch Checklist

- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
Hannah Jin
2025-03-12 21:39:28 -07:00
committed by GitHub
parent 4670159521
commit 6aaa4eb9e3
6 changed files with 284 additions and 36 deletions

View File

@@ -607,9 +607,191 @@ class RenderTable extends RenderBox {
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
config.role = SemanticsRole.table;
config.isSemanticBoundary = true;
config.explicitChildNodes = true;
}
final Map<int, _Index> _idToIndexMap = <int, _Index>{};
final Map<int, SemanticsNode> _cachedRows = <int, SemanticsNode>{};
final Map<_Index, SemanticsNode> _cachedCells = <_Index, SemanticsNode>{};
/// Provides custom semantics for tables by generating nodes for rows and maybe cells.
///
/// Table rows are not RenderObjects, so their semantics nodes must be created separately.
/// And if a cell has mutiple semantics node or has a different semantic role, we create
/// a new semantics node to wrap it.
@override
void assembleSemanticsNode(
SemanticsNode node,
SemanticsConfiguration config,
Iterable<SemanticsNode> children,
) {
final List<SemanticsNode> rows = <SemanticsNode>[];
final List<List<List<SemanticsNode>>> rawCells = List<List<List<SemanticsNode>>>.generate(
_rows,
(int rowIndex) =>
List<List<SemanticsNode>>.generate(_columns, (int columnIndex) => <SemanticsNode>[]),
);
Rect rectWithOffset(SemanticsNode node) {
final Offset offset =
(node.transform != null ? MatrixUtils.getAsTranslation(node.transform!) : null) ??
Offset.zero;
return node.rect.shift(offset);
}
int findRowIndex(double top) {
for (int i = _rowTops.length - 1; i >= 0; i--) {
if (_rowTops[i] <= top) {
return i;
}
}
return -1;
}
int findColumnIndex(double left) {
if (_columnLefts == null) {
return -1;
}
for (int i = _columnLefts!.length - 1; i >= 0; i--) {
if (_columnLefts!.elementAt(i) <= left) {
return i;
}
}
return -1;
}
void shiftTransform(SemanticsNode node, double dx, double dy) {
final Matrix4? previousTransform = node.transform;
final Offset offset =
(previousTransform != null ? MatrixUtils.getAsTranslation(previousTransform) : null) ??
Offset.zero;
final Matrix4 newTransform = Matrix4.translationValues(offset.dx + dx, offset.dy + dy, 0);
node.transform = newTransform;
}
for (final SemanticsNode child in children) {
if (_idToIndexMap.containsKey(child.id)) {
final _Index index = _idToIndexMap[child.id]!;
final int y = index.y;
final int x = index.x;
if (y < _rows && x < _columns) {
rawCells[y][x].add(child);
}
} else {
final Rect rect = rectWithOffset(child);
final int y = findRowIndex(rect.top);
final int x = findColumnIndex(rect.left);
if (y != -1 && x != -1) {
rawCells[y][x].add(child);
}
}
}
for (int y = 0; y < _rows; y++) {
final Rect rowBox = getRowBox(y);
// Skip row if it's empty
if (rowBox.height == 0) {
continue;
}
final SemanticsNode newRow =
_cachedRows[y] ??
(_cachedRows[y] = SemanticsNode(
showOnScreen: () {
showOnScreen(descendant: this, rect: rowBox);
},
));
// The list of cells of this Row.
final List<SemanticsNode> cells = <SemanticsNode>[];
for (int x = 0; x < columns; x++) {
final List<SemanticsNode> rawChildrens = rawCells[y][x];
if (rawChildrens.isEmpty) {
continue;
}
// If the cell has multiple children or the only child is not a cell or columnHeader,
// create a new semantic node with role cell to wrap it.
// This can happen when the cell has a different semantic role, or the cell doesn't have a semantic
// role because user is not using the `TableCell` widget.
final bool addCellWrapper =
rawChildrens.length > 1 ||
(rawChildrens.single.role != SemanticsRole.cell &&
rawChildrens.single.role != SemanticsRole.columnHeader);
final SemanticsNode cell =
addCellWrapper
? (_cachedCells[_Index(y, x)] ??
(_cachedCells[_Index(y, x)] =
SemanticsNode()..updateWith(
config: SemanticsConfiguration()..role = SemanticsRole.cell,
childrenInInversePaintOrder: rawChildrens,
)))
: rawChildrens.single;
final double cellWidth =
x == _columns - 1
? rowBox.width - _columnLefts!.elementAt(x)
: _columnLefts!.elementAt(x + 1) - _columnLefts!.elementAt(x);
// Skip cell if it's invisible
if (cellWidth <= 0.0) {
continue;
}
// Add wrapper transform
if (addCellWrapper) {
cell
..transform = Matrix4.translationValues(_columnLefts!.elementAt(x), 0, 0)
..rect = Rect.fromLTWH(0, 0, cellWidth, rowBox.height);
}
for (final SemanticsNode child in rawChildrens) {
_idToIndexMap[child.id] = _Index(y, x);
// Shift child transform.
final Rect localRect = rectWithOffset(child);
// The rect should satisfy 0 <= localRect.top < localRect.bottom <= rowBox.height
final double dy = localRect.top >= rowBox.height ? -_rowTops.elementAt(y) : 0.0;
// if addCellWrapper is true, the rect is relative to the cell
// The rect should satisfy 0 <= localRect.left < localRect.right <= cellWidth
// if addCellWrapper is false, the rect is relative to the raw
// The rect should satisfy _columnLefts!.elementAt(x) <= localRect.left < localRect.right <= _columnLefts!.elementAt(x+1)
final double dx =
addCellWrapper
? ((localRect.left >= cellWidth) ? -_columnLefts!.elementAt(x) : 0.0)
: (localRect.right <= _columnLefts!.elementAt(x)
? _columnLefts!.elementAt(x)
: 0.0);
if (dx != 0 || dy != 0) {
shiftTransform(child, dx, dy);
}
}
cell.indexInParent = x;
cells.add(cell);
}
newRow
..updateWith(
config:
SemanticsConfiguration()
..indexInParent = y
..role = SemanticsRole.row,
childrenInInversePaintOrder: cells,
)
..transform = Matrix4.translationValues(rowBox.left, rowBox.top, 0)
..rect = Rect.fromLTWH(0, 0, rowBox.width, rowBox.height);
rows.add(newRow);
}
node.updateWith(config: config, childrenInInversePaintOrder: rows);
}
/// Replaces the children of this table with the given cells.
///
/// The cells are divided into the specified number of columns before
@@ -1375,3 +1557,10 @@ class RenderTable extends RenderBox {
];
}
}
/// Index for a cell.
class _Index {
_Index(this.y, this.x);
int y;
int x;
}

View File

@@ -110,13 +110,13 @@ sealed class _DebugSemanticsRoleChecks {
SemanticsRole.tab => _semanticsTab,
SemanticsRole.tabBar => _semanticsTabBar,
SemanticsRole.tabPanel => _noCheckRequired,
SemanticsRole.table => _noCheckRequired,
SemanticsRole.table => _semanticsTable,
SemanticsRole.cell => _semanticsCell,
SemanticsRole.row => _semanticsRow,
SemanticsRole.columnHeader => _semanticsColumnHeader,
SemanticsRole.radioGroup => _semanticsRadioGroup,
// TODO(chunhtai): add checks when the roles are used in framework.
// https://github.com/flutter/flutter/issues/159741.
SemanticsRole.row => _unimplemented,
SemanticsRole.searchBox => _unimplemented,
SemanticsRole.dragHandle => _unimplemented,
SemanticsRole.spinButton => _unimplemented,
@@ -165,16 +165,42 @@ sealed class _DebugSemanticsRoleChecks {
return error;
}
static FlutterError? _semanticsCell(SemanticsNode node) {
static FlutterError? _semanticsTable(SemanticsNode node) {
FlutterError? error;
node.visitChildren((SemanticsNode child) {
if (child.getSemanticsData().role != SemanticsRole.row) {
error = FlutterError('Children of Table must have the row role');
}
return error == null;
});
return error;
}
static FlutterError? _semanticsRow(SemanticsNode node) {
if (node.parent?.role != SemanticsRole.table) {
return FlutterError('A cell must be a child of a table');
return FlutterError('A row must be a child of a table');
}
FlutterError? error;
node.visitChildren((SemanticsNode child) {
if (child.getSemanticsData().role != SemanticsRole.cell &&
child.getSemanticsData().role != SemanticsRole.columnHeader) {
error = FlutterError('Children of Row must have the cell or columnHeader role');
}
return error == null;
});
return error;
}
static FlutterError? _semanticsCell(SemanticsNode node) {
if (node.parent?.role != SemanticsRole.row && node.parent?.role != SemanticsRole.cell) {
return FlutterError('A cell must be a child of a row or another cell');
}
return null;
}
static FlutterError? _semanticsColumnHeader(SemanticsNode node) {
if (node.parent?.role != SemanticsRole.table) {
return FlutterError('A columnHeader must be a child of a table');
if (node.parent?.role != SemanticsRole.row && node.parent?.role != SemanticsRole.cell) {
return FlutterError('A columnHeader must be a child or another cell');
}
return null;
}

View File

@@ -437,14 +437,30 @@ class _TableElement extends RenderObjectElement {
///
/// To create an empty [TableCell], provide a [SizedBox.shrink]
/// as the [child].
class TableCell extends ParentDataWidget<TableCellParentData> {
class TableCell extends StatelessWidget {
/// Creates a widget that controls how a child of a [Table] is aligned.
TableCell({super.key, this.verticalAlignment, required Widget child})
: super(child: Semantics(role: SemanticsRole.cell, child: child));
const TableCell({super.key, this.verticalAlignment, required this.child});
/// How this cell is aligned vertically.
final TableCellVerticalAlignment? verticalAlignment;
/// The child of this cell.
final Widget child;
@override
Widget build(BuildContext context) {
return _TableCell(
verticalAlignment: verticalAlignment,
child: Semantics(role: SemanticsRole.cell, child: child),
);
}
}
class _TableCell extends ParentDataWidget<TableCellParentData> {
const _TableCell({this.verticalAlignment, required super.child});
final TableCellVerticalAlignment? verticalAlignment;
@override
void applyParentData(RenderObject renderObject) {
final TableCellParentData parentData = renderObject.parentData! as TableCellParentData;

View File

@@ -2248,24 +2248,34 @@ void main() {
role: SemanticsRole.table,
children: <TestSemantics>[
TestSemantics(
label: 'Column 1',
textDirection: TextDirection.ltr,
role: SemanticsRole.columnHeader,
role: SemanticsRole.row,
children: <TestSemantics>[
TestSemantics(
label: 'Column 1',
textDirection: TextDirection.ltr,
role: SemanticsRole.columnHeader,
),
TestSemantics(
label: 'Column 2',
textDirection: TextDirection.ltr,
role: SemanticsRole.columnHeader,
),
],
),
TestSemantics(
label: 'Column 2',
textDirection: TextDirection.ltr,
role: SemanticsRole.columnHeader,
),
TestSemantics(
label: 'Data Cell 1',
textDirection: TextDirection.ltr,
role: SemanticsRole.cell,
),
TestSemantics(
label: 'Data Cell 2',
textDirection: TextDirection.ltr,
role: SemanticsRole.cell,
role: SemanticsRole.row,
children: <TestSemantics>[
TestSemantics(
label: 'Data Cell 1',
textDirection: TextDirection.ltr,
role: SemanticsRole.cell,
),
TestSemantics(
label: 'Data Cell 2',
textDirection: TextDirection.ltr,
role: SemanticsRole.cell,
),
],
),
],
),

View File

@@ -28,6 +28,7 @@ void main() {
'RenderTable#00000 NEEDS-PAINT\n'
' │ parentData: <none>\n'
' │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
' │ semantic boundary\n'
' │ size: Size(800.0, 600.0)\n'
' │ default column width: FlexColumnWidth(1.0)\n'
' │ table size: 0×0\n'
@@ -98,6 +99,7 @@ void main() {
'RenderTable#00000 relayoutBoundary=up1 NEEDS-PAINT NEEDS-COMPOSITING-BITS-UPDATE\n'
' │ parentData: offset=Offset(335.0, 185.0) (can use size)\n'
' │ constraints: BoxConstraints(0.0<=w<=800.0, 0.0<=h<=600.0)\n'
' │ semantic boundary\n'
' │ size: Size(130.0, 230.0)\n'
' │ default column width: IntrinsicColumnWidth(flex: null)\n'
' │ table size: 5×5\n'

View File

@@ -959,11 +959,11 @@ void main() {
MaterialApp(
home: Scaffold(
body: Table(
children: <TableRow>[
children: const <TableRow>[
TableRow(
children: <Widget>[
TableCell(child: const Text('Data Cell 1')),
TableCell(child: const Text('Data Cell 2')),
TableCell(child: Text('Data Cell 1')),
TableCell(child: Text('Data Cell 2')),
],
),
],
@@ -986,14 +986,19 @@ void main() {
role: SemanticsRole.table,
children: <TestSemantics>[
TestSemantics(
label: 'Data Cell 1',
textDirection: TextDirection.ltr,
role: SemanticsRole.cell,
),
TestSemantics(
label: 'Data Cell 2',
textDirection: TextDirection.ltr,
role: SemanticsRole.cell,
role: SemanticsRole.row,
children: <TestSemantics>[
TestSemantics(
label: 'Data Cell 1',
textDirection: TextDirection.ltr,
role: SemanticsRole.cell,
),
TestSemantics(
label: 'Data Cell 2',
textDirection: TextDirection.ltr,
role: SemanticsRole.cell,
),
],
),
],
),