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.
tabPanel,
/// A pop up dialog.
dialog,
/// An alert dialog.
alertDialog,
}
/// 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
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.

View File

@ -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;
}
}
}

View File

@ -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),
};
}

View File

@ -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: <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);
void _testFocusable() {

View File

@ -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<CupertinoAlertDialog> {
child: CupertinoPopupSurface(
isSurfacePainted: false,
child: Semantics(
role: SemanticsRole.alertDialog,
namesRoute: true,
scopesRoute: true,
explicitChildNodes: true,
@ -1332,6 +1333,7 @@ class _CupertinoActionSheetState extends State<CupertinoActionSheet> {
namesRoute: true,
scopesRoute: true,
explicitChildNodes: true,
role: SemanticsRole.dialog,
label: 'Alert',
child: CupertinoUserInterfaceLevel(
data: CupertinoUserInterfaceLevelData.elevated,

View File

@ -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,
);
}

View File

@ -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,

View File

@ -7,9 +7,10 @@
@Tags(<String>['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>[SemanticsFlag.scopesRoute, SemanticsFlag.namesRoute],
label: 'Alert',
role: SemanticsRole.dialog,
children: <TestSemantics>[
TestSemantics(
flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling],

View File

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

View File

@ -1616,6 +1616,7 @@ void main() {
children: <TestSemantics>[
TestSemantics(
id: 4,
role: SemanticsRole.alertDialog,
children: <TestSemantics>[
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>[
TestSemantics(
id: 4,
role: SemanticsRole.dialog,
children: <TestSemantics>[
// Title semantics does not merge into the semantics
// node 4.