add semantics role and tab (#161260)

fixes https://github.com/flutter/flutter/issues/157134

## 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:
chunhtai 2025-01-10 16:08:55 -08:00 committed by GitHub
parent 50f7120de5
commit 6b8b57913d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 579 additions and 133 deletions

View File

@ -43284,6 +43284,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/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.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/semantics_helper.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/tappable.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/services.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/services.dart + ../../../flutter/LICENSE
@ -46226,6 +46227,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/scrollable.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/semantics.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/semantics_helper.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/tappable.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/services.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/services.dart

View File

@ -238,6 +238,59 @@ void sendSemanticsUpdate() {
_semanticsUpdate(builder.build()); _semanticsUpdate(builder.build());
} }
@pragma('vm:entry-point')
void sendSemanticsUpdateWithRole() {
final SemanticsUpdateBuilder builder = SemanticsUpdateBuilder();
final Float64List transform = Float64List(16);
final Int32List childrenInTraversalOrder = Int32List(0);
final Int32List childrenInHitTestOrder = Int32List(0);
final Int32List additionalActions = Int32List(0);
// Identity matrix 4x4.
transform[0] = 1;
transform[5] = 1;
transform[10] = 1;
builder.updateNode(
id: 0,
flags: 0,
actions: 0,
maxValueLength: 0,
currentValueLength: 0,
textSelectionBase: -1,
textSelectionExtent: -1,
platformViewId: -1,
scrollChildren: 0,
scrollIndex: 0,
scrollPosition: 0,
scrollExtentMax: 0,
scrollExtentMin: 0,
rect: Rect.fromLTRB(0, 0, 10, 10),
elevation: 0,
thickness: 0,
identifier: "identifier",
label: "label",
labelAttributes: const <StringAttribute>[],
value: "value",
valueAttributes: const <StringAttribute>[],
increasedValue: "increasedValue",
increasedValueAttributes: const <StringAttribute>[],
decreasedValue: "decreasedValue",
decreasedValueAttributes: const <StringAttribute>[],
hint: "hint",
hintAttributes: const <StringAttribute>[],
tooltip: "tooltip",
textDirection: TextDirection.ltr,
transform: transform,
childrenInTraversalOrder: childrenInTraversalOrder,
childrenInHitTestOrder: childrenInHitTestOrder,
additionalActions: additionalActions,
headingLevel: 0,
linkUrl: '',
role: SemanticsRole.tab,
);
_semanticsUpdate(builder.build());
}
@pragma('vm:external-name', 'SemanticsUpdate') @pragma('vm:external-name', 'SemanticsUpdate')
external void _semanticsUpdate(SemanticsUpdate update); external void _semanticsUpdate(SemanticsUpdate update);

View File

@ -339,6 +339,31 @@ class SemanticsAction {
String toString() => 'SemanticsAction.$name'; String toString() => 'SemanticsAction.$name';
} }
/// An enum to describe the role for a semantics node.
///
/// The roles are translated into native accessibility roles in each platform.
enum SemanticsRole {
/// Does not represent any role.
none,
/// A tab button.
///
/// see also:
///
/// * [tabBar], which is the role for containers of tab buttons.
tab,
/// Contains tab buttons.
///
/// see also:
///
/// * [tab], which is the role for tab buttons.
tabBar,
/// The main display for a tab.
tabPanel,
}
/// A Boolean value that can be associated with a semantics node. /// A Boolean value that can be associated with a semantics node.
// //
// When changes are made to this class, the equivalent APIs in // When changes are made to this class, the equivalent APIs in
@ -960,6 +985,9 @@ abstract class SemanticsUpdateBuilder {
/// The `linkUrl` describes the URI that this node links to. If the node is /// The `linkUrl` describes the URI that this node links to. If the node is
/// not a link, this should be an empty string. /// not a link, this should be an empty string.
/// ///
/// The `role` describes the role of this node. Defaults to
/// [SemanticsRole.none] if not set.
///
/// See also: /// See also:
/// ///
/// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/heading_role /// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/heading_role
@ -1000,6 +1028,7 @@ abstract class SemanticsUpdateBuilder {
required Int32List additionalActions, required Int32List additionalActions,
int headingLevel = 0, int headingLevel = 0,
String linkUrl = '', String linkUrl = '',
SemanticsRole role = SemanticsRole.none,
}); });
/// Update the custom semantics action associated with the given `id`. /// Update the custom semantics action associated with the given `id`.
@ -1075,6 +1104,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
required Int32List additionalActions, required Int32List additionalActions,
int headingLevel = 0, int headingLevel = 0,
String linkUrl = '', String linkUrl = '',
SemanticsRole role = SemanticsRole.none,
}) { }) {
assert(_matrix4IsValid(transform)); assert(_matrix4IsValid(transform));
assert( assert(
@ -1120,6 +1150,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
additionalActions, additionalActions,
headingLevel, headingLevel,
linkUrl, linkUrl,
role.index,
); );
} }
@ -1164,6 +1195,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
Handle, Handle,
Int32, Int32,
Handle, Handle,
Int32,
) )
>(symbol: 'SemanticsUpdateBuilder::updateNode') >(symbol: 'SemanticsUpdateBuilder::updateNode')
external void _updateNode( external void _updateNode(
@ -1205,6 +1237,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
Int32List additionalActions, Int32List additionalActions,
int headingLevel, int headingLevel,
String linkUrl, String linkUrl,
int role,
); );
@override @override

View File

@ -57,6 +57,19 @@ const int kHorizontalScrollSemanticsActions =
const int kScrollableSemanticsActions = const int kScrollableSemanticsActions =
kVerticalScrollSemanticsActions | kHorizontalScrollSemanticsActions; kVerticalScrollSemanticsActions | kHorizontalScrollSemanticsActions;
/// C/C++ representation of `SemanticsRole` defined in
/// `lib/ui/semantics.dart`.
///\warning This must match the `SemanticsRole` enum in
/// `lib/ui/semantics.dart`.
/// See also:
/// - file://./../../../lib/ui/semantics.dart
enum class SemanticsRole : int32_t {
kNone = 0,
kTab = 1,
kTabBar = 2,
kTabPanel = 3,
};
/// C/C++ representation of `SemanticsFlags` defined in /// C/C++ representation of `SemanticsFlags` defined in
/// `lib/ui/semantics.dart`. /// `lib/ui/semantics.dart`.
///\warning This must match the `SemanticsFlags` enum in ///\warning This must match the `SemanticsFlags` enum in
@ -148,6 +161,7 @@ struct SemanticsNode {
int32_t headingLevel = 0; int32_t headingLevel = 0;
std::string linkUrl; std::string linkUrl;
SemanticsRole role;
}; };
// Contains semantic nodes that need to be updated. // Contains semantic nodes that need to be updated.

View File

@ -68,7 +68,8 @@ void SemanticsUpdateBuilder::updateNode(
const tonic::Int32List& childrenInHitTestOrder, const tonic::Int32List& childrenInHitTestOrder,
const tonic::Int32List& localContextActions, const tonic::Int32List& localContextActions,
int headingLevel, int headingLevel,
std::string linkUrl) { std::string linkUrl,
int role) {
FML_CHECK(scrollChildren == 0 || FML_CHECK(scrollChildren == 0 ||
(scrollChildren > 0 && childrenInHitTestOrder.data())) (scrollChildren > 0 && childrenInHitTestOrder.data()))
<< "Semantics update contained scrollChildren but did not have " << "Semantics update contained scrollChildren but did not have "
@ -119,10 +120,11 @@ void SemanticsUpdateBuilder::updateNode(
node.customAccessibilityActions = std::vector<int32_t>( node.customAccessibilityActions = std::vector<int32_t>(
localContextActions.data(), localContextActions.data(),
localContextActions.data() + localContextActions.num_elements()); localContextActions.data() + localContextActions.num_elements());
nodes_[id] = node;
node.headingLevel = headingLevel; node.headingLevel = headingLevel;
node.linkUrl = std::move(linkUrl); node.linkUrl = std::move(linkUrl);
node.role = static_cast<SemanticsRole>(role);
nodes_[id] = node;
} }
void SemanticsUpdateBuilder::updateCustomAction(int id, void SemanticsUpdateBuilder::updateCustomAction(int id,

View File

@ -67,7 +67,8 @@ class SemanticsUpdateBuilder
const tonic::Int32List& childrenInHitTestOrder, const tonic::Int32List& childrenInHitTestOrder,
const tonic::Int32List& customAccessibilityActions, const tonic::Int32List& customAccessibilityActions,
int headingLevel, int headingLevel,
std::string linkUrl); std::string linkUrl,
int role);
void updateCustomAction(int id, void updateCustomAction(int id,
std::string label, std::string label,

View File

@ -88,5 +88,48 @@ TEST_F(SemanticsUpdateBuilderTest, CanHandleAttributedStrings) {
DestroyShell(std::move(shell), task_runners); DestroyShell(std::move(shell), task_runners);
} }
TEST_F(SemanticsUpdateBuilderTest, CanHandleSemanticsRole) {
auto message_latch = std::make_shared<fml::AutoResetWaitableEvent>();
auto nativeSemanticsUpdate = [message_latch](Dart_NativeArguments args) {
auto handle = Dart_GetNativeArgument(args, 0);
intptr_t peer = 0;
Dart_Handle result = Dart_GetNativeInstanceField(
handle, tonic::DartWrappable::kPeerIndex, &peer);
ASSERT_FALSE(Dart_IsError(result));
SemanticsUpdate* update = reinterpret_cast<SemanticsUpdate*>(peer);
SemanticsNodeUpdates nodes = update->takeNodes();
ASSERT_EQ(nodes.size(), (size_t)1);
auto node = nodes.find(0)->second;
// Should match the updateNode in ui_test.dart.
ASSERT_EQ(node.role, SemanticsRole::kTab);
message_latch->Signal();
};
Settings settings = CreateSettingsForFixture();
TaskRunners task_runners("test", // label
GetCurrentTaskRunner(), // platform
CreateNewThread(), // raster
CreateNewThread(), // ui
CreateNewThread() // io
);
AddNativeCallback("SemanticsUpdate",
CREATE_NATIVE_ENTRY(nativeSemanticsUpdate));
std::unique_ptr<Shell> shell = CreateShell(settings, task_runners);
ASSERT_TRUE(shell->IsSetup());
auto configuration = RunConfiguration::InferFromSettings(settings);
configuration.SetEntrypoint("sendSemanticsUpdateWithRole");
shell->RunEngine(std::move(configuration), [](auto result) {
ASSERT_EQ(result, Engine::RunStatus::Success);
});
message_latch->Wait();
DestroyShell(std::move(shell), task_runners);
}
} // namespace testing } // namespace testing
} // namespace flutter } // namespace flutter

View File

@ -255,6 +255,9 @@ class SemanticsFlag {
String toString() => 'SemanticsFlag.$name'; String toString() => 'SemanticsFlag.$name';
} }
// Mirrors engine/src/flutter/lib/ui/semantics.dart
enum SemanticsRole { none, tab, tabBar, tabPanel }
// When adding a new StringAttributeType, the classes in these file must be // When adding a new StringAttributeType, the classes in these file must be
// updated as well. // updated as well.
// * engine/src/flutter/lib/ui/semantics.dart // * engine/src/flutter/lib/ui/semantics.dart
@ -341,6 +344,7 @@ class SemanticsUpdateBuilder {
required Int32List additionalActions, required Int32List additionalActions,
int headingLevel = 0, int headingLevel = 0,
String? linkUrl, String? linkUrl,
SemanticsRole role = SemanticsRole.none,
}) { }) {
if (transform.length != 16) { if (transform.length != 16) {
throw ArgumentError('transform argument must have 16 entries.'); throw ArgumentError('transform argument must have 16 entries.');
@ -382,6 +386,7 @@ class SemanticsUpdateBuilder {
platformViewId: platformViewId, platformViewId: platformViewId,
headingLevel: headingLevel, headingLevel: headingLevel,
linkUrl: linkUrl, linkUrl: linkUrl,
role: role,
), ),
); );
} }

View File

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

View File

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

View File

@ -55,7 +55,7 @@ class SemanticCheckable extends SemanticRole {
SemanticCheckable(SemanticsObject semanticsObject) SemanticCheckable(SemanticsObject semanticsObject)
: _kind = _checkableKindFromSemanticsFlag(semanticsObject), : _kind = _checkableKindFromSemanticsFlag(semanticsObject),
super.withBasics( super.withBasics(
SemanticRoleKind.checkable, EngineSemanticsRole.checkable,
semanticsObject, semanticsObject,
preferredLabelRepresentation: LabelRepresentation.ariaLabel, preferredLabelRepresentation: LabelRepresentation.ariaLabel,
) { ) {

View File

@ -19,7 +19,7 @@ import 'semantics.dart';
class SemanticHeader extends SemanticRole { class SemanticHeader extends SemanticRole {
SemanticHeader(SemanticsObject semanticsObject) SemanticHeader(SemanticsObject semanticsObject)
: super.withBasics( : super.withBasics(
SemanticRoleKind.header, EngineSemanticsRole.header,
semanticsObject, semanticsObject,
// Why use sizedSpan? // Why use sizedSpan?

View File

@ -10,7 +10,7 @@ import 'semantics.dart';
/// level (h1 ... h6). /// level (h1 ... h6).
class SemanticHeading extends SemanticRole { class SemanticHeading extends SemanticRole {
SemanticHeading(SemanticsObject semanticsObject) SemanticHeading(SemanticsObject semanticsObject)
: super.blank(SemanticRoleKind.heading, semanticsObject) { : super.blank(EngineSemanticsRole.heading, semanticsObject) {
addFocusManagement(); addFocusManagement();
addLiveRegion(); addLiveRegion();
addRouteName(); addRouteName();

View File

@ -12,7 +12,7 @@ import 'semantics.dart';
/// Screen-readers takes advantage of "aria-label" to describe the visual. /// Screen-readers takes advantage of "aria-label" to describe the visual.
class SemanticImage extends SemanticRole { class SemanticImage extends SemanticRole {
SemanticImage(SemanticsObject semanticsObject) SemanticImage(SemanticsObject semanticsObject)
: super.blank(SemanticRoleKind.image, semanticsObject) { : super.blank(EngineSemanticsRole.image, semanticsObject) {
// The following behaviors can coexist with images. `LabelAndValue` is // The following behaviors can coexist with images. `LabelAndValue` is
// not used because this behavior uses special auxiliary elements to // not used because this behavior uses special auxiliary elements to
// supply ARIA labels. // supply ARIA labels.

View File

@ -22,7 +22,7 @@ import 'semantics.dart';
class SemanticIncrementable extends SemanticRole { class SemanticIncrementable extends SemanticRole {
SemanticIncrementable(SemanticsObject semanticsObject) SemanticIncrementable(SemanticsObject semanticsObject)
: _focusManager = AccessibilityFocusManager(semanticsObject.owner), : _focusManager = AccessibilityFocusManager(semanticsObject.owner),
super.blank(SemanticRoleKind.incrementable, semanticsObject) { super.blank(EngineSemanticsRole.incrementable, semanticsObject) {
// The following generic roles can coexist with incrementables. Generic focus // The following generic roles can coexist with incrementables. Generic focus
// management is not used by this role because the root DOM element is not // management is not used by this role because the root DOM element is not
// the one being focused on, but the internal `<input>` element. // the one being focused on, but the internal `<input>` element.

View File

@ -9,7 +9,7 @@ import '../semantics.dart';
class SemanticLink extends SemanticRole { class SemanticLink extends SemanticRole {
SemanticLink(SemanticsObject semanticsObject) SemanticLink(SemanticsObject semanticsObject)
: super.withBasics( : super.withBasics(
SemanticRoleKind.link, EngineSemanticsRole.link,
semanticsObject, semanticsObject,
preferredLabelRepresentation: LabelRepresentation.domText, preferredLabelRepresentation: LabelRepresentation.domText,
) { ) {

View File

@ -23,7 +23,7 @@ import 'semantics.dart';
class SemanticPlatformView extends SemanticRole { class SemanticPlatformView extends SemanticRole {
SemanticPlatformView(SemanticsObject semanticsObject) SemanticPlatformView(SemanticsObject semanticsObject)
: super.withBasics( : super.withBasics(
SemanticRoleKind.platformView, EngineSemanticsRole.platformView,
semanticsObject, semanticsObject,
preferredLabelRepresentation: LabelRepresentation.ariaLabel, preferredLabelRepresentation: LabelRepresentation.ariaLabel,
); );

View File

@ -16,7 +16,7 @@ import '../util.dart';
/// of an explicit route label set on the route itself. /// of an explicit route label set on the route itself.
class SemanticRoute extends SemanticRole { class SemanticRoute extends SemanticRole {
SemanticRoute(SemanticsObject semanticsObject) SemanticRoute(SemanticsObject semanticsObject)
: super.blank(SemanticRoleKind.route, semanticsObject) { : super.blank(EngineSemanticsRole.route, semanticsObject) {
// The following behaviors can coexist with the route. Generic `RouteName` // The following behaviors can coexist with the route. Generic `RouteName`
// and `LabelAndValue` are not used by this role because when the route // and `LabelAndValue` are not used by this role because when the route
// names its own route an `aria-label` is used instead of // names its own route an `aria-label` is used instead of
@ -158,10 +158,10 @@ class RouteName extends SemanticBehavior {
void _lookUpNearestAncestorRoute() { void _lookUpNearestAncestorRoute() {
SemanticsObject? parent = semanticsObject.parent; SemanticsObject? parent = semanticsObject.parent;
while (parent != null && parent.semanticRole?.kind != SemanticRoleKind.route) { while (parent != null && parent.semanticRole?.kind != EngineSemanticsRole.route) {
parent = parent.parent; parent = parent.parent;
} }
if (parent != null && parent.semanticRole?.kind == SemanticRoleKind.route) { if (parent != null && parent.semanticRole?.kind == EngineSemanticsRole.route) {
_route = parent.semanticRole! as SemanticRoute; _route = parent.semanticRole! as SemanticRoute;
} }
} }

View File

@ -25,7 +25,7 @@ import 'package:ui/ui.dart' as ui;
class SemanticScrollable extends SemanticRole { class SemanticScrollable extends SemanticRole {
SemanticScrollable(SemanticsObject semanticsObject) SemanticScrollable(SemanticsObject semanticsObject)
: super.withBasics( : super.withBasics(
SemanticRoleKind.scrollable, EngineSemanticsRole.scrollable,
semanticsObject, semanticsObject,
preferredLabelRepresentation: LabelRepresentation.ariaLabel, preferredLabelRepresentation: LabelRepresentation.ariaLabel,
) { ) {

View File

@ -32,6 +32,7 @@ import 'platform_view.dart';
import 'route.dart'; import 'route.dart';
import 'scrollable.dart'; import 'scrollable.dart';
import 'semantics_helper.dart'; import 'semantics_helper.dart';
import 'tabs.dart';
import 'tappable.dart'; import 'tappable.dart';
import 'text_field.dart'; import 'text_field.dart';
@ -235,6 +236,7 @@ class SemanticsNodeUpdate {
required this.additionalActions, required this.additionalActions,
required this.headingLevel, required this.headingLevel,
this.linkUrl, this.linkUrl,
required this.role,
}); });
/// See [ui.SemanticsUpdateBuilder.updateNode]. /// See [ui.SemanticsUpdateBuilder.updateNode].
@ -341,13 +343,16 @@ class SemanticsNodeUpdate {
/// See [ui.SemanticsUpdateBuilder.updateNode]. /// See [ui.SemanticsUpdateBuilder.updateNode].
final String? linkUrl; final String? linkUrl;
/// See [ui.SemanticsUpdateBuilder.updateNode].
final ui.SemanticsRole role;
} }
/// Identifies [SemanticRole] implementations. /// Identifies [SemanticRole] implementations.
/// ///
/// Each value corresponds to the most specific role a semantics node plays in /// Each value corresponds to the most specific role a semantics node plays in
/// the semantics tree. /// the semantics tree.
enum SemanticRoleKind { enum EngineSemanticsRole {
/// Supports incrementing and/or decrementing its value. /// Supports incrementing and/or decrementing its value.
incrementable, incrementable,
@ -402,6 +407,15 @@ enum SemanticRoleKind {
/// Denotes a header. /// Denotes a header.
header, header,
/// An individual tab button.
tab,
/// Contains tab buttons.
tabList,
/// A main content for a tab.
tabPanel,
/// A role used when a more specific role cannot be assigend to /// A role used when a more specific role cannot be assigend to
/// a [SemanticsObject]. /// a [SemanticsObject].
/// ///
@ -442,7 +456,7 @@ abstract class SemanticRole {
late final DomElement element; late final DomElement element;
/// The kind of the role that this . /// The kind of the role that this .
final SemanticRoleKind kind; final EngineSemanticsRole kind;
/// The semantics object managed by this role. /// The semantics object managed by this role.
final SemanticsObject semanticsObject; final SemanticsObject semanticsObject;
@ -678,7 +692,7 @@ abstract class SemanticRole {
final class GenericRole extends SemanticRole { final class GenericRole extends SemanticRole {
GenericRole(SemanticsObject semanticsObject) GenericRole(SemanticsObject semanticsObject)
: super.withBasics( : super.withBasics(
SemanticRoleKind.generic, EngineSemanticsRole.generic,
semanticsObject, semanticsObject,
// Prefer sized span because if this is a leaf it is frequently a Text widget. // Prefer sized span because if this is a leaf it is frequently a Text widget.
// But if it turns out to be a container, then LabelAndValue will automatically // But if it turns out to be a container, then LabelAndValue will automatically
@ -1232,6 +1246,9 @@ class SemanticsObject {
/// Controls the semantics tree that this node participates in. /// Controls the semantics tree that this node participates in.
final EngineSemanticsOwner owner; final EngineSemanticsOwner owner;
/// The role of this node.
late ui.SemanticsRole role;
/// Bitfield showing which fields have been updated but have not yet been /// Bitfield showing which fields have been updated but have not yet been
/// applied to the DOM. /// applied to the DOM.
/// ///
@ -1518,6 +1535,8 @@ class SemanticsObject {
_markLinkUrlDirty(); _markLinkUrlDirty();
} }
role = update.role;
// Apply updates to the DOM. // Apply updates to the DOM.
_updateRole(); _updateRole();
@ -1714,51 +1733,66 @@ class SemanticsObject {
/// semantics flags and actions. /// semantics flags and actions.
SemanticRole? semanticRole; SemanticRole? semanticRole;
SemanticRoleKind _getSemanticRoleKind() { EngineSemanticsRole _getEngineSemanticsRole() {
// The most specific role should take precedence. // The most specific role should take precedence.
if (isPlatformView) { if (isPlatformView) {
return SemanticRoleKind.platformView; return EngineSemanticsRole.platformView;
} else if (isHeading) { }
switch (role) {
case ui.SemanticsRole.tab:
return EngineSemanticsRole.tab;
case ui.SemanticsRole.tabPanel:
return EngineSemanticsRole.tabPanel;
case ui.SemanticsRole.tabBar:
return EngineSemanticsRole.tabList;
case ui.SemanticsRole.none:
// fallback to checking semantics properties.
}
if (isHeading) {
// IMPORTANT: because headings also cover certain kinds of headers, the // IMPORTANT: because headings also cover certain kinds of headers, the
// `heading` role has precedence over the `header` role. // `heading` role has precedence over the `header` role.
return SemanticRoleKind.heading; return EngineSemanticsRole.heading;
} else if (isTextField) { } else if (isTextField) {
return SemanticRoleKind.textField; return EngineSemanticsRole.textField;
} else if (isIncrementable) { } else if (isIncrementable) {
return SemanticRoleKind.incrementable; return EngineSemanticsRole.incrementable;
} else if (isVisualOnly) { } else if (isVisualOnly) {
return SemanticRoleKind.image; return EngineSemanticsRole.image;
} else if (isCheckable) { } else if (isCheckable) {
return SemanticRoleKind.checkable; return EngineSemanticsRole.checkable;
} else if (isButton) { } else if (isButton) {
return SemanticRoleKind.button; return EngineSemanticsRole.button;
} else if (isScrollContainer) { } else if (isScrollContainer) {
return SemanticRoleKind.scrollable; return EngineSemanticsRole.scrollable;
} else if (scopesRoute) { } else if (scopesRoute) {
return SemanticRoleKind.route; return EngineSemanticsRole.route;
} else if (isLink) { } else if (isLink) {
return SemanticRoleKind.link; return EngineSemanticsRole.link;
} else if (isHeader) { } else if (isHeader) {
return SemanticRoleKind.header; return EngineSemanticsRole.header;
} else { } else {
return SemanticRoleKind.generic; return EngineSemanticsRole.generic;
} }
} }
SemanticRole _createSemanticRole(SemanticRoleKind role) { SemanticRole _createSemanticRole(EngineSemanticsRole role) {
return switch (role) { return switch (role) {
SemanticRoleKind.textField => SemanticTextField(this), EngineSemanticsRole.textField => SemanticTextField(this),
SemanticRoleKind.scrollable => SemanticScrollable(this), EngineSemanticsRole.scrollable => SemanticScrollable(this),
SemanticRoleKind.incrementable => SemanticIncrementable(this), EngineSemanticsRole.incrementable => SemanticIncrementable(this),
SemanticRoleKind.button => SemanticButton(this), EngineSemanticsRole.button => SemanticButton(this),
SemanticRoleKind.checkable => SemanticCheckable(this), EngineSemanticsRole.checkable => SemanticCheckable(this),
SemanticRoleKind.route => SemanticRoute(this), EngineSemanticsRole.route => SemanticRoute(this),
SemanticRoleKind.image => SemanticImage(this), EngineSemanticsRole.image => SemanticImage(this),
SemanticRoleKind.platformView => SemanticPlatformView(this), EngineSemanticsRole.platformView => SemanticPlatformView(this),
SemanticRoleKind.link => SemanticLink(this), EngineSemanticsRole.link => SemanticLink(this),
SemanticRoleKind.heading => SemanticHeading(this), EngineSemanticsRole.heading => SemanticHeading(this),
SemanticRoleKind.header => SemanticHeader(this), EngineSemanticsRole.header => SemanticHeader(this),
SemanticRoleKind.generic => GenericRole(this), EngineSemanticsRole.tab => SemanticTab(this),
EngineSemanticsRole.tabList => SemanticTabList(this),
EngineSemanticsRole.tabPanel => SemanticTabPanel(this),
EngineSemanticsRole.generic => GenericRole(this),
}; };
} }
@ -1766,7 +1800,7 @@ class SemanticsObject {
/// update the DOM. /// update the DOM.
void _updateRole() { void _updateRole() {
SemanticRole? currentSemanticRole = semanticRole; SemanticRole? currentSemanticRole = semanticRole;
final SemanticRoleKind kind = _getSemanticRoleKind(); final EngineSemanticsRole kind = _getEngineSemanticsRole();
final DomElement? previousElement = semanticRole?.element; final DomElement? previousElement = semanticRole?.element;
if (currentSemanticRole != null) { if (currentSemanticRole != null) {

View File

@ -0,0 +1,64 @@
// 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 an interactive element inside a tablist that, when activated,
/// displays its associated tabpanel.
///
/// Uses aria tab role to convey this semantic information to the element.
///
/// Screen-readers takes advantage of "aria-label" to describe the visual.
class SemanticTab extends SemanticRole {
SemanticTab(SemanticsObject semanticsObject)
: super.withBasics(
EngineSemanticsRole.tab,
semanticsObject,
preferredLabelRepresentation: LabelRepresentation.ariaLabel,
) {
setAriaRole('tab');
}
@override
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
}
/// Indicates the main display for a tab when activated.
///
/// Uses aria tabpanel role to convey this semantic information to the element.
///
/// Screen-readers takes advantage of "aria-label" to describe the visual.
class SemanticTabPanel extends SemanticRole {
SemanticTabPanel(SemanticsObject semanticsObject)
: super.withBasics(
EngineSemanticsRole.tabPanel,
semanticsObject,
preferredLabelRepresentation: LabelRepresentation.ariaLabel,
) {
setAriaRole('tabpanel');
}
@override
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
}
/// Indicates a container that contains multiple tabs.
///
/// Uses aria tablist role to convey this semantic information to the element.
///
/// Screen-readers takes advantage of "aria-label" to describe the visual.
class SemanticTabList extends SemanticRole {
SemanticTabList(SemanticsObject semanticsObject)
: super.withBasics(
EngineSemanticsRole.tabList,
semanticsObject,
preferredLabelRepresentation: LabelRepresentation.ariaLabel,
) {
setAriaRole('tablist');
}
@override
bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false;
}

View File

@ -9,7 +9,7 @@ import 'package:ui/ui.dart' as ui;
class SemanticButton extends SemanticRole { class SemanticButton extends SemanticRole {
SemanticButton(SemanticsObject semanticsObject) SemanticButton(SemanticsObject semanticsObject)
: super.withBasics( : super.withBasics(
SemanticRoleKind.button, EngineSemanticsRole.button,
semanticsObject, semanticsObject,
preferredLabelRepresentation: LabelRepresentation.domText, preferredLabelRepresentation: LabelRepresentation.domText,
) { ) {

View File

@ -196,7 +196,7 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy {
/// events even when VoiceOver is enabled. /// events even when VoiceOver is enabled.
class SemanticTextField extends SemanticRole { class SemanticTextField extends SemanticRole {
SemanticTextField(SemanticsObject semanticsObject) SemanticTextField(SemanticsObject semanticsObject)
: super.blank(SemanticRoleKind.textField, semanticsObject) { : super.blank(EngineSemanticsRole.textField, semanticsObject) {
_initializeEditableElement(); _initializeEditableElement();
} }

View File

@ -122,6 +122,9 @@ void runSemanticsTests() {
group('link', () { group('link', () {
_testLink(); _testLink();
}); });
group('tabs', () {
_testTabs();
});
} }
void _testSemanticRole() { void _testSemanticRole() {
@ -198,7 +201,7 @@ void _testRoleLifecycle() {
tester.expectSemantics('<sem role="button"></sem>'); tester.expectSemantics('<sem role="button"></sem>');
final SemanticsObject node = owner().debugSemanticsTree![0]!; final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.semanticRole?.kind, SemanticRoleKind.button); expect(node.semanticRole?.kind, EngineSemanticsRole.button);
expect( expect(
node.semanticRole?.debugSemanticBehaviorTypes, node.semanticRole?.debugSemanticBehaviorTypes,
containsAll(<Type>[Focusable, Tappable, LabelAndValue]), containsAll(<Type>[Focusable, Tappable, LabelAndValue]),
@ -221,7 +224,7 @@ void _testRoleLifecycle() {
tester.expectSemantics('<sem role="button">a label</sem>'); tester.expectSemantics('<sem role="button">a label</sem>');
final SemanticsObject node = owner().debugSemanticsTree![0]!; final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.semanticRole?.kind, SemanticRoleKind.button); expect(node.semanticRole?.kind, EngineSemanticsRole.button);
expect( expect(
node.semanticRole?.debugSemanticBehaviorTypes, node.semanticRole?.debugSemanticBehaviorTypes,
containsAll(<Type>[Focusable, Tappable, LabelAndValue]), containsAll(<Type>[Focusable, Tappable, LabelAndValue]),
@ -653,7 +656,7 @@ void _testEngineSemanticsOwner() {
); );
// Rudely replace the role with a mock, and trigger an update. // Rudely replace the role with a mock, and trigger an update.
final MockRole mockRole = MockRole(SemanticRoleKind.generic, semanticsObject); final MockRole mockRole = MockRole(EngineSemanticsRole.generic, semanticsObject);
semanticsObject.semanticRole = mockRole; semanticsObject.semanticRole = mockRole;
pumpSemantics(label: 'World'); pumpSemantics(label: 'World');
@ -869,7 +872,7 @@ void _testText() {
expectSemanticsTree(owner(), '''<sem><span>plain text</span></sem>'''); expectSemanticsTree(owner(), '''<sem><span>plain text</span></sem>''');
final SemanticsObject node = owner().debugSemanticsTree![0]!; final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.semanticRole?.kind, SemanticRoleKind.generic); expect(node.semanticRole?.kind, EngineSemanticsRole.generic);
expect(node.semanticRole!.behaviors!.map((m) => m.runtimeType).toList(), <Type>[ expect(node.semanticRole!.behaviors!.map((m) => m.runtimeType).toList(), <Type>[
Focusable, Focusable,
LiveRegion, LiveRegion,
@ -896,7 +899,7 @@ void _testText() {
expectSemanticsTree(owner(), '''<sem flt-tappable=""><span>tappable text</span></sem>'''); expectSemanticsTree(owner(), '''<sem flt-tappable=""><span>tappable text</span></sem>''');
final SemanticsObject node = owner().debugSemanticsTree![0]!; final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.semanticRole?.kind, SemanticRoleKind.generic); expect(node.semanticRole?.kind, EngineSemanticsRole.generic);
expect(node.semanticRole!.behaviors!.map((m) => m.runtimeType).toList(), <Type>[ expect(node.semanticRole!.behaviors!.map((m) => m.runtimeType).toList(), <Type>[
Focusable, Focusable,
LiveRegion, LiveRegion,
@ -1686,7 +1689,7 @@ void _testIncrementables() {
</sem>'''); </sem>''');
final SemanticsObject node = owner().debugSemanticsTree![0]!; final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.semanticRole?.kind, SemanticRoleKind.incrementable); expect(node.semanticRole?.kind, EngineSemanticsRole.incrementable);
expect( expect(
reason: 'Incrementables use custom focus management', reason: 'Incrementables use custom focus management',
node.semanticRole!.debugSemanticBehaviorTypes, node.semanticRole!.debugSemanticBehaviorTypes,
@ -1881,7 +1884,7 @@ void _testTextField() {
// https://github.com/flutter/flutter/issues/147200 // https://github.com/flutter/flutter/issues/147200
expect(inputElement.value, ''); expect(inputElement.value, '');
expect(node.semanticRole?.kind, SemanticRoleKind.textField); expect(node.semanticRole?.kind, EngineSemanticsRole.textField);
expect( expect(
reason: 'Text fields use custom focus management', reason: 'Text fields use custom focus management',
node.semanticRole!.debugSemanticBehaviorTypes, node.semanticRole!.debugSemanticBehaviorTypes,
@ -1920,7 +1923,7 @@ void _testCheckables() {
'''); ''');
final SemanticsObject node = owner().debugSemanticsTree![0]!; final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.semanticRole?.kind, SemanticRoleKind.checkable); expect(node.semanticRole?.kind, EngineSemanticsRole.checkable);
expect( expect(
reason: 'Checkables use generic semantic behaviors', reason: 'Checkables use generic semantic behaviors',
node.semanticRole!.debugSemanticBehaviorTypes, node.semanticRole!.debugSemanticBehaviorTypes,
@ -2294,7 +2297,7 @@ void _testSelectables() {
expectSemanticsTree(owner(), '<sem flt-tappable role="checkbox" aria-checked="true"></sem>'); expectSemanticsTree(owner(), '<sem flt-tappable role="checkbox" aria-checked="true"></sem>');
final node = owner().debugSemanticsTree![0]!; final node = owner().debugSemanticsTree![0]!;
expect(node.semanticRole!.kind, SemanticRoleKind.checkable); expect(node.semanticRole!.kind, EngineSemanticsRole.checkable);
expect(node.semanticRole!.debugSemanticBehaviorTypes, isNot(contains(Selectable))); expect(node.semanticRole!.debugSemanticBehaviorTypes, isNot(contains(Selectable)));
expect(node.element.getAttribute('aria-selected'), isNull); expect(node.element.getAttribute('aria-selected'), isNull);
@ -2325,7 +2328,7 @@ void _testTappable() {
'''); ''');
final SemanticsObject node = owner().debugSemanticsTree![0]!; final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.semanticRole?.kind, SemanticRoleKind.button); expect(node.semanticRole?.kind, EngineSemanticsRole.button);
expect(node.semanticRole?.debugSemanticBehaviorTypes, containsAll(<Type>[Focusable, Tappable])); expect(node.semanticRole?.debugSemanticBehaviorTypes, containsAll(<Type>[Focusable, Tappable]));
expect(tester.getSemanticsObject(0).element.tabIndex, 0); expect(tester.getSemanticsObject(0).element.tabIndex, 0);
@ -3010,7 +3013,7 @@ void _testRoute() {
<sem role="dialog" aria-label="this is a route label"><sem-c><sem></sem></sem-c></sem> <sem role="dialog" aria-label="this is a route label"><sem-c><sem></sem></sem-c></sem>
'''); ''');
expect(owner().debugSemanticsTree![0]!.semanticRole?.kind, SemanticRoleKind.route); expect(owner().debugSemanticsTree![0]!.semanticRole?.kind, EngineSemanticsRole.route);
semantics().semanticsEnabled = false; semantics().semanticsEnabled = false;
}); });
@ -3049,7 +3052,7 @@ void _testRoute() {
<sem role="dialog" aria-label=""><sem-c><sem></sem></sem-c></sem> <sem role="dialog" aria-label=""><sem-c><sem></sem></sem-c></sem>
'''); ''');
expect(owner().debugSemanticsTree![0]!.semanticRole?.kind, SemanticRoleKind.route); expect(owner().debugSemanticsTree![0]!.semanticRole?.kind, EngineSemanticsRole.route);
semantics().semanticsEnabled = false; semantics().semanticsEnabled = false;
}); });
@ -3091,8 +3094,8 @@ void _testRoute() {
pumpSemantics(label: 'Route label'); pumpSemantics(label: 'Route label');
expect(owner().debugSemanticsTree![0]!.semanticRole?.kind, SemanticRoleKind.route); expect(owner().debugSemanticsTree![0]!.semanticRole?.kind, EngineSemanticsRole.route);
expect(owner().debugSemanticsTree![2]!.semanticRole?.kind, SemanticRoleKind.generic); expect(owner().debugSemanticsTree![2]!.semanticRole?.kind, EngineSemanticsRole.generic);
expect( expect(
owner().debugSemanticsTree![2]!.semanticRole?.debugSemanticBehaviorTypes, owner().debugSemanticsTree![2]!.semanticRole?.debugSemanticBehaviorTypes,
contains(RouteName), contains(RouteName),
@ -3116,7 +3119,7 @@ void _testRoute() {
<sem role="dialog"></sem> <sem role="dialog"></sem>
'''); ''');
expect(owner().debugSemanticsTree![0]!.semanticRole?.kind, SemanticRoleKind.route); expect(owner().debugSemanticsTree![0]!.semanticRole?.kind, EngineSemanticsRole.route);
expect(owner().debugSemanticsTree![0]!.semanticRole?.behaviors, isNot(contains(RouteName))); expect(owner().debugSemanticsTree![0]!.semanticRole?.behaviors, isNot(contains(RouteName)));
semantics().semanticsEnabled = false; semantics().semanticsEnabled = false;
@ -3154,7 +3157,7 @@ void _testRoute() {
</sem> </sem>
'''); ''');
expect(owner().debugSemanticsTree![0]!.semanticRole?.kind, SemanticRoleKind.generic); expect(owner().debugSemanticsTree![0]!.semanticRole?.kind, EngineSemanticsRole.generic);
expect( expect(
owner().debugSemanticsTree![2]!.semanticRole?.debugSemanticBehaviorTypes, owner().debugSemanticsTree![2]!.semanticRole?.debugSemanticBehaviorTypes,
contains(RouteName), contains(RouteName),
@ -3516,7 +3519,7 @@ void _testFocusable() {
final SemanticsObject node = owner().debugSemanticsTree![1]!; final SemanticsObject node = owner().debugSemanticsTree![1]!;
expect(node.isFocusable, isTrue); expect(node.isFocusable, isTrue);
expect(node.semanticRole?.kind, SemanticRoleKind.generic); expect(node.semanticRole?.kind, EngineSemanticsRole.generic);
expect(node.semanticRole?.debugSemanticBehaviorTypes, contains(Focusable)); expect(node.semanticRole?.debugSemanticBehaviorTypes, contains(Focusable));
final DomElement element = node.element; final DomElement element = node.element;
@ -3581,6 +3584,71 @@ void _testLink() {
}); });
} }
void _testTabs() {
test('nodes with tab role', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
SemanticsObject pumpSemantics() {
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
role: ui.SemanticsRole.tab,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
return tester.getSemanticsObject(0);
}
final SemanticsObject object = pumpSemantics();
expect(object.semanticRole?.kind, EngineSemanticsRole.tab);
expect(object.element.getAttribute('role'), 'tab');
});
test('nodes with tab panel role', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
SemanticsObject pumpSemantics() {
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
role: ui.SemanticsRole.tabPanel,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
return tester.getSemanticsObject(0);
}
final SemanticsObject object = pumpSemantics();
expect(object.semanticRole?.kind, EngineSemanticsRole.tabPanel);
expect(object.element.getAttribute('role'), 'tabpanel');
});
test('nodes with tab bar role', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
SemanticsObject pumpSemantics() {
final SemanticsTester tester = SemanticsTester(owner());
tester.updateNode(
id: 0,
role: ui.SemanticsRole.tabBar,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
return tester.getSemanticsObject(0);
}
final SemanticsObject object = pumpSemantics();
expect(object.semanticRole?.kind, EngineSemanticsRole.tabList);
expect(object.element.getAttribute('role'), 'tablist');
});
}
/// A facade in front of [ui.SemanticsUpdateBuilder.updateNode] that /// A facade in front of [ui.SemanticsUpdateBuilder.updateNode] that
/// supplies default values for semantics attributes. /// supplies default values for semantics attributes.
void updateNode( void updateNode(

View File

@ -115,6 +115,7 @@ class SemanticsTester {
List<SemanticsNodeUpdate>? children, List<SemanticsNodeUpdate>? children,
int? headingLevel, int? headingLevel,
String? linkUrl, String? linkUrl,
ui.SemanticsRole? role,
}) { }) {
// Flags // Flags
if (hasCheckedState ?? false) { if (hasCheckedState ?? false) {
@ -323,6 +324,7 @@ class SemanticsTester {
additionalActions: additionalActions ?? Int32List(0), additionalActions: additionalActions ?? Int32List(0),
headingLevel: headingLevel ?? 0, headingLevel: headingLevel ?? 0,
linkUrl: linkUrl, linkUrl: linkUrl,
role: role ?? ui.SemanticsRole.none,
); );
_nodeUpdates.add(update); _nodeUpdates.add(update);
return update; return update;

View File

@ -46,7 +46,7 @@ Future<void> testMain() async {
<sem><span>Hello</span></sem>'''); <sem><span>Hello</span></sem>''');
final SemanticsObject node = owner().debugSemanticsTree![0]!; final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.semanticRole?.kind, SemanticRoleKind.generic); expect(node.semanticRole?.kind, EngineSemanticsRole.generic);
expect( expect(
reason: 'A node with a label should get a LabelAndValue role', reason: 'A node with a label should get a LabelAndValue role',
node.semanticRole!.debugSemanticBehaviorTypes, node.semanticRole!.debugSemanticBehaviorTypes,

View File

@ -6,7 +6,7 @@
library; library;
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui' show lerpDouble; import 'dart:ui' show SemanticsRole, lerpDouble;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/gestures.dart' show DragStartBehavior;
@ -209,9 +209,12 @@ class Tab extends StatelessWidget implements PreferredSizeWidget {
); );
} }
return SizedBox( return Semantics(
role: SemanticsRole.tab,
child: SizedBox(
height: height ?? calculatedHeight, height: height ?? calculatedHeight,
child: Center(widthFactor: 1.0, child: label), child: Center(widthFactor: 1.0, child: label),
),
); );
} }
@ -1865,7 +1868,8 @@ class _TabBarState extends State<TabBar> {
wrappedTabs[index], wrappedTabs[index],
Semantics( Semantics(
selected: index == _currentIndex, selected: index == _currentIndex,
label: localizations.tabLabel(tabIndex: index + 1, tabCount: tabCount), label:
kIsWeb ? null : localizations.tabLabel(tabIndex: index + 1, tabCount: tabCount),
), ),
], ],
), ),
@ -1876,7 +1880,9 @@ class _TabBarState extends State<TabBar> {
} }
} }
Widget tabBar = CustomPaint( Widget tabBar = Semantics(
role: SemanticsRole.tabBar,
child: CustomPaint(
painter: _indicatorPainter, painter: _indicatorPainter,
child: _TabStyle( child: _TabStyle(
animation: kAlwaysDismissedAnimation, animation: kAlwaysDismissedAnimation,
@ -1894,6 +1900,7 @@ class _TabBarState extends State<TabBar> {
children: wrappedTabs, children: wrappedTabs,
), ),
), ),
),
); );
if (widget.isScrollable) { if (widget.isScrollable) {
@ -2131,7 +2138,11 @@ class _TabBarViewState extends State<TabBarView> {
} }
void _updateChildren() { void _updateChildren() {
_childrenWithKey = KeyedSubtree.ensureUniqueKeysForList(widget.children); _childrenWithKey = KeyedSubtree.ensureUniqueKeysForList(
widget.children.map<Widget>((Widget child) {
return Semantics(role: SemanticsRole.tabPanel, child: child);
}).toList(),
);
} }
void _handleTabControllerAnimationTick() { void _handleTabControllerAnimationTick() {

View File

@ -4430,6 +4430,9 @@ class RenderSemanticsAnnotations extends RenderProxyBox {
if (_properties.tagForChildren != null) { if (_properties.tagForChildren != null) {
config.addTagForChildren(_properties.tagForChildren!); config.addTagForChildren(_properties.tagForChildren!);
} }
if (properties.role != null) {
config.role = _properties.role!;
}
// Registering _perform* as action handlers instead of the user provided // Registering _perform* as action handlers instead of the user provided
// ones to ensure that changing a user provided handler from a non-null to // ones to ensure that changing a user provided handler from a non-null to
// another non-null value doesn't require a semantics update. // another non-null value doesn't require a semantics update.

View File

@ -16,6 +16,7 @@ import 'dart:ui'
Rect, Rect,
SemanticsAction, SemanticsAction,
SemanticsFlag, SemanticsFlag,
SemanticsRole,
SemanticsUpdate, SemanticsUpdate,
SemanticsUpdateBuilder, SemanticsUpdateBuilder,
StringAttribute, StringAttribute,
@ -481,6 +482,7 @@ class SemanticsData with Diagnosticable {
required this.currentValueLength, required this.currentValueLength,
required this.headingLevel, required this.headingLevel,
required this.linkUrl, required this.linkUrl,
required this.role,
this.tags, this.tags,
this.transform, this.transform,
this.customSemanticsActionIds, this.customSemanticsActionIds,
@ -738,6 +740,9 @@ class SemanticsData with Diagnosticable {
/// * [CustomSemanticsAction], for an explanation of custom actions. /// * [CustomSemanticsAction], for an explanation of custom actions.
final List<int>? customSemanticsActionIds; final List<int>? customSemanticsActionIds;
/// {@macro flutter.semantics.SemanticsNode.role}
final SemanticsRole role;
/// Whether [flags] contains the given flag. /// Whether [flags] contains the given flag.
bool hasFlag(SemanticsFlag flag) => (flags & flag.index) != 0; bool hasFlag(SemanticsFlag flag) => (flags & flag.index) != 0;
@ -826,6 +831,7 @@ class SemanticsData with Diagnosticable {
other.thickness == thickness && other.thickness == thickness &&
other.headingLevel == headingLevel && other.headingLevel == headingLevel &&
other.linkUrl == linkUrl && other.linkUrl == linkUrl &&
other.role == role &&
_sortedListsEqual(other.customSemanticsActionIds, customSemanticsActionIds); _sortedListsEqual(other.customSemanticsActionIds, customSemanticsActionIds);
} }
@ -859,6 +865,7 @@ class SemanticsData with Diagnosticable {
headingLevel, headingLevel,
linkUrl, linkUrl,
customSemanticsActionIds == null ? null : Object.hashAll(customSemanticsActionIds!), customSemanticsActionIds == null ? null : Object.hashAll(customSemanticsActionIds!),
role,
), ),
); );
@ -1026,6 +1033,7 @@ class SemanticsProperties extends DiagnosticableTree {
this.onFocus, this.onFocus,
this.onDismiss, this.onDismiss,
this.customSemanticsActions, this.customSemanticsActions,
this.role,
}) : assert( }) : assert(
label == null || attributedLabel == null, label == null || attributedLabel == null,
'Only one of label or attributedLabel should be provided', 'Only one of label or attributedLabel should be provided',
@ -1799,6 +1807,19 @@ class SemanticsProperties extends DiagnosticableTree {
/// * [CustomSemanticsAction], for an explanation of custom actions. /// * [CustomSemanticsAction], for an explanation of custom actions.
final Map<CustomSemanticsAction, VoidCallback>? customSemanticsActions; final Map<CustomSemanticsAction, VoidCallback>? customSemanticsActions;
/// {@template flutter.semantics.SemanticsProperties.role}
/// A enum to describe what role the subtree represents.
///
/// Setting the role for a widget subtree helps assistive technologies, such
/// as screen readers, understand and interact with the UI correctly.
///
/// Defaults to [SemanticsRole.none] if not set, which means the subtree does
/// not represent any complex ui or controls.
///
/// For a list of available roles, see [SemanticsRole].
/// {@endtemplate}
final SemanticsRole? role;
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
@ -1835,6 +1856,7 @@ class SemanticsProperties extends DiagnosticableTree {
properties.add(AttributedStringProperty('attributedHint', attributedHint, defaultValue: null)); properties.add(AttributedStringProperty('attributedHint', attributedHint, defaultValue: null));
properties.add(StringProperty('tooltip', tooltip, defaultValue: null)); properties.add(StringProperty('tooltip', tooltip, defaultValue: null));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null)); properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null));
properties.add(EnumProperty<SemanticsRole>('role', role, defaultValue: null));
properties.add(DiagnosticsProperty<SemanticsSortKey>('sortKey', sortKey, defaultValue: null)); properties.add(DiagnosticsProperty<SemanticsSortKey>('sortKey', sortKey, defaultValue: null));
properties.add( properties.add(
DiagnosticsProperty<SemanticsHintOverrides>( DiagnosticsProperty<SemanticsHintOverrides>(
@ -2392,6 +2414,7 @@ class SemanticsNode with DiagnosticableTreeMixin {
_mergeAllDescendantsIntoThisNode != config.isMergingSemanticsOfDescendants || _mergeAllDescendantsIntoThisNode != config.isMergingSemanticsOfDescendants ||
_areUserActionsBlocked != config.isBlockingUserActions || _areUserActionsBlocked != config.isBlockingUserActions ||
_headingLevel != config._headingLevel || _headingLevel != config._headingLevel ||
_linkUrl != config._linkUrl ||
_linkUrl != config._linkUrl; _linkUrl != config._linkUrl;
} }
@ -2708,6 +2731,17 @@ class SemanticsNode with DiagnosticableTreeMixin {
Uri? get linkUrl => _linkUrl; Uri? get linkUrl => _linkUrl;
Uri? _linkUrl = _kEmptyConfig._linkUrl; Uri? _linkUrl = _kEmptyConfig._linkUrl;
/// {@template flutter.semantics.SemanticsNode.role}
/// The role this node represents
///
/// A semantics node's role helps assistive technologies, such as screen
/// readers, understand and interact with the UI correctly.
///
/// For a list of possible roles, see [SemanticsRole].
/// {@endtemplate}
SemanticsRole get role => _role;
SemanticsRole _role = _kEmptyConfig.role;
bool _canPerformAction(SemanticsAction action) => _actions.containsKey(action); bool _canPerformAction(SemanticsAction action) => _actions.containsKey(action);
static final SemanticsConfiguration _kEmptyConfig = SemanticsConfiguration(); static final SemanticsConfiguration _kEmptyConfig = SemanticsConfiguration();
@ -2773,6 +2807,7 @@ class SemanticsNode with DiagnosticableTreeMixin {
_areUserActionsBlocked = config.isBlockingUserActions; _areUserActionsBlocked = config.isBlockingUserActions;
_headingLevel = config._headingLevel; _headingLevel = config._headingLevel;
_linkUrl = config._linkUrl; _linkUrl = config._linkUrl;
_role = config._role;
_replaceChildren(childrenInInversePaintOrder ?? const <SemanticsNode>[]); _replaceChildren(childrenInInversePaintOrder ?? const <SemanticsNode>[]);
if (mergeAllDescendantsIntoThisNodeValueChanged) { if (mergeAllDescendantsIntoThisNodeValueChanged) {
@ -2821,6 +2856,7 @@ class SemanticsNode with DiagnosticableTreeMixin {
final double elevation = _elevation; final double elevation = _elevation;
double thickness = _thickness; double thickness = _thickness;
Uri? linkUrl = _linkUrl; Uri? linkUrl = _linkUrl;
SemanticsRole role = _role;
final Set<int> customSemanticsActionIds = <int>{}; final Set<int> customSemanticsActionIds = <int>{};
for (final CustomSemanticsAction action in _customSemanticsActions.keys) { for (final CustomSemanticsAction action in _customSemanticsActions.keys) {
customSemanticsActionIds.add(CustomSemanticsAction.getIdentifier(action)); customSemanticsActionIds.add(CustomSemanticsAction.getIdentifier(action));
@ -2876,6 +2912,9 @@ class SemanticsNode with DiagnosticableTreeMixin {
if (attributedDecreasedValue.string == '') { if (attributedDecreasedValue.string == '') {
attributedDecreasedValue = node._attributedDecreasedValue; attributedDecreasedValue = node._attributedDecreasedValue;
} }
if (role == SemanticsRole.none) {
role = node._role;
}
if (tooltip == '') { if (tooltip == '') {
tooltip = node._tooltip; tooltip = node._tooltip;
} }
@ -2949,6 +2988,7 @@ class SemanticsNode with DiagnosticableTreeMixin {
customSemanticsActionIds: customSemanticsActionIds.toList()..sort(), customSemanticsActionIds: customSemanticsActionIds.toList()..sort(),
headingLevel: headingLevel, headingLevel: headingLevel,
linkUrl: linkUrl, linkUrl: linkUrl,
role: role,
); );
} }
@ -3026,6 +3066,7 @@ class SemanticsNode with DiagnosticableTreeMixin {
additionalActions: customSemanticsActionIds ?? _kEmptyCustomSemanticsActionsList, additionalActions: customSemanticsActionIds ?? _kEmptyCustomSemanticsActionsList,
headingLevel: data.headingLevel, headingLevel: data.headingLevel,
linkUrl: data.linkUrl?.toString() ?? '', linkUrl: data.linkUrl?.toString() ?? '',
role: data.role,
); );
_dirty = false; _dirty = false;
} }
@ -3202,6 +3243,9 @@ class SemanticsNode with DiagnosticableTreeMixin {
properties.add( properties.add(
EnumProperty<TextDirection>('textDirection', _textDirection, defaultValue: null), EnumProperty<TextDirection>('textDirection', _textDirection, defaultValue: null),
); );
if (_role != SemanticsRole.none) {
properties.add(EnumProperty<SemanticsRole>('role', _role));
}
properties.add(DiagnosticsProperty<SemanticsSortKey>('sortKey', sortKey, defaultValue: null)); properties.add(DiagnosticsProperty<SemanticsSortKey>('sortKey', sortKey, defaultValue: null));
if (_textSelection?.isValid ?? false) { if (_textSelection?.isValid ?? false) {
properties.add( properties.add(
@ -4560,6 +4604,14 @@ class SemanticsConfiguration {
_hasBeenAnnotated = true; _hasBeenAnnotated = true;
} }
/// {@macro flutter.semantics.SemanticsProperties.role}
SemanticsRole get role => _role;
SemanticsRole _role = SemanticsRole.none;
set role(SemanticsRole value) {
_role = value;
_hasBeenAnnotated = true;
}
/// A textual description of the owning [RenderObject]. /// A textual description of the owning [RenderObject].
/// ///
/// Setting this attribute will override the [attributedLabel]. /// Setting this attribute will override the [attributedLabel].
@ -5313,6 +5365,9 @@ class SemanticsConfiguration {
if (_attributedDecreasedValue.string == '') { if (_attributedDecreasedValue.string == '') {
_attributedDecreasedValue = child._attributedDecreasedValue; _attributedDecreasedValue = child._attributedDecreasedValue;
} }
if (_role == SemanticsRole.none) {
_role = child._role;
}
_attributedHint = _concatAttributedString( _attributedHint = _concatAttributedString(
thisAttributedString: _attributedHint, thisAttributedString: _attributedHint,
thisTextDirection: textDirection, thisTextDirection: textDirection,
@ -5365,7 +5420,8 @@ class SemanticsConfiguration {
.._customSemanticsActions.addAll(_customSemanticsActions) .._customSemanticsActions.addAll(_customSemanticsActions)
..isBlockingUserActions = isBlockingUserActions ..isBlockingUserActions = isBlockingUserActions
.._headingLevel = _headingLevel .._headingLevel = _headingLevel
.._linkUrl = _linkUrl; .._linkUrl = _linkUrl
.._role = _role;
} }
} }

View File

@ -9,7 +9,7 @@
library; library;
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui' as ui show Image, ImageFilter, TextHeightBehavior; import 'dart:ui' as ui show Image, ImageFilter, SemanticsRole, TextHeightBehavior;
import 'package:flutter/animation.dart'; import 'package:flutter/animation.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -7288,6 +7288,7 @@ class Semantics extends SingleChildRenderObjectWidget {
VoidCallback? onDidLoseAccessibilityFocus, VoidCallback? onDidLoseAccessibilityFocus,
VoidCallback? onFocus, VoidCallback? onFocus,
Map<CustomSemanticsAction, VoidCallback>? customSemanticsActions, Map<CustomSemanticsAction, VoidCallback>? customSemanticsActions,
ui.SemanticsRole? role,
}) : this.fromProperties( }) : this.fromProperties(
key: key, key: key,
child: child, child: child,
@ -7362,6 +7363,7 @@ class Semantics extends SingleChildRenderObjectWidget {
onTapHint != null || onLongPressHint != null onTapHint != null || onLongPressHint != null
? SemanticsHintOverrides(onTapHint: onTapHint, onLongPressHint: onLongPressHint) ? SemanticsHintOverrides(onTapHint: onTapHint, onLongPressHint: onLongPressHint)
: null, : null,
role: role,
), ),
); );

View File

@ -3897,25 +3897,32 @@ void main() {
children: <TestSemantics>[ children: <TestSemantics>[
TestSemantics( TestSemantics(
id: 4, id: 4,
rect: const Rect.fromLTRB(0.0, 0.0, 232.0, 600.0),
role: SemanticsRole.tabBar,
children: <TestSemantics>[
TestSemantics(
id: 5,
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
flags: <SemanticsFlag>[ flags: <SemanticsFlag>[
SemanticsFlag.hasSelectedState, SemanticsFlag.hasSelectedState,
SemanticsFlag.isSelected, SemanticsFlag.isSelected,
SemanticsFlag.isFocusable, SemanticsFlag.isFocusable,
], ],
label: 'TAB #0\nTab 1 of 2', label: 'TAB #0${kIsWeb ? '' : '\nTab 1 of 2'}',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight), rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
role: SemanticsRole.tab,
transform: Matrix4.translationValues(0.0, 276.0, 0.0), transform: Matrix4.translationValues(0.0, 276.0, 0.0),
), ),
TestSemantics( TestSemantics(
id: 5, id: 6,
flags: <SemanticsFlag>[ flags: <SemanticsFlag>[
SemanticsFlag.hasSelectedState, SemanticsFlag.hasSelectedState,
SemanticsFlag.isFocusable, SemanticsFlag.isFocusable,
], ],
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
label: 'TAB #1\nTab 2 of 2', label: 'TAB #1${kIsWeb ? '' : '\nTab 2 of 2'}',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight), rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
role: SemanticsRole.tab,
transform: Matrix4.translationValues(116.0, 276.0, 0.0), transform: Matrix4.translationValues(116.0, 276.0, 0.0),
), ),
], ],
@ -3925,6 +3932,8 @@ void main() {
], ],
), ),
], ],
),
],
); );
expect(semantics, hasSemantics(expectedSemantics)); expect(semantics, hasSemantics(expectedSemantics));
@ -3953,8 +3962,8 @@ void main() {
), ),
); );
const String tab0title = 'This is a very wide tab #0\nTab 1 of 20'; const String tab0title = 'This is a very wide tab #0${kIsWeb ? '' : '\nTab 1 of 20'}';
const String tab10title = 'This is a very wide tab #10\nTab 11 of 20'; const String tab10title = 'This is a very wide tab #10${kIsWeb ? '' : '\nTab 11 of 20'}';
const List<SemanticsFlag> hiddenFlags = <SemanticsFlag>[ const List<SemanticsFlag> hiddenFlags = <SemanticsFlag>[
SemanticsFlag.isHidden, SemanticsFlag.isHidden,
@ -4165,25 +4174,32 @@ void main() {
children: <TestSemantics>[ children: <TestSemantics>[
TestSemantics( TestSemantics(
id: 4, id: 4,
rect: const Rect.fromLTRB(0.0, 0.0, 232.0, 600.0),
role: SemanticsRole.tabBar,
children: <TestSemantics>[
TestSemantics(
id: 5,
flags: <SemanticsFlag>[ flags: <SemanticsFlag>[
SemanticsFlag.hasSelectedState, SemanticsFlag.hasSelectedState,
SemanticsFlag.isSelected, SemanticsFlag.isSelected,
SemanticsFlag.isFocusable, SemanticsFlag.isFocusable,
], ],
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
label: 'Semantics override 0\nTab 1 of 2', label: 'Semantics override 0${kIsWeb ? '' : '\nTab 1 of 2'}',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight), rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
role: SemanticsRole.tab,
transform: Matrix4.translationValues(0.0, 276.0, 0.0), transform: Matrix4.translationValues(0.0, 276.0, 0.0),
), ),
TestSemantics( TestSemantics(
id: 5, id: 6,
flags: <SemanticsFlag>[ flags: <SemanticsFlag>[
SemanticsFlag.hasSelectedState, SemanticsFlag.hasSelectedState,
SemanticsFlag.isFocusable, SemanticsFlag.isFocusable,
], ],
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
label: 'Semantics override 1\nTab 2 of 2', label: 'Semantics override 1${kIsWeb ? '' : '\nTab 2 of 2'}',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight), rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
role: SemanticsRole.tab,
transform: Matrix4.translationValues(116.0, 276.0, 0.0), transform: Matrix4.translationValues(116.0, 276.0, 0.0),
), ),
], ],
@ -4193,6 +4209,8 @@ void main() {
], ],
), ),
], ],
),
],
); );
expect(semantics, hasSemantics(expectedSemantics)); expect(semantics, hasSemantics(expectedSemantics));
@ -5982,9 +6000,10 @@ void main() {
label: 'Tab 1 of 2', label: 'Tab 1 of 2',
id: 1, id: 1,
rect: TestSemantics.fullScreen, rect: TestSemantics.fullScreen,
role: SemanticsRole.tabBar,
children: <TestSemantics>[ children: <TestSemantics>[
TestSemantics( TestSemantics(
label: 'TAB1\nTab 1 of 2', label: 'TAB1${kIsWeb ? '' : '\nTab 1 of 2'}',
flags: <SemanticsFlag>[ flags: <SemanticsFlag>[
SemanticsFlag.isFocusable, SemanticsFlag.isFocusable,
SemanticsFlag.isSelected, SemanticsFlag.isSelected,
@ -5993,13 +6012,15 @@ void main() {
id: 2, id: 2,
rect: TestSemantics.fullScreen, rect: TestSemantics.fullScreen,
actions: 1 | SemanticsAction.focus.index, actions: 1 | SemanticsAction.focus.index,
role: SemanticsRole.tab,
), ),
TestSemantics( TestSemantics(
label: 'TAB2\nTab 2 of 2', label: 'TAB2${kIsWeb ? '' : '\nTab 2 of 2'}',
flags: <SemanticsFlag>[SemanticsFlag.isFocusable, SemanticsFlag.hasSelectedState], flags: <SemanticsFlag>[SemanticsFlag.isFocusable, SemanticsFlag.hasSelectedState],
id: 3, id: 3,
rect: TestSemantics.fullScreen, rect: TestSemantics.fullScreen,
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus], actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
role: SemanticsRole.tab,
), ),
TestSemantics( TestSemantics(
id: 4, id: 4,
@ -6010,7 +6031,12 @@ void main() {
rect: TestSemantics.fullScreen, rect: TestSemantics.fullScreen,
actions: <SemanticsAction>[SemanticsAction.scrollLeft], actions: <SemanticsAction>[SemanticsAction.scrollLeft],
children: <TestSemantics>[ children: <TestSemantics>[
TestSemantics(id: 5, rect: TestSemantics.fullScreen, label: 'PAGE1'), TestSemantics(
id: 5,
rect: TestSemantics.fullScreen,
label: 'PAGE1',
role: SemanticsRole.tabPanel,
),
], ],
), ),
], ],

View File

@ -228,6 +228,7 @@ class SemanticsUpdateBuilderSpy extends Fake implements ui.SemanticsUpdateBuilde
required Int32List additionalActions, required Int32List additionalActions,
int headingLevel = 0, int headingLevel = 0,
String? linkUrl, String? linkUrl,
ui.SemanticsRole role = ui.SemanticsRole.none,
}) { }) {
// Makes sure we don't send the same id twice. // Makes sure we don't send the same id twice.
assert(!observations.containsKey(id)); assert(!observations.containsKey(id));

View File

@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/physics.dart'; import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
@ -53,6 +55,7 @@ class TestSemantics {
this.scrollIndex, this.scrollIndex,
this.scrollChildren, this.scrollChildren,
Iterable<SemanticsTag>? tags, Iterable<SemanticsTag>? tags,
this.role = SemanticsRole.none,
}) : assert(flags is int || flags is List<SemanticsFlag>), }) : assert(flags is int || flags is List<SemanticsFlag>),
assert(actions is int || actions is List<SemanticsAction>), assert(actions is int || actions is List<SemanticsAction>),
tags = tags?.toSet() ?? <SemanticsTag>{}; tags = tags?.toSet() ?? <SemanticsTag>{};
@ -76,6 +79,7 @@ class TestSemantics {
this.scrollIndex, this.scrollIndex,
this.scrollChildren, this.scrollChildren,
Iterable<SemanticsTag>? tags, Iterable<SemanticsTag>? tags,
this.role = SemanticsRole.none,
}) : id = 0, }) : id = 0,
assert(flags is int || flags is List<SemanticsFlag>), assert(flags is int || flags is List<SemanticsFlag>),
assert(actions is int || actions is List<SemanticsAction>), assert(actions is int || actions is List<SemanticsAction>),
@ -115,6 +119,7 @@ class TestSemantics {
this.scrollIndex, this.scrollIndex,
this.scrollChildren, this.scrollChildren,
Iterable<SemanticsTag>? tags, Iterable<SemanticsTag>? tags,
this.role = SemanticsRole.none,
}) : assert(flags is int || flags is List<SemanticsFlag>), }) : assert(flags is int || flags is List<SemanticsFlag>),
assert(actions is int || actions is List<SemanticsAction>), assert(actions is int || actions is List<SemanticsAction>),
transform = _applyRootChildScale(transform), transform = _applyRootChildScale(transform),
@ -243,6 +248,11 @@ class TestSemantics {
final int? headingLevel; final int? headingLevel;
/// The expected role for the node.
///
/// Defaults to SemanticsRole.none if not set.
final SemanticsRole role;
bool _matches( bool _matches(
SemanticsNode? node, SemanticsNode? node,
Map<dynamic, dynamic> matchState, { Map<dynamic, dynamic> matchState, {
@ -378,6 +388,10 @@ class TestSemantics {
); );
} }
if (role != node.role) {
return fail('expected node id $id to have role $role but found role ${node.role}');
}
if (children.isEmpty) { if (children.isEmpty) {
return true; return true;
} }
@ -781,6 +795,9 @@ class SemanticsTester {
if (node.textDirection != null) { if (node.textDirection != null) {
buf.writeln(' textDirection: ${node.textDirection},'); buf.writeln(' textDirection: ${node.textDirection},');
} }
if (node.role != SemanticsRole.none) {
buf.writeln(' role: ${node.role},');
}
if (node.hasChildren) { if (node.hasChildren) {
buf.writeln(' children: <TestSemantics>['); buf.writeln(' children: <TestSemantics>[');
for (final SemanticsNode child in node.debugListChildrenInOrder(childOrder)) { for (final SemanticsNode child in node.debugListChildrenInOrder(childOrder)) {

View File

@ -6,6 +6,7 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -717,6 +718,7 @@ void main() {
maxValueLength: 15, maxValueLength: 15,
headingLevel: 0, headingLevel: 0,
linkUrl: Uri(path: 'l'), linkUrl: Uri(path: 'l'),
role: ui.SemanticsRole.none,
); );
final _FakeSemanticsNode node = _FakeSemanticsNode(data); final _FakeSemanticsNode node = _FakeSemanticsNode(data);
@ -1015,6 +1017,7 @@ void main() {
maxValueLength: 15, maxValueLength: 15,
headingLevel: 0, headingLevel: 0,
linkUrl: Uri(path: 'l'), linkUrl: Uri(path: 'l'),
role: ui.SemanticsRole.none,
); );
final _FakeSemanticsNode node = _FakeSemanticsNode(data); final _FakeSemanticsNode node = _FakeSemanticsNode(data);
@ -1110,6 +1113,7 @@ void main() {
maxValueLength: 15, maxValueLength: 15,
headingLevel: 0, headingLevel: 0,
linkUrl: null, linkUrl: null,
role: ui.SemanticsRole.none,
); );
final _FakeSemanticsNode node = _FakeSemanticsNode(data); final _FakeSemanticsNode node = _FakeSemanticsNode(data);
@ -1212,6 +1216,7 @@ void main() {
maxValueLength: 15, maxValueLength: 15,
headingLevel: 0, headingLevel: 0,
linkUrl: null, linkUrl: null,
role: ui.SemanticsRole.none,
); );
final _FakeSemanticsNode emptyNode = _FakeSemanticsNode(emptyData); final _FakeSemanticsNode emptyNode = _FakeSemanticsNode(emptyData);
@ -1242,6 +1247,7 @@ void main() {
customSemanticsActionIds: <int>[CustomSemanticsAction.getIdentifier(action)], customSemanticsActionIds: <int>[CustomSemanticsAction.getIdentifier(action)],
headingLevel: 0, headingLevel: 0,
linkUrl: Uri(path: 'l'), linkUrl: Uri(path: 'l'),
role: ui.SemanticsRole.none,
); );
final _FakeSemanticsNode fullNode = _FakeSemanticsNode(fullData); final _FakeSemanticsNode fullNode = _FakeSemanticsNode(fullData);
@ -1328,6 +1334,7 @@ void main() {
customSemanticsActionIds: <int>[CustomSemanticsAction.getIdentifier(action)], customSemanticsActionIds: <int>[CustomSemanticsAction.getIdentifier(action)],
headingLevel: 0, headingLevel: 0,
linkUrl: null, linkUrl: null,
role: ui.SemanticsRole.none,
); );
final _FakeSemanticsNode node = _FakeSemanticsNode(data); final _FakeSemanticsNode node = _FakeSemanticsNode(data);