adds status and alert roles (#164925)

<!--
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/162287
fixes https://github.com/flutter/flutter/issues/162286

## 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-03-13 08:54:08 -07:00 committed by GitHub
parent ea4cdcf39e
commit 884de61855
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 283 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@ -282,6 +282,8 @@ enum SemanticsRole {
progressBar,
hotKey,
radioGroup,
status,
alert,
}
// When adding a new StringAttributeType, the classes in these file must be

View File

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

View File

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

View File

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

View File

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

View File

@ -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()));

View File

@ -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].

View File

@ -421,6 +421,34 @@ void main() {
expect(data.controlsNodes, <String>{'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(

View File

@ -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: <Widget>[
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: <Widget>[
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);
});
});
}