Adds dialog and alertdialog role (#162692)

<!--
Thanks for filing a pull request!
Reviewers are typically assigned within a week of filing a request.
To learn more about code review, see our documentation on Tree Hygiene:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
-->

fixes https://github.com/flutter/flutter/issues/162124
fixes https://github.com/flutter/flutter/issues/157207
fixes https://github.com/flutter/flutter/issues/157204

## 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-02-11 11:51:05 -08:00 committed by GitHub
parent 04cbda2b1a
commit 21471aa236
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 211 additions and 38 deletions

View File

@ -362,6 +362,12 @@ enum SemanticsRole {
/// The main display for a tab. /// The main display for a tab.
tabPanel, tabPanel,
/// A pop up dialog.
dialog,
/// An alert dialog.
alertDialog,
} }
/// A Boolean value that can be associated with a semantics node. /// A Boolean value that can be associated with a semantics node.

View File

@ -256,7 +256,7 @@ class SemanticsFlag {
} }
// Mirrors engine/src/flutter/lib/ui/semantics.dart // 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 // When adding a new StringAttributeType, the classes in these file must be
// updated as well. // updated as well.

View File

@ -6,17 +6,8 @@ import '../dom.dart';
import '../semantics.dart'; import '../semantics.dart';
import '../util.dart'; import '../util.dart';
/// Denotes that all descendant nodes are inside a route. class SemanticRouteBase extends SemanticRole {
/// SemanticRouteBase(super.kind, super.object) : super.blank() {
/// 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) {
// The following behaviors can coexist with the route. Generic `RouteName` // The following behaviors can coexist with the route. Generic `RouteName`
// and `LabelAndValue` are not used by this role because when the route // and `LabelAndValue` are not used by this role because when the route
// names its own route an `aria-label` is used instead of // names its own route an `aria-label` is used instead of
@ -42,13 +33,6 @@ class SemanticRoute extends SemanticRole {
// Case 2: nothing requested explicit focus. Focus on the first descendant. // Case 2: nothing requested explicit focus. Focus on the first descendant.
_setDefaultFocus(); _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() { 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]. /// Supplies a description for the nearest ancestor [SemanticRoute].
/// ///
/// This role is assigned to nodes that have `namesRoute` set but not /// This role is assigned to nodes that have `namesRoute` set but not
@ -121,7 +162,7 @@ class SemanticRoute extends SemanticRole {
class RouteName extends SemanticBehavior { class RouteName extends SemanticBehavior {
RouteName(super.semanticsObject, super.owner); RouteName(super.semanticsObject, super.owner);
SemanticRoute? _route; SemanticRouteBase? _route;
@override @override
void update() { void update() {
@ -139,7 +180,7 @@ class RouteName extends SemanticBehavior {
} }
if (semanticsObject.isLabelDirty) { if (semanticsObject.isLabelDirty) {
final SemanticRoute? route = _route; final SemanticRouteBase? route = _route;
if (route != null) { if (route != null) {
// Already attached to a route, just update the description. // Already attached to a route, just update the description.
route.describeBy(this); route.describeBy(this);
@ -158,11 +199,11 @@ class RouteName extends SemanticBehavior {
void _lookUpNearestAncestorRoute() { void _lookUpNearestAncestorRoute() {
SemanticsObject? parent = semanticsObject.parent; SemanticsObject? parent = semanticsObject.parent;
while (parent != null && parent.semanticRole?.kind != EngineSemanticsRole.route) { while (parent != null && (parent.semanticRole is! SemanticRouteBase)) {
parent = parent.parent; parent = parent.parent;
} }
if (parent != null && parent.semanticRole?.kind == EngineSemanticsRole.route) { if (parent != null) {
_route = parent.semanticRole! as SemanticRoute; _route = parent.semanticRole! as SemanticRouteBase;
} }
} }
} }

View File

@ -416,6 +416,12 @@ enum EngineSemanticsRole {
/// A main content for a tab. /// A main content for a tab.
tabPanel, tabPanel,
/// A popup dialog.
dialog,
/// An alert dialog.
alertDialog,
/// A role used when a more specific role cannot be assigend to /// A role used when a more specific role cannot be assigend to
/// a [SemanticsObject]. /// a [SemanticsObject].
/// ///
@ -1745,6 +1751,10 @@ class SemanticsObject {
return EngineSemanticsRole.tabPanel; return EngineSemanticsRole.tabPanel;
case ui.SemanticsRole.tabBar: case ui.SemanticsRole.tabBar:
return EngineSemanticsRole.tabList; return EngineSemanticsRole.tabList;
case ui.SemanticsRole.dialog:
return EngineSemanticsRole.dialog;
case ui.SemanticsRole.alertDialog:
return EngineSemanticsRole.alertDialog;
case ui.SemanticsRole.none: case ui.SemanticsRole.none:
// fallback to checking semantics properties. // fallback to checking semantics properties.
} }
@ -1794,6 +1804,8 @@ class SemanticsObject {
EngineSemanticsRole.tab => SemanticTab(this), EngineSemanticsRole.tab => SemanticTab(this),
EngineSemanticsRole.tabList => SemanticTabList(this), EngineSemanticsRole.tabList => SemanticTabList(this),
EngineSemanticsRole.tabPanel => SemanticTabPanel(this), EngineSemanticsRole.tabPanel => SemanticTabPanel(this),
EngineSemanticsRole.dialog => SemanticDialog(this),
EngineSemanticsRole.alertDialog => SemanticAlertDialog(this),
EngineSemanticsRole.generic => GenericRole(this), EngineSemanticsRole.generic => GenericRole(this),
}; };
} }

View File

@ -112,6 +112,7 @@ void runSemanticsTests() {
}); });
group('route', () { group('route', () {
_testRoute(); _testRoute();
_testDialogs();
}); });
group('focusable', () { group('focusable', () {
_testFocusable(); _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: <SemanticsNodeUpdate>[
tester.updateNode(
id: 1,
children: <SemanticsNodeUpdate>[
tester.updateNode(id: 2, namesRoute: true, label: label),
],
),
],
);
tester.apply();
expectSemanticsTree(owner(), '''
<sem role="dialog" aria-describedby="flt-semantic-node-2">
<sem-c>
<sem>
<sem-c>
<sem><span>$label</span></sem>
</sem-c>
</sem>
</sem-c>
</sem>
''');
}
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); typedef CapturedAction = (int nodeId, ui.SemanticsAction action, Object? args);
void _testFocusable() { void _testFocusable() {

View File

@ -9,7 +9,7 @@
library; library;
import 'dart:math' as math; 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/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
@ -463,6 +463,7 @@ class _CupertinoAlertDialogState extends State<CupertinoAlertDialog> {
child: CupertinoPopupSurface( child: CupertinoPopupSurface(
isSurfacePainted: false, isSurfacePainted: false,
child: Semantics( child: Semantics(
role: SemanticsRole.alertDialog,
namesRoute: true, namesRoute: true,
scopesRoute: true, scopesRoute: true,
explicitChildNodes: true, explicitChildNodes: true,
@ -1332,6 +1333,7 @@ class _CupertinoActionSheetState extends State<CupertinoActionSheet> {
namesRoute: true, namesRoute: true,
scopesRoute: true, scopesRoute: true,
explicitChildNodes: true, explicitChildNodes: true,
role: SemanticsRole.dialog,
label: 'Alert', label: 'Alert',
child: CupertinoUserInterfaceLevel( child: CupertinoUserInterfaceLevel(
data: CupertinoUserInterfaceLevelData.elevated, data: CupertinoUserInterfaceLevelData.elevated,

View File

@ -10,7 +10,7 @@
/// @docImport 'text_button.dart'; /// @docImport 'text_button.dart';
library; library;
import 'dart:ui' show clampDouble, lerpDouble; import 'dart:ui' show SemanticsRole, clampDouble, lerpDouble;
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
@ -67,6 +67,7 @@ class Dialog extends StatelessWidget {
this.shape, this.shape,
this.alignment, this.alignment,
this.child, this.child,
this.semanticsRole = SemanticsRole.dialog,
}) : assert(elevation == null || elevation >= 0.0), }) : assert(elevation == null || elevation >= 0.0),
_fullscreen = false; _fullscreen = false;
@ -79,6 +80,7 @@ class Dialog extends StatelessWidget {
this.insetAnimationDuration = Duration.zero, this.insetAnimationDuration = Duration.zero,
this.insetAnimationCurve = Curves.decelerate, this.insetAnimationCurve = Curves.decelerate,
this.child, this.child,
this.semanticsRole = SemanticsRole.dialog,
}) : elevation = 0, }) : elevation = 0,
shadowColor = null, shadowColor = null,
surfaceTintColor = null, surfaceTintColor = null,
@ -229,6 +231,11 @@ class Dialog extends StatelessWidget {
/// This value is used to determine if this is a fullscreen dialog. /// This value is used to determine if this is a fullscreen dialog.
final bool _fullscreen; final bool _fullscreen;
/// The role this dialog represent in assist technologies.
///
/// Defaults to [SemanticsRole.dialog].
final SemanticsRole semanticsRole;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context); final ThemeData theme = Theme.of(context);
@ -268,7 +275,9 @@ class Dialog extends StatelessWidget {
); );
} }
return AnimatedPadding( return Semantics(
role: semanticsRole,
child: AnimatedPadding(
padding: effectivePadding, padding: effectivePadding,
duration: insetAnimationDuration, duration: insetAnimationDuration,
curve: insetAnimationCurve, curve: insetAnimationCurve,
@ -280,6 +289,7 @@ class Dialog extends StatelessWidget {
context: context, context: context,
child: dialogChild, child: dialogChild,
), ),
),
); );
} }
} }
@ -918,6 +928,7 @@ class AlertDialog extends StatelessWidget {
clipBehavior: clipBehavior, clipBehavior: clipBehavior,
shape: shape, shape: shape,
alignment: alignment, alignment: alignment,
semanticsRole: SemanticsRole.alertDialog,
child: dialogChild, child: dialogChild,
); );
} }

View File

@ -104,6 +104,8 @@ final int _kUnblockedUserActions =
/// A static class to conduct semantics role checks. /// A static class to conduct semantics role checks.
sealed class _DebugSemanticsRoleChecks { sealed class _DebugSemanticsRoleChecks {
static FlutterError? _checkSemanticsData(SemanticsNode node) => switch (node.role) { static FlutterError? _checkSemanticsData(SemanticsNode node) => switch (node.role) {
SemanticsRole.alertDialog => _noCheckRequired,
SemanticsRole.dialog => _noCheckRequired,
SemanticsRole.none => _noCheckRequired, SemanticsRole.none => _noCheckRequired,
SemanticsRole.tab => _semanticsTab, SemanticsRole.tab => _semanticsTab,
SemanticsRole.tabBar => _semanticsTabBar, SemanticsRole.tabBar => _semanticsTabBar,

View File

@ -7,9 +7,10 @@
@Tags(<String>['reduced-test-set']) @Tags(<String>['reduced-test-set'])
library; library;
import 'dart:ui';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -1760,6 +1761,7 @@ void main() {
TestSemantics( TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute], flags: <SemanticsFlag>[SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute],
label: 'Alert', label: 'Alert',
role: SemanticsRole.dialog,
children: <TestSemantics>[ children: <TestSemantics>[
TestSemantics( TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling], flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling],

View File

@ -711,6 +711,7 @@ void main() {
SemanticsFlag.scopesRoute, SemanticsFlag.scopesRoute,
SemanticsFlag.namesRoute, SemanticsFlag.namesRoute,
], ],
role: SemanticsRole.alertDialog,
label: 'Alert', label: 'Alert',
children: <TestSemantics>[ children: <TestSemantics>[
TestSemantics( TestSemantics(

View File

@ -1616,6 +1616,7 @@ void main() {
children: <TestSemantics>[ children: <TestSemantics>[
TestSemantics( TestSemantics(
id: 4, id: 4,
role: SemanticsRole.alertDialog,
children: <TestSemantics>[ children: <TestSemantics>[
TestSemantics(id: 5, label: 'title', textDirection: TextDirection.ltr), TestSemantics(id: 5, label: 'title', textDirection: TextDirection.ltr),
// The content semantics does not merge into the semantics // The content semantics does not merge into the semantics
@ -1803,6 +1804,7 @@ void main() {
children: <TestSemantics>[ children: <TestSemantics>[
TestSemantics( TestSemantics(
id: 4, id: 4,
role: SemanticsRole.dialog,
children: <TestSemantics>[ children: <TestSemantics>[
// Title semantics does not merge into the semantics // Title semantics does not merge into the semantics
// node 4. // node 4.