diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index a6b7f3b490cc3..2759069858445 100644 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -43012,7 +43012,6 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/scene_view.dart + ../../../fl 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/checkable.dart + ../../../flutter/LICENSE -ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/dialog.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/focusable.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/heading.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/image.dart + ../../../flutter/LICENSE @@ -43021,6 +43020,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dar ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/link.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/live_region.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/platform_view.dart + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/route.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/semantics_helper.dart + ../../../flutter/LICENSE @@ -45899,7 +45899,6 @@ 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/checkable.dart -FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/dialog.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/focusable.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/heading.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/image.dart @@ -45908,6 +45907,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/link.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/live_region.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/platform_view.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/route.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/scrollable.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/semantics_helper.dart diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart index b02fd59e2025c..cdf615a388617 100644 --- a/lib/web_ui/lib/src/engine.dart +++ b/lib/web_ui/lib/src/engine.dart @@ -145,7 +145,6 @@ export 'engine/scene_painting.dart'; export 'engine/scene_view.dart'; export 'engine/semantics/accessibility.dart'; export 'engine/semantics/checkable.dart'; -export 'engine/semantics/dialog.dart'; export 'engine/semantics/focusable.dart'; export 'engine/semantics/heading.dart'; export 'engine/semantics/image.dart'; @@ -154,6 +153,7 @@ export 'engine/semantics/label_and_value.dart'; export 'engine/semantics/link.dart'; export 'engine/semantics/live_region.dart'; export 'engine/semantics/platform_view.dart'; +export 'engine/semantics/route.dart'; export 'engine/semantics/scrollable.dart'; export 'engine/semantics/semantics.dart'; export 'engine/semantics/semantics_helper.dart'; diff --git a/lib/web_ui/lib/src/engine/semantics/focusable.dart b/lib/web_ui/lib/src/engine/semantics/focusable.dart index b3289e35da829..54bc0d4e55392 100644 --- a/lib/web_ui/lib/src/engine/semantics/focusable.dart +++ b/lib/web_ui/lib/src/engine/semantics/focusable.dart @@ -254,14 +254,14 @@ class AccessibilityFocusManager { // as it is subject to non-local effects. Let's say the framework decides // that a semantics node is currently not focused. That would lead to // changeFocus(false) to be called. However, what if this node is inside - // a dialog, and nothing else in the dialog is focused. The Flutter + // a route, and nothing else in the route is focused? The Flutter // framework expects that the screen reader will focus on the first (in - // traversal order) focusable element inside the dialog and send a + // traversal order) focusable element inside the route and send a // SemanticsAction.focus action. Screen readers on the web do not do // that, and so the web engine has to implement this behavior directly. So - // the dialog will look for a focusable element and request focus on it, + // the route will look for a focusable element and request focus on it, // but now there may be a race between this method unsetting the focus and - // the dialog requesting focus on the same element. + // the route requesting focus on the same element. return; } diff --git a/lib/web_ui/lib/src/engine/semantics/dialog.dart b/lib/web_ui/lib/src/engine/semantics/route.dart similarity index 55% rename from lib/web_ui/lib/src/engine/semantics/dialog.dart rename to lib/web_ui/lib/src/engine/semantics/route.dart index 84896677dd21b..89052e65827f3 100644 --- a/lib/web_ui/lib/src/engine/semantics/dialog.dart +++ b/lib/web_ui/lib/src/engine/semantics/route.dart @@ -6,19 +6,27 @@ import '../dom.dart'; import '../semantics.dart'; import '../util.dart'; -/// Provides accessibility for routes, including dialogs and pop-up menus. -class SemanticDialog extends SemanticRole { - SemanticDialog(SemanticsObject semanticsObject) : super.blank(SemanticRoleKind.dialog, semanticsObject) { - // The following behaviors can coexist with dialog. Generic `RouteName` - // and `LabelAndValue` are not used by this role because when the dialog - // names its own route an `aria-label` is used instead of `aria-describedby`. +/// 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(SemanticRoleKind.route, semanticsObject) { + // 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 + // `aria-describedby`. addFocusManagement(); addLiveRegion(); - // When a route/dialog shows up it is expected that the screen reader will - // focus on something inside it. There could be two possibilities: + // When a route is pushed it is expected that the screen reader will focus + // on something inside it. There could be two possibilities: // - // 1. The framework explicitly marked a node inside the dialog as focused + // 1. The framework explicitly marked a node inside the route as focused // via the `isFocusable` and `isFocused` flags. In this case, the node // will request focus directly and there's nothing to do on top of that. // 2. No node inside the route takes focus explicitly. In this case, the @@ -53,103 +61,114 @@ class SemanticDialog extends SemanticRole { void update() { super.update(); - // If semantic object corresponding to the dialog also provides the label - // for itself it is applied as `aria-label`. See also [describeBy]. + // If semantic object corresponding to the route also provides the label for + // itself it is applied as `aria-label`. See also [describeBy]. if (semanticsObject.namesRoute) { final String? label = semanticsObject.label; assert(() { if (label == null || label.trim().isEmpty) { printWarning( 'Semantic node ${semanticsObject.id} had both scopesRoute and ' - 'namesRoute set, indicating a self-labelled dialog, but it is ' - 'missing the label. A dialog should be labelled either by setting ' + 'namesRoute set, indicating a self-labelled route, but it is ' + 'missing the label. A route should be labelled either by setting ' 'namesRoute on itself and providing a label, or by containing a ' 'child node with namesRoute that can describe it with its content.' ); } return true; }()); + setAttribute('aria-label', label ?? ''); - setAriaRole('dialog'); + _assignRole(); } } - /// Sets the description of this dialog based on a [RouteName] descendant - /// node, unless the dialog provides its own label. + /// Sets the description of this route based on a [RouteName] descendant + /// node, unless the route provides its own label. void describeBy(RouteName routeName) { if (semanticsObject.namesRoute) { - // The dialog provides its own label, which takes precedence. + // The route provides its own label, which takes precedence. return; } - setAriaRole('dialog'); + _assignRole(); setAttribute( 'aria-describedby', routeName.semanticsObject.element.id, ); } + void _assignRole() { + // 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'); + } + @override bool focusAsRouteDefault() { - // Dialogs are the ones that look inside themselves to find elements to - // focus on. It doesn't make sense to focus on the dialog itself. + // Routes are the ones that look inside themselves to find elements to + // focus on. It doesn't make sense to focus on the route itself. return false; } } -/// Supplies a description for the nearest ancestor [SemanticDialog]. +/// Supplies a description for the nearest ancestor [SemanticRoute]. /// /// This role is assigned to nodes that have `namesRoute` set but not -/// `scopesRoute`. When both flags are set the node only gets the [SemanticDialog] role. +/// `scopesRoute`. When both flags are set the node only gets the +/// [SemanticRoute] role. /// -/// If the ancestor dialog is missing, this role has no effect. It is up to the -/// framework, widget, and app authors to make sure a route name is scoped under -/// a route. +/// If the ancestor route is missing, this role has no effect. It is up to the +/// framework, widget, and app authors to make sure a route name is scoped +/// under a route. class RouteName extends SemanticBehavior { RouteName(super.semanticsObject, super.owner); - SemanticDialog? _dialog; + SemanticRoute? _route; @override void update() { // NOTE(yjbanov): this does not handle the case when the node structure - // changes such that this RouteName is no longer attached to the same - // dialog. While this is technically expressible using the semantics API, - // after discussing this case with customers I decided that this case is not + // changes such that this RouteName is no longer attached to the same route. + // While this is technically expressible using the semantics API, after + // discussing this case with customers I decided that this case is not // interesting enough to support. A tree restructure like this is likely to // confuse screen readers, and it would add complexity to the engine's // semantics code. Since reparenting can be done with no update to either - // the Dialog or RouteName we'd have to scan intermediate nodes for - // structural changes. + // the SemanticRoute or RouteName we'd have to scan intermediate nodes + // for structural changes. if (!semanticsObject.namesRoute) { return; } if (semanticsObject.isLabelDirty) { - final SemanticDialog? dialog = _dialog; - if (dialog != null) { - // Already attached to a dialog, just update the description. - dialog.describeBy(this); + final SemanticRoute? route = _route; + if (route != null) { + // Already attached to a route, just update the description. + route.describeBy(this); } else { // Setting the label for the first time. Wait for the DOM tree to be - // established, then find the nearest dialog and update its label. + // established, then find the nearest route and update its label. semanticsObject.owner.addOneTimePostUpdateCallback(() { if (!isDisposed) { - _lookUpNearestAncestorDialog(); - _dialog?.describeBy(this); + _lookUpNearestAncestorRoute(); + _route?.describeBy(this); } }); } } } - void _lookUpNearestAncestorDialog() { + void _lookUpNearestAncestorRoute() { SemanticsObject? parent = semanticsObject.parent; - while (parent != null && parent.semanticRole?.kind != SemanticRoleKind.dialog) { + while (parent != null && parent.semanticRole?.kind != SemanticRoleKind.route) { parent = parent.parent; } - if (parent != null && parent.semanticRole?.kind == SemanticRoleKind.dialog) { - _dialog = parent.semanticRole! as SemanticDialog; + if (parent != null && parent.semanticRole?.kind == SemanticRoleKind.route) { + _route = parent.semanticRole! as SemanticRoute; } } } diff --git a/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart index a35651ba210e4..9b7135c8b79b2 100644 --- a/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -20,7 +20,6 @@ import '../vector_math.dart'; import '../window.dart'; import 'accessibility.dart'; import 'checkable.dart'; -import 'dialog.dart'; import 'focusable.dart'; import 'heading.dart'; import 'image.dart'; @@ -29,6 +28,7 @@ import 'label_and_value.dart'; import 'link.dart'; import 'live_region.dart'; import 'platform_view.dart'; +import 'route.dart'; import 'scrollable.dart'; import 'semantics_helper.dart'; import 'tappable.dart'; @@ -379,19 +379,19 @@ enum SemanticRoleKind { /// There are 3 possible situations: /// /// * The node also has the `namesRoute` bit set. This means that the node's - /// `label` describes the dialog, which can be expressed by adding the + /// `label` describes the route, which can be expressed by adding the /// `aria-label` attribute. /// * A descendant node has the `namesRoute` bit set. This means that the - /// child's content describes the dialog. The child may simply be labelled, - /// or it may be a subtree of nodes that describe the dialog together. The + /// child's content describes the route. The child may simply be labelled, + /// or it may be a subtree of nodes that describe the route together. The /// nearest HTML equivalent is `aria-describedby`. The child acquires the /// [routeName] role, which manages the relevant ARIA attributes. /// * There is no `namesRoute` bit anywhere in the sub-tree rooted at the - /// current node. In this case it's likely not a dialog at all, and the node + /// current node. In this case it's likely not a route at all, and the node /// should not get a label or the "dialog" role. It's just a group of /// children. For example, a modal barrier has `scopesRoute` set but marking - /// it as a dialog would be wrong. - dialog, + /// it as a route would be wrong. + route, /// The node's role is to host a platform view. platformView, @@ -1653,7 +1653,7 @@ class SemanticsObject { } else if (isScrollContainer) { return SemanticRoleKind.scrollable; } else if (scopesRoute) { - return SemanticRoleKind.dialog; + return SemanticRoleKind.route; } else if (isLink) { return SemanticRoleKind.link; } else { @@ -1668,7 +1668,7 @@ class SemanticsObject { SemanticRoleKind.incrementable => SemanticIncrementable(this), SemanticRoleKind.button => SemanticButton(this), SemanticRoleKind.checkable => SemanticCheckable(this), - SemanticRoleKind.dialog => SemanticDialog(this), + SemanticRoleKind.route => SemanticRoute(this), SemanticRoleKind.image => SemanticImage(this), SemanticRoleKind.platformView => SemanticPlatformView(this), SemanticRoleKind.link => SemanticLink(this), diff --git a/lib/web_ui/test/engine/semantics/semantics_test.dart b/lib/web_ui/test/engine/semantics/semantics_test.dart index 5c5e25fda0269..e55fc2cfda883 100644 --- a/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -102,8 +102,8 @@ void runSemanticsTests() { group('group', () { _testGroup(); }); - group('dialog', () { - _testDialog(); + group('route', () { + _testRoute(); }); group('focusable', () { _testFocusable(); @@ -2970,7 +2970,7 @@ void _testGroup() { }); } -void _testDialog() { +void _testRoute() { test('renders named and labeled routes', () { semantics() ..debugOverrideTimestampFunction(() => _testTime) @@ -2979,7 +2979,7 @@ void _testDialog() { final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder(); updateNode( builder, - label: 'this is a dialog label', + label: 'this is a route label', flags: 0 | ui.SemanticsFlag.scopesRoute.index | ui.SemanticsFlag.namesRoute.index, transform: Matrix4.identity().toFloat64(), rect: const ui.Rect.fromLTRB(0, 0, 100, 50), @@ -2995,12 +2995,12 @@ void _testDialog() { owner().updateSemantics(builder.build()); expectSemanticsTree(owner(), ''' - + '''); expect( owner().debugSemanticsTree![0]!.semanticRole?.kind, - SemanticRoleKind.dialog, + SemanticRoleKind.route, ); semantics().semanticsEnabled = false; @@ -3034,7 +3034,7 @@ void _testDialog() { expect( warnings, [ - 'Semantic node 0 had both scopesRoute and namesRoute set, indicating a self-labelled dialog, but it is missing the label. A dialog should be labelled either by setting namesRoute on itself and providing a label, or by containing a child node with namesRoute that can describe it with its content.', + 'Semantic node 0 had both scopesRoute and namesRoute set, indicating a self-labelled route, but it is missing the label. A route should be labelled either by setting namesRoute on itself and providing a label, or by containing a child node with namesRoute that can describe it with its content.', ], ); @@ -3045,13 +3045,13 @@ void _testDialog() { expect( owner().debugSemanticsTree![0]!.semanticRole?.kind, - SemanticRoleKind.dialog, + SemanticRoleKind.route, ); semantics().semanticsEnabled = false; }); - test('dialog can be described by a descendant', () { + test('route can be described by a descendant', () { semantics() ..debugOverrideTimestampFunction(() => _testTime) ..semanticsEnabled = true; @@ -3090,11 +3090,11 @@ void _testDialog() { '''); } - pumpSemantics(label: 'Dialog label'); + pumpSemantics(label: 'Route label'); expect( owner().debugSemanticsTree![0]!.semanticRole?.kind, - SemanticRoleKind.dialog, + SemanticRoleKind.route, ); expect( owner().debugSemanticsTree![2]!.semanticRole?.kind, @@ -3105,12 +3105,12 @@ void _testDialog() { contains(RouteName), ); - pumpSemantics(label: 'Updated dialog label'); + pumpSemantics(label: 'Updated route label'); semantics().semanticsEnabled = false; }); - test('scopesRoute alone sets the dialog role with no label', () { + test('scopesRoute alone sets the SemanticRoute role with no label', () { final List warnings = []; printWarning = warnings.add; @@ -3132,7 +3132,7 @@ void _testDialog() { expect( owner().debugSemanticsTree![0]!.semanticRole?.kind, - SemanticRoleKind.dialog, + SemanticRoleKind.route, ); expect( owner().debugSemanticsTree![0]!.semanticRole?.behaviors, @@ -3190,7 +3190,7 @@ void _testDialog() { semantics().semanticsEnabled = false; }); - // Test the simple scenario of a dialog coming up and containing focusable + // Test the simple scenario of a route coming up and containing focusable // descendants that are not initially focused. The expectation is that the // first descendant will be auto-focused. test('focuses on the first unfocused Focusable', () async { @@ -3248,9 +3248,9 @@ void _testDialog() { semantics().semanticsEnabled = false; }); - // Test the scenario of a dialog coming up and containing focusable + // Test the scenario of a route coming up and containing focusable // descendants with one of them explicitly requesting focus. The expectation - // is that the dialog will not attempt to auto-focus on anything and let the + // is that the route will not attempt to auto-focus on anything and let the // respective descendant take focus. test('does nothing if a descendant asks for focus explicitly', () async { semantics() @@ -3306,7 +3306,7 @@ void _testDialog() { semantics().semanticsEnabled = false; }); - // Test the scenario of a dialog coming up and containing non-focusable + // Test the scenario of a route coming up and containing non-focusable // descendants that can have a11y focus. The expectation is that the first // descendant will be auto-focused, even if it's not input-focusable. test('focuses on the first non-focusable descedant', () async { @@ -3378,8 +3378,8 @@ void _testDialog() { }); // This mostly makes sure the engine doesn't crash if given a completely empty - // dialog trying to find something to focus on. - test('does nothing if nothing is focusable inside the dialog', () async { + // route trying to find something to focus on. + test('does nothing if nothing is focusable inside the route', () async { semantics() ..debugOverrideTimestampFunction(() => _testTime) ..semanticsEnabled = true;