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/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/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
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/semantics.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/text_field.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/services.dart

View File

@ -238,6 +238,59 @@ void sendSemanticsUpdate() {
_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')
external void _semanticsUpdate(SemanticsUpdate update);

View File

@ -339,6 +339,31 @@ class SemanticsAction {
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.
//
// 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
/// 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:
///
/// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/heading_role
@ -1000,6 +1028,7 @@ abstract class SemanticsUpdateBuilder {
required Int32List additionalActions,
int headingLevel = 0,
String linkUrl = '',
SemanticsRole role = SemanticsRole.none,
});
/// Update the custom semantics action associated with the given `id`.
@ -1075,6 +1104,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
required Int32List additionalActions,
int headingLevel = 0,
String linkUrl = '',
SemanticsRole role = SemanticsRole.none,
}) {
assert(_matrix4IsValid(transform));
assert(
@ -1120,6 +1150,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
additionalActions,
headingLevel,
linkUrl,
role.index,
);
}
@ -1164,6 +1195,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
Handle,
Int32,
Handle,
Int32,
)
>(symbol: 'SemanticsUpdateBuilder::updateNode')
external void _updateNode(
@ -1205,6 +1237,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
Int32List additionalActions,
int headingLevel,
String linkUrl,
int role,
);
@override

View File

@ -57,6 +57,19 @@ const int kHorizontalScrollSemanticsActions =
const int kScrollableSemanticsActions =
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
/// `lib/ui/semantics.dart`.
///\warning This must match the `SemanticsFlags` enum in
@ -148,6 +161,7 @@ struct SemanticsNode {
int32_t headingLevel = 0;
std::string linkUrl;
SemanticsRole role;
};
// Contains semantic nodes that need to be updated.

View File

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

View File

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

View File

@ -88,5 +88,48 @@ TEST_F(SemanticsUpdateBuilderTest, CanHandleAttributedStrings) {
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 flutter

View File

@ -255,6 +255,9 @@ class SemanticsFlag {
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
// updated as well.
// * engine/src/flutter/lib/ui/semantics.dart
@ -341,6 +344,7 @@ class SemanticsUpdateBuilder {
required Int32List additionalActions,
int headingLevel = 0,
String? linkUrl,
SemanticsRole role = SemanticsRole.none,
}) {
if (transform.length != 16) {
throw ArgumentError('transform argument must have 16 entries.');
@ -382,6 +386,7 @@ class SemanticsUpdateBuilder {
platformViewId: platformViewId,
headingLevel: headingLevel,
linkUrl: linkUrl,
role: role,
),
);
}

View File

@ -159,6 +159,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/tabs.dart';
export 'engine/semantics/tappable.dart';
export 'engine/semantics/text_field.dart';
export 'engine/services/buffers.dart';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,6 +32,7 @@ import 'platform_view.dart';
import 'route.dart';
import 'scrollable.dart';
import 'semantics_helper.dart';
import 'tabs.dart';
import 'tappable.dart';
import 'text_field.dart';
@ -235,6 +236,7 @@ class SemanticsNodeUpdate {
required this.additionalActions,
required this.headingLevel,
this.linkUrl,
required this.role,
});
/// See [ui.SemanticsUpdateBuilder.updateNode].
@ -341,13 +343,16 @@ class SemanticsNodeUpdate {
/// See [ui.SemanticsUpdateBuilder.updateNode].
final String? linkUrl;
/// See [ui.SemanticsUpdateBuilder.updateNode].
final ui.SemanticsRole role;
}
/// Identifies [SemanticRole] implementations.
///
/// Each value corresponds to the most specific role a semantics node plays in
/// the semantics tree.
enum SemanticRoleKind {
enum EngineSemanticsRole {
/// Supports incrementing and/or decrementing its value.
incrementable,
@ -402,6 +407,15 @@ enum SemanticRoleKind {
/// Denotes a 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 [SemanticsObject].
///
@ -442,7 +456,7 @@ abstract class SemanticRole {
late final DomElement element;
/// The kind of the role that this .
final SemanticRoleKind kind;
final EngineSemanticsRole kind;
/// The semantics object managed by this role.
final SemanticsObject semanticsObject;
@ -678,7 +692,7 @@ abstract class SemanticRole {
final class GenericRole extends SemanticRole {
GenericRole(SemanticsObject semanticsObject)
: super.withBasics(
SemanticRoleKind.generic,
EngineSemanticsRole.generic,
semanticsObject,
// 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
@ -1232,6 +1246,9 @@ class SemanticsObject {
/// Controls the semantics tree that this node participates in.
final EngineSemanticsOwner owner;
/// The role of this node.
late ui.SemanticsRole role;
/// Bitfield showing which fields have been updated but have not yet been
/// applied to the DOM.
///
@ -1518,6 +1535,8 @@ class SemanticsObject {
_markLinkUrlDirty();
}
role = update.role;
// Apply updates to the DOM.
_updateRole();
@ -1714,51 +1733,66 @@ class SemanticsObject {
/// semantics flags and actions.
SemanticRole? semanticRole;
SemanticRoleKind _getSemanticRoleKind() {
EngineSemanticsRole _getEngineSemanticsRole() {
// The most specific role should take precedence.
if (isPlatformView) {
return SemanticRoleKind.platformView;
} else if (isHeading) {
return EngineSemanticsRole.platformView;
}
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
// `heading` role has precedence over the `header` role.
return SemanticRoleKind.heading;
return EngineSemanticsRole.heading;
} else if (isTextField) {
return SemanticRoleKind.textField;
return EngineSemanticsRole.textField;
} else if (isIncrementable) {
return SemanticRoleKind.incrementable;
return EngineSemanticsRole.incrementable;
} else if (isVisualOnly) {
return SemanticRoleKind.image;
return EngineSemanticsRole.image;
} else if (isCheckable) {
return SemanticRoleKind.checkable;
return EngineSemanticsRole.checkable;
} else if (isButton) {
return SemanticRoleKind.button;
return EngineSemanticsRole.button;
} else if (isScrollContainer) {
return SemanticRoleKind.scrollable;
return EngineSemanticsRole.scrollable;
} else if (scopesRoute) {
return SemanticRoleKind.route;
return EngineSemanticsRole.route;
} else if (isLink) {
return SemanticRoleKind.link;
return EngineSemanticsRole.link;
} else if (isHeader) {
return SemanticRoleKind.header;
return EngineSemanticsRole.header;
} else {
return SemanticRoleKind.generic;
return EngineSemanticsRole.generic;
}
}
SemanticRole _createSemanticRole(SemanticRoleKind role) {
SemanticRole _createSemanticRole(EngineSemanticsRole role) {
return switch (role) {
SemanticRoleKind.textField => SemanticTextField(this),
SemanticRoleKind.scrollable => SemanticScrollable(this),
SemanticRoleKind.incrementable => SemanticIncrementable(this),
SemanticRoleKind.button => SemanticButton(this),
SemanticRoleKind.checkable => SemanticCheckable(this),
SemanticRoleKind.route => SemanticRoute(this),
SemanticRoleKind.image => SemanticImage(this),
SemanticRoleKind.platformView => SemanticPlatformView(this),
SemanticRoleKind.link => SemanticLink(this),
SemanticRoleKind.heading => SemanticHeading(this),
SemanticRoleKind.header => SemanticHeader(this),
SemanticRoleKind.generic => GenericRole(this),
EngineSemanticsRole.textField => SemanticTextField(this),
EngineSemanticsRole.scrollable => SemanticScrollable(this),
EngineSemanticsRole.incrementable => SemanticIncrementable(this),
EngineSemanticsRole.button => SemanticButton(this),
EngineSemanticsRole.checkable => SemanticCheckable(this),
EngineSemanticsRole.route => SemanticRoute(this),
EngineSemanticsRole.image => SemanticImage(this),
EngineSemanticsRole.platformView => SemanticPlatformView(this),
EngineSemanticsRole.link => SemanticLink(this),
EngineSemanticsRole.heading => SemanticHeading(this),
EngineSemanticsRole.header => SemanticHeader(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.
void _updateRole() {
SemanticRole? currentSemanticRole = semanticRole;
final SemanticRoleKind kind = _getSemanticRoleKind();
final EngineSemanticsRole kind = _getEngineSemanticsRole();
final DomElement? previousElement = semanticRole?.element;
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 {
SemanticButton(SemanticsObject semanticsObject)
: super.withBasics(
SemanticRoleKind.button,
EngineSemanticsRole.button,
semanticsObject,
preferredLabelRepresentation: LabelRepresentation.domText,
) {

View File

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

View File

@ -122,6 +122,9 @@ void runSemanticsTests() {
group('link', () {
_testLink();
});
group('tabs', () {
_testTabs();
});
}
void _testSemanticRole() {
@ -198,7 +201,7 @@ void _testRoleLifecycle() {
tester.expectSemantics('<sem role="button"></sem>');
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, LabelAndValue]),
@ -221,7 +224,7 @@ void _testRoleLifecycle() {
tester.expectSemantics('<sem role="button">a label</sem>');
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, LabelAndValue]),
@ -653,7 +656,7 @@ void _testEngineSemanticsOwner() {
);
// 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;
pumpSemantics(label: 'World');
@ -869,7 +872,7 @@ void _testText() {
expectSemanticsTree(owner(), '''<sem><span>plain text</span></sem>''');
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>[
Focusable,
LiveRegion,
@ -896,7 +899,7 @@ void _testText() {
expectSemanticsTree(owner(), '''<sem flt-tappable=""><span>tappable text</span></sem>''');
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>[
Focusable,
LiveRegion,
@ -1686,7 +1689,7 @@ void _testIncrementables() {
</sem>''');
final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.semanticRole?.kind, SemanticRoleKind.incrementable);
expect(node.semanticRole?.kind, EngineSemanticsRole.incrementable);
expect(
reason: 'Incrementables use custom focus management',
node.semanticRole!.debugSemanticBehaviorTypes,
@ -1881,7 +1884,7 @@ void _testTextField() {
// https://github.com/flutter/flutter/issues/147200
expect(inputElement.value, '');
expect(node.semanticRole?.kind, SemanticRoleKind.textField);
expect(node.semanticRole?.kind, EngineSemanticsRole.textField);
expect(
reason: 'Text fields use custom focus management',
node.semanticRole!.debugSemanticBehaviorTypes,
@ -1920,7 +1923,7 @@ void _testCheckables() {
''');
final SemanticsObject node = owner().debugSemanticsTree![0]!;
expect(node.semanticRole?.kind, SemanticRoleKind.checkable);
expect(node.semanticRole?.kind, EngineSemanticsRole.checkable);
expect(
reason: 'Checkables use generic semantic behaviors',
node.semanticRole!.debugSemanticBehaviorTypes,
@ -2294,7 +2297,7 @@ void _testSelectables() {
expectSemanticsTree(owner(), '<sem flt-tappable role="checkbox" aria-checked="true"></sem>');
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.element.getAttribute('aria-selected'), isNull);
@ -2325,7 +2328,7 @@ void _testTappable() {
''');
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(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>
''');
expect(owner().debugSemanticsTree![0]!.semanticRole?.kind, SemanticRoleKind.route);
expect(owner().debugSemanticsTree![0]!.semanticRole?.kind, EngineSemanticsRole.route);
semantics().semanticsEnabled = false;
});
@ -3049,7 +3052,7 @@ void _testRoute() {
<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;
});
@ -3091,8 +3094,8 @@ void _testRoute() {
pumpSemantics(label: 'Route label');
expect(owner().debugSemanticsTree![0]!.semanticRole?.kind, SemanticRoleKind.route);
expect(owner().debugSemanticsTree![2]!.semanticRole?.kind, SemanticRoleKind.generic);
expect(owner().debugSemanticsTree![0]!.semanticRole?.kind, EngineSemanticsRole.route);
expect(owner().debugSemanticsTree![2]!.semanticRole?.kind, EngineSemanticsRole.generic);
expect(
owner().debugSemanticsTree![2]!.semanticRole?.debugSemanticBehaviorTypes,
contains(RouteName),
@ -3116,7 +3119,7 @@ void _testRoute() {
<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)));
semantics().semanticsEnabled = false;
@ -3154,7 +3157,7 @@ void _testRoute() {
</sem>
''');
expect(owner().debugSemanticsTree![0]!.semanticRole?.kind, SemanticRoleKind.generic);
expect(owner().debugSemanticsTree![0]!.semanticRole?.kind, EngineSemanticsRole.generic);
expect(
owner().debugSemanticsTree![2]!.semanticRole?.debugSemanticBehaviorTypes,
contains(RouteName),
@ -3516,7 +3519,7 @@ void _testFocusable() {
final SemanticsObject node = owner().debugSemanticsTree![1]!;
expect(node.isFocusable, isTrue);
expect(node.semanticRole?.kind, SemanticRoleKind.generic);
expect(node.semanticRole?.kind, EngineSemanticsRole.generic);
expect(node.semanticRole?.debugSemanticBehaviorTypes, contains(Focusable));
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
/// supplies default values for semantics attributes.
void updateNode(

View File

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

View File

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

View File

@ -6,7 +6,7 @@
library;
import 'dart:math' as math;
import 'dart:ui' show lerpDouble;
import 'dart:ui' show SemanticsRole, lerpDouble;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show DragStartBehavior;
@ -209,9 +209,12 @@ class Tab extends StatelessWidget implements PreferredSizeWidget {
);
}
return SizedBox(
height: height ?? calculatedHeight,
child: Center(widthFactor: 1.0, child: label),
return Semantics(
role: SemanticsRole.tab,
child: SizedBox(
height: height ?? calculatedHeight,
child: Center(widthFactor: 1.0, child: label),
),
);
}
@ -1865,7 +1868,8 @@ class _TabBarState extends State<TabBar> {
wrappedTabs[index],
Semantics(
selected: index == _currentIndex,
label: localizations.tabLabel(tabIndex: index + 1, tabCount: tabCount),
label:
kIsWeb ? null : localizations.tabLabel(tabIndex: index + 1, tabCount: tabCount),
),
],
),
@ -1876,22 +1880,25 @@ class _TabBarState extends State<TabBar> {
}
}
Widget tabBar = CustomPaint(
painter: _indicatorPainter,
child: _TabStyle(
animation: kAlwaysDismissedAnimation,
isSelected: false,
isPrimary: widget._isPrimary,
labelColor: widget.labelColor,
unselectedLabelColor: widget.unselectedLabelColor,
labelStyle: widget.labelStyle,
unselectedLabelStyle: widget.unselectedLabelStyle,
defaults: _defaults,
child: _TabLabelBar(
onPerformLayout: _saveTabOffsets,
mainAxisSize:
effectiveTabAlignment == TabAlignment.fill ? MainAxisSize.max : MainAxisSize.min,
children: wrappedTabs,
Widget tabBar = Semantics(
role: SemanticsRole.tabBar,
child: CustomPaint(
painter: _indicatorPainter,
child: _TabStyle(
animation: kAlwaysDismissedAnimation,
isSelected: false,
isPrimary: widget._isPrimary,
labelColor: widget.labelColor,
unselectedLabelColor: widget.unselectedLabelColor,
labelStyle: widget.labelStyle,
unselectedLabelStyle: widget.unselectedLabelStyle,
defaults: _defaults,
child: _TabLabelBar(
onPerformLayout: _saveTabOffsets,
mainAxisSize:
effectiveTabAlignment == TabAlignment.fill ? MainAxisSize.max : MainAxisSize.min,
children: wrappedTabs,
),
),
),
);
@ -2131,7 +2138,11 @@ class _TabBarViewState extends State<TabBarView> {
}
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() {

View File

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

View File

@ -16,6 +16,7 @@ import 'dart:ui'
Rect,
SemanticsAction,
SemanticsFlag,
SemanticsRole,
SemanticsUpdate,
SemanticsUpdateBuilder,
StringAttribute,
@ -481,6 +482,7 @@ class SemanticsData with Diagnosticable {
required this.currentValueLength,
required this.headingLevel,
required this.linkUrl,
required this.role,
this.tags,
this.transform,
this.customSemanticsActionIds,
@ -738,6 +740,9 @@ class SemanticsData with Diagnosticable {
/// * [CustomSemanticsAction], for an explanation of custom actions.
final List<int>? customSemanticsActionIds;
/// {@macro flutter.semantics.SemanticsNode.role}
final SemanticsRole role;
/// Whether [flags] contains the given flag.
bool hasFlag(SemanticsFlag flag) => (flags & flag.index) != 0;
@ -826,6 +831,7 @@ class SemanticsData with Diagnosticable {
other.thickness == thickness &&
other.headingLevel == headingLevel &&
other.linkUrl == linkUrl &&
other.role == role &&
_sortedListsEqual(other.customSemanticsActionIds, customSemanticsActionIds);
}
@ -859,6 +865,7 @@ class SemanticsData with Diagnosticable {
headingLevel,
linkUrl,
customSemanticsActionIds == null ? null : Object.hashAll(customSemanticsActionIds!),
role,
),
);
@ -1026,6 +1033,7 @@ class SemanticsProperties extends DiagnosticableTree {
this.onFocus,
this.onDismiss,
this.customSemanticsActions,
this.role,
}) : assert(
label == null || attributedLabel == null,
'Only one of label or attributedLabel should be provided',
@ -1799,6 +1807,19 @@ class SemanticsProperties extends DiagnosticableTree {
/// * [CustomSemanticsAction], for an explanation of custom actions.
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
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
@ -1835,6 +1856,7 @@ class SemanticsProperties extends DiagnosticableTree {
properties.add(AttributedStringProperty('attributedHint', attributedHint, defaultValue: null));
properties.add(StringProperty('tooltip', tooltip, 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<SemanticsHintOverrides>(
@ -2392,6 +2414,7 @@ class SemanticsNode with DiagnosticableTreeMixin {
_mergeAllDescendantsIntoThisNode != config.isMergingSemanticsOfDescendants ||
_areUserActionsBlocked != config.isBlockingUserActions ||
_headingLevel != config._headingLevel ||
_linkUrl != config._linkUrl ||
_linkUrl != config._linkUrl;
}
@ -2708,6 +2731,17 @@ class SemanticsNode with DiagnosticableTreeMixin {
Uri? get linkUrl => _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);
static final SemanticsConfiguration _kEmptyConfig = SemanticsConfiguration();
@ -2773,6 +2807,7 @@ class SemanticsNode with DiagnosticableTreeMixin {
_areUserActionsBlocked = config.isBlockingUserActions;
_headingLevel = config._headingLevel;
_linkUrl = config._linkUrl;
_role = config._role;
_replaceChildren(childrenInInversePaintOrder ?? const <SemanticsNode>[]);
if (mergeAllDescendantsIntoThisNodeValueChanged) {
@ -2821,6 +2856,7 @@ class SemanticsNode with DiagnosticableTreeMixin {
final double elevation = _elevation;
double thickness = _thickness;
Uri? linkUrl = _linkUrl;
SemanticsRole role = _role;
final Set<int> customSemanticsActionIds = <int>{};
for (final CustomSemanticsAction action in _customSemanticsActions.keys) {
customSemanticsActionIds.add(CustomSemanticsAction.getIdentifier(action));
@ -2876,6 +2912,9 @@ class SemanticsNode with DiagnosticableTreeMixin {
if (attributedDecreasedValue.string == '') {
attributedDecreasedValue = node._attributedDecreasedValue;
}
if (role == SemanticsRole.none) {
role = node._role;
}
if (tooltip == '') {
tooltip = node._tooltip;
}
@ -2949,6 +2988,7 @@ class SemanticsNode with DiagnosticableTreeMixin {
customSemanticsActionIds: customSemanticsActionIds.toList()..sort(),
headingLevel: headingLevel,
linkUrl: linkUrl,
role: role,
);
}
@ -3026,6 +3066,7 @@ class SemanticsNode with DiagnosticableTreeMixin {
additionalActions: customSemanticsActionIds ?? _kEmptyCustomSemanticsActionsList,
headingLevel: data.headingLevel,
linkUrl: data.linkUrl?.toString() ?? '',
role: data.role,
);
_dirty = false;
}
@ -3202,6 +3243,9 @@ class SemanticsNode with DiagnosticableTreeMixin {
properties.add(
EnumProperty<TextDirection>('textDirection', _textDirection, defaultValue: null),
);
if (_role != SemanticsRole.none) {
properties.add(EnumProperty<SemanticsRole>('role', _role));
}
properties.add(DiagnosticsProperty<SemanticsSortKey>('sortKey', sortKey, defaultValue: null));
if (_textSelection?.isValid ?? false) {
properties.add(
@ -4560,6 +4604,14 @@ class SemanticsConfiguration {
_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].
///
/// Setting this attribute will override the [attributedLabel].
@ -5313,6 +5365,9 @@ class SemanticsConfiguration {
if (_attributedDecreasedValue.string == '') {
_attributedDecreasedValue = child._attributedDecreasedValue;
}
if (_role == SemanticsRole.none) {
_role = child._role;
}
_attributedHint = _concatAttributedString(
thisAttributedString: _attributedHint,
thisTextDirection: textDirection,
@ -5365,7 +5420,8 @@ class SemanticsConfiguration {
.._customSemanticsActions.addAll(_customSemanticsActions)
..isBlockingUserActions = isBlockingUserActions
.._headingLevel = _headingLevel
.._linkUrl = _linkUrl;
.._linkUrl = _linkUrl
.._role = _role;
}
}

View File

@ -9,7 +9,7 @@
library;
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/foundation.dart';
@ -7288,6 +7288,7 @@ class Semantics extends SingleChildRenderObjectWidget {
VoidCallback? onDidLoseAccessibilityFocus,
VoidCallback? onFocus,
Map<CustomSemanticsAction, VoidCallback>? customSemanticsActions,
ui.SemanticsRole? role,
}) : this.fromProperties(
key: key,
child: child,
@ -7362,6 +7363,7 @@ class Semantics extends SingleChildRenderObjectWidget {
onTapHint != null || onLongPressHint != null
? SemanticsHintOverrides(onTapHint: onTapHint, onLongPressHint: onLongPressHint)
: null,
role: role,
),
);

View File

@ -3897,26 +3897,35 @@ void main() {
children: <TestSemantics>[
TestSemantics(
id: 4,
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
flags: <SemanticsFlag>[
SemanticsFlag.hasSelectedState,
SemanticsFlag.isSelected,
SemanticsFlag.isFocusable,
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],
flags: <SemanticsFlag>[
SemanticsFlag.hasSelectedState,
SemanticsFlag.isSelected,
SemanticsFlag.isFocusable,
],
label: 'TAB #0${kIsWeb ? '' : '\nTab 1 of 2'}',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
role: SemanticsRole.tab,
transform: Matrix4.translationValues(0.0, 276.0, 0.0),
),
TestSemantics(
id: 6,
flags: <SemanticsFlag>[
SemanticsFlag.hasSelectedState,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
label: 'TAB #1${kIsWeb ? '' : '\nTab 2 of 2'}',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
role: SemanticsRole.tab,
transform: Matrix4.translationValues(116.0, 276.0, 0.0),
),
],
label: 'TAB #0\nTab 1 of 2',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
transform: Matrix4.translationValues(0.0, 276.0, 0.0),
),
TestSemantics(
id: 5,
flags: <SemanticsFlag>[
SemanticsFlag.hasSelectedState,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
label: 'TAB #1\nTab 2 of 2',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
transform: Matrix4.translationValues(116.0, 276.0, 0.0),
),
],
),
@ -3953,8 +3962,8 @@ void main() {
),
);
const String tab0title = 'This is a very wide tab #0\nTab 1 of 20';
const String tab10title = 'This is a very wide tab #10\nTab 11 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${kIsWeb ? '' : '\nTab 11 of 20'}';
const List<SemanticsFlag> hiddenFlags = <SemanticsFlag>[
SemanticsFlag.isHidden,
@ -4165,26 +4174,35 @@ void main() {
children: <TestSemantics>[
TestSemantics(
id: 4,
flags: <SemanticsFlag>[
SemanticsFlag.hasSelectedState,
SemanticsFlag.isSelected,
SemanticsFlag.isFocusable,
rect: const Rect.fromLTRB(0.0, 0.0, 232.0, 600.0),
role: SemanticsRole.tabBar,
children: <TestSemantics>[
TestSemantics(
id: 5,
flags: <SemanticsFlag>[
SemanticsFlag.hasSelectedState,
SemanticsFlag.isSelected,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
label: 'Semantics override 0${kIsWeb ? '' : '\nTab 1 of 2'}',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
role: SemanticsRole.tab,
transform: Matrix4.translationValues(0.0, 276.0, 0.0),
),
TestSemantics(
id: 6,
flags: <SemanticsFlag>[
SemanticsFlag.hasSelectedState,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
label: 'Semantics override 1${kIsWeb ? '' : '\nTab 2 of 2'}',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
role: SemanticsRole.tab,
transform: Matrix4.translationValues(116.0, 276.0, 0.0),
),
],
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
label: 'Semantics override 0\nTab 1 of 2',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
transform: Matrix4.translationValues(0.0, 276.0, 0.0),
),
TestSemantics(
id: 5,
flags: <SemanticsFlag>[
SemanticsFlag.hasSelectedState,
SemanticsFlag.isFocusable,
],
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
label: 'Semantics override 1\nTab 2 of 2',
rect: const Rect.fromLTRB(0.0, 0.0, 116.0, kTextTabBarHeight),
transform: Matrix4.translationValues(116.0, 276.0, 0.0),
),
],
),
@ -5982,9 +6000,10 @@ void main() {
label: 'Tab 1 of 2',
id: 1,
rect: TestSemantics.fullScreen,
role: SemanticsRole.tabBar,
children: <TestSemantics>[
TestSemantics(
label: 'TAB1\nTab 1 of 2',
label: 'TAB1${kIsWeb ? '' : '\nTab 1 of 2'}',
flags: <SemanticsFlag>[
SemanticsFlag.isFocusable,
SemanticsFlag.isSelected,
@ -5993,13 +6012,15 @@ void main() {
id: 2,
rect: TestSemantics.fullScreen,
actions: 1 | SemanticsAction.focus.index,
role: SemanticsRole.tab,
),
TestSemantics(
label: 'TAB2\nTab 2 of 2',
label: 'TAB2${kIsWeb ? '' : '\nTab 2 of 2'}',
flags: <SemanticsFlag>[SemanticsFlag.isFocusable, SemanticsFlag.hasSelectedState],
id: 3,
rect: TestSemantics.fullScreen,
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
role: SemanticsRole.tab,
),
TestSemantics(
id: 4,
@ -6010,7 +6031,12 @@ void main() {
rect: TestSemantics.fullScreen,
actions: <SemanticsAction>[SemanticsAction.scrollLeft],
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,
int headingLevel = 0,
String? linkUrl,
ui.SemanticsRole role = ui.SemanticsRole.none,
}) {
// Makes sure we don't send the same id twice.
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
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
@ -53,6 +55,7 @@ class TestSemantics {
this.scrollIndex,
this.scrollChildren,
Iterable<SemanticsTag>? tags,
this.role = SemanticsRole.none,
}) : assert(flags is int || flags is List<SemanticsFlag>),
assert(actions is int || actions is List<SemanticsAction>),
tags = tags?.toSet() ?? <SemanticsTag>{};
@ -76,6 +79,7 @@ class TestSemantics {
this.scrollIndex,
this.scrollChildren,
Iterable<SemanticsTag>? tags,
this.role = SemanticsRole.none,
}) : id = 0,
assert(flags is int || flags is List<SemanticsFlag>),
assert(actions is int || actions is List<SemanticsAction>),
@ -115,6 +119,7 @@ class TestSemantics {
this.scrollIndex,
this.scrollChildren,
Iterable<SemanticsTag>? tags,
this.role = SemanticsRole.none,
}) : assert(flags is int || flags is List<SemanticsFlag>),
assert(actions is int || actions is List<SemanticsAction>),
transform = _applyRootChildScale(transform),
@ -243,6 +248,11 @@ class TestSemantics {
final int? headingLevel;
/// The expected role for the node.
///
/// Defaults to SemanticsRole.none if not set.
final SemanticsRole role;
bool _matches(
SemanticsNode? node,
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) {
return true;
}
@ -781,6 +795,9 @@ class SemanticsTester {
if (node.textDirection != null) {
buf.writeln(' textDirection: ${node.textDirection},');
}
if (node.role != SemanticsRole.none) {
buf.writeln(' role: ${node.role},');
}
if (node.hasChildren) {
buf.writeln(' children: <TestSemantics>[');
for (final SemanticsNode child in node.debugListChildrenInOrder(childOrder)) {

View File

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