[web] generalize focusability in semantics (flutter/engine#41831)

Introduce 2 new classes for a11y focus management:

* `AccessibilityFocusManager`: a generic class that attaches "focus" and "blur" event handlers, and forwards the events to the framework as `SemanticsAction.didGainAccessibilityFocus` and `SemanticsAction.didLoseAccessibilityFocus` respectively. Provides the `changeFocus` method for the framework to move a11y focus to the target element.
* `Focusable`: a role manager that provides generic focus management functionality to `SemanticsObject`s that don't need anything special.

Rewrites focus management using the above two classes as follows:

* All focusable nodes except text fields and incrementables get the `Focusable` role (all custom focus stuff in `Tappable` was removed and delegated to `Focusable`).
* `Incrementable` uses a custom `<input>` internally and so it cannot use the `Focusable` role. Instead, it uses `AccessibilityFocusManager` to manage the focus on the `<input>` element.

Behavioral changes:

* Fixes https://github.com/flutter/flutter/issues/118737, but more generally fixes all nodes that use the `isFocusable` and `hasFocus` bits.
* `Tappable` only partially implemented focusability (e.g. it didn't generate the respective `SemanticsAction` events). Now by delegating to `Focusable`, it will inherit all the functionality.
* `Incrementable` and `Checkable` (checkboxes, radios, switches) get focus management features for the first time.
* Elements that are not inherently focusable (text, images) can now be focused if semantics requires them to be.
* `TextField` is left alone for now as focus and on-screen keyboard interact with each other in non-obvious ways.
This commit is contained in:
Yegor
2023-05-10 15:40:20 -07:00
committed by GitHub
parent 9a60f2897e
commit bdfce08b64
9 changed files with 515 additions and 17 deletions

View File

@@ -1977,6 +1977,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics.dart + ../../../flu
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/image.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart + ../../../flutter/LICENSE
@@ -4592,6 +4593,7 @@ 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/image.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/semantics/label_and_value.dart

View File

@@ -134,6 +134,7 @@ export 'engine/safe_browser_api.dart';
export 'engine/semantics/accessibility.dart';
export 'engine/semantics/checkable.dart';
export 'engine/semantics/dialog.dart';
export 'engine/semantics/focusable.dart';
export 'engine/semantics/image.dart';
export 'engine/semantics/incrementable.dart';
export 'engine/semantics/label_and_value.dart';

View File

@@ -4,6 +4,7 @@
export 'semantics/accessibility.dart';
export 'semantics/checkable.dart';
export 'semantics/focusable.dart';
export 'semantics/image.dart';
export 'semantics/incrementable.dart';
export 'semantics/label_and_value.dart';

View File

@@ -0,0 +1,193 @@
// 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 'package:ui/ui.dart' as ui;
import '../dom.dart';
import '../platform_dispatcher.dart';
import '../util.dart';
import 'semantics.dart';
/// Supplies generic accessibility focus features to semantics nodes that have
/// [ui.SemanticsFlag.isFocusable] set.
///
/// Assumes that the element being focused on is [SemanticsObject.element]. Role
/// managers with special needs can implement custom focus management and
/// exclude this role manager.
///
/// `"tab-index=0"` is used because `<flt-semantics>` is not intrinsically
/// focusable. Examples of intrinsically focusable elements include:
///
/// * <button>
/// * <input> (of any type)
/// * <a>
/// * <textarea>
///
/// See also:
///
/// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets
class Focusable extends RoleManager {
Focusable(SemanticsObject semanticsObject)
: _focusManager = AccessibilityFocusManager(semanticsObject.owner),
super(Role.focusable, semanticsObject) {
_focusManager.manage(semanticsObject.id, semanticsObject.element);
}
final AccessibilityFocusManager _focusManager;
@override
void update() {
_focusManager.changeFocus(semanticsObject.hasFocus && (!semanticsObject.hasEnabledState || semanticsObject.isEnabled));
}
@override
void dispose() {
_focusManager.stopManaging();
}
}
/// Objects associated with the element whose focus is being managed.
typedef _FocusTarget = ({
/// [SemanticsObject.id] of the semantics node being managed.
int semanticsNodeId,
/// The element whose focus is being managed.
DomElement element,
/// The listener for the "focus" DOM event.
DomEventListener domFocusListener,
/// The listener for the "blur" DOM event.
DomEventListener domBlurListener,
});
/// Implements accessibility focus management for arbitrary elements.
///
/// Unlike [Focusable], which implements focus features on [SemanticsObject]s
/// whose [SemanticsObject.element] is directly focusable, this class can help
/// implementing focus features on custom elements. For example, [Incrementable]
/// uses a custom `<input>` tag internally while its root-level element is not
/// focusable. However, it can still use this class to manage the focus of the
/// internal element.
class AccessibilityFocusManager {
/// Creates a focus manager tied to a specific [EngineSemanticsOwner].
AccessibilityFocusManager(this._owner);
final EngineSemanticsOwner _owner;
_FocusTarget? _target;
/// Starts managing the focus of the given [element].
///
/// The "focus" and "blur" DOM events are forwarded to the framework-side
/// semantics node with ID [semanticsNodeId] as [ui.SemanticsAction]s.
///
/// If this manage was already managing a different element, stops managing
/// the old element and starts managing the new one.
///
/// Calling this with the same element but a different [semanticsNodeId] will
/// cause any future focus/blur events to be forwarded to the new ID.
void manage(int semanticsNodeId, DomElement element) {
if (identical(element, _target?.element)) {
final _FocusTarget previousTarget = _target!;
if (semanticsNodeId == previousTarget.semanticsNodeId) {
return;
}
// No need to hook up new DOM listeners. The existing ones are good enough.
_target = (
semanticsNodeId: semanticsNodeId,
element: previousTarget.element,
domFocusListener: previousTarget.domFocusListener,
domBlurListener: previousTarget.domBlurListener,
);
return;
}
if (_target != null) {
// The element changed. Clear the old element before initializing the new one.
stopManaging();
}
final _FocusTarget newTarget = (
semanticsNodeId: semanticsNodeId,
element: element,
domFocusListener: createDomEventListener((_) => _setFocusFromDom(true)),
domBlurListener: createDomEventListener((_) => _setFocusFromDom(false)),
);
_target = newTarget;
element.tabIndex = 0;
element.addEventListener('focus', newTarget.domFocusListener);
element.addEventListener('blur', newTarget.domBlurListener);
}
/// Stops managing the focus of the current element, if any.
void stopManaging() {
final _FocusTarget? target = _target;
if (target == null) {
/// Nothing is being managed. Just return.
return;
}
target.element.removeEventListener('focus', target.domFocusListener);
target.element.removeEventListener('blur', target.domBlurListener);
_target = null;
}
void _setFocusFromDom(bool acquireFocus) {
final _FocusTarget? target = _target;
if (target == null) {
// DOM events can be asynchronous. By the time the event reaches here, the
// focus manager may have been disposed of.
return;
}
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
target.semanticsNodeId,
acquireFocus
? ui.SemanticsAction.didGainAccessibilityFocus
: ui.SemanticsAction.didLoseAccessibilityFocus,
null,
);
}
/// Requests focus or blur on the DOM element.
void changeFocus(bool value) {
final _FocusTarget? target = _target;
if (target == null) {
// Nothing is being managed right now.
assert(() {
printWarning(
'Cannot change focus to $value. No element is being managed by this '
'AccessibilityFocusManager.'
);
return true;
}());
return;
}
// Delay the focus request until the final DOM structure is established
// because the element may not yet be attached to the DOM, or it may be
// reparented and lose focus again.
_owner.addOneTimePostUpdateCallback(() {
if (_target != target) {
// The element may have been swapped or the manager may have been disposed
// of between the focus change request and the post update callback
// invocation. So check again that the element is still the same and is
// not null.
return;
}
if (value) {
target.element.focus();
} else {
target.element.blur();
}
});
}
}

View File

@@ -6,6 +6,7 @@ import 'package:ui/ui.dart' as ui;
import '../dom.dart';
import '../platform_dispatcher.dart';
import 'focusable.dart';
import 'semantics.dart';
/// Adds increment/decrement event handling to a semantics object.
@@ -19,7 +20,8 @@ import 'semantics.dart';
/// gestures must be interpreted by the Flutter framework.
class Incrementable extends RoleManager {
Incrementable(SemanticsObject semanticsObject)
: super(Role.incrementable, semanticsObject) {
: _focusManager = AccessibilityFocusManager(semanticsObject.owner),
super(Role.incrementable, semanticsObject) {
semanticsObject.element.append(_element);
_element.type = 'range';
_element.setAttribute('role', 'slider');
@@ -47,11 +49,14 @@ class Incrementable extends RoleManager {
update();
};
semanticsObject.owner.addGestureModeListener(_gestureModeListener);
_focusManager.manage(semanticsObject.id, _element);
}
/// The HTML element used to render semantics to the browser.
final DomHTMLInputElement _element = createDomHTMLInputElement();
final AccessibilityFocusManager _focusManager;
/// The value used by the input element.
///
/// Flutter values are strings, and are not necessarily numbers. In order to
@@ -82,6 +87,7 @@ class Incrementable extends RoleManager {
case GestureMode.pointerEvents:
_disableBrowserGestureHandling();
}
_focusManager.changeFocus(semanticsObject.hasFocus);
}
void _enableBrowserGestureHandling() {
@@ -134,6 +140,7 @@ class Incrementable extends RoleManager {
@override
void dispose() {
assert(_gestureModeListener != null);
_focusManager.stopManaging();
semanticsObject.owner.removeGestureModeListener(_gestureModeListener);
_gestureModeListener = null;
_disableBrowserGestureHandling();

View File

@@ -18,6 +18,7 @@ import '../util.dart';
import '../vector_math.dart';
import 'checkable.dart';
import 'dialog.dart';
import 'focusable.dart';
import 'image.dart';
import 'incrementable.dart';
import 'label_and_value.dart';
@@ -326,6 +327,10 @@ class SemanticsNodeUpdate {
/// Identifies one of the roles a [SemanticsObject] plays.
enum Role {
/// Supplies generic accessibility focus features to semantics nodes that have
/// [ui.SemanticsFlag.isFocusable] set.
focusable,
/// Supports incrementing and/or decrementing its value.
incrementable,
@@ -375,6 +380,7 @@ enum Role {
typedef RoleManagerFactory = RoleManager Function(SemanticsObject object);
final Map<Role, RoleManagerFactory> _roleFactories = <Role, RoleManagerFactory>{
Role.focusable: (SemanticsObject object) => Focusable(object),
Role.incrementable: (SemanticsObject object) => Incrementable(object),
Role.scrollable: (SemanticsObject object) => Scrollable(object),
Role.labelAndValue: (SemanticsObject object) => LabelAndValue(object),
@@ -834,8 +840,24 @@ class SemanticsObject {
hasAction(ui.SemanticsAction.scrollDown) ||
hasAction(ui.SemanticsAction.scrollUp);
/// Whether this object represents a widget that can receive input focus.
bool get isFocusable => hasFlag(ui.SemanticsFlag.isFocusable);
/// Whether this object currently has input focus.
///
/// This value only makes sense if [isFocusable] is true.
bool get hasFocus => hasFlag(ui.SemanticsFlag.isFocused);
/// Whether this object can be in one of "enabled" or "disabled" state.
///
/// If this is true, [isEnabled] communicates the state.
bool get hasEnabledState => hasFlag(ui.SemanticsFlag.hasEnabledState);
/// Whether this object is enabled.
///
/// This field is only meaningful if [hasEnabledState] is true.
bool get isEnabled => hasFlag(ui.SemanticsFlag.isEnabled);
/// Whether this object represents a hotizontally scrollable area.
bool get isHorizontalScrollContainer =>
hasAction(ui.SemanticsAction.scrollLeft) ||
@@ -1262,7 +1284,7 @@ class SemanticsObject {
RoleManager? debugRoleManagerFor(Role role) => _roleManagers[role];
/// Detects the roles that this semantics object corresponds to and manages
/// the lifecycles of [SemanticsObjectRole] objects.
/// the lifecycles of [RoleManager] objects.
void _updateRoles() {
// Some role managers manage labels themselves for various role-specific reasons.
final bool managesOwnLabel = isTextField || isDialog || isVisualOnly;
@@ -1271,6 +1293,11 @@ class SemanticsObject {
_updateRole(Role.dialog, isDialog);
_updateRole(Role.textField, isTextField);
// The generic `Focusable` role manager can be used for everything except
// text fields and incrementables, which have special needs not satisfied by
// the generic implementation.
_updateRole(Role.focusable, isFocusable && !isTextField && !isIncrementable);
final bool shouldUseTappableRole =
(hasAction(ui.SemanticsAction.tap) || hasFlag(ui.SemanticsFlag.isButton)) &&
// Text fields manage their own focus/tap interactions. Tappable role

View File

@@ -24,10 +24,6 @@ class Tappable extends RoleManager {
void update() {
final DomElement element = semanticsObject.element;
// "tab-index=0" is used to allow keyboard traversal of non-form elements.
// See also: https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets
element.tabIndex = 0;
semanticsObject.setAriaRole(
'button', semanticsObject.hasFlag(ui.SemanticsFlag.isButton));
@@ -57,13 +53,6 @@ class Tappable extends RoleManager {
_stopListening();
}
}
// Request focus so that the AT shifts a11y focus to this node.
if (semanticsObject.isFlagsDirty && semanticsObject.hasFocus) {
semanticsObject.owner.addOneTimePostUpdateCallback(() {
element.focus();
});
}
}
void _stopListening() {

View File

@@ -85,6 +85,9 @@ void runSemanticsTests() {
group('dialog', () {
_testDialog();
});
group('focusable', () {
_testFocusable();
});
}
void _testEngineAccessibilityBuilder() {
@@ -1247,6 +1250,14 @@ void _testIncrementables() {
<input aria-valuenow="1" aria-valuetext="d" aria-valuemax="1" aria-valuemin="1">
</sem>''');
final SemanticsObject node = semantics().debugSemanticsTree![0]!;
expect(node.debugRoleManagerFor(Role.incrementable), isNotNull);
expect(
reason: 'Incrementables use custom focus management',
node.debugRoleManagerFor(Role.focusable),
isNull,
);
semantics().semanticsEnabled = false;
});
@@ -1342,6 +1353,49 @@ void _testIncrementables() {
semantics().semanticsEnabled = false;
});
test('sends focus events', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
void pumpSemantics({ required bool isFocused }) {
final SemanticsTester tester = SemanticsTester(semantics());
tester.updateNode(
id: 0,
hasIncrease: true,
isFocusable: true,
isFocused: isFocused,
hasEnabledState: true,
isEnabled: true,
value: 'd',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
}
final List<CapturedAction> capturedActions = <CapturedAction>[];
EnginePlatformDispatcher.instance.onSemanticsAction = (int nodeId, ui.SemanticsAction action, ByteData? args) {
capturedActions.add((nodeId, action, args));
};
pumpSemantics(isFocused: false);
expect(capturedActions, isEmpty);
pumpSemantics(isFocused: true);
expect(capturedActions, <CapturedAction>[
(0, ui.SemanticsAction.didGainAccessibilityFocus, null),
]);
pumpSemantics(isFocused: false);
expect(capturedActions, <CapturedAction>[
(0, ui.SemanticsAction.didGainAccessibilityFocus, null),
(0, ui.SemanticsAction.didLoseAccessibilityFocus, null),
]);
semantics().semanticsEnabled = false;
});
}
void _testTextField() {
@@ -1366,6 +1420,14 @@ void _testTextField() {
<input value="hello" />
</sem>''');
final SemanticsObject node = semantics().debugSemanticsTree![0]!;
expect(node.debugRoleManagerFor(Role.textField), isNotNull);
expect(
reason: 'Text fields use custom focus management',
node.debugRoleManagerFor(Role.focusable),
isNull,
);
semantics().semanticsEnabled = false;
});
@@ -1403,7 +1465,6 @@ void _testTextField() {
semantics().semanticsEnabled = false;
}, // TODO(yjbanov): https://github.com/flutter/flutter/issues/46638
// TODO(yjbanov): https://github.com/flutter/flutter/issues/50590
// TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
skip: browserEngine != BrowserEngine.blink);
}
@@ -1421,7 +1482,8 @@ void _testCheckables() {
ui.SemanticsFlag.isEnabled.index |
ui.SemanticsFlag.hasEnabledState.index |
ui.SemanticsFlag.hasToggledState.index |
ui.SemanticsFlag.isToggled.index,
ui.SemanticsFlag.isToggled.index |
ui.SemanticsFlag.isFocusable.index,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
@@ -1431,6 +1493,10 @@ void _testCheckables() {
<sem role="switch" aria-checked="true" style="$rootSemanticStyle"></sem>
''');
final SemanticsObject node = semantics().debugSemanticsTree![0]!;
expect(node.debugRoleManagerFor(Role.checkable), isNotNull);
expect(node.debugRoleManagerFor(Role.focusable), isNotNull);
semantics().semanticsEnabled = false;
});
@@ -1638,6 +1704,53 @@ void _testCheckables() {
semantics().semanticsEnabled = false;
});
test('sends focus events', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
void pumpSemantics({ required bool isFocused }) {
final SemanticsTester tester = SemanticsTester(semantics());
tester.updateNode(
id: 0,
// The following combination of actions and flags describe a checkbox.
hasTap: true,
hasEnabledState: true,
isEnabled: true,
hasCheckedState: true,
isFocusable: true,
isFocused: isFocused,
value: 'd',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
}
final List<CapturedAction> capturedActions = <CapturedAction>[];
EnginePlatformDispatcher.instance.onSemanticsAction = (int nodeId, ui.SemanticsAction action, ByteData? args) {
capturedActions.add((nodeId, action, args));
};
pumpSemantics(isFocused: false);
expect(capturedActions, isEmpty);
pumpSemantics(isFocused: true);
expect(capturedActions, <CapturedAction>[
(0, ui.SemanticsAction.didGainAccessibilityFocus, null),
]);
pumpSemantics(isFocused: false);
expect(capturedActions, <CapturedAction>[
(0, ui.SemanticsAction.didGainAccessibilityFocus, null),
(0, ui.SemanticsAction.didLoseAccessibilityFocus, null),
]);
semantics().semanticsEnabled = false;
});
}
void _testTappable() {
@@ -1649,6 +1762,7 @@ void _testTappable() {
final SemanticsTester tester = SemanticsTester(semantics());
tester.updateNode(
id: 0,
isFocusable: true,
hasTap: true,
hasEnabledState: true,
isEnabled: true,
@@ -1661,6 +1775,9 @@ void _testTappable() {
<sem role="button" style="$rootSemanticStyle"></sem>
''');
final SemanticsObject node = semantics().debugSemanticsTree![0]!;
expect(node.debugRoleManagerFor(Role.tappable), isNotNull);
expect(node.debugRoleManagerFor(Role.focusable), isNotNull);
expect(tester.getSemanticsObject(0).element.tabIndex, 0);
semantics().semanticsEnabled = false;
@@ -1737,6 +1854,7 @@ void _testTappable() {
hasEnabledState: true,
isEnabled: true,
isButton: true,
isFocusable: true,
isFocused: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
@@ -1745,6 +1863,53 @@ void _testTappable() {
expect(domDocument.activeElement, tester.getSemanticsObject(0).element);
semantics().semanticsEnabled = false;
});
test('sends focus events', () async {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
void pumpSemantics({ required bool isFocused }) {
final SemanticsTester tester = SemanticsTester(semantics());
tester.updateNode(
id: 0,
// The following combination of actions and flags describe a button.
hasTap: true,
hasEnabledState: true,
isEnabled: true,
isButton: true,
isFocusable: true,
isFocused: isFocused,
value: 'd',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
}
final List<CapturedAction> capturedActions = <CapturedAction>[];
EnginePlatformDispatcher.instance.onSemanticsAction = (int nodeId, ui.SemanticsAction action, ByteData? args) {
capturedActions.add((nodeId, action, args));
};
pumpSemantics(isFocused: false);
expect(capturedActions, isEmpty);
pumpSemantics(isFocused: true);
expect(capturedActions, <CapturedAction>[
(0, ui.SemanticsAction.didGainAccessibilityFocus, null),
]);
pumpSemantics(isFocused: false);
expect(capturedActions, <CapturedAction>[
(0, ui.SemanticsAction.didGainAccessibilityFocus, null),
(0, ui.SemanticsAction.didLoseAccessibilityFocus, null),
]);
semantics().semanticsEnabled = false;
});
}
void _testImage() {
@@ -2257,9 +2422,123 @@ void _testDialog() {
});
}
typedef CapturedAction = (int nodeId, ui.SemanticsAction action, ByteData? args);
void _testFocusable() {
test('AccessibilityFocusManager can manage element focus', () async {
final EngineSemanticsOwner owner = semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
void pumpSemantics() {
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
updateNode(
builder,
label: 'Dummy root element',
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
childrenInHitTestOrder: Int32List.fromList(<int>[]),
childrenInTraversalOrder: Int32List.fromList(<int>[]),
);
semantics().updateSemantics(builder.build());
}
final List<CapturedAction> capturedActions = <CapturedAction>[];
EnginePlatformDispatcher.instance.onSemanticsAction = (int nodeId, ui.SemanticsAction action, ByteData? args) {
capturedActions.add((nodeId, action, args));
};
expect(capturedActions, isEmpty);
final AccessibilityFocusManager manager = AccessibilityFocusManager(owner);
expect(capturedActions, isEmpty);
final DomElement element = createDomElement('test-element');
expect(element.tabIndex, -1);
domDocument.body!.append(element);
manager.manage(1, element);
expect(element.tabIndex, 0);
expect(capturedActions, isEmpty);
manager.changeFocus(true);
pumpSemantics(); // triggers post-update callbacks
expect(capturedActions, <CapturedAction>[
(1, ui.SemanticsAction.didGainAccessibilityFocus, null),
]);
capturedActions.clear();
manager.changeFocus(false);
pumpSemantics(); // triggers post-update callbacks
expect(capturedActions, <CapturedAction>[
(1, ui.SemanticsAction.didLoseAccessibilityFocus, null),
]);
capturedActions.clear();
manager.stopManaging();
manager.changeFocus(true);
pumpSemantics(); // triggers post-update callbacks
expect(capturedActions, isEmpty);
semantics().semanticsEnabled = false;
});
test('applies generic Focusable role', () {
semantics()
..debugOverrideTimestampFunction(() => _testTime)
..semanticsEnabled = true;
{
final SemanticsTester tester = SemanticsTester(semantics());
tester.updateNode(
id: 0,
transform: Matrix4.identity().toFloat64(),
children: <SemanticsNodeUpdate>[
tester.updateNode(
id: 1,
label: 'focusable text',
isFocusable: true,
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
),
],
);
tester.apply();
}
expectSemanticsTree('''
<sem role="group" style="$rootSemanticStyle">
<sem-c>
<sem aria-label="focusable text"></sem>
</sem-c>
</sem>
''');
final SemanticsObject node = semantics().debugSemanticsTree![1]!;
expect(node.isFocusable, isTrue);
expect(node.debugRoleManagerFor(Role.focusable), isA<Focusable>());
final DomElement element = node.element;
expect(domDocument.activeElement, isNot(element));
{
final SemanticsTester tester = SemanticsTester(semantics());
tester.updateNode(
id: 1,
label: 'test focusable',
isFocusable: true,
isFocused: true,
transform: Matrix4.identity().toFloat64(),
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
);
tester.apply();
}
expect(domDocument.activeElement, element);
semantics().semanticsEnabled = false;
});
}
/// A facade in front of [ui.SemanticsUpdateBuilder.updateNode] that
/// supplies default values for semantics attributes.
// TODO(yjbanov): move this to TestSemanticsBuilder
void updateNode(
ui.SemanticsUpdateBuilder builder, {
int id = 0,

View File

@@ -111,7 +111,6 @@ void testMain() {
expect(await logger.actionLog.first, ui.SemanticsAction.tap);
}, // TODO(yjbanov): https://github.com/flutter/flutter/issues/46638
// TODO(yjbanov): https://github.com/flutter/flutter/issues/50590
// TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
skip: browserEngine != BrowserEngine.blink);
test('Syncs semantic state from framework', () {