[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:
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user