diff --git a/engine/src/flutter/lib/ui/fixtures/ui_test.dart b/engine/src/flutter/lib/ui/fixtures/ui_test.dart index 28962a7884..fc920d608e 100644 --- a/engine/src/flutter/lib/ui/fixtures/ui_test.dart +++ b/engine/src/flutter/lib/ui/fixtures/ui_test.dart @@ -235,6 +235,7 @@ void sendSemanticsUpdate() { headingLevel: 0, linkUrl: '', controlsNodes: null, + inputType: SemanticsInputType.none, ); _semanticsUpdate(builder.build()); } @@ -289,6 +290,7 @@ void sendSemanticsUpdateWithRole() { linkUrl: '', role: SemanticsRole.tab, controlsNodes: null, + inputType: SemanticsInputType.none, ); _semanticsUpdate(builder.build()); } diff --git a/engine/src/flutter/lib/ui/semantics.dart b/engine/src/flutter/lib/ui/semantics.dart index e69ed58887..c378be80fb 100644 --- a/engine/src/flutter/lib/ui/semantics.dart +++ b/engine/src/flutter/lib/ui/semantics.dart @@ -506,6 +506,29 @@ enum SemanticsRole { alert, } +/// Describe the type of data for an input field. +/// +/// This is typically used to complement text fields. +enum SemanticsInputType { + /// The default for non text field. + none, + + /// Describes a generic text field. + text, + + /// Describes a url text field. + url, + + /// Describes a text field for phone input. + phone, + + /// Describes a text field that act as a search box. + search, + + /// Describes a text field for email input. + email, +} + /// A Boolean value that can be associated with a semantics node. // // When changes are made to this class, the equivalent APIs in @@ -1232,6 +1255,7 @@ abstract class SemanticsUpdateBuilder { SemanticsRole role = SemanticsRole.none, required List? controlsNodes, SemanticsValidationResult validationResult = SemanticsValidationResult.none, + required SemanticsInputType inputType, }); /// Update the custom semantics action associated with the given `id`. @@ -1310,6 +1334,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 SemanticsRole role = SemanticsRole.none, required List? controlsNodes, SemanticsValidationResult validationResult = SemanticsValidationResult.none, + required SemanticsInputType inputType, }) { assert(_matrix4IsValid(transform)); assert( @@ -1358,6 +1383,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 role.index, controlsNodes, validationResult.index, + inputType.index, ); } @@ -1405,6 +1431,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 Int32, Handle, Int32, + Int32, ) >(symbol: 'SemanticsUpdateBuilder::updateNode') external void _updateNode( @@ -1449,6 +1476,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 int role, List? controlsNodes, int validationResultIndex, + int inputType, ); @override diff --git a/engine/src/flutter/lib/web_ui/lib/semantics.dart b/engine/src/flutter/lib/web_ui/lib/semantics.dart index 8e8c677c86..476f33fc40 100644 --- a/engine/src/flutter/lib/web_ui/lib/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/semantics.dart @@ -298,6 +298,9 @@ enum SemanticsRole { alert, } +// Mirrors engine/src/flutter/lib/ui/semantics.dart +enum SemanticsInputType { none, text, url, phone, search, email } + // When adding a new StringAttributeType, the classes in these file must be // updated as well. // * engine/src/flutter/lib/ui/semantics.dart @@ -389,6 +392,7 @@ class SemanticsUpdateBuilder { SemanticsRole role = SemanticsRole.none, required List? controlsNodes, SemanticsValidationResult validationResult = SemanticsValidationResult.none, + required SemanticsInputType inputType, }) { if (transform.length != 16) { throw ArgumentError('transform argument must have 16 entries.'); @@ -433,6 +437,7 @@ class SemanticsUpdateBuilder { role: role, controlsNodes: controlsNodes, validationResult: validationResult, + inputType: inputType, ), ); } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart index 8a5edc0800..390321a9bf 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -247,6 +247,7 @@ class SemanticsNodeUpdate { required this.role, required this.controlsNodes, required this.validationResult, + required this.inputType, }); /// See [ui.SemanticsUpdateBuilder.updateNode]. @@ -362,6 +363,9 @@ class SemanticsNodeUpdate { /// See [ui.SemanticsUpdateBuilder.updateNode]. final ui.SemanticsValidationResult validationResult; + + /// See [ui.SemanticsUpdateBuilder.updateNode]. + final ui.SemanticsInputType inputType; } /// Identifies [SemanticRole] implementations. @@ -1416,6 +1420,8 @@ class SemanticsObject { /// The role of this node. late ui.SemanticsRole role; + late ui.SemanticsInputType inputType; + /// List of nodes whose contents are controlled by this node. /// /// The list contains [identifier]s of those nodes. @@ -1712,6 +1718,8 @@ class SemanticsObject { role = update.role; + inputType = update.inputType; + if (!unorderedListEqual(controlsNodes, update.controlsNodes)) { controlsNodes = update.controlsNodes; _markControlsNodesDirty(); diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart index c50e55d840..74d7faa435 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart @@ -226,8 +226,7 @@ class SemanticTextField extends SemanticRole { } DomHTMLInputElement _createSingleLineField() { - return createDomHTMLInputElement() - ..type = semanticsObject.hasFlag(ui.SemanticsFlag.isObscured) ? 'password' : 'text'; + return createDomHTMLInputElement(); } DomHTMLTextAreaElement _createMultiLineField() { @@ -339,12 +338,37 @@ class SemanticTextField extends SemanticRole { } else { editableElement.removeAttribute('aria-required'); } + _updateInputType(); } void _updateEnabledState() { (editableElement as DomElementWithDisabledProperty).disabled = !semanticsObject.isEnabled; } + void _updateInputType() { + if (semanticsObject.hasFlag(ui.SemanticsFlag.isMultiline)) { + // text area can't be annotated with input type + return; + } + final DomHTMLInputElement input = editableElement as DomHTMLInputElement; + if (semanticsObject.hasFlag(ui.SemanticsFlag.isObscured)) { + input.type = 'password'; + } else { + switch (semanticsObject.inputType) { + case ui.SemanticsInputType.search: + input.type = 'search'; + case ui.SemanticsInputType.email: + input.type = 'email'; + case ui.SemanticsInputType.url: + input.type = 'url'; + case ui.SemanticsInputType.phone: + input.type = 'tel'; + default: + input.type = 'text'; + } + } + } + @override void dispose() { super.dispose(); diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart index 152da7b9e7..10420cc21e 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -4859,6 +4859,7 @@ void updateNode( String? linkUrl, List? controlsNodes, ui.SemanticsRole role = ui.SemanticsRole.none, + ui.SemanticsInputType inputType = ui.SemanticsInputType.none, }) { transform ??= Float64List.fromList(Matrix4.identity().storage); childrenInTraversalOrder ??= Int32List(0); @@ -4902,6 +4903,7 @@ void updateNode( headingLevel: headingLevel, linkUrl: linkUrl, controlsNodes: controlsNodes, + inputType: inputType, ); } diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart index 7eb6aa4ef6..24e742249b 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart @@ -122,6 +122,7 @@ class SemanticsTester { ui.SemanticsRole? role, List? controlsNodes, ui.SemanticsValidationResult validationResult = ui.SemanticsValidationResult.none, + ui.SemanticsInputType inputType = ui.SemanticsInputType.none, }) { // Flags if (hasCheckedState ?? false) { @@ -345,6 +346,7 @@ class SemanticsTester { role: role ?? ui.SemanticsRole.none, controlsNodes: controlsNodes, validationResult: validationResult, + inputType: inputType, ); _nodeUpdates.add(update); return update; diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/text_field_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/text_field_test.dart index 9819c1234e..7e72f0d7e0 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/text_field_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/text_field_test.dart @@ -117,6 +117,22 @@ void testMain() { expect(inputElement.disabled, isFalse); }); + test('renders text fields with input types', () { + const inputTypeEnumToString = { + ui.SemanticsInputType.none: 'text', + ui.SemanticsInputType.text: 'text', + ui.SemanticsInputType.url: 'url', + ui.SemanticsInputType.phone: 'tel', + ui.SemanticsInputType.search: 'search', + ui.SemanticsInputType.email: 'email', + }; + for (final ui.SemanticsInputType type in ui.SemanticsInputType.values) { + createTextFieldSemantics(value: 'text', inputType: type); + + expectSemanticsTree(owner(), ''); + } + }); + test('renders a disabled text field', () { createTextFieldSemantics(isEnabled: false, value: 'hello'); expectSemanticsTree(owner(), ''''''); @@ -498,6 +514,7 @@ SemanticsObject createTextFieldSemantics({ ui.Rect rect = const ui.Rect.fromLTRB(0, 0, 100, 50), int textSelectionBase = 0, int textSelectionExtent = 0, + ui.SemanticsInputType inputType = ui.SemanticsInputType.text, }) { final tester = SemanticsTester(owner()); tester.updateNode( @@ -516,6 +533,7 @@ SemanticsObject createTextFieldSemantics({ textDirection: ui.TextDirection.ltr, textSelectionBase: textSelectionBase, textSelectionExtent: textSelectionExtent, + inputType: inputType, ); tester.apply(); return tester.getSemanticsObject(0); diff --git a/engine/src/flutter/shell/platform/embedder/fixtures/main.dart b/engine/src/flutter/shell/platform/embedder/fixtures/main.dart index 9146033df0..c83292dc23 100644 --- a/engine/src/flutter/shell/platform/embedder/fixtures/main.dart +++ b/engine/src/flutter/shell/platform/embedder/fixtures/main.dart @@ -199,6 +199,7 @@ Future a11y_main() async { textDirection: TextDirection.ltr, additionalActions: Int32List(0), controlsNodes: null, + inputType: SemanticsInputType.none, ) ..updateNode( id: 84, @@ -235,6 +236,7 @@ Future a11y_main() async { childrenInHitTestOrder: Int32List(0), childrenInTraversalOrder: Int32List(0), controlsNodes: null, + inputType: SemanticsInputType.none, ) ..updateNode( id: 96, @@ -271,6 +273,7 @@ Future a11y_main() async { textDirection: TextDirection.ltr, additionalActions: Int32List(0), controlsNodes: null, + inputType: SemanticsInputType.none, ) ..updateNode( id: 128, @@ -307,6 +310,7 @@ Future a11y_main() async { childrenInHitTestOrder: Int32List(0), childrenInTraversalOrder: Int32List(0), controlsNodes: null, + inputType: SemanticsInputType.none, ) ..updateCustomAction(id: 21, label: 'Archive', hint: 'archive message'); @@ -395,6 +399,7 @@ Future a11y_string_attributes() async { textDirection: TextDirection.ltr, additionalActions: Int32List(0), controlsNodes: null, + inputType: SemanticsInputType.none, ); PlatformDispatcher.instance.views.first.updateSemantics(builder.build()); @@ -1692,6 +1697,7 @@ Future a11y_main_multi_view() async { textDirection: TextDirection.ltr, additionalActions: Int32List(0), controlsNodes: null, + inputType: SemanticsInputType.none, ); } diff --git a/engine/src/flutter/shell/platform/windows/fixtures/main.dart b/engine/src/flutter/shell/platform/windows/fixtures/main.dart index 8174aa8afb..a9d341cee0 100644 --- a/engine/src/flutter/shell/platform/windows/fixtures/main.dart +++ b/engine/src/flutter/shell/platform/windows/fixtures/main.dart @@ -467,6 +467,7 @@ Future sendSemanticsTreeInfo() async { additionalActions: additionalActions, role: ui.SemanticsRole.tab, controlsNodes: null, + inputType: ui.SemanticsInputType.none, ); return builder.build(); } diff --git a/engine/src/flutter/testing/ios_scenario_app/lib/src/locale_initialization.dart b/engine/src/flutter/testing/ios_scenario_app/lib/src/locale_initialization.dart index e129f8a771..c482f49d32 100644 --- a/engine/src/flutter/testing/ios_scenario_app/lib/src/locale_initialization.dart +++ b/engine/src/flutter/testing/ios_scenario_app/lib/src/locale_initialization.dart @@ -76,6 +76,7 @@ class LocaleInitialization extends Scenario { childrenInHitTestOrder: Int32List(0), additionalActions: Int32List(0), controlsNodes: null, + inputType: SemanticsInputType.none, ); final SemanticsUpdate semanticsUpdate = semanticsUpdateBuilder.build(); @@ -137,6 +138,7 @@ class LocaleInitialization extends Scenario { childrenInHitTestOrder: Int32List(0), additionalActions: Int32List(0), controlsNodes: null, + inputType: SemanticsInputType.none, ); final SemanticsUpdate semanticsUpdate = semanticsUpdateBuilder.build(); diff --git a/packages/flutter/lib/src/material/search_anchor.dart b/packages/flutter/lib/src/material/search_anchor.dart index 6cfe2bcec6..d365256ef0 100644 --- a/packages/flutter/lib/src/material/search_anchor.dart +++ b/packages/flutter/lib/src/material/search_anchor.dart @@ -1699,36 +1699,39 @@ class _SearchBarState extends State { Expanded( child: Padding( padding: effectivePadding, - child: TextField( - autofocus: widget.autoFocus, - onTap: widget.onTap, - onTapAlwaysCalled: true, - onTapOutside: widget.onTapOutside, - focusNode: _focusNode, - onChanged: widget.onChanged, - onSubmitted: widget.onSubmitted, - controller: widget.controller, - style: effectiveTextStyle, - enabled: widget.enabled, - decoration: InputDecoration(hintText: widget.hintText).applyDefaults( - InputDecorationTheme( - hintStyle: effectiveHintStyle, - // The configuration below is to make sure that the text field - // in `SearchBar` will not be overridden by the overall `InputDecorationTheme` - enabledBorder: InputBorder.none, - border: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - // Setting `isDense` to true to allow the text field height to be - // smaller than 48.0 - isDense: true, + child: Semantics( + inputType: SemanticsInputType.search, + child: TextField( + autofocus: widget.autoFocus, + onTap: widget.onTap, + onTapAlwaysCalled: true, + onTapOutside: widget.onTapOutside, + focusNode: _focusNode, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + controller: widget.controller, + style: effectiveTextStyle, + enabled: widget.enabled, + decoration: InputDecoration(hintText: widget.hintText).applyDefaults( + InputDecorationTheme( + hintStyle: effectiveHintStyle, + // The configuration below is to make sure that the text field + // in `SearchBar` will not be overridden by the overall `InputDecorationTheme` + enabledBorder: InputBorder.none, + border: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + // Setting `isDense` to true to allow the text field height to be + // smaller than 48.0 + isDense: true, + ), ), + textCapitalization: effectiveTextCapitalization, + textInputAction: widget.textInputAction, + keyboardType: widget.keyboardType, + scrollPadding: widget.scrollPadding, + contextMenuBuilder: widget.contextMenuBuilder, ), - textCapitalization: effectiveTextCapitalization, - textInputAction: widget.textInputAction, - keyboardType: widget.keyboardType, - scrollPadding: widget.scrollPadding, - contextMenuBuilder: widget.contextMenuBuilder, ), ), ), diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index f8b9b57888..c3939c60b9 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -7,7 +7,7 @@ library; import 'dart:collection'; import 'dart:math' as math; -import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, LineMetrics, TextBox; +import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, LineMetrics, SemanticsInputType, TextBox; import 'package:characters/characters.dart'; import 'package:flutter/foundation.dart'; @@ -1382,7 +1382,10 @@ class RenderEditable extends RenderBox ..textDirection = textDirection ..isFocused = hasFocus ..isTextField = true - ..isReadOnly = readOnly; + ..isReadOnly = readOnly + // This is the default for customer that uses RenderEditable directly. + // The real value is typically set by EditableText. + ..inputType = ui.SemanticsInputType.text; if (hasFocus && selectionEnabled) { config.onSetSelection = _handleSetSelection; diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index dc2d67d40c..9e927b9df3 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -4550,6 +4550,10 @@ class RenderSemanticsAnnotations extends RenderProxyBox { config.validationResult = _properties.validationResult; } + if (_properties.inputType != null) { + config.inputType = _properties.inputType!; + } + // Registering _perform* as action handlers instead of the user provided // ones to ensure that changing a user provided handler from a non-null to // another non-null value doesn't require a semantics update. diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index 6d0cc29be8..f4b5e0fe20 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -16,6 +16,7 @@ import 'dart:ui' Rect, SemanticsAction, SemanticsFlag, + SemanticsInputType, SemanticsRole, SemanticsUpdate, SemanticsUpdateBuilder, @@ -737,6 +738,7 @@ class SemanticsData with Diagnosticable { required this.role, required this.controlsNodes, required this.validationResult, + required this.inputType, this.tags, this.transform, this.customSemanticsActionIds, @@ -1005,6 +1007,9 @@ class SemanticsData with Diagnosticable { /// {@macro flutter.semantics.SemanticsProperties.validationResult} final SemanticsValidationResult validationResult; + /// {@macro flutter.semantics.SemanticsNode.inputType} + final SemanticsInputType inputType; + /// Whether [flags] contains the given flag. bool hasFlag(SemanticsFlag flag) => (flags & flag.index) != 0; @@ -1110,6 +1115,7 @@ class SemanticsData with Diagnosticable { other.linkUrl == linkUrl && other.role == role && other.validationResult == validationResult && + other.inputType == inputType && _sortedListsEqual(other.customSemanticsActionIds, customSemanticsActionIds) && setEquals(controlsNodes, other.controlsNodes); } @@ -1147,6 +1153,7 @@ class SemanticsData with Diagnosticable { role, validationResult, controlsNodes == null ? null : Object.hashAll(controlsNodes!), + inputType, ), ); @@ -1293,6 +1300,9 @@ class SemanticsProperties extends DiagnosticableTree { this.textDirection, this.sortKey, this.tagForChildren, + this.role, + this.controlsNodes, + this.inputType, this.onTap, this.onLongPress, this.onScrollLeft, @@ -1315,8 +1325,6 @@ class SemanticsProperties extends DiagnosticableTree { this.onFocus, this.onDismiss, this.customSemanticsActions, - this.role, - this.controlsNodes, this.validationResult = SemanticsValidationResult.none, }) : assert( label == null || attributedLabel == null, @@ -2161,6 +2169,17 @@ class SemanticsProperties extends DiagnosticableTree { /// {@endtemplate} final SemanticsValidationResult validationResult; + /// {@template flutter.semantics.SemanticsProperties.inputType} + /// The input type for of a editable widget. + /// + /// This property is only used when the subtree represents a text field. + /// + /// Assistive technologies use this property to provide better information to + /// users. For example, screen reader reads out the input type of text field + /// when focused. + /// {@endtemplate} + final SemanticsInputType? inputType; + @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); @@ -3139,6 +3158,18 @@ class SemanticsNode with DiagnosticableTreeMixin { SemanticsValidationResult get validationResult => _validationResult; SemanticsValidationResult _validationResult = _kEmptyConfig.validationResult; + /// {@template flutter.semantics.SemanticsNode.inputType} + /// The input type for of a editable node. + /// + /// This property is only used when this node represents a text field. + /// + /// Assistive technologies use this property to provide better information to + /// users. For example, screen reader reads out the input type of text field + /// when focused. + /// {@endtemplate} + SemanticsInputType get inputType => _inputType; + SemanticsInputType _inputType = _kEmptyConfig.inputType; + bool _canPerformAction(SemanticsAction action) => _actions.containsKey(action); static final SemanticsConfiguration _kEmptyConfig = SemanticsConfiguration(); @@ -3207,6 +3238,8 @@ class SemanticsNode with DiagnosticableTreeMixin { _role = config._role; _controlsNodes = config._controlsNodes; _validationResult = config._validationResult; + _inputType = config._inputType; + _replaceChildren(childrenInInversePaintOrder ?? const []); if (mergeAllDescendantsIntoThisNodeValueChanged) { @@ -3258,6 +3291,7 @@ class SemanticsNode with DiagnosticableTreeMixin { SemanticsRole role = _role; Set? controlsNodes = _controlsNodes; SemanticsValidationResult validationResult = _validationResult; + SemanticsInputType inputType = _inputType; final Set customSemanticsActionIds = {}; for (final CustomSemanticsAction action in _customSemanticsActions.keys) { customSemanticsActionIds.add(CustomSemanticsAction.getIdentifier(action)); @@ -3316,6 +3350,9 @@ class SemanticsNode with DiagnosticableTreeMixin { if (role == SemanticsRole.none) { role = node._role; } + if (inputType == SemanticsInputType.none) { + inputType = node._inputType; + } if (tooltip == '') { tooltip = node._tooltip; } @@ -3409,6 +3446,7 @@ class SemanticsNode with DiagnosticableTreeMixin { role: role, controlsNodes: controlsNodes, validationResult: validationResult, + inputType: inputType, ); } @@ -3496,6 +3534,7 @@ class SemanticsNode with DiagnosticableTreeMixin { role: data.role, controlsNodes: data.controlsNodes?.toList(), validationResult: data.validationResult, + inputType: data.inputType, ); _dirty = false; } @@ -3693,6 +3732,9 @@ class SemanticsNode with DiagnosticableTreeMixin { properties.add(DoubleProperty('elevation', elevation, defaultValue: 0.0)); properties.add(DoubleProperty('thickness', thickness, defaultValue: 0.0)); properties.add(IntProperty('headingLevel', _headingLevel, defaultValue: 0)); + if (_inputType != SemanticsInputType.none) { + properties.add(EnumProperty('inputType', _inputType)); + } } /// Returns a string representation of this node and its descendants. @@ -5674,6 +5716,14 @@ class SemanticsConfiguration { _hasBeenAnnotated = true; } + /// {@macro flutter.semantics.SemanticsProperties.inputType} + SemanticsInputType get inputType => _inputType; + SemanticsInputType _inputType = SemanticsInputType.none; + set inputType(SemanticsInputType value) { + _inputType = value; + _hasBeenAnnotated = true; + } + // TAGS /// The set of tags that this configuration wants to add to all child @@ -5846,6 +5896,9 @@ class SemanticsConfiguration { if (_role == SemanticsRole.none) { _role = child._role; } + if (_inputType == SemanticsInputType.none) { + _inputType = child._inputType; + } _attributedHint = _concatAttributedString( thisAttributedString: _attributedHint, thisTextDirection: textDirection, @@ -5916,7 +5969,8 @@ class SemanticsConfiguration { .._linkUrl = _linkUrl .._role = _role .._controlsNodes = _controlsNodes - .._validationResult = _validationResult; + .._validationResult = _validationResult + .._inputType = _inputType; } } diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 2fd93488ff..fc75a8c48e 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -9,7 +9,7 @@ library; import 'dart:math' as math; -import 'dart:ui' as ui show Image, ImageFilter, TextHeightBehavior; +import 'dart:ui' as ui show Image, ImageFilter, SemanticsInputType, TextHeightBehavior; import 'package:flutter/animation.dart'; import 'package:flutter/foundation.dart'; @@ -7385,6 +7385,7 @@ class Semantics extends SingleChildRenderObjectWidget { SemanticsRole? role, Set? controlsNodes, SemanticsValidationResult validationResult = SemanticsValidationResult.none, + ui.SemanticsInputType? inputType, }) : this.fromProperties( key: key, child: child, @@ -7463,6 +7464,7 @@ class Semantics extends SingleChildRenderObjectWidget { role: role, controlsNodes: controlsNodes, validationResult: validationResult, + inputType: inputType, ), ); diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 7ac303a179..3dbcaaf2c5 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -5563,6 +5563,17 @@ class EditableTextState extends State (null, final double textScaleFactor) => TextScaler.linear(textScaleFactor), (null, null) => MediaQuery.textScalerOf(context), }; + final ui.SemanticsInputType inputType; + switch (widget.keyboardType) { + case TextInputType.phone: + inputType = ui.SemanticsInputType.phone; + case TextInputType.url: + inputType = ui.SemanticsInputType.url; + case TextInputType.emailAddress: + inputType = ui.SemanticsInputType.email; + default: + inputType = ui.SemanticsInputType.text; + } return _CompositionCallback( compositeCallback: _compositeCallback, @@ -5654,6 +5665,7 @@ class EditableTextState extends State return CompositedTransformTarget( link: _toolbarLayerLink, child: Semantics( + inputType: inputType, onCopy: _semanticsOnCopy(controls), onCut: _semanticsOnCut(controls), onPaste: _semanticsOnPaste(controls), diff --git a/packages/flutter/test/material/search_anchor_test.dart b/packages/flutter/test/material/search_anchor_test.dart index 4b0ab2c798..184c4a31b6 100644 --- a/packages/flutter/test/material/search_anchor_test.dart +++ b/packages/flutter/test/material/search_anchor_test.dart @@ -3223,7 +3223,7 @@ void main() { expect(controller.value.text, initValue); }); - testWidgets('Disabled SearchBar semantics node still contains value', ( + testWidgets('Disabled SearchBar semantics node still contains value and inputType', ( WidgetTester tester, ) async { final SemanticsTester semantics = SemanticsTester(tester); @@ -3236,7 +3236,23 @@ void main() { ), ); - expect(semantics, includesNodeWith(actions: [], value: 'text')); + expect( + semantics, + includesNodeWith( + actions: [], + value: 'text', + inputType: SemanticsInputType.search, + ), + ); + semantics.dispose(); + }); + + testWidgets('SearchBar semantics node has search input type', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget(const MaterialApp(home: Material(child: Center(child: SearchBar())))); + + expect(semantics, includesNodeWith(inputType: SemanticsInputType.search)); semantics.dispose(); }); diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart index 6fa1fc2a6a..55d0ff0d30 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -3,6 +3,8 @@ // found in the LICENSE file. import 'dart:math' as math; +import 'dart:ui'; + import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -135,6 +137,21 @@ void main() { expect(editable.size.height, 100); }); + test('has default semantics input type', () { + const InlineSpan text = TextSpan(text: 'text'); + final RenderEditable editable = RenderEditable( + textDirection: TextDirection.ltr, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + offset: ViewportOffset.zero(), + textSelectionDelegate: _FakeEditableTextState(), + text: text, + ); + final SemanticsConfiguration config = SemanticsConfiguration(); + editable.describeSemanticsConfiguration(config); + expect(config.inputType, SemanticsInputType.text); + }); + test('Reports the height of the first line when maxLines is 1', () { final InlineSpan multilineSpan = TextSpan( text: 'liiiiines\n' * 10, diff --git a/packages/flutter/test/semantics/semantics_update_test.dart b/packages/flutter/test/semantics/semantics_update_test.dart index 4ec0923e40..8f6c7d6c3d 100644 --- a/packages/flutter/test/semantics/semantics_update_test.dart +++ b/packages/flutter/test/semantics/semantics_update_test.dart @@ -231,6 +231,7 @@ class SemanticsUpdateBuilderSpy extends Fake implements ui.SemanticsUpdateBuilde SemanticsRole role = SemanticsRole.none, required List? controlsNodes, SemanticsValidationResult validationResult = SemanticsValidationResult.none, + required ui.SemanticsInputType inputType, }) { // Makes sure we don't send the same id twice. assert(!observations.containsKey(id)); diff --git a/packages/flutter/test/widgets/basic_test.dart b/packages/flutter/test/widgets/basic_test.dart index 7af66de948..9af1c71950 100644 --- a/packages/flutter/test/widgets/basic_test.dart +++ b/packages/flutter/test/widgets/basic_test.dart @@ -624,6 +624,23 @@ void main() { expect(data.controlsNodes, {'abc', 'ghi', 'def'}); }); + testWidgets('Semantics can set semantics input type', (WidgetTester tester) async { + final UniqueKey key1 = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Semantics( + key: key1, + inputType: SemanticsInputType.phone, + child: const SizedBox(width: 10, height: 10), + ), + ), + ), + ); + final SemanticsNode node1 = tester.getSemantics(find.byKey(key1)); + expect(node1.inputType, SemanticsInputType.phone); + }); + testWidgets('Semantics can set alert rule', (WidgetTester tester) async { final UniqueKey key = UniqueKey(); await tester.pumpWidget( diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 190e398a51..512b33c359 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -4135,8 +4135,8 @@ void main() { ), ); - final RenderEditable render = tester.allRenderObjects.whereType().first; - final int semanticsId = render.debugSemantics!.id; + final SemanticsNode node = find.semantics.byValue('test').evaluate().first; + final int semanticsId = node.id; expect(controller.selection.baseOffset, 4); expect(controller.selection.extentOffset, 4); @@ -4241,8 +4241,8 @@ void main() { ), ); - final RenderEditable render = tester.allRenderObjects.whereType().first; - final int semanticsId = render.debugSemantics!.id; + final SemanticsNode node = find.semantics.byValue('test for words').evaluate().first; + final int semanticsId = node.id; expect(controller.selection.baseOffset, 14); expect(controller.selection.extentOffset, 14); @@ -4356,8 +4356,8 @@ void main() { ), ); - final RenderEditable render = tester.allRenderObjects.whereType().first; - final int semanticsId = render.debugSemantics!.id; + final SemanticsNode node = find.semantics.byValue('test').evaluate().first; + final int semanticsId = node.id; expect(controller.selection.baseOffset, 4); expect(controller.selection.extentOffset, 4); @@ -4473,8 +4473,8 @@ void main() { ), ); - final RenderEditable render = tester.allRenderObjects.whereType().first; - final int semanticsId = render.debugSemantics!.id; + final SemanticsNode node = find.semantics.byValue('test for words').evaluate().first; + final int semanticsId = node.id; expect(controller.selection.baseOffset, 14); expect(controller.selection.extentOffset, 14); @@ -4932,7 +4932,7 @@ void main() { await tester.pump(); final SemanticsOwner owner = tester.binding.pipelineOwner.semanticsOwner!; - const int expectedNodeId = 5; + const int expectedNodeId = 4; expect( semantics, @@ -5016,7 +5016,8 @@ void main() { await tester.pump(); final SemanticsOwner owner = tester.binding.pipelineOwner.semanticsOwner!; - const int expectedNodeId = 5; + final SemanticsNode node = find.semantics.byValue('ABCDEFG').evaluate().first; + final int expectedNodeId = node.id; expect(controller.value.selection.isCollapsed, isTrue); diff --git a/packages/flutter/test/widgets/semantics_tester.dart b/packages/flutter/test/widgets/semantics_tester.dart index 1c6eefd5d2..eb5f6bfa9b 100644 --- a/packages/flutter/test/widgets/semantics_tester.dart +++ b/packages/flutter/test/widgets/semantics_tester.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:ui'; + import 'package:flutter/foundation.dart'; import 'package:flutter/physics.dart'; import 'package:flutter/rendering.dart'; @@ -587,6 +589,7 @@ class SemanticsTester { int? currentValueLength, int? maxValueLength, SemanticsNode? ancestor, + SemanticsInputType? inputType, }) { bool checkNode(SemanticsNode node) { if (label != null && node.label != label) { @@ -670,6 +673,9 @@ class SemanticsTester { if (maxValueLength != null && node.maxValueLength != maxValueLength) { return false; } + if (inputType != null && node.inputType != inputType) { + return false; + } return true; } @@ -950,6 +956,7 @@ class _IncludesNodeWith extends Matcher { this.scrollExtentMin, this.maxValueLength, this.currentValueLength, + this.inputType, }) : assert( label != null || value != null || @@ -962,7 +969,8 @@ class _IncludesNodeWith extends Matcher { scrollExtentMax != null || scrollExtentMin != null || maxValueLength != null || - currentValueLength != null, + currentValueLength != null || + inputType != null, ); final AttributedString? attributedLabel; final AttributedString? attributedValue; @@ -981,6 +989,7 @@ class _IncludesNodeWith extends Matcher { final double? scrollExtentMin; final int? currentValueLength; final int? maxValueLength; + final SemanticsInputType? inputType; @override bool matches(covariant SemanticsTester item, Map matchState) { @@ -1003,6 +1012,7 @@ class _IncludesNodeWith extends Matcher { scrollExtentMin: scrollExtentMin, currentValueLength: currentValueLength, maxValueLength: maxValueLength, + inputType: inputType, ) .isNotEmpty; } @@ -1038,6 +1048,7 @@ class _IncludesNodeWith extends Matcher { if (scrollExtentMin != null) 'scrollExtentMin "$scrollExtentMin"', if (currentValueLength != null) 'currentValueLength "$currentValueLength"', if (maxValueLength != null) 'maxValueLength "$maxValueLength"', + if (inputType != null) 'inputType $inputType', ]; return strings.join(', '); } @@ -1065,6 +1076,7 @@ Matcher includesNodeWith({ double? scrollExtentMin, int? maxValueLength, int? currentValueLength, + SemanticsInputType? inputType, }) { return _IncludesNodeWith( label: label, @@ -1084,5 +1096,6 @@ Matcher includesNodeWith({ scrollExtentMin: scrollExtentMin, maxValueLength: maxValueLength, currentValueLength: currentValueLength, + inputType: inputType, ); } diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart index 558a49a063..8732e96ef9 100644 --- a/packages/flutter_test/lib/src/matchers.dart +++ b/packages/flutter_test/lib/src/matchers.dart @@ -686,6 +686,7 @@ Matcher matchesSemantics({ int? maxValueLength, int? currentValueLength, SemanticsValidationResult validationResult = SemanticsValidationResult.none, + ui.SemanticsInputType? inputType, // Flags // bool hasCheckedState = false, bool isChecked = false, @@ -770,6 +771,7 @@ Matcher matchesSemantics({ maxValueLength: maxValueLength, currentValueLength: currentValueLength, validationResult: validationResult, + inputType: inputType, // Flags hasCheckedState: hasCheckedState, isChecked: isChecked, @@ -882,6 +884,7 @@ Matcher containsSemantics({ int? maxValueLength, int? currentValueLength, SemanticsValidationResult validationResult = SemanticsValidationResult.none, + ui.SemanticsInputType? inputType, // Flags bool? hasCheckedState, bool? isChecked, @@ -966,6 +969,7 @@ Matcher containsSemantics({ maxValueLength: maxValueLength, currentValueLength: currentValueLength, validationResult: validationResult, + inputType: inputType, // Flags hasCheckedState: hasCheckedState, isChecked: isChecked, @@ -2403,6 +2407,7 @@ class _MatchesSemanticsData extends Matcher { required this.maxValueLength, required this.currentValueLength, required this.validationResult, + required this.inputType, // Flags required bool? hasCheckedState, required bool? isChecked, @@ -2556,6 +2561,7 @@ class _MatchesSemanticsData extends Matcher { final int? platformViewId; final int? maxValueLength; final int? currentValueLength; + final ui.SemanticsInputType? inputType; final List? children; final SemanticsValidationResult validationResult; @@ -2603,6 +2609,9 @@ class _MatchesSemanticsData extends Matcher { if (tooltip != null) { description.add(' with tooltip: $tooltip'); } + if (inputType != null) { + description.add(' with inputType: $inputType'); + } if (actions.isNotEmpty) { final List expectedActions = actions.entries @@ -2813,6 +2822,9 @@ class _MatchesSemanticsData extends Matcher { if (validationResult != data.validationResult) { return failWithDescription(matchState, 'validationResult was: ${data.validationResult}'); } + if (inputType != null && inputType != data.inputType) { + return failWithDescription(matchState, 'inputType was: ${data.inputType}'); + } if (actions.isNotEmpty) { final List unexpectedActions = []; final List missingActions = []; diff --git a/packages/flutter_test/test/matchers_test.dart b/packages/flutter_test/test/matchers_test.dart index 35b16b59d9..3f4176cc96 100644 --- a/packages/flutter_test/test/matchers_test.dart +++ b/packages/flutter_test/test/matchers_test.dart @@ -732,6 +732,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + inputType: ui.SemanticsInputType.none, ); final _FakeSemanticsNode node = _FakeSemanticsNode(data); @@ -1036,6 +1037,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + inputType: ui.SemanticsInputType.none, ); final _FakeSemanticsNode node = _FakeSemanticsNode(data); @@ -1136,6 +1138,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + inputType: ui.SemanticsInputType.none, ); final _FakeSemanticsNode node = _FakeSemanticsNode(data); @@ -1243,6 +1246,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + inputType: ui.SemanticsInputType.none, ); final _FakeSemanticsNode emptyNode = _FakeSemanticsNode(emptyData); @@ -1276,6 +1280,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + inputType: ui.SemanticsInputType.none, ); final _FakeSemanticsNode fullNode = _FakeSemanticsNode(fullData); @@ -1365,6 +1370,7 @@ void main() { role: ui.SemanticsRole.none, controlsNodes: null, validationResult: SemanticsValidationResult.none, + inputType: ui.SemanticsInputType.none, ); final _FakeSemanticsNode node = _FakeSemanticsNode(data);