diff --git a/engine/src/flutter/ci/licenses_golden/licenses_flutter b/engine/src/flutter/ci/licenses_golden/licenses_flutter index 35f1912aff..22db36f9f4 100644 --- a/engine/src/flutter/ci/licenses_golden/licenses_flutter +++ b/engine/src/flutter/ci/licenses_golden/licenses_flutter @@ -42615,6 +42615,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/scene_painting.dart + ../../. ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/scene_view.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/accessibility.dart + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/alert.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/expandable.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/focusable.dart + ../../../flutter/LICENSE @@ -45583,6 +45584,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/scene_painting.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/scene_view.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/accessibility.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/alert.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/checkable.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/expandable.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/focusable.dart diff --git a/engine/src/flutter/lib/ui/semantics.dart b/engine/src/flutter/lib/ui/semantics.dart index 240c8a9fb1..8d56db159d 100644 --- a/engine/src/flutter/lib/ui/semantics.dart +++ b/engine/src/flutter/lib/ui/semantics.dart @@ -462,6 +462,23 @@ enum SemanticsRole { /// A group of radio buttons. radioGroup, + + /// A component to provide advisory information that is not important to + /// justify an [alert]. + /// + /// For example, a loading message for a web page. + status, + + /// A component to provide important and usually time-sensitive information. + /// + /// The alert role should only be used for information that requires the + /// user's immediate attention, for example: + /// + /// * An invalid value was entered into a form field. + /// * The user's login session is about to expire. + /// * The connection to the server was lost so local changes will not be + /// saved. + alert, } /// A Boolean value that can be associated with a semantics node. diff --git a/engine/src/flutter/lib/ui/semantics/semantics_node.h b/engine/src/flutter/lib/ui/semantics/semantics_node.h index 55900ced43..bc173f4736 100644 --- a/engine/src/flutter/lib/ui/semantics/semantics_node.h +++ b/engine/src/flutter/lib/ui/semantics/semantics_node.h @@ -94,6 +94,8 @@ enum class SemanticsRole : int32_t { kProgressBar = 22, kHotKey = 23, kRadioGroup = 24, + kStatus = 25, + kAlert = 26, }; /// C/C++ representation of `SemanticsFlags` defined in diff --git a/engine/src/flutter/lib/web_ui/lib/semantics.dart b/engine/src/flutter/lib/web_ui/lib/semantics.dart index 7643c8e366..f34ca6934f 100644 --- a/engine/src/flutter/lib/web_ui/lib/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/semantics.dart @@ -282,6 +282,8 @@ enum SemanticsRole { progressBar, hotKey, radioGroup, + status, + alert, } // When adding a new StringAttributeType, the classes in these file must be diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine.dart b/engine/src/flutter/lib/web_ui/lib/src/engine.dart index 23794f5236..3fa9ce9b6c 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine.dart @@ -104,6 +104,7 @@ export 'engine/scene_builder.dart'; export 'engine/scene_painting.dart'; export 'engine/scene_view.dart'; export 'engine/semantics/accessibility.dart'; +export 'engine/semantics/alert.dart'; export 'engine/semantics/checkable.dart'; export 'engine/semantics/expandable.dart'; export 'engine/semantics/focusable.dart'; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart index f6b9cff1ed..b25d6e9eab 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. export 'semantics/accessibility.dart'; +export 'semantics/alert.dart'; export 'semantics/checkable.dart'; export 'semantics/expandable.dart'; export 'semantics/focusable.dart'; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/alert.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/alert.dart new file mode 100644 index 0000000000..b6d2b80925 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/alert.dart @@ -0,0 +1,46 @@ +// 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'; + +/// Renders a piece of alert. +/// +/// Uses the ARIA role "alert". +/// +/// An alert is similar to [SemanticStatus], but with a higher importantness. +/// For example, a form validation error text. +class SemanticAlert extends SemanticRole { + SemanticAlert(SemanticsObject semanticsObject) + : super.withBasics( + EngineSemanticsRole.alert, + semanticsObject, + preferredLabelRepresentation: LabelRepresentation.ariaLabel, + ) { + setAriaRole('alert'); + } + + @override + bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false; +} + +/// Renders a piece of status. +/// +/// Uses the ARIA role "status". +/// +/// A status is typically used for status updates, such as loading messages, +/// which do not justify to be [SemanticAlert]s. +class SemanticStatus extends SemanticRole { + SemanticStatus(SemanticsObject semanticsObject) + : super.withBasics( + EngineSemanticsRole.status, + semanticsObject, + preferredLabelRepresentation: LabelRepresentation.ariaLabel, + ) { + setAriaRole('status'); + } + + @override + bool focusAsRouteDefault() => focusable?.focusAsRouteDefault() ?? false; +} diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart index 385735ff90..375c3ba0a2 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -19,6 +19,7 @@ import '../util.dart'; import '../vector_math.dart'; import '../window.dart'; import 'accessibility.dart'; +import 'alert.dart'; import 'checkable.dart'; import 'expandable.dart'; import 'focusable.dart'; @@ -443,6 +444,13 @@ enum EngineSemanticsRole { /// A cell in a [table] contains header information for a column. columnHeader, + /// A component provide advisory information that is not import to justify + /// an [alert]. + status, + + /// A component provide important and usually time-sensitive information. + alert, + /// A role used when a more specific role cannot be assigend to /// a [SemanticsObject]. /// @@ -1847,6 +1855,10 @@ class SemanticsObject { return EngineSemanticsRole.columnHeader; case ui.SemanticsRole.radioGroup: return EngineSemanticsRole.radioGroup; + case ui.SemanticsRole.alert: + return EngineSemanticsRole.alert; + case ui.SemanticsRole.status: + return EngineSemanticsRole.status; // TODO(chunhtai): implement these roles. // https://github.com/flutter/flutter/issues/159741. case ui.SemanticsRole.searchBox: @@ -1919,6 +1931,8 @@ class SemanticsObject { EngineSemanticsRole.cell => SemanticCell(this), EngineSemanticsRole.row => SemanticRow(this), EngineSemanticsRole.columnHeader => SemanticColumnHeader(this), + EngineSemanticsRole.alert => SemanticAlert(this), + EngineSemanticsRole.status => SemanticStatus(this), EngineSemanticsRole.generic => GenericRole(this), }; } diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart index 2b90c94dc6..1e3d786c40 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -110,6 +110,9 @@ void runSemanticsTests() { group('accessibility builder', () { _testEngineAccessibilityBuilder(); }); + group('alert', () { + _testAlerts(); + }); group('group', () { _testGroup(); }); @@ -297,6 +300,50 @@ void _testEngineAccessibilityBuilder() { }); } +void _testAlerts() { + test('nodes with alert role', () { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + SemanticsObject pumpSemantics() { + final SemanticsTester tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + role: ui.SemanticsRole.alert, + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + ); + tester.apply(); + return tester.getSemanticsObject(0); + } + + final SemanticsObject object = pumpSemantics(); + expect(object.semanticRole?.kind, EngineSemanticsRole.alert); + expect(object.element.getAttribute('role'), 'alert'); + }); + + test('nodes with status role', () { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + SemanticsObject pumpSemantics() { + final SemanticsTester tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + role: ui.SemanticsRole.status, + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + ); + tester.apply(); + return tester.getSemanticsObject(0); + } + + final SemanticsObject object = pumpSemantics(); + expect(object.semanticRole?.kind, EngineSemanticsRole.status); + expect(object.element.getAttribute('role'), 'status'); + }); +} + void _testEngineSemanticsOwner() { test('instantiates a singleton', () { expect(semantics(), same(semantics())); diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index bbb60cbf6f..ca60d96dba 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -115,6 +115,8 @@ sealed class _DebugSemanticsRoleChecks { SemanticsRole.row => _semanticsRow, SemanticsRole.columnHeader => _semanticsColumnHeader, SemanticsRole.radioGroup => _semanticsRadioGroup, + SemanticsRole.alert => _noLiveRegion, + SemanticsRole.status => _noLiveRegion, // TODO(chunhtai): add checks when the roles are used in framework. // https://github.com/flutter/flutter/issues/159741. SemanticsRole.searchBox => _unimplemented, @@ -237,6 +239,18 @@ sealed class _DebugSemanticsRoleChecks { node.visitChildren(validateRadioGroupChildren); return error; } + + static FlutterError? _noLiveRegion(SemanticsNode node) { + final SemanticsData data = node.getSemanticsData(); + if (data.hasFlag(SemanticsFlag.isLiveRegion)) { + return FlutterError( + 'Node ${node.id} has role ${data.role} but is also a live region. ' + 'A node can not have ${data.role} and be live region at the same time. ' + 'Either remove the role or the live region', + ); + } + return null; + } } /// A tag for a [SemanticsNode]. diff --git a/packages/flutter/test/widgets/basic_test.dart b/packages/flutter/test/widgets/basic_test.dart index 8e3685a301..5c1a4ecb8a 100644 --- a/packages/flutter/test/widgets/basic_test.dart +++ b/packages/flutter/test/widgets/basic_test.dart @@ -421,6 +421,34 @@ void main() { expect(data.controlsNodes, {'abc', 'ghi', 'def'}); }); + testWidgets('Semantics can set alert rule', (WidgetTester tester) async { + final UniqueKey key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Semantics(key: key, role: SemanticsRole.alert, child: const Placeholder()), + ), + ), + ); + final SemanticsNode node = tester.getSemantics(find.byKey(key)); + final SemanticsData data = node.getSemanticsData(); + expect(data.role, SemanticsRole.alert); + }); + + testWidgets('Semantics can set status rule', (WidgetTester tester) async { + final UniqueKey key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Semantics(key: key, role: SemanticsRole.status, child: const Placeholder()), + ), + ), + ); + final SemanticsNode node = tester.getSemantics(find.byKey(key)); + final SemanticsData data = node.getSemanticsData(); + expect(data.role, SemanticsRole.status); + }); + testWidgets('Semantics can merge attributed strings', (WidgetTester tester) async { final UniqueKey key = UniqueKey(); await tester.pumpWidget( diff --git a/packages/flutter/test/widgets/semantics_role_checks_test.dart b/packages/flutter/test/widgets/semantics_role_checks_test.dart index 07c3e37d34..c7dc02a233 100644 --- a/packages/flutter/test/widgets/semantics_role_checks_test.dart +++ b/packages/flutter/test/widgets/semantics_role_checks_test.dart @@ -267,4 +267,113 @@ void main() { expect(tester.takeException(), isNull); }); }); + + group('alert and status', () { + testWidgets('failure case, alert and live region', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Semantics( + role: SemanticsRole.alert, + liveRegion: true, + child: const SizedBox.square(dimension: 1), + ), + ), + ); + final Object? exception = tester.takeException(); + expect(exception, isFlutterError); + final FlutterError error = exception! as FlutterError; + expect( + error.message, + startsWith('Node 1 has role SemanticsRole.alert but is also a live region.'), + ); + }); + + testWidgets('failure case, status and live region', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Semantics( + role: SemanticsRole.status, + liveRegion: true, + child: const SizedBox.square(dimension: 1), + ), + ), + ); + final Object? exception = tester.takeException(); + expect(exception, isFlutterError); + final FlutterError error = exception! as FlutterError; + expect( + error.message, + startsWith('Node 1 has role SemanticsRole.status but is also a live region.'), + ); + }); + + testWidgets('success case', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Semantics( + explicitChildNodes: true, + child: Column( + children: [ + Semantics(role: SemanticsRole.status, child: const SizedBox.square(dimension: 1)), + Semantics(role: SemanticsRole.alert, child: const SizedBox.square(dimension: 1)), + ], + ), + ), + ), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('success case, radio buttons with labels', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Semantics( + role: SemanticsRole.radioGroup, + explicitChildNodes: true, + child: Column( + children: [ + Semantics( + label: 'Option A', + child: Semantics( + checked: false, + inMutuallyExclusiveGroup: true, + child: const SizedBox.square(dimension: 1), + ), + ), + Semantics( + label: 'Option B', + child: Semantics( + checked: true, + inMutuallyExclusiveGroup: true, + child: const SizedBox.square(dimension: 1), + ), + ), + ], + ), + ), + ), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('success case, radio group with no checkable children', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Semantics( + role: SemanticsRole.radioGroup, + explicitChildNodes: true, + child: Semantics(toggled: true, child: const SizedBox.square(dimension: 1)), + ), + ), + ); + expect(tester.takeException(), isNull); + }); + }); }