diff --git a/lib/web_ui/lib/src/engine/dom.dart b/lib/web_ui/lib/src/engine/dom.dart index b11e9e97a7458..9dc23ba24e4d1 100644 --- a/lib/web_ui/lib/src/engine/dom.dart +++ b/lib/web_ui/lib/src/engine/dom.dart @@ -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 {} diff --git a/lib/web_ui/lib/src/engine/semantics/focusable.dart b/lib/web_ui/lib/src/engine/semantics/focusable.dart index 35fff64a50158..331e1cd50c061 100644 --- a/lib/web_ui/lib/src/engine/semantics/focusable.dart +++ b/lib/web_ui/lib/src/engine/semantics/focusable.dart @@ -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 diff --git a/lib/web_ui/lib/src/engine/semantics/text_field.dart b/lib/web_ui/lib/src/engine/semantics/text_field.dart index bb79ea1df52d9..f43c2d6eb27ee 100644 --- a/lib/web_ui/lib/src/engine/semantics/text_field.dart +++ b/lib/web_ui/lib/src/engine/semantics/text_field.dart @@ -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(); diff --git a/lib/web_ui/test/engine/semantics/semantics_test.dart b/lib/web_ui/test/engine/semantics/semantics_test.dart index 117ebc55cb60a..2288d8b0bb961 100644 --- a/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -1776,7 +1776,7 @@ void _testIncrementables() { pumpSemantics(isFocused: true); expect(capturedActions, [ - (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, [ - (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, [ - (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, [ - (0, ui.SemanticsAction.didLoseAccessibilityFocus, null), - ]); + expect(capturedActions, isEmpty); semantics().semanticsEnabled = false; }); @@ -2340,17 +2338,19 @@ void _testTappable() { pumpSemantics(isFocused: true); expect(capturedActions, [ - (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, [ - (0, ui.SemanticsAction.didLoseAccessibilityFocus, null), - ]); + expect(capturedActions, isEmpty); semantics().semanticsEnabled = false; }); @@ -3180,7 +3180,7 @@ void _testDialog() { expect( capturedActions, [ - (2, ui.SemanticsAction.didGainAccessibilityFocus, null), + (2, ui.SemanticsAction.focus, null), ], ); @@ -3242,7 +3242,7 @@ void _testDialog() { expect( capturedActions, [ - (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, [ - (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, [ - (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, [ - (1, ui.SemanticsAction.didGainAccessibilityFocus, null), + (1, ui.SemanticsAction.focus, null), ]); capturedActions.clear(); diff --git a/lib/web_ui/test/engine/semantics/semantics_tester.dart b/lib/web_ui/test/engine/semantics/semantics_tester.dart index f9a626c486cf7..e14a2c6a18132 100644 --- a/lib/web_ui/test/engine/semantics/semantics_tester.dart +++ b/lib/web_ui/test/engine/semantics/semantics_tester.dart @@ -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; } diff --git a/lib/web_ui/test/engine/semantics/text_field_test.dart b/lib/web_ui/test/engine/semantics/text_field_test.dart index aee5e1b331a52..f1dc9203f1647 100644 --- a/lib/web_ui/test/engine/semantics/text_field_test.dart +++ b/lib/web_ui/test/engine/semantics/text_field_test.dart @@ -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(), ''''''); + 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: [ 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 createTwoFieldSemanticsForIos(SemanticsTester builder, children: [ builder.updateNode( id: 1, + isEnabled: true, isTextField: true, value: 'Hello', label: 'Hello', @@ -981,6 +995,7 @@ Map createTwoFieldSemanticsForIos(SemanticsTester builder, ), builder.updateNode( id: 2, + isEnabled: true, isTextField: true, value: 'World', label: 'World', @@ -1001,6 +1016,7 @@ Map createTwoFieldSemanticsForIos(SemanticsTester builder, children: [ builder.updateNode( id: 1, + isEnabled: true, isTextField: true, value: 'Hello', label: 'Hello', @@ -1009,6 +1025,7 @@ Map createTwoFieldSemanticsForIos(SemanticsTester builder, ), builder.updateNode( id: 2, + isEnabled: true, isTextField: true, value: 'World', label: 'World',