diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index 6bffc72ff49e3..41c5012b5a2be 100644 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -43970,6 +43970,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 @@ -46909,6 +46910,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 diff --git a/lib/ui/fixtures/ui_test.dart b/lib/ui/fixtures/ui_test.dart index c7b0967d6a4dd..cd34d3c889e3d 100644 --- a/lib/ui/fixtures/ui_test.dart +++ b/lib/ui/fixtures/ui_test.dart @@ -238,6 +238,74 @@ 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); + transform[0] = 1; + transform[1] = 0; + transform[2] = 0; + transform[3] = 0; + + transform[4] = 0; + transform[5] = 1; + transform[6] = 0; + transform[7] = 0; + + transform[8] = 0; + transform[9] = 0; + transform[10] = 1; + transform[11] = 0; + + transform[12] = 0; + transform[13] = 0; + transform[14] = 0; + transform[15] = 0; + 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 [], + value: "value", + valueAttributes: const [], + increasedValue: "increasedValue", + increasedValueAttributes: const [], + decreasedValue: "decreasedValue", + decreasedValueAttributes: const [], + hint: "hint", + hintAttributes: const [], + 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); diff --git a/lib/ui/semantics.dart b/lib/ui/semantics.dart index 59052b157b687..a850d87b3e8ca 100644 --- a/lib/ui/semantics.dart +++ b/lib/ui/semantics.dart @@ -309,6 +309,29 @@ 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 @@ -940,6 +963,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`. @@ -1012,6 +1036,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 implem required Int32List additionalActions, int headingLevel = 0, String linkUrl = '', + SemanticsRole role = SemanticsRole.none, }) { assert(_matrix4IsValid(transform)); assert ( @@ -1057,6 +1082,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 implem additionalActions, headingLevel, linkUrl, + role.index, ); } @Native< @@ -1099,7 +1125,8 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 implem Handle, Handle, Int32, - Handle)>(symbol: 'SemanticsUpdateBuilder::updateNode') + Handle, + Int32)>(symbol: 'SemanticsUpdateBuilder::updateNode') external void _updateNode( int id, int flags, @@ -1138,7 +1165,8 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 implem Int32List childrenInHitTestOrder, Int32List additionalActions, int headingLevel, - String linkUrl); + String linkUrl, + int role,); @override void updateCustomAction({required int id, String? label, String? hint, int overrideId = -1}) { diff --git a/lib/ui/semantics/semantics_node.h b/lib/ui/semantics/semantics_node.h index 0f02ff3202f18..810052c29f208 100644 --- a/lib/ui/semantics/semantics_node.h +++ b/lib/ui/semantics/semantics_node.h @@ -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,8 @@ struct SemanticsNode { int32_t headingLevel = 0; std::string linkUrl; + + SemanticsRole role; }; // Contains semantic nodes that need to be updated. diff --git a/lib/ui/semantics/semantics_update_builder.cc b/lib/ui/semantics/semantics_update_builder.cc index b853d3ae5226f..b161398637331 100644 --- a/lib/ui/semantics/semantics_update_builder.cc +++ b/lib/ui/semantics/semantics_update_builder.cc @@ -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 " @@ -123,6 +124,7 @@ void SemanticsUpdateBuilder::updateNode( node.headingLevel = headingLevel; node.linkUrl = std::move(linkUrl); + node.role = (SemanticsRole)role; } void SemanticsUpdateBuilder::updateCustomAction(int id, diff --git a/lib/ui/semantics/semantics_update_builder.h b/lib/ui/semantics/semantics_update_builder.h index e944c251099f9..8d560313107b9 100644 --- a/lib/ui/semantics/semantics_update_builder.h +++ b/lib/ui/semantics/semantics_update_builder.h @@ -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, diff --git a/lib/ui/semantics/semantics_update_builder_unittests.cc b/lib/ui/semantics/semantics_update_builder_unittests.cc index 004d7f8f78296..20390c0038b45 100644 --- a/lib/ui/semantics/semantics_update_builder_unittests.cc +++ b/lib/ui/semantics/semantics_update_builder_unittests.cc @@ -88,5 +88,48 @@ TEST_F(SemanticsUpdateBuilderTest, CanHandleAttributedStrings) { DestroyShell(std::move(shell), task_runners); } +TEST_F(SemanticsUpdateBuilderTest, CanHandleSemanticsRole) { + auto message_latch = std::make_shared(); + + 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(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 = 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 diff --git a/lib/web_ui/lib/semantics.dart b/lib/web_ui/lib/semantics.dart index 786eaacc5454f..2625b1a41a315 100644 --- a/lib/web_ui/lib/semantics.dart +++ b/lib/web_ui/lib/semantics.dart @@ -201,6 +201,14 @@ 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 @@ -294,6 +302,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.'); @@ -334,6 +343,7 @@ class SemanticsUpdateBuilder { platformViewId: platformViewId, headingLevel: headingLevel, linkUrl: linkUrl, + role: role, )); } diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart index d2491003efc86..15e73e114823c 100644 --- a/lib/web_ui/lib/src/engine.dart +++ b/lib/web_ui/lib/src/engine.dart @@ -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'; diff --git a/lib/web_ui/lib/src/engine/semantics.dart b/lib/web_ui/lib/src/engine/semantics.dart index 036faceca171b..a33fd833e5f76 100644 --- a/lib/web_ui/lib/src/engine/semantics.dart +++ b/lib/web_ui/lib/src/engine/semantics.dart @@ -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'; diff --git a/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart index d35c9c1e69f36..affa93a96a291 100644 --- a/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -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'; @@ -236,6 +237,7 @@ class SemanticsNodeUpdate { required this.additionalActions, required this.headingLevel, this.linkUrl, + required this.role, }); /// See [ui.SemanticsUpdateBuilder.updateNode]. @@ -342,6 +344,9 @@ class SemanticsNodeUpdate { /// See [ui.SemanticsUpdateBuilder.updateNode]. final String? linkUrl; + + /// See [ui.SemanticsUpdateBuilder.updateNode]. + final ui.SemanticsRole role; } /// Identifies [SemanticRole] implementations. @@ -403,6 +408,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]. /// @@ -1222,6 +1236,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. /// @@ -1513,6 +1530,8 @@ class SemanticsObject { _markLinkUrlDirty(); } + role = update.role; + // Apply updates to the DOM. _updateRole(); @@ -1718,7 +1737,18 @@ class SemanticsObject { // The most specific role should take precedence. if (isPlatformView) { return SemanticRoleKind.platformView; - } else if (isHeading) { + } + switch (role) { + case ui.SemanticsRole.tab: + return SemanticRoleKind.tab; + case ui.SemanticsRole.tabPanel: + return SemanticRoleKind.tabPanel; + case ui.SemanticsRole.tabBar: + return SemanticRoleKind.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; @@ -1758,6 +1788,9 @@ class SemanticsObject { SemanticRoleKind.link => SemanticLink(this), SemanticRoleKind.heading => SemanticHeading(this), SemanticRoleKind.header => SemanticHeader(this), + SemanticRoleKind.tab => SemanticTab(this), + SemanticRoleKind.tabList => SemanticTabList(this), + SemanticRoleKind.tabPanel => SemanticTabPanel(this), SemanticRoleKind.generic => GenericRole(this), }; } diff --git a/lib/web_ui/lib/src/engine/semantics/tabs.dart b/lib/web_ui/lib/src/engine/semantics/tabs.dart new file mode 100644 index 0000000000000..07419d71f2e94 --- /dev/null +++ b/lib/web_ui/lib/src/engine/semantics/tabs.dart @@ -0,0 +1,73 @@ +// 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( + SemanticRoleKind.tab, + semanticsObject, + preferredLabelRepresentation: LabelRepresentation.ariaLabel, + ); + @override + bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false; + + @override + void update() { + super.update(); + setAriaRole('tab'); + } +} + +/// 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( + SemanticRoleKind.tabPanel, + semanticsObject, + preferredLabelRepresentation: LabelRepresentation.ariaLabel, + ); + @override + bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false; + + @override + void update() { + super.update(); + setAriaRole('tabpanel'); + } +} + +/// 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( + SemanticRoleKind.tabList, + semanticsObject, + preferredLabelRepresentation: LabelRepresentation.ariaLabel, + ); + @override + bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false; + + @override + void update() { + super.update(); + setAriaRole('tablist'); + } +} diff --git a/lib/web_ui/test/engine/semantics/semantics_test.dart b/lib/web_ui/test/engine/semantics/semantics_test.dart index 5975b9f6a2581..c2c7f752eeb77 100644 --- a/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -117,6 +117,10 @@ void runSemanticsTests() { group('link', () { _testLink(); }); + + group('tabs', () { + _testTabs(); + }); } void _testSemanticRole() { @@ -3808,6 +3812,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, SemanticRoleKind.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, SemanticRoleKind.tabPanel); + expect(object.element.getAttribute('role'), 'tabpanel'); + }); + + 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.tabBar, + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + ); + tester.apply(); + return tester.getSemanticsObject(0); + } + + final SemanticsObject object = pumpSemantics(); + expect(object.semanticRole?.kind, SemanticRoleKind.tabList); + expect(object.element.getAttribute('role'), 'tablist'); + }); +} + /// A facade in front of [ui.SemanticsUpdateBuilder.updateNode] that /// supplies default values for semantics attributes. void updateNode( diff --git a/lib/web_ui/test/engine/semantics/semantics_tester.dart b/lib/web_ui/test/engine/semantics/semantics_tester.dart index f003a7b27f151..4d0f3bd8b7a3f 100644 --- a/lib/web_ui/test/engine/semantics/semantics_tester.dart +++ b/lib/web_ui/test/engine/semantics/semantics_tester.dart @@ -115,6 +115,7 @@ class SemanticsTester { List? 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; diff --git a/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm b/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm index 39882196e8e99..bf6c150cc7506 100644 --- a/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm +++ b/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm @@ -801,6 +801,13 @@ - (UIAccessibilityTraits)accessibilityTraits { self.node.HasAction(flutter::SemanticsAction::kDecrease)) { traits |= UIAccessibilityTraitAdjustable; } + switch (self.node.role) { + case flutter::SemanticsRole::kTabBar: + traits |= UIAccessibilityTraitTabBar; + break; + default: + break; + } // This should also capture radio buttons. if (self.node.HasFlag(flutter::SemanticsFlags::kHasToggledState) || self.node.HasFlag(flutter::SemanticsFlags::kHasCheckedState)) {