mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
[web] switch from .didGain/LoseAccessibilityFocus to .focus (flutter/engine#53134)
Stop using `SemanticsAction.didGain/LoseAccessibilityFocus` on the web, start using `SemanticsAction.focus`. This is because on the web, a11y focus is not observable, only input focus is. Sending `SemanticsAction.focus` will guarantee that the framework move focus to the respective widget. There currently is no "unfocus" signal, because it seems to be already covered: either another widget gains focus, or an HTML DOM element outside the Flutter view does, both of which have their respective signals already. More details in the discussion in the issue https://github.com/flutter/flutter/issues/83809. Fixes https://github.com/flutter/flutter/issues/83809 Fixes https://github.com/flutter/flutter/issues/148285 Fixes https://github.com/flutter/flutter/issues/143337
This commit is contained in:
parent
3d2fa03445
commit
f1978535f0
@ -2737,6 +2737,30 @@ DomCompositionEvent createDomCompositionEvent(String type,
|
||||
}
|
||||
}
|
||||
|
||||
/// This is a pseudo-type for DOM elements that have the boolean `disabled`
|
||||
/// property.
|
||||
///
|
||||
/// This type cannot be part of the actual type hierarchy because each DOM type
|
||||
/// defines its `disabled` property ad hoc, without inheriting it from a common
|
||||
/// type, e.g. [DomHTMLInputElement] and [DomHTMLTextAreaElement].
|
||||
///
|
||||
/// To use, simply cast any element known to have the `disabled` property to
|
||||
/// this type using `as DomElementWithDisabledProperty`, then read and write
|
||||
/// this property as normal.
|
||||
@JS()
|
||||
@staticInterop
|
||||
class DomElementWithDisabledProperty extends DomHTMLElement {}
|
||||
|
||||
extension DomElementWithDisabledPropertyExtension on DomElementWithDisabledProperty {
|
||||
@JS('disabled')
|
||||
external JSBoolean? get _disabled;
|
||||
bool? get disabled => _disabled?.toDart;
|
||||
|
||||
@JS('disabled')
|
||||
external set _disabled(JSBoolean? value);
|
||||
set disabled(bool? value) => _disabled = value?.toJS;
|
||||
}
|
||||
|
||||
@JS()
|
||||
@staticInterop
|
||||
class DomHTMLInputElement extends DomHTMLElement {}
|
||||
|
||||
@ -81,9 +81,6 @@ typedef _FocusTarget = ({
|
||||
|
||||
/// 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.
|
||||
@ -135,7 +132,6 @@ class AccessibilityFocusManager {
|
||||
semanticsNodeId: semanticsNodeId,
|
||||
element: previousTarget.element,
|
||||
domFocusListener: previousTarget.domFocusListener,
|
||||
domBlurListener: previousTarget.domBlurListener,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -148,14 +144,12 @@ class AccessibilityFocusManager {
|
||||
final _FocusTarget newTarget = (
|
||||
semanticsNodeId: semanticsNodeId,
|
||||
element: element,
|
||||
domFocusListener: createDomEventListener((_) => _setFocusFromDom(true)),
|
||||
domBlurListener: createDomEventListener((_) => _setFocusFromDom(false)),
|
||||
domFocusListener: createDomEventListener((_) => _didReceiveDomFocus()),
|
||||
);
|
||||
_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.
|
||||
@ -170,10 +164,9 @@ class AccessibilityFocusManager {
|
||||
}
|
||||
|
||||
target.element.removeEventListener('focus', target.domFocusListener);
|
||||
target.element.removeEventListener('blur', target.domBlurListener);
|
||||
}
|
||||
|
||||
void _setFocusFromDom(bool acquireFocus) {
|
||||
void _didReceiveDomFocus() {
|
||||
final _FocusTarget? target = _target;
|
||||
|
||||
if (target == null) {
|
||||
@ -184,9 +177,7 @@ class AccessibilityFocusManager {
|
||||
|
||||
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
|
||||
target.semanticsNodeId,
|
||||
acquireFocus
|
||||
? ui.SemanticsAction.didGainAccessibilityFocus
|
||||
: ui.SemanticsAction.didLoseAccessibilityFocus,
|
||||
ui.SemanticsAction.focus,
|
||||
null,
|
||||
);
|
||||
}
|
||||
@ -229,7 +220,7 @@ class AccessibilityFocusManager {
|
||||
// a dialog, and nothing else in the dialog 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
|
||||
// didGainAccessibilityFocus action. Screen readers on the web do not do
|
||||
// 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,
|
||||
// but now there may be a race between this method unsetting the focus and
|
||||
|
||||
@ -257,6 +257,7 @@ class TextField extends PrimaryRoleManager {
|
||||
editableElement = semanticsObject.hasFlag(ui.SemanticsFlag.isMultiline)
|
||||
? createDomHTMLTextAreaElement()
|
||||
: createDomHTMLInputElement();
|
||||
_updateEnabledState();
|
||||
|
||||
// On iOS, even though the semantic text field is transparent, the cursor
|
||||
// and text highlighting are still visible. The cursor and text selection
|
||||
@ -310,16 +311,7 @@ class TextField extends PrimaryRoleManager {
|
||||
}
|
||||
|
||||
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
|
||||
semanticsObject.id, ui.SemanticsAction.didGainAccessibilityFocus, null);
|
||||
}));
|
||||
activeEditableElement.addEventListener('blur',
|
||||
createDomEventListener((DomEvent event) {
|
||||
if (EngineSemantics.instance.gestureMode != GestureMode.browserGestures) {
|
||||
return;
|
||||
}
|
||||
|
||||
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
|
||||
semanticsObject.id, ui.SemanticsAction.didLoseAccessibilityFocus, null);
|
||||
semanticsObject.id, ui.SemanticsAction.focus, null);
|
||||
}));
|
||||
}
|
||||
|
||||
@ -433,20 +425,19 @@ class TextField extends PrimaryRoleManager {
|
||||
// and wait for a tap event before invoking the iOS workaround and creating
|
||||
// the editable element.
|
||||
if (editableElement != null) {
|
||||
_updateEnabledState();
|
||||
activeEditableElement.style
|
||||
..width = '${semanticsObject.rect!.width}px'
|
||||
..height = '${semanticsObject.rect!.height}px';
|
||||
|
||||
if (semanticsObject.hasFocus) {
|
||||
if (domDocument.activeElement !=
|
||||
activeEditableElement) {
|
||||
if (domDocument.activeElement != activeEditableElement && semanticsObject.isEnabled) {
|
||||
semanticsObject.owner.addOneTimePostUpdateCallback(() {
|
||||
activeEditableElement.focus();
|
||||
});
|
||||
}
|
||||
SemanticsTextEditingStrategy._instance?.activate(this);
|
||||
} else if (domDocument.activeElement ==
|
||||
activeEditableElement) {
|
||||
} else if (domDocument.activeElement == activeEditableElement) {
|
||||
if (!isIosSafari) {
|
||||
SemanticsTextEditingStrategy._instance?.deactivate(this);
|
||||
// Only apply text, because this node is not focused.
|
||||
@ -466,6 +457,16 @@ class TextField extends PrimaryRoleManager {
|
||||
}
|
||||
}
|
||||
|
||||
void _updateEnabledState() {
|
||||
final DomElement? element = editableElement;
|
||||
|
||||
if (element == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
(element as DomElementWithDisabledProperty).disabled = !semanticsObject.isEnabled;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
|
||||
@ -1776,7 +1776,7 @@ void _testIncrementables() {
|
||||
|
||||
pumpSemantics(isFocused: true);
|
||||
expect(capturedActions, <CapturedAction>[
|
||||
(0, ui.SemanticsAction.didGainAccessibilityFocus, null),
|
||||
(0, ui.SemanticsAction.focus, null),
|
||||
]);
|
||||
capturedActions.clear();
|
||||
|
||||
@ -1787,10 +1787,12 @@ void _testIncrementables() {
|
||||
isEmpty,
|
||||
);
|
||||
|
||||
// The web doesn't send didLoseAccessibilityFocus as on the web,
|
||||
// accessibility focus is not observable, only input focus is. As of this
|
||||
// writing, there is no SemanticsAction.unfocus action, so the test simply
|
||||
// asserts that no actions are being sent as a result of blur.
|
||||
element.blur();
|
||||
expect(capturedActions, <CapturedAction>[
|
||||
(0, ui.SemanticsAction.didLoseAccessibilityFocus, null),
|
||||
]);
|
||||
expect(capturedActions, isEmpty);
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
});
|
||||
@ -1821,15 +1823,14 @@ void _testTextField() {
|
||||
|
||||
|
||||
final SemanticsObject node = owner().debugSemanticsTree![0]!;
|
||||
final TextField textFieldRole = node.primaryRole! as TextField;
|
||||
final DomHTMLInputElement inputElement = textFieldRole.activeEditableElement as DomHTMLInputElement;
|
||||
|
||||
// TODO(yjbanov): this used to attempt to test that value="hello" but the
|
||||
// test was a false positive. We should revise this test and
|
||||
// make sure it tests the right things:
|
||||
// https://github.com/flutter/flutter/issues/147200
|
||||
expect(
|
||||
(node.element as DomHTMLInputElement).value,
|
||||
isNull,
|
||||
);
|
||||
expect(inputElement.value, '');
|
||||
|
||||
expect(node.primaryRole?.role, PrimaryRole.textField);
|
||||
expect(
|
||||
@ -1852,8 +1853,8 @@ void _testTextField() {
|
||||
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
|
||||
updateNode(
|
||||
builder,
|
||||
actions: 0 | ui.SemanticsAction.didGainAccessibilityFocus.index,
|
||||
flags: 0 | ui.SemanticsFlag.isTextField.index,
|
||||
actions: 0 | ui.SemanticsAction.focus.index,
|
||||
flags: 0 | ui.SemanticsFlag.isTextField.index | ui.SemanticsFlag.isEnabled.index,
|
||||
value: 'hello',
|
||||
transform: Matrix4.identity().toFloat64(),
|
||||
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
|
||||
@ -1870,7 +1871,7 @@ void _testTextField() {
|
||||
|
||||
expect(owner().semanticsHost.ownerDocument?.activeElement, textField);
|
||||
expect(await logger.idLog.first, 0);
|
||||
expect(await logger.actionLog.first, ui.SemanticsAction.didGainAccessibilityFocus);
|
||||
expect(await logger.actionLog.first, ui.SemanticsAction.focus);
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
}, // TODO(yjbanov): https://github.com/flutter/flutter/issues/46638
|
||||
@ -2156,7 +2157,7 @@ void _testCheckables() {
|
||||
|
||||
pumpSemantics(isFocused: true);
|
||||
expect(capturedActions, <CapturedAction>[
|
||||
(0, ui.SemanticsAction.didGainAccessibilityFocus, null),
|
||||
(0, ui.SemanticsAction.focus, null),
|
||||
]);
|
||||
capturedActions.clear();
|
||||
|
||||
@ -2166,15 +2167,12 @@ void _testCheckables() {
|
||||
pumpSemantics(isFocused: false);
|
||||
expect(capturedActions, isEmpty);
|
||||
|
||||
// If the element is blurred by the browser, then we do want to notify the
|
||||
// framework. This is because screen reader can be focused on something
|
||||
// other than what the framework is focused on, and notifying the framework
|
||||
// about the loss of focus on a node is information that the framework did
|
||||
// not have before.
|
||||
// The web doesn't send didLoseAccessibilityFocus as on the web,
|
||||
// accessibility focus is not observable, only input focus is. As of this
|
||||
// writing, there is no SemanticsAction.unfocus action, so the test simply
|
||||
// asserts that no actions are being sent as a result of blur.
|
||||
element.blur();
|
||||
expect(capturedActions, <CapturedAction>[
|
||||
(0, ui.SemanticsAction.didLoseAccessibilityFocus, null),
|
||||
]);
|
||||
expect(capturedActions, isEmpty);
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
});
|
||||
@ -2340,17 +2338,19 @@ void _testTappable() {
|
||||
|
||||
pumpSemantics(isFocused: true);
|
||||
expect(capturedActions, <CapturedAction>[
|
||||
(0, ui.SemanticsAction.didGainAccessibilityFocus, null),
|
||||
(0, ui.SemanticsAction.focus, null),
|
||||
]);
|
||||
capturedActions.clear();
|
||||
|
||||
pumpSemantics(isFocused: false);
|
||||
expect(capturedActions, isEmpty);
|
||||
|
||||
// The web doesn't send didLoseAccessibilityFocus as on the web,
|
||||
// accessibility focus is not observable, only input focus is. As of this
|
||||
// writing, there is no SemanticsAction.unfocus action, so the test simply
|
||||
// asserts that no actions are being sent as a result of blur.
|
||||
element.blur();
|
||||
expect(capturedActions, <CapturedAction>[
|
||||
(0, ui.SemanticsAction.didLoseAccessibilityFocus, null),
|
||||
]);
|
||||
expect(capturedActions, isEmpty);
|
||||
|
||||
semantics().semanticsEnabled = false;
|
||||
});
|
||||
@ -3180,7 +3180,7 @@ void _testDialog() {
|
||||
expect(
|
||||
capturedActions,
|
||||
<CapturedAction>[
|
||||
(2, ui.SemanticsAction.didGainAccessibilityFocus, null),
|
||||
(2, ui.SemanticsAction.focus, null),
|
||||
],
|
||||
);
|
||||
|
||||
@ -3242,7 +3242,7 @@ void _testDialog() {
|
||||
expect(
|
||||
capturedActions,
|
||||
<CapturedAction>[
|
||||
(3, ui.SemanticsAction.didGainAccessibilityFocus, null),
|
||||
(3, ui.SemanticsAction.focus, null),
|
||||
],
|
||||
);
|
||||
|
||||
@ -3392,7 +3392,7 @@ void _testFocusable() {
|
||||
pumpSemantics(); // triggers post-update callbacks
|
||||
expect(domDocument.activeElement, element);
|
||||
expect(capturedActions, <CapturedAction>[
|
||||
(1, ui.SemanticsAction.didGainAccessibilityFocus, null),
|
||||
(1, ui.SemanticsAction.focus, null),
|
||||
]);
|
||||
capturedActions.clear();
|
||||
|
||||
@ -3405,9 +3405,11 @@ void _testFocusable() {
|
||||
// Browser blurs the element
|
||||
element.blur();
|
||||
expect(domDocument.activeElement, isNot(element));
|
||||
expect(capturedActions, <CapturedAction>[
|
||||
(1, ui.SemanticsAction.didLoseAccessibilityFocus, null),
|
||||
]);
|
||||
// The web doesn't send didLoseAccessibilityFocus as on the web,
|
||||
// accessibility focus is not observable, only input focus is. As of this
|
||||
// writing, there is no SemanticsAction.unfocus action, so the test simply
|
||||
// asserts that no actions are being sent as a result of blur.
|
||||
expect(capturedActions, isEmpty);
|
||||
capturedActions.clear();
|
||||
|
||||
// Request focus again
|
||||
@ -3415,7 +3417,7 @@ void _testFocusable() {
|
||||
pumpSemantics(); // triggers post-update callbacks
|
||||
expect(domDocument.activeElement, element);
|
||||
expect(capturedActions, <CapturedAction>[
|
||||
(1, ui.SemanticsAction.didGainAccessibilityFocus, null),
|
||||
(1, ui.SemanticsAction.focus, null),
|
||||
]);
|
||||
capturedActions.clear();
|
||||
|
||||
|
||||
@ -75,6 +75,7 @@ class SemanticsTester {
|
||||
bool? hasPaste,
|
||||
bool? hasDidGainAccessibilityFocus,
|
||||
bool? hasDidLoseAccessibilityFocus,
|
||||
bool? hasFocus,
|
||||
bool? hasCustomAction,
|
||||
bool? hasDismiss,
|
||||
bool? hasMoveCursorForwardByWord,
|
||||
@ -242,6 +243,9 @@ class SemanticsTester {
|
||||
if (hasDidLoseAccessibilityFocus ?? false) {
|
||||
actions |= ui.SemanticsAction.didLoseAccessibilityFocus.index;
|
||||
}
|
||||
if (hasFocus ?? false) {
|
||||
actions |= ui.SemanticsAction.focus.index;
|
||||
}
|
||||
if (hasCustomAction ?? false) {
|
||||
actions |= ui.SemanticsAction.customAction.index;
|
||||
}
|
||||
|
||||
@ -102,15 +102,26 @@ void testMain() {
|
||||
// make sure it tests the right things:
|
||||
// https://github.com/flutter/flutter/issues/147200
|
||||
final SemanticsObject node = owner().debugSemanticsTree![0]!;
|
||||
expect(
|
||||
(node.element as DomHTMLInputElement).value,
|
||||
isNull,
|
||||
);
|
||||
final TextField textFieldRole = node.primaryRole! as TextField;
|
||||
final DomHTMLInputElement inputElement = textFieldRole.activeEditableElement as DomHTMLInputElement;
|
||||
expect(inputElement.tagName.toLowerCase(), 'input');
|
||||
expect(inputElement.value, '');
|
||||
expect(inputElement.disabled, isFalse);
|
||||
});
|
||||
|
||||
test('renders a disabled text field', () {
|
||||
createTextFieldSemantics(isEnabled: false, value: 'hello');
|
||||
expectSemanticsTree(owner(), '''<sem><input /></sem>''');
|
||||
final SemanticsObject node = owner().debugSemanticsTree![0]!;
|
||||
final TextField textFieldRole = node.primaryRole! as TextField;
|
||||
final DomHTMLInputElement inputElement = textFieldRole.activeEditableElement as DomHTMLInputElement;
|
||||
expect(inputElement.tagName.toLowerCase(), 'input');
|
||||
expect(inputElement.disabled, isTrue);
|
||||
});
|
||||
|
||||
// TODO(yjbanov): this test will need to be adjusted for Safari when we add
|
||||
// Safari testing.
|
||||
test('sends a didGainAccessibilityFocus/didLoseAccessibilityFocus action when browser requests focus/blur', () async {
|
||||
test('sends a SemanticsAction.focus action when browser requests focus', () async {
|
||||
final SemanticsActionLogger logger = SemanticsActionLogger();
|
||||
createTextFieldSemantics(value: 'hello');
|
||||
|
||||
@ -123,13 +134,11 @@ void testMain() {
|
||||
|
||||
expect(owner().semanticsHost.ownerDocument?.activeElement, textField);
|
||||
expect(await logger.idLog.first, 0);
|
||||
expect(await logger.actionLog.first, ui.SemanticsAction.didGainAccessibilityFocus);
|
||||
expect(await logger.actionLog.first, ui.SemanticsAction.focus);
|
||||
|
||||
textField.blur();
|
||||
|
||||
expect(owner().semanticsHost.ownerDocument?.activeElement, isNot(textField));
|
||||
expect(await logger.idLog.first, 0);
|
||||
expect(await logger.actionLog.first, ui.SemanticsAction.didLoseAccessibilityFocus);
|
||||
}, // TODO(yjbanov): https://github.com/flutter/flutter/issues/46638
|
||||
// TODO(yjbanov): https://github.com/flutter/flutter/issues/50590
|
||||
skip: ui_web.browser.browserEngine != ui_web.BrowserEngine.blink);
|
||||
@ -427,6 +436,7 @@ void testMain() {
|
||||
children: <SemanticsNodeUpdate>[
|
||||
builder.updateNode(
|
||||
id: 1,
|
||||
isEnabled: true,
|
||||
isTextField: true,
|
||||
value: 'Hello',
|
||||
isFocused: focusFieldId == 1,
|
||||
@ -434,6 +444,7 @@ void testMain() {
|
||||
),
|
||||
builder.updateNode(
|
||||
id: 2,
|
||||
isEnabled: true,
|
||||
isTextField: true,
|
||||
value: 'World',
|
||||
isFocused: focusFieldId == 2,
|
||||
@ -884,6 +895,7 @@ void testMain() {
|
||||
SemanticsObject createTextFieldSemantics({
|
||||
required String value,
|
||||
String label = '',
|
||||
bool isEnabled = true,
|
||||
bool isFocused = false,
|
||||
bool isMultiline = false,
|
||||
ui.Rect rect = const ui.Rect.fromLTRB(0, 0, 100, 50),
|
||||
@ -893,6 +905,7 @@ SemanticsObject createTextFieldSemantics({
|
||||
final SemanticsTester tester = SemanticsTester(owner());
|
||||
tester.updateNode(
|
||||
id: 0,
|
||||
isEnabled: isEnabled,
|
||||
label: label,
|
||||
value: value,
|
||||
isTextField: true,
|
||||
@ -973,6 +986,7 @@ Map<int, SemanticsObject> createTwoFieldSemanticsForIos(SemanticsTester builder,
|
||||
children: <SemanticsNodeUpdate>[
|
||||
builder.updateNode(
|
||||
id: 1,
|
||||
isEnabled: true,
|
||||
isTextField: true,
|
||||
value: 'Hello',
|
||||
label: 'Hello',
|
||||
@ -981,6 +995,7 @@ Map<int, SemanticsObject> createTwoFieldSemanticsForIos(SemanticsTester builder,
|
||||
),
|
||||
builder.updateNode(
|
||||
id: 2,
|
||||
isEnabled: true,
|
||||
isTextField: true,
|
||||
value: 'World',
|
||||
label: 'World',
|
||||
@ -1001,6 +1016,7 @@ Map<int, SemanticsObject> createTwoFieldSemanticsForIos(SemanticsTester builder,
|
||||
children: <SemanticsNodeUpdate>[
|
||||
builder.updateNode(
|
||||
id: 1,
|
||||
isEnabled: true,
|
||||
isTextField: true,
|
||||
value: 'Hello',
|
||||
label: 'Hello',
|
||||
@ -1009,6 +1025,7 @@ Map<int, SemanticsObject> createTwoFieldSemanticsForIos(SemanticsTester builder,
|
||||
),
|
||||
builder.updateNode(
|
||||
id: 2,
|
||||
isEnabled: true,
|
||||
isTextField: true,
|
||||
value: 'World',
|
||||
label: 'World',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user