From cb44b5a386876ec7949f0b9366756df4127ee758 Mon Sep 17 00:00:00 2001 From: Hannah Jin Date: Thu, 7 Aug 2025 14:31:24 -0700 Subject: [PATCH] [A11y] TextField prefix icon and suffix icon create a sibling node' (#173312) prefix/suffix icon should have their own merge group. Noted a textfield can have suffix and suffix icon the same time so they should be different merge group. Co-authored-by: chunhtai <47866232+chunhtai@users.noreply.github.com> --- .../lib/src/material/input_decorator.dart | 48 ++++++++++++------- .../test/material/text_field_test.dart | 42 ++++++++++++++++ 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/packages/flutter/lib/src/material/input_decorator.dart b/packages/flutter/lib/src/material/input_decorator.dart index cf59b573f87..85016c2e03d 100644 --- a/packages/flutter/lib/src/material/input_decorator.dart +++ b/packages/flutter/lib/src/material/input_decorator.dart @@ -13,6 +13,7 @@ library; import 'dart:math' as math; import 'dart:ui' show lerpDouble; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; @@ -1685,25 +1686,28 @@ class _RenderDecoration extends RenderBox ) { final ChildSemanticsConfigurationsResultBuilder builder = ChildSemanticsConfigurationsResultBuilder(); - List? prefixMergeGroup; - List? suffixMergeGroup; + + final Map> mergeGroups = + >{}; + final Set tags = { + _InputDecoratorState._kPrefixSemanticsTag, + _InputDecoratorState._kPrefixIconSemanticsTag, + _InputDecoratorState._kSuffixSemanticsTag, + _InputDecoratorState._kSuffixIconSemanticsTag, + }; + for (final SemanticsConfiguration childConfig in childConfigs) { - if (childConfig.tagsChildrenWith(_InputDecoratorState._kPrefixSemanticsTag)) { - prefixMergeGroup ??= []; - prefixMergeGroup.add(childConfig); - } else if (childConfig.tagsChildrenWith(_InputDecoratorState._kSuffixSemanticsTag)) { - suffixMergeGroup ??= []; - suffixMergeGroup.add(childConfig); + final SemanticsTag? tag = tags.firstWhereOrNull( + (SemanticsTag tag) => childConfig.tagsChildrenWith(tag), + ); + if (tag != null) { + mergeGroups.putIfAbsent(tag, () => []).add(childConfig); } else { builder.markAsMergeUp(childConfig); } } - if (prefixMergeGroup != null) { - builder.markAsSiblingMergeGroup(prefixMergeGroup); - } - if (suffixMergeGroup != null) { - builder.markAsSiblingMergeGroup(suffixMergeGroup); - } + + mergeGroups.values.forEach(builder.markAsSiblingMergeGroup); return builder.build(); } @@ -1985,7 +1989,13 @@ class _InputDecoratorState extends State with TickerProviderStat name: hashCode.toString(), ); static const SemanticsTag _kPrefixSemanticsTag = SemanticsTag('_InputDecoratorState.prefix'); + static const SemanticsTag _kPrefixIconSemanticsTag = SemanticsTag( + '_InputDecoratorState.prefixIcon', + ); static const SemanticsTag _kSuffixSemanticsTag = SemanticsTag('_InputDecoratorState.suffix'); + static const SemanticsTag _kSuffixIconSemanticsTag = SemanticsTag( + '_InputDecoratorState.suffixIcon', + ); @override void initState() { @@ -2466,7 +2476,10 @@ class _InputDecoratorState extends State with TickerProviderStat iconSize: WidgetStatePropertyAll(iconSize), ).merge(iconButtonTheme.style), ), - child: Semantics(child: decoration.prefixIcon), + child: Semantics( + tagForChildren: _kPrefixIconSemanticsTag, + child: decoration.prefixIcon, + ), ), ), ), @@ -2503,7 +2516,10 @@ class _InputDecoratorState extends State with TickerProviderStat iconSize: WidgetStatePropertyAll(iconSize), ).merge(iconButtonTheme.style), ), - child: Semantics(child: decoration.suffixIcon), + child: Semantics( + tagForChildren: _kSuffixIconSemanticsTag, + child: decoration.suffixIcon, + ), ), ), ), diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 4b602d5cf55..3dc22210cc0 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -5675,6 +5675,48 @@ void main() { semantics.dispose(); }); + testWidgets('TextField prefix icon and suffix icon create a sibling node', ( + WidgetTester tester, + ) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget( + overlay( + child: TextField( + controller: _textEditingController(text: 'some text'), + decoration: const InputDecoration(prefixIcon: Text('Prefix'), suffixIcon: Text('Suffix')), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 1, + textDirection: TextDirection.ltr, + value: 'some text', + actions: [SemanticsAction.tap, SemanticsAction.focus], + inputType: ui.SemanticsInputType.text, + currentValueLength: 9, + flags: [ + SemanticsFlag.isTextField, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + ], + ), + TestSemantics.rootChild(id: 2, textDirection: TextDirection.ltr, label: 'Prefix'), + TestSemantics.rootChild(id: 3, textDirection: TextDirection.ltr, label: 'Suffix'), + ], + ), + ignoreTransform: true, + ignoreRect: true, + ), + ); + semantics.dispose(); + }); + testWidgets('TextField with specified suffixStyle', (WidgetTester tester) async { final TextStyle suffixStyle = TextStyle(color: Colors.pink[500], fontSize: 10.0);