diff --git a/engine/src/flutter/lib/ui/semantics.dart b/engine/src/flutter/lib/ui/semantics.dart index 21c0b33904..681b3ab0d7 100644 --- a/engine/src/flutter/lib/ui/semantics.dart +++ b/engine/src/flutter/lib/ui/semantics.dart @@ -362,6 +362,12 @@ enum SemanticsRole { /// The main display for a tab. tabPanel, + + /// A pop up dialog. + dialog, + + /// An alert dialog. + alertDialog, } /// A Boolean value that can be associated with a semantics node. diff --git a/engine/src/flutter/lib/web_ui/lib/semantics.dart b/engine/src/flutter/lib/web_ui/lib/semantics.dart index 487a77d6e9..1ae7efab33 100644 --- a/engine/src/flutter/lib/web_ui/lib/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/semantics.dart @@ -256,7 +256,7 @@ class SemanticsFlag { } // Mirrors engine/src/flutter/lib/ui/semantics.dart -enum SemanticsRole { none, tab, tabBar, tabPanel } +enum SemanticsRole { none, tab, tabBar, tabPanel, dialog, alertDialog } // When adding a new StringAttributeType, the classes in these file must be // updated as well. diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/route.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/route.dart index 4262c3ffdb..b8300c9703 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/route.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/route.dart @@ -6,17 +6,8 @@ import '../dom.dart'; import '../semantics.dart'; import '../util.dart'; -/// Denotes that all descendant nodes are inside a route. -/// -/// Routes can include dialogs, pop-up menus, sub-screens, and more. -/// -/// See also: -/// -/// * [RouteName], which provides a description for this route in the absense -/// of an explicit route label set on the route itself. -class SemanticRoute extends SemanticRole { - SemanticRoute(SemanticsObject semanticsObject) - : super.blank(EngineSemanticsRole.route, semanticsObject) { +class SemanticRouteBase extends SemanticRole { + SemanticRouteBase(super.kind, super.object) : super.blank() { // 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 @@ -42,13 +33,6 @@ class SemanticRoute extends SemanticRole { // Case 2: nothing requested explicit focus. Focus on the first descendant. _setDefaultFocus(); }); - - // Lacking any more specific information, ARIA role "dialog" is the - // closest thing to Flutter's route. This can be revisited if better - // options become available, especially if the framework volunteers more - // specific information about the route. Other attributes in the vicinity - // of routes include: "alertdialog", `aria-modal`, "menu", "tooltip". - setAriaRole('dialog'); } void _setDefaultFocus() { @@ -109,6 +93,63 @@ class SemanticRoute extends SemanticRole { } } +/// Denotes that all descendant nodes are inside a route. +/// +/// See also: +/// +/// * [RouteName], which provides a description for this route in the absence +/// of an explicit route label set on the route itself. +class SemanticRoute extends SemanticRouteBase { + SemanticRoute(SemanticsObject object) : super(EngineSemanticsRole.route, object) { + // Lacking any more specific information, ARIA role "dialog" is the + // closest thing to Flutter's route. This can be revisited if better + // options become available, especially if the framework volunteers more + // specific information about the route. Other attributes in the vicinity + // of routes include: "alertdialog", `aria-modal`, "menu", "tooltip". + setAriaRole('dialog'); + } +} + +/// Indicates the container as a pop dialog. +/// +/// Uses aria dialog role to convey this semantic information to the element. +/// +/// Setting this role will also set aria-modal to true, which helps screen +/// reader better understand this section of screen. +/// +/// Screen-readers take advantage of "aria-label" to describe the visual. +/// +/// See also: +/// +/// * [RouteName], which provides a description for this route in the absence +/// of an explicit route label set on the route itself. +class SemanticDialog extends SemanticRouteBase { + SemanticDialog(SemanticsObject object) : super(EngineSemanticsRole.dialog, object) { + setAriaRole('dialog'); + setAttribute('aria-modal', true); + } +} + +/// Indicates the container as an alert dialog. +/// +/// Uses aria alertdialog role to convey this semantic information to the element. +/// +/// Setting this role will also set aria-modal to true, which helps screen +/// reader better understand this section of screen. +/// +/// Screen-readers takes advantage of "aria-label" to describe the visual. +/// +/// See also: +/// +/// * [RouteName], which provides a description for this route in the absence +/// of an explicit route label set on the route itself. +class SemanticAlertDialog extends SemanticRouteBase { + SemanticAlertDialog(SemanticsObject object) : super(EngineSemanticsRole.alertDialog, object) { + setAriaRole('alertdialog'); + setAttribute('aria-modal', true); + } +} + /// Supplies a description for the nearest ancestor [SemanticRoute]. /// /// This role is assigned to nodes that have `namesRoute` set but not @@ -121,7 +162,7 @@ class SemanticRoute extends SemanticRole { class RouteName extends SemanticBehavior { RouteName(super.semanticsObject, super.owner); - SemanticRoute? _route; + SemanticRouteBase? _route; @override void update() { @@ -139,7 +180,7 @@ class RouteName extends SemanticBehavior { } if (semanticsObject.isLabelDirty) { - final SemanticRoute? route = _route; + final SemanticRouteBase? route = _route; if (route != null) { // Already attached to a route, just update the description. route.describeBy(this); @@ -158,11 +199,11 @@ class RouteName extends SemanticBehavior { void _lookUpNearestAncestorRoute() { SemanticsObject? parent = semanticsObject.parent; - while (parent != null && parent.semanticRole?.kind != EngineSemanticsRole.route) { + while (parent != null && (parent.semanticRole is! SemanticRouteBase)) { parent = parent.parent; } - if (parent != null && parent.semanticRole?.kind == EngineSemanticsRole.route) { - _route = parent.semanticRole! as SemanticRoute; + if (parent != null) { + _route = parent.semanticRole! as SemanticRouteBase; } } } 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 05dbf88c9b..78a66b0d2f 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 @@ -416,6 +416,12 @@ enum EngineSemanticsRole { /// A main content for a tab. tabPanel, + /// A popup dialog. + dialog, + + /// An alert dialog. + alertDialog, + /// A role used when a more specific role cannot be assigend to /// a [SemanticsObject]. /// @@ -1745,6 +1751,10 @@ class SemanticsObject { return EngineSemanticsRole.tabPanel; case ui.SemanticsRole.tabBar: return EngineSemanticsRole.tabList; + case ui.SemanticsRole.dialog: + return EngineSemanticsRole.dialog; + case ui.SemanticsRole.alertDialog: + return EngineSemanticsRole.alertDialog; case ui.SemanticsRole.none: // fallback to checking semantics properties. } @@ -1794,6 +1804,8 @@ class SemanticsObject { EngineSemanticsRole.tab => SemanticTab(this), EngineSemanticsRole.tabList => SemanticTabList(this), EngineSemanticsRole.tabPanel => SemanticTabPanel(this), + EngineSemanticsRole.dialog => SemanticDialog(this), + EngineSemanticsRole.alertDialog => SemanticAlertDialog(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 d7c3998c9c..e326dedc75 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 @@ -112,6 +112,7 @@ void runSemanticsTests() { }); group('route', () { _testRoute(); + _testDialogs(); }); group('focusable', () { _testFocusable(); @@ -3352,6 +3353,99 @@ void _testRoute() { }); } +void _testDialogs() { + test('nodes with dialog role', () { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + SemanticsObject pumpSemantics() { + final SemanticsTester tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + role: ui.SemanticsRole.dialog, + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + ); + tester.apply(); + return tester.getSemanticsObject(0); + } + + final SemanticsObject object = pumpSemantics(); + expect(object.semanticRole?.kind, EngineSemanticsRole.dialog); + expect(object.element.getAttribute('role'), 'dialog'); + }); + + test('nodes with alertdialog role', () { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + SemanticsObject pumpSemantics() { + final SemanticsTester tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + role: ui.SemanticsRole.alertDialog, + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + ); + tester.apply(); + return tester.getSemanticsObject(0); + } + + final SemanticsObject object = pumpSemantics(); + expect(object.semanticRole?.kind, EngineSemanticsRole.alertDialog); + expect(object.element.getAttribute('role'), 'alertdialog'); + }); + + test('dialog can be described by a descendant', () { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + void pumpSemantics({required String label}) { + final SemanticsTester tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + role: ui.SemanticsRole.dialog, + transform: Matrix4.identity().toFloat64(), + children: [ + tester.updateNode( + id: 1, + children: [ + tester.updateNode(id: 2, namesRoute: true, label: label), + ], + ), + ], + ); + tester.apply(); + + expectSemanticsTree(owner(), ''' + + + + + $label + + + + + '''); + } + + pumpSemantics(label: 'Route label'); + + expect(owner().debugSemanticsTree![0]!.semanticRole?.kind, EngineSemanticsRole.dialog); + expect(owner().debugSemanticsTree![2]!.semanticRole?.kind, EngineSemanticsRole.generic); + expect( + owner().debugSemanticsTree![2]!.semanticRole?.debugSemanticBehaviorTypes, + contains(RouteName), + ); + + pumpSemantics(label: 'Updated route label'); + + semantics().semanticsEnabled = false; + }); +} + typedef CapturedAction = (int nodeId, ui.SemanticsAction action, Object? args); void _testFocusable() { diff --git a/packages/flutter/lib/src/cupertino/dialog.dart b/packages/flutter/lib/src/cupertino/dialog.dart index c2deec75e8..336453d122 100644 --- a/packages/flutter/lib/src/cupertino/dialog.dart +++ b/packages/flutter/lib/src/cupertino/dialog.dart @@ -9,7 +9,7 @@ library; import 'dart:math' as math; -import 'dart:ui' show ImageFilter, lerpDouble; +import 'dart:ui' show ImageFilter, SemanticsRole, lerpDouble; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; @@ -463,6 +463,7 @@ class _CupertinoAlertDialogState extends State { child: CupertinoPopupSurface( isSurfacePainted: false, child: Semantics( + role: SemanticsRole.alertDialog, namesRoute: true, scopesRoute: true, explicitChildNodes: true, @@ -1332,6 +1333,7 @@ class _CupertinoActionSheetState extends State { namesRoute: true, scopesRoute: true, explicitChildNodes: true, + role: SemanticsRole.dialog, label: 'Alert', child: CupertinoUserInterfaceLevel( data: CupertinoUserInterfaceLevelData.elevated, diff --git a/packages/flutter/lib/src/material/dialog.dart b/packages/flutter/lib/src/material/dialog.dart index 0b9239261d..abd63a58a2 100644 --- a/packages/flutter/lib/src/material/dialog.dart +++ b/packages/flutter/lib/src/material/dialog.dart @@ -10,7 +10,7 @@ /// @docImport 'text_button.dart'; library; -import 'dart:ui' show clampDouble, lerpDouble; +import 'dart:ui' show SemanticsRole, clampDouble, lerpDouble; import 'package:flutter/cupertino.dart'; @@ -67,6 +67,7 @@ class Dialog extends StatelessWidget { this.shape, this.alignment, this.child, + this.semanticsRole = SemanticsRole.dialog, }) : assert(elevation == null || elevation >= 0.0), _fullscreen = false; @@ -79,6 +80,7 @@ class Dialog extends StatelessWidget { this.insetAnimationDuration = Duration.zero, this.insetAnimationCurve = Curves.decelerate, this.child, + this.semanticsRole = SemanticsRole.dialog, }) : elevation = 0, shadowColor = null, surfaceTintColor = null, @@ -229,6 +231,11 @@ class Dialog extends StatelessWidget { /// This value is used to determine if this is a fullscreen dialog. final bool _fullscreen; + /// The role this dialog represent in assist technologies. + /// + /// Defaults to [SemanticsRole.dialog]. + final SemanticsRole semanticsRole; + @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); @@ -268,17 +275,20 @@ class Dialog extends StatelessWidget { ); } - return AnimatedPadding( - padding: effectivePadding, - duration: insetAnimationDuration, - curve: insetAnimationCurve, - child: MediaQuery.removeViewInsets( - removeLeft: true, - removeTop: true, - removeRight: true, - removeBottom: true, - context: context, - child: dialogChild, + return Semantics( + role: semanticsRole, + child: AnimatedPadding( + padding: effectivePadding, + duration: insetAnimationDuration, + curve: insetAnimationCurve, + child: MediaQuery.removeViewInsets( + removeLeft: true, + removeTop: true, + removeRight: true, + removeBottom: true, + context: context, + child: dialogChild, + ), ), ); } @@ -918,6 +928,7 @@ class AlertDialog extends StatelessWidget { clipBehavior: clipBehavior, shape: shape, alignment: alignment, + semanticsRole: SemanticsRole.alertDialog, child: dialogChild, ); } diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index 798e593d19..fe7731844f 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -104,6 +104,8 @@ final int _kUnblockedUserActions = /// A static class to conduct semantics role checks. sealed class _DebugSemanticsRoleChecks { static FlutterError? _checkSemanticsData(SemanticsNode node) => switch (node.role) { + SemanticsRole.alertDialog => _noCheckRequired, + SemanticsRole.dialog => _noCheckRequired, SemanticsRole.none => _noCheckRequired, SemanticsRole.tab => _semanticsTab, SemanticsRole.tabBar => _semanticsTabBar, diff --git a/packages/flutter/test/cupertino/action_sheet_test.dart b/packages/flutter/test/cupertino/action_sheet_test.dart index c3fa39d206..9f02faf91b 100644 --- a/packages/flutter/test/cupertino/action_sheet_test.dart +++ b/packages/flutter/test/cupertino/action_sheet_test.dart @@ -7,9 +7,10 @@ @Tags(['reduced-test-set']) library; +import 'dart:ui'; + import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -1760,6 +1761,7 @@ void main() { TestSemantics( flags: [SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], label: 'Alert', + role: SemanticsRole.dialog, children: [ TestSemantics( flags: [SemanticsFlag.hasImplicitScrolling], diff --git a/packages/flutter/test/cupertino/dialog_test.dart b/packages/flutter/test/cupertino/dialog_test.dart index 96bbcc16e9..c36a467687 100644 --- a/packages/flutter/test/cupertino/dialog_test.dart +++ b/packages/flutter/test/cupertino/dialog_test.dart @@ -711,6 +711,7 @@ void main() { SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute, ], + role: SemanticsRole.alertDialog, label: 'Alert', children: [ TestSemantics( diff --git a/packages/flutter/test/material/dialog_test.dart b/packages/flutter/test/material/dialog_test.dart index 92ea069310..894f58cb41 100644 --- a/packages/flutter/test/material/dialog_test.dart +++ b/packages/flutter/test/material/dialog_test.dart @@ -1616,6 +1616,7 @@ void main() { children: [ TestSemantics( id: 4, + role: SemanticsRole.alertDialog, children: [ TestSemantics(id: 5, label: 'title', textDirection: TextDirection.ltr), // The content semantics does not merge into the semantics @@ -1803,6 +1804,7 @@ void main() { children: [ TestSemantics( id: 4, + role: SemanticsRole.dialog, children: [ // Title semantics does not merge into the semantics // node 4.