Add table related semantics role (#162339)

issue: https://github.com/flutter/flutter/issues/45205

This PR added roles: table,cell,row,columnheader in engine side. 

Also added table, cell , columnheader in framework widgets `Table` and
`DataTable`.

This PR didn't add `row` in framework side because right now TableCell
is ParentDataWidget expecting its parent to be a Table, tableRow is not
a widget or a renderObject, but just some data. If we want to add a role
to `row`, we need to do some refactor to `TableCell`, `TableRow` and
`Table`. If we need to add row, I will do that in another PR.




## 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-02-13 18:01:28 -08:00 committed by GitHub
parent 0fc52bc91f
commit 7cf38fd93c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 437 additions and 21 deletions

View File

@ -42731,6 +42731,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/route.dart + ../../
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/semantics_helper.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/table.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/tabs.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/tappable.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart + ../../../flutter/LICENSE
@ -45705,6 +45706,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/route.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/semantics_helper.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/table.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/tabs.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/tappable.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart

View File

@ -348,14 +348,14 @@ enum SemanticsRole {
/// A tab button.
///
/// see also:
/// See also:
///
/// * [tabBar], which is the role for containers of tab buttons.
tab,
/// Contains tab buttons.
///
/// see also:
/// See also:
///
/// * [tab], which is the role for tab buttons.
tabBar,
@ -368,6 +368,34 @@ enum SemanticsRole {
/// An alert dialog.
alertDialog,
/// A table structure containing data arranged in rows and columns.
///
/// See also:
///
/// * [cell], [row], [columnHeader] for table related roles.
table,
/// A cell in a [table] that does not contain column or row header information.
///
/// See also:
///
/// * [table],[row], [columnHeader] for table related roles.
cell,
/// A row of [cell]s or or [columnHeader]s in a [table].
///
/// See also:
///
/// * [table] ,[cell],[columnHeader] for table related roles.
row,
/// A cell in a [table] contains header information for a column.
///
/// See also:
///
/// * [table] ,[cell], [row] for table related roles.
columnHeader,
}
/// A Boolean value that can be associated with a semantics node.

View File

@ -68,6 +68,10 @@ enum class SemanticsRole : int32_t {
kTab = 1,
kTabBar = 2,
kTabPanel = 3,
kTable = 4,
kCell = 5,
kRow = 6,
kColumnHeader = 7,
};
/// C/C++ representation of `SemanticsFlags` defined in

View File

@ -256,7 +256,19 @@ class SemanticsFlag {
}
// Mirrors engine/src/flutter/lib/ui/semantics.dart
enum SemanticsRole { none, tab, tabBar, tabPanel, dialog, alertDialog }
enum SemanticsRole {
none,
tab,
tabBar,
tabPanel,
dialog,
alertDialog,
table,
cell,
row,
columnHeader,
}
// When adding a new StringAttributeType, the classes in these file must be
// updated as well.

View File

@ -160,6 +160,7 @@ export 'engine/semantics/route.dart';
export 'engine/semantics/scrollable.dart';
export 'engine/semantics/semantics.dart';
export 'engine/semantics/semantics_helper.dart';
export 'engine/semantics/table.dart';
export 'engine/semantics/tabs.dart';
export 'engine/semantics/tappable.dart';
export 'engine/semantics/text_field.dart';

View File

@ -16,6 +16,7 @@ export 'semantics/platform_view.dart';
export 'semantics/scrollable.dart';
export 'semantics/semantics.dart';
export 'semantics/semantics_helper.dart';
export 'semantics/table.dart';
export 'semantics/tabs.dart';
export 'semantics/tappable.dart';
export 'semantics/text_field.dart';

View File

@ -32,6 +32,7 @@ import 'platform_view.dart';
import 'route.dart';
import 'scrollable.dart';
import 'semantics_helper.dart';
import 'table.dart';
import 'tabs.dart';
import 'tappable.dart';
import 'text_field.dart';
@ -422,6 +423,18 @@ enum EngineSemanticsRole {
/// An alert dialog.
alertDialog,
/// A table structure containing data arranged in rows and columns.
table,
/// A cell in a [table] that does not contain column or row header information.
cell,
/// A row of [cell]s or or [columnHeader]s in a [table].
row,
/// A cell in a [table] contains header information for a column.
columnHeader,
/// A role used when a more specific role cannot be assigend to
/// a [SemanticsObject].
///
@ -1755,6 +1768,14 @@ class SemanticsObject {
return EngineSemanticsRole.dialog;
case ui.SemanticsRole.alertDialog:
return EngineSemanticsRole.alertDialog;
case ui.SemanticsRole.table:
return EngineSemanticsRole.table;
case ui.SemanticsRole.cell:
return EngineSemanticsRole.cell;
case ui.SemanticsRole.row:
return EngineSemanticsRole.row;
case ui.SemanticsRole.columnHeader:
return EngineSemanticsRole.columnHeader;
case ui.SemanticsRole.none:
// fallback to checking semantics properties.
}
@ -1806,6 +1827,10 @@ class SemanticsObject {
EngineSemanticsRole.tabPanel => SemanticTabPanel(this),
EngineSemanticsRole.dialog => SemanticDialog(this),
EngineSemanticsRole.alertDialog => SemanticAlertDialog(this),
EngineSemanticsRole.table => SemanticTable(this),
EngineSemanticsRole.cell => SemanticCell(this),
EngineSemanticsRole.row => SemanticRow(this),
EngineSemanticsRole.columnHeader => SemanticColumnHeader(this),
EngineSemanticsRole.generic => GenericRole(this),
};
}

View File

@ -0,0 +1,82 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'label_and_value.dart';
import 'semantics.dart';
/// Indicates a table element.
///
/// Uses aria table role to convey this semantic information to the element.
///
/// Screen-readers takes advantage of "aria-label" to describe the visual.
class SemanticTable extends SemanticRole {
SemanticTable(SemanticsObject semanticsObject)
: super.withBasics(
EngineSemanticsRole.table,
semanticsObject,
preferredLabelRepresentation: LabelRepresentation.ariaLabel,
) {
setAriaRole('table');
}
@override
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
}
/// Indicates a table cell element.
///
/// Uses aria cell role to convey this semantic information to the element.
///
/// Screen-readers takes advantage of "aria-label" to describe the visual.
class SemanticCell extends SemanticRole {
SemanticCell(SemanticsObject semanticsObject)
: super.withBasics(
EngineSemanticsRole.cell,
semanticsObject,
preferredLabelRepresentation: LabelRepresentation.ariaLabel,
) {
setAriaRole('cell');
}
@override
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
}
/// Indicates a table row element.
///
/// Uses aria row role to convey this semantic information to the element.
///
/// Screen-readers takes advantage of "aria-label" to describe the visual.
class SemanticRow extends SemanticRole {
SemanticRow(SemanticsObject semanticsObject)
: super.withBasics(
EngineSemanticsRole.row,
semanticsObject,
preferredLabelRepresentation: LabelRepresentation.ariaLabel,
) {
setAriaRole('row');
}
@override
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
}
/// Indicates a table column header element.
///
/// Uses aria columnheader role to convey this semantic information to the element.
///
/// Screen-readers takes advantage of "aria-label" to describe the visual.
class SemanticColumnHeader extends SemanticRole {
SemanticColumnHeader(SemanticsObject semanticsObject)
: super.withBasics(
EngineSemanticsRole.columnHeader,
semanticsObject,
preferredLabelRepresentation: LabelRepresentation.ariaLabel,
) {
setAriaRole('columnheader');
}
@override
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
}

View File

@ -123,6 +123,9 @@ void runSemanticsTests() {
group('tabs', () {
_testTabs();
});
group('table', () {
_testTables();
});
}
void _testSemanticRole() {
@ -3723,6 +3726,94 @@ void _testTabs() {
});
}
void _testTables() {
test('nodes with table role', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
SemanticsObject pumpSemantics() {
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
role: ui.SemanticsRole.table,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
return tester.getSemanticsObject(0);
}
final SemanticsObject object = pumpSemantics();
expect(object.semanticRole?.kind, EngineSemanticsRole.table);
expect(object.element.getAttribute('role'), 'table');
});
test('nodes with cell role', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
SemanticsObject pumpSemantics() {
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
role: ui.SemanticsRole.cell,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
return tester.getSemanticsObject(0);
}
final SemanticsObject object = pumpSemantics();
expect(object.semanticRole?.kind, EngineSemanticsRole.cell);
expect(object.element.getAttribute('role'), 'cell');
});
test('nodes with row role', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
SemanticsObject pumpSemantics() {
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
role: ui.SemanticsRole.row,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
return tester.getSemanticsObject(0);
}
final SemanticsObject object = pumpSemantics();
expect(object.semanticRole?.kind, EngineSemanticsRole.row);
expect(object.element.getAttribute('role'), 'row');
});
test('nodes with column header role', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
SemanticsObject pumpSemantics() {
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
role: ui.SemanticsRole.columnHeader,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
return tester.getSemanticsObject(0);
}
final SemanticsObject object = pumpSemantics();
expect(object.semanticRole?.kind, EngineSemanticsRole.columnHeader);
expect(object.element.getAttribute('role'), 'columnheader');
});
semantics().semanticsEnabled = false;
}
/// A facade in front of [ui.SemanticsUpdateBuilder.updateNode] that
/// supplies default values for semantics attributes.
void updateNode(

View File

@ -7,6 +7,7 @@
library;
import 'dart:math' as math;
import 'dart:ui' show SemanticsRole;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
@ -879,22 +880,25 @@ class DataTable extends StatelessWidget {
}) {
final ThemeData themeData = Theme.of(context);
final DataTableThemeData dataTableTheme = DataTableTheme.of(context);
label = Row(
textDirection: numeric ? TextDirection.rtl : null,
mainAxisAlignment: headingRowAlignment,
children: <Widget>[
if (headingRowAlignment == MainAxisAlignment.center && onSort != null)
const SizedBox(width: _SortArrowState._arrowIconSize + _sortArrowPadding),
label,
if (onSort != null) ...<Widget>[
_SortArrow(
visible: sorted,
up: sorted ? ascending : null,
duration: _sortArrowAnimationDuration,
),
const SizedBox(width: _sortArrowPadding),
label = Semantics(
role: SemanticsRole.columnHeader,
child: Row(
textDirection: numeric ? TextDirection.rtl : null,
mainAxisAlignment: headingRowAlignment,
children: <Widget>[
if (headingRowAlignment == MainAxisAlignment.center && onSort != null)
const SizedBox(width: _SortArrowState._arrowIconSize + _sortArrowPadding),
label,
if (onSort != null) ...<Widget>[
_SortArrow(
visible: sorted,
up: sorted ? ascending : null,
duration: _sortArrowAnimationDuration,
),
const SizedBox(width: _sortArrowPadding),
],
],
],
),
);
final TextStyle effectiveHeadingTextStyle =
@ -1013,7 +1017,7 @@ class DataTable extends StatelessWidget {
child: label,
);
}
return label;
return TableCell(child: label);
}
@override

View File

@ -4,8 +4,10 @@
import 'dart:collection';
import 'dart:math' as math;
import 'dart:ui' show SemanticsRole;
import 'package:flutter/foundation.dart';
import 'package:flutter/semantics.dart';
import 'box.dart';
import 'object.dart';
@ -601,6 +603,13 @@ class RenderTable extends RenderBox {
}
}
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
config.role = SemanticsRole.table;
config.explicitChildNodes = true;
}
/// Replaces the children of this table with the given cells.
///
/// The cells are divided into the specified number of columns before

View File

@ -110,8 +110,14 @@ sealed class _DebugSemanticsRoleChecks {
SemanticsRole.tab => _semanticsTab,
SemanticsRole.tabBar => _semanticsTabBar,
SemanticsRole.tabPanel => _noCheckRequired,
SemanticsRole.table => _noCheckRequired,
SemanticsRole.cell => _semanticsCell,
SemanticsRole.row => _unimplementedError,
SemanticsRole.columnHeader => _semanticsColumnHeader,
}(node);
static FlutterError? _unimplementedError(SemanticsNode node) =>
FlutterError('This semantics role is not implemented');
static FlutterError? _noCheckRequired(SemanticsNode node) => null;
static FlutterError? _semanticsTab(SemanticsNode node) {
@ -140,6 +146,20 @@ sealed class _DebugSemanticsRoleChecks {
});
return error;
}
static FlutterError? _semanticsCell(SemanticsNode node) {
if (node.parent?.role != SemanticsRole.table) {
return FlutterError('A cell must be a child of a table');
}
return null;
}
static FlutterError? _semanticsColumnHeader(SemanticsNode node) {
if (node.parent?.role != SemanticsRole.table) {
return FlutterError('A columnHeader must be a child of a table');
}
return null;
}
}
/// A tag for a [SemanticsNode].

View File

@ -7,6 +7,7 @@
library;
import 'dart:collection';
import 'dart:ui' show SemanticsRole;
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
@ -438,7 +439,8 @@ class _TableElement extends RenderObjectElement {
/// as the [child].
class TableCell extends ParentDataWidget<TableCellParentData> {
/// Creates a widget that controls how a child of a [Table] is aligned.
const TableCell({super.key, this.verticalAlignment, required super.child});
TableCell({super.key, this.verticalAlignment, required Widget child})
: super(child: Semantics(role: SemanticsRole.cell, child: child));
/// How this cell is aligned vertically.
final TableCellVerticalAlignment? verticalAlignment;

View File

@ -6,6 +6,7 @@
library;
import 'dart:math' as math;
import 'dart:ui' show SemanticsRole;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
@ -13,6 +14,7 @@ import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:vector_math/vector_math_64.dart' show Matrix3;
import '../widgets/semantics_tester.dart';
import 'data_table_test_utils.dart';
void main() {
@ -2211,6 +2213,78 @@ void main() {
expect(table.columnWidths![1], const IntrinsicColumnWidth());
expect(table.columnWidths![2], const IntrinsicColumnWidth(flex: 1));
});
testWidgets('DataTable has correct roles in semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DataTable(
columns: const <DataColumn>[
DataColumn(label: Text('Column 1')),
DataColumn(label: Text('Column 2')),
],
rows: const <DataRow>[
DataRow(
cells: <DataCell>[DataCell(Text('Data Cell 1')), DataCell(Text('Data Cell 2'))],
),
],
),
),
),
);
final TestSemantics expectedSemantics = TestSemantics.root(
children: <TestSemantics>[
TestSemantics(
textDirection: TextDirection.ltr,
children: <TestSemantics>[
TestSemantics(
children: <TestSemantics>[
TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute],
children: <TestSemantics>[
TestSemantics(
role: SemanticsRole.table,
children: <TestSemantics>[
TestSemantics(
label: 'Column 1',
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,
),
],
),
],
),
],
),
],
),
],
);
expect(
semantics,
hasSemantics(expectedSemantics, ignoreTransform: true, ignoreId: true, ignoreRect: true),
);
semantics.dispose();
});
}
RenderParagraph _getTextRenderObject(WidgetTester tester, String text) {

View File

@ -2,9 +2,11 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui' show SemanticsRole;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'semantics_tester.dart';
class TestStatefulWidget extends StatefulWidget {
const TestStatefulWidget({super.key});
@ -951,5 +953,64 @@ void main() {
expect(boxD.size.height, greaterThan(boxA.size.height));
});
// TODO(ianh): Test handling of TableCell object
testWidgets('Table has correct roles in semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Table(
children: <TableRow>[
TableRow(
children: <Widget>[
TableCell(child: const Text('Data Cell 1')),
TableCell(child: const Text('Data Cell 2')),
],
),
],
),
),
),
);
final TestSemantics expectedSemantics = TestSemantics.root(
children: <TestSemantics>[
TestSemantics(
textDirection: TextDirection.ltr,
children: <TestSemantics>[
TestSemantics(
children: <TestSemantics>[
TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute],
children: <TestSemantics>[
TestSemantics(
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,
),
],
),
],
),
],
),
],
),
],
);
expect(
semantics,
hasSemantics(expectedSemantics, ignoreTransform: true, ignoreId: true, ignoreRect: true),
);
semantics.dispose();
});
}