Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit a22270b

Browse files
authored
[web] switch from .didGain/LoseAccessibilityFocus to .focus (#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 flutter/flutter#83809. Fixes flutter/flutter#83809 Fixes flutter/flutter#148285 Fixes flutter/flutter#143337
1 parent 4a3215c commit a22270b

File tree

6 files changed

+105
-66
lines changed

6 files changed

+105
-66
lines changed

lib/web_ui/lib/src/engine/dom.dart

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2737,6 +2737,30 @@ DomCompositionEvent createDomCompositionEvent(String type,
27372737
}
27382738
}
27392739

2740+
/// This is a pseudo-type for DOM elements that have the boolean `disabled`
2741+
/// property.
2742+
///
2743+
/// This type cannot be part of the actual type hierarchy because each DOM type
2744+
/// defines its `disabled` property ad hoc, without inheriting it from a common
2745+
/// type, e.g. [DomHTMLInputElement] and [DomHTMLTextAreaElement].
2746+
///
2747+
/// To use, simply cast any element known to have the `disabled` property to
2748+
/// this type using `as DomElementWithDisabledProperty`, then read and write
2749+
/// this property as normal.
2750+
@JS()
2751+
@staticInterop
2752+
class DomElementWithDisabledProperty extends DomHTMLElement {}
2753+
2754+
extension DomElementWithDisabledPropertyExtension on DomElementWithDisabledProperty {
2755+
@JS('disabled')
2756+
external JSBoolean? get _disabled;
2757+
bool? get disabled => _disabled?.toDart;
2758+
2759+
@JS('disabled')
2760+
external set _disabled(JSBoolean? value);
2761+
set disabled(bool? value) => _disabled = value?.toJS;
2762+
}
2763+
27402764
@JS()
27412765
@staticInterop
27422766
class DomHTMLInputElement extends DomHTMLElement {}

lib/web_ui/lib/src/engine/semantics/focusable.dart

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,6 @@ typedef _FocusTarget = ({
8181

8282
/// The listener for the "focus" DOM event.
8383
DomEventListener domFocusListener,
84-
85-
/// The listener for the "blur" DOM event.
86-
DomEventListener domBlurListener,
8784
});
8885

8986
/// Implements accessibility focus management for arbitrary elements.
@@ -135,7 +132,6 @@ class AccessibilityFocusManager {
135132
semanticsNodeId: semanticsNodeId,
136133
element: previousTarget.element,
137134
domFocusListener: previousTarget.domFocusListener,
138-
domBlurListener: previousTarget.domBlurListener,
139135
);
140136
return;
141137
}
@@ -148,14 +144,12 @@ class AccessibilityFocusManager {
148144
final _FocusTarget newTarget = (
149145
semanticsNodeId: semanticsNodeId,
150146
element: element,
151-
domFocusListener: createDomEventListener((_) => _setFocusFromDom(true)),
152-
domBlurListener: createDomEventListener((_) => _setFocusFromDom(false)),
147+
domFocusListener: createDomEventListener((_) => _didReceiveDomFocus()),
153148
);
154149
_target = newTarget;
155150

156151
element.tabIndex = 0;
157152
element.addEventListener('focus', newTarget.domFocusListener);
158-
element.addEventListener('blur', newTarget.domBlurListener);
159153
}
160154

161155
/// Stops managing the focus of the current element, if any.
@@ -170,10 +164,9 @@ class AccessibilityFocusManager {
170164
}
171165

172166
target.element.removeEventListener('focus', target.domFocusListener);
173-
target.element.removeEventListener('blur', target.domBlurListener);
174167
}
175168

176-
void _setFocusFromDom(bool acquireFocus) {
169+
void _didReceiveDomFocus() {
177170
final _FocusTarget? target = _target;
178171

179172
if (target == null) {
@@ -184,9 +177,7 @@ class AccessibilityFocusManager {
184177

185178
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
186179
target.semanticsNodeId,
187-
acquireFocus
188-
? ui.SemanticsAction.didGainAccessibilityFocus
189-
: ui.SemanticsAction.didLoseAccessibilityFocus,
180+
ui.SemanticsAction.focus,
190181
null,
191182
);
192183
}
@@ -229,7 +220,7 @@ class AccessibilityFocusManager {
229220
// a dialog, and nothing else in the dialog is focused. The Flutter
230221
// framework expects that the screen reader will focus on the first (in
231222
// traversal order) focusable element inside the dialog and send a
232-
// didGainAccessibilityFocus action. Screen readers on the web do not do
223+
// SemanticsAction.focus action. Screen readers on the web do not do
233224
// that, and so the web engine has to implement this behavior directly. So
234225
// the dialog will look for a focusable element and request focus on it,
235226
// but now there may be a race between this method unsetting the focus and

lib/web_ui/lib/src/engine/semantics/text_field.dart

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ class TextField extends PrimaryRoleManager {
257257
editableElement = semanticsObject.hasFlag(ui.SemanticsFlag.isMultiline)
258258
? createDomHTMLTextAreaElement()
259259
: createDomHTMLInputElement();
260+
_updateEnabledState();
260261

261262
// On iOS, even though the semantic text field is transparent, the cursor
262263
// and text highlighting are still visible. The cursor and text selection
@@ -310,16 +311,7 @@ class TextField extends PrimaryRoleManager {
310311
}
311312

312313
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
313-
semanticsObject.id, ui.SemanticsAction.didGainAccessibilityFocus, null);
314-
}));
315-
activeEditableElement.addEventListener('blur',
316-
createDomEventListener((DomEvent event) {
317-
if (EngineSemantics.instance.gestureMode != GestureMode.browserGestures) {
318-
return;
319-
}
320-
321-
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
322-
semanticsObject.id, ui.SemanticsAction.didLoseAccessibilityFocus, null);
314+
semanticsObject.id, ui.SemanticsAction.focus, null);
323315
}));
324316
}
325317

@@ -433,20 +425,19 @@ class TextField extends PrimaryRoleManager {
433425
// and wait for a tap event before invoking the iOS workaround and creating
434426
// the editable element.
435427
if (editableElement != null) {
428+
_updateEnabledState();
436429
activeEditableElement.style
437430
..width = '${semanticsObject.rect!.width}px'
438431
..height = '${semanticsObject.rect!.height}px';
439432

440433
if (semanticsObject.hasFocus) {
441-
if (domDocument.activeElement !=
442-
activeEditableElement) {
434+
if (domDocument.activeElement != activeEditableElement && semanticsObject.isEnabled) {
443435
semanticsObject.owner.addOneTimePostUpdateCallback(() {
444436
activeEditableElement.focus();
445437
});
446438
}
447439
SemanticsTextEditingStrategy._instance?.activate(this);
448-
} else if (domDocument.activeElement ==
449-
activeEditableElement) {
440+
} else if (domDocument.activeElement == activeEditableElement) {
450441
if (!isIosSafari) {
451442
SemanticsTextEditingStrategy._instance?.deactivate(this);
452443
// Only apply text, because this node is not focused.
@@ -466,6 +457,16 @@ class TextField extends PrimaryRoleManager {
466457
}
467458
}
468459

460+
void _updateEnabledState() {
461+
final DomElement? element = editableElement;
462+
463+
if (element == null) {
464+
return;
465+
}
466+
467+
(element as DomElementWithDisabledProperty).disabled = !semanticsObject.isEnabled;
468+
}
469+
469470
@override
470471
void dispose() {
471472
super.dispose();

lib/web_ui/test/engine/semantics/semantics_test.dart

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1776,7 +1776,7 @@ void _testIncrementables() {
17761776

17771777
pumpSemantics(isFocused: true);
17781778
expect(capturedActions, <CapturedAction>[
1779-
(0, ui.SemanticsAction.didGainAccessibilityFocus, null),
1779+
(0, ui.SemanticsAction.focus, null),
17801780
]);
17811781
capturedActions.clear();
17821782

@@ -1787,10 +1787,12 @@ void _testIncrementables() {
17871787
isEmpty,
17881788
);
17891789

1790+
// The web doesn't send didLoseAccessibilityFocus as on the web,
1791+
// accessibility focus is not observable, only input focus is. As of this
1792+
// writing, there is no SemanticsAction.unfocus action, so the test simply
1793+
// asserts that no actions are being sent as a result of blur.
17901794
element.blur();
1791-
expect(capturedActions, <CapturedAction>[
1792-
(0, ui.SemanticsAction.didLoseAccessibilityFocus, null),
1793-
]);
1795+
expect(capturedActions, isEmpty);
17941796

17951797
semantics().semanticsEnabled = false;
17961798
});
@@ -1821,15 +1823,14 @@ void _testTextField() {
18211823

18221824

18231825
final SemanticsObject node = owner().debugSemanticsTree![0]!;
1826+
final TextField textFieldRole = node.primaryRole! as TextField;
1827+
final DomHTMLInputElement inputElement = textFieldRole.activeEditableElement as DomHTMLInputElement;
18241828

18251829
// TODO(yjbanov): this used to attempt to test that value="hello" but the
18261830
// test was a false positive. We should revise this test and
18271831
// make sure it tests the right things:
18281832
// https://github.com/flutter/flutter/issues/147200
1829-
expect(
1830-
(node.element as DomHTMLInputElement).value,
1831-
isNull,
1832-
);
1833+
expect(inputElement.value, '');
18331834

18341835
expect(node.primaryRole?.role, PrimaryRole.textField);
18351836
expect(
@@ -1852,8 +1853,8 @@ void _testTextField() {
18521853
final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
18531854
updateNode(
18541855
builder,
1855-
actions: 0 | ui.SemanticsAction.didGainAccessibilityFocus.index,
1856-
flags: 0 | ui.SemanticsFlag.isTextField.index,
1856+
actions: 0 | ui.SemanticsAction.focus.index,
1857+
flags: 0 | ui.SemanticsFlag.isTextField.index | ui.SemanticsFlag.isEnabled.index,
18571858
value: 'hello',
18581859
transform: Matrix4.identity().toFloat64(),
18591860
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
@@ -1870,7 +1871,7 @@ void _testTextField() {
18701871

18711872
expect(owner().semanticsHost.ownerDocument?.activeElement, textField);
18721873
expect(await logger.idLog.first, 0);
1873-
expect(await logger.actionLog.first, ui.SemanticsAction.didGainAccessibilityFocus);
1874+
expect(await logger.actionLog.first, ui.SemanticsAction.focus);
18741875

18751876
semantics().semanticsEnabled = false;
18761877
}, // TODO(yjbanov): https://github.com/flutter/flutter/issues/46638
@@ -2156,7 +2157,7 @@ void _testCheckables() {
21562157

21572158
pumpSemantics(isFocused: true);
21582159
expect(capturedActions, <CapturedAction>[
2159-
(0, ui.SemanticsAction.didGainAccessibilityFocus, null),
2160+
(0, ui.SemanticsAction.focus, null),
21602161
]);
21612162
capturedActions.clear();
21622163

@@ -2166,15 +2167,12 @@ void _testCheckables() {
21662167
pumpSemantics(isFocused: false);
21672168
expect(capturedActions, isEmpty);
21682169

2169-
// If the element is blurred by the browser, then we do want to notify the
2170-
// framework. This is because screen reader can be focused on something
2171-
// other than what the framework is focused on, and notifying the framework
2172-
// about the loss of focus on a node is information that the framework did
2173-
// not have before.
2170+
// The web doesn't send didLoseAccessibilityFocus as on the web,
2171+
// accessibility focus is not observable, only input focus is. As of this
2172+
// writing, there is no SemanticsAction.unfocus action, so the test simply
2173+
// asserts that no actions are being sent as a result of blur.
21742174
element.blur();
2175-
expect(capturedActions, <CapturedAction>[
2176-
(0, ui.SemanticsAction.didLoseAccessibilityFocus, null),
2177-
]);
2175+
expect(capturedActions, isEmpty);
21782176

21792177
semantics().semanticsEnabled = false;
21802178
});
@@ -2340,17 +2338,19 @@ void _testTappable() {
23402338

23412339
pumpSemantics(isFocused: true);
23422340
expect(capturedActions, <CapturedAction>[
2343-
(0, ui.SemanticsAction.didGainAccessibilityFocus, null),
2341+
(0, ui.SemanticsAction.focus, null),
23442342
]);
23452343
capturedActions.clear();
23462344

23472345
pumpSemantics(isFocused: false);
23482346
expect(capturedActions, isEmpty);
23492347

2348+
// The web doesn't send didLoseAccessibilityFocus as on the web,
2349+
// accessibility focus is not observable, only input focus is. As of this
2350+
// writing, there is no SemanticsAction.unfocus action, so the test simply
2351+
// asserts that no actions are being sent as a result of blur.
23502352
element.blur();
2351-
expect(capturedActions, <CapturedAction>[
2352-
(0, ui.SemanticsAction.didLoseAccessibilityFocus, null),
2353-
]);
2353+
expect(capturedActions, isEmpty);
23542354

23552355
semantics().semanticsEnabled = false;
23562356
});
@@ -3180,7 +3180,7 @@ void _testDialog() {
31803180
expect(
31813181
capturedActions,
31823182
<CapturedAction>[
3183-
(2, ui.SemanticsAction.didGainAccessibilityFocus, null),
3183+
(2, ui.SemanticsAction.focus, null),
31843184
],
31853185
);
31863186

@@ -3242,7 +3242,7 @@ void _testDialog() {
32423242
expect(
32433243
capturedActions,
32443244
<CapturedAction>[
3245-
(3, ui.SemanticsAction.didGainAccessibilityFocus, null),
3245+
(3, ui.SemanticsAction.focus, null),
32463246
],
32473247
);
32483248

@@ -3392,7 +3392,7 @@ void _testFocusable() {
33923392
pumpSemantics(); // triggers post-update callbacks
33933393
expect(domDocument.activeElement, element);
33943394
expect(capturedActions, <CapturedAction>[
3395-
(1, ui.SemanticsAction.didGainAccessibilityFocus, null),
3395+
(1, ui.SemanticsAction.focus, null),
33963396
]);
33973397
capturedActions.clear();
33983398

@@ -3405,17 +3405,19 @@ void _testFocusable() {
34053405
// Browser blurs the element
34063406
element.blur();
34073407
expect(domDocument.activeElement, isNot(element));
3408-
expect(capturedActions, <CapturedAction>[
3409-
(1, ui.SemanticsAction.didLoseAccessibilityFocus, null),
3410-
]);
3408+
// The web doesn't send didLoseAccessibilityFocus as on the web,
3409+
// accessibility focus is not observable, only input focus is. As of this
3410+
// writing, there is no SemanticsAction.unfocus action, so the test simply
3411+
// asserts that no actions are being sent as a result of blur.
3412+
expect(capturedActions, isEmpty);
34113413
capturedActions.clear();
34123414

34133415
// Request focus again
34143416
manager.changeFocus(true);
34153417
pumpSemantics(); // triggers post-update callbacks
34163418
expect(domDocument.activeElement, element);
34173419
expect(capturedActions, <CapturedAction>[
3418-
(1, ui.SemanticsAction.didGainAccessibilityFocus, null),
3420+
(1, ui.SemanticsAction.focus, null),
34193421
]);
34203422
capturedActions.clear();
34213423

lib/web_ui/test/engine/semantics/semantics_tester.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ class SemanticsTester {
7575
bool? hasPaste,
7676
bool? hasDidGainAccessibilityFocus,
7777
bool? hasDidLoseAccessibilityFocus,
78+
bool? hasFocus,
7879
bool? hasCustomAction,
7980
bool? hasDismiss,
8081
bool? hasMoveCursorForwardByWord,
@@ -242,6 +243,9 @@ class SemanticsTester {
242243
if (hasDidLoseAccessibilityFocus ?? false) {
243244
actions |= ui.SemanticsAction.didLoseAccessibilityFocus.index;
244245
}
246+
if (hasFocus ?? false) {
247+
actions |= ui.SemanticsAction.focus.index;
248+
}
245249
if (hasCustomAction ?? false) {
246250
actions |= ui.SemanticsAction.customAction.index;
247251
}

0 commit comments

Comments
 (0)