From e45ee2aaed25502792c6536fff21983958195f6d Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Fri, 28 Aug 2026 11:56:16 -0700 Subject: [PATCH 1/4] [material_ui] Fixes SearchAnchor and SearchBar semantics --- .../material_ui/lib/src/search_anchor.dart | 10 ++- ...hange_2026_08_27_search_bar_semantics.yaml | 3 + .../material_ui/test/search_anchor_test.dart | 74 +++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 packages/material_ui/pending_changelogs/change_2026_08_27_search_bar_semantics.yaml diff --git a/packages/material_ui/lib/src/search_anchor.dart b/packages/material_ui/lib/src/search_anchor.dart index 845de4d3ddb1..cd7b113c78b9 100644 --- a/packages/material_ui/lib/src/search_anchor.dart +++ b/packages/material_ui/lib/src/search_anchor.dart @@ -606,7 +606,11 @@ class _SearchAnchorState extends State { duration: _kAnchorFadeDuration, child: IgnorePointer( ignoring: !widget.enabled, - child: GestureDetector(onTap: _openView, child: widget.builder(context, _searchController)), + child: GestureDetector( + excludeFromSemantics: true, + onTap: _openView, + child: widget.builder(context, _searchController), + ), ), ); } @@ -1825,6 +1829,10 @@ class _SearchBarState extends State { child: IgnorePointer( ignoring: !widget.enabled, child: InkWell( + canRequestFocus: false, + // Avoid providing duplicate semantics actions that the TextField + // already provides. + excludeFromSemantics: true, onTap: () { widget.onTap?.call(); if (!_focusNode.hasFocus) { diff --git a/packages/material_ui/pending_changelogs/change_2026_08_27_search_bar_semantics.yaml b/packages/material_ui/pending_changelogs/change_2026_08_27_search_bar_semantics.yaml new file mode 100644 index 000000000000..a353e374d3f8 --- /dev/null +++ b/packages/material_ui/pending_changelogs/change_2026_08_27_search_bar_semantics.yaml @@ -0,0 +1,3 @@ +changelog: | + - Fixes unlabeled tap target semantics in `SearchBar`. +version: patch diff --git a/packages/material_ui/test/search_anchor_test.dart b/packages/material_ui/test/search_anchor_test.dart index d810d74dce07..ddd5e9bb0249 100644 --- a/packages/material_ui/test/search_anchor_test.dart +++ b/packages/material_ui/test/search_anchor_test.dart @@ -3407,6 +3407,80 @@ void main() { semantics.dispose(); }); + testWidgets('SearchBar meets labeledTapTargetGuideline', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SearchBar( + hintText: 'Search...', + trailing: [ + IconButton(tooltip: 'Clear', icon: const Icon(Icons.clear), onPressed: () {}), + ], + ), + ), + ), + ), + ); + + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + semantics.dispose(); + }); + + testWidgets('SearchAnchor.bar meets labeledTapTargetGuideline', (WidgetTester tester) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SearchAnchor.bar( + barHintText: 'Search...', + barTrailing: [ + IconButton(tooltip: 'Clear', icon: const Icon(Icons.clear), onPressed: () {}), + ], + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ), + ); + + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + semantics.dispose(); + }); + + testWidgets('SearchBar does not produce an intermediate unlabeled semantics node', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SearchBar( + hintText: 'Search...', + trailing: [ + IconButton(tooltip: 'Clear', icon: const Icon(Icons.clear), onPressed: () {}), + ], + ), + ), + ), + ), + ); + + for (final SemanticsNode node in semantics.nodesWith( + actions: [SemanticsAction.tap], + )) { + final bool isTextField = node.hasFlag(SemanticsFlag.isTextField); + final bool hasLabel = node.label.isNotEmpty; + final bool hasTooltip = node.tooltip.isNotEmpty; + final bool hasValue = node.value.isNotEmpty; + expect(isTextField || hasLabel || hasTooltip || hasValue, isTrue); + } + semantics.dispose(); + }); + testWidgets('Check SearchBar opacity when disabled', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( From 8cfeff4bfe073aa31c458a376db37b3cecabcf66 Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Fri, 28 Aug 2026 12:08:35 -0700 Subject: [PATCH 2/4] update --- packages/material_ui/lib/src/search_anchor.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/material_ui/lib/src/search_anchor.dart b/packages/material_ui/lib/src/search_anchor.dart index cd7b113c78b9..0dcf7ef3123e 100644 --- a/packages/material_ui/lib/src/search_anchor.dart +++ b/packages/material_ui/lib/src/search_anchor.dart @@ -607,6 +607,7 @@ class _SearchAnchorState extends State { child: IgnorePointer( ignoring: !widget.enabled, child: GestureDetector( + // Avoid providing duplicate semantics actions. excludeFromSemantics: true, onTap: _openView, child: widget.builder(context, _searchController), From d89cf51b01b9ad2d5eae403a4f76c5bd1d71d424 Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Fri, 28 Aug 2026 12:18:40 -0700 Subject: [PATCH 3/4] remove test --- .../material_ui/test/search_anchor_test.dart | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/packages/material_ui/test/search_anchor_test.dart b/packages/material_ui/test/search_anchor_test.dart index ddd5e9bb0249..cb7a1fb8454d 100644 --- a/packages/material_ui/test/search_anchor_test.dart +++ b/packages/material_ui/test/search_anchor_test.dart @@ -3450,37 +3450,6 @@ void main() { semantics.dispose(); }); - testWidgets('SearchBar does not produce an intermediate unlabeled semantics node', ( - WidgetTester tester, - ) async { - final semantics = SemanticsTester(tester); - await tester.pumpWidget( - MaterialApp( - home: Material( - child: Center( - child: SearchBar( - hintText: 'Search...', - trailing: [ - IconButton(tooltip: 'Clear', icon: const Icon(Icons.clear), onPressed: () {}), - ], - ), - ), - ), - ), - ); - - for (final SemanticsNode node in semantics.nodesWith( - actions: [SemanticsAction.tap], - )) { - final bool isTextField = node.hasFlag(SemanticsFlag.isTextField); - final bool hasLabel = node.label.isNotEmpty; - final bool hasTooltip = node.tooltip.isNotEmpty; - final bool hasValue = node.value.isNotEmpty; - expect(isTextField || hasLabel || hasTooltip || hasValue, isTrue); - } - semantics.dispose(); - }); - testWidgets('Check SearchBar opacity when disabled', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( From d6d2cd92dbe643d568a121c9cd5a06f9729b1b5a Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Thu, 3 Sep 2026 13:21:24 -0700 Subject: [PATCH 4/4] add option --- .../lib/search_anchor/search_anchor.2.dart | 1 + .../lib/search_anchor/search_anchor.3.dart | 1 + .../lib/search_anchor/search_anchor.4.dart | 1 + .../lib/search_anchor/search_anchor.5.dart | 80 +++++++++++++++++++ .../lib/search_anchor/search_bar.0.dart | 1 + .../search_anchor/search_anchor.5_test.dart | 35 ++++++++ .../material_ui/lib/src/search_anchor.dart | 68 +++++++++++++--- ...hange_2026_08_27_search_bar_semantics.yaml | 3 +- .../material_ui/test/search_anchor_test.dart | 78 ++++++++++++++++++ 9 files changed, 258 insertions(+), 10 deletions(-) create mode 100644 packages/material_ui/example/lib/search_anchor/search_anchor.5.dart create mode 100644 packages/material_ui/example/test/search_anchor/search_anchor.5_test.dart diff --git a/packages/material_ui/example/lib/search_anchor/search_anchor.2.dart b/packages/material_ui/example/lib/search_anchor/search_anchor.2.dart index b025aeb93dcb..ab11486b4373 100644 --- a/packages/material_ui/example/lib/search_anchor/search_anchor.2.dart +++ b/packages/material_ui/example/lib/search_anchor/search_anchor.2.dart @@ -27,6 +27,7 @@ class _SearchBarAppState extends State { body: Column( children: [ SearchAnchor( + enableTapHandling: false, searchController: controller, builder: (BuildContext context, SearchController controller) { return IconButton( diff --git a/packages/material_ui/example/lib/search_anchor/search_anchor.3.dart b/packages/material_ui/example/lib/search_anchor/search_anchor.3.dart index 5a9882a79363..b2c2429a612d 100644 --- a/packages/material_ui/example/lib/search_anchor/search_anchor.3.dart +++ b/packages/material_ui/example/lib/search_anchor/search_anchor.3.dart @@ -43,6 +43,7 @@ class _AsyncSearchAnchorState extends State<_AsyncSearchAnchor> { @override Widget build(BuildContext context) { return SearchAnchor( + enableTapHandling: false, builder: (BuildContext context, SearchController controller) { return IconButton( icon: const Icon(Icons.search), diff --git a/packages/material_ui/example/lib/search_anchor/search_anchor.4.dart b/packages/material_ui/example/lib/search_anchor/search_anchor.4.dart index ae918534f71c..ac6709ac6120 100644 --- a/packages/material_ui/example/lib/search_anchor/search_anchor.4.dart +++ b/packages/material_ui/example/lib/search_anchor/search_anchor.4.dart @@ -73,6 +73,7 @@ class _AsyncSearchAnchorState extends State<_AsyncSearchAnchor> { @override Widget build(BuildContext context) { return SearchAnchor( + enableTapHandling: false, builder: (BuildContext context, SearchController controller) { return IconButton( icon: const Icon(Icons.search), diff --git a/packages/material_ui/example/lib/search_anchor/search_anchor.5.dart b/packages/material_ui/example/lib/search_anchor/search_anchor.5.dart new file mode 100644 index 000000000000..4a7fc09c0f5a --- /dev/null +++ b/packages/material_ui/example/lib/search_anchor/search_anchor.5.dart @@ -0,0 +1,80 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// #region body +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [SearchAnchor] with [SearchAnchor.enableTapHandling]. + +void main() => runApp(const SearchAnchorCustomSearchBarApp()); + +class SearchAnchorCustomSearchBarApp extends StatefulWidget { + const SearchAnchorCustomSearchBarApp({super.key}); + + @override + State createState() => + _SearchAnchorCustomSearchBarAppState(); +} + +class _SearchAnchorCustomSearchBarAppState + extends State { + String? selectedItem; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Custom Search Bar Anchor Sample')), + body: Align( + alignment: Alignment.topCenter, + child: Column( + children: [ + const SizedBox(height: 16), + SearchAnchor( + // Set to false because SearchBar handles its own tap events. + // This prevents duplicate gesture recognizers and duplicate + // tap semantics actions. + enableTapHandling: false, + builder: (BuildContext context, SearchController controller) { + return SearchBar( + controller: controller, + hintText: 'Search items...', + onTap: () { + controller.openView(); + }, + onChanged: (String value) { + controller.openView(); + }, + leading: const Icon(Icons.search), + ); + }, + suggestionsBuilder: + (BuildContext context, SearchController controller) { + return List.generate(5, (int index) { + final String item = 'Item $index'; + return ListTile( + title: Text(item), + onTap: () { + setState(() { + selectedItem = item; + controller.closeView(item); + }); + }, + ); + }); + }, + ), + const SizedBox(height: 16), + if (selectedItem == null) + const Text('No item selected') + else + Text('Selected item: $selectedItem'), + ], + ), + ), + ), + ); + } +} +// #endregion body diff --git a/packages/material_ui/example/lib/search_anchor/search_bar.0.dart b/packages/material_ui/example/lib/search_anchor/search_bar.0.dart index 377d3108d406..6381796dfa8d 100644 --- a/packages/material_ui/example/lib/search_anchor/search_bar.0.dart +++ b/packages/material_ui/example/lib/search_anchor/search_bar.0.dart @@ -30,6 +30,7 @@ class _SearchBarAppState extends State { body: Padding( padding: const .all(8.0), child: SearchAnchor( + enableTapHandling: false, builder: (BuildContext context, SearchController controller) { return SearchBar( controller: controller, diff --git a/packages/material_ui/example/test/search_anchor/search_anchor.5_test.dart b/packages/material_ui/example/test/search_anchor/search_anchor.5_test.dart new file mode 100644 index 000000000000..1677b6ca5c15 --- /dev/null +++ b/packages/material_ui/example/test/search_anchor/search_anchor.5_test.dart @@ -0,0 +1,35 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/search_anchor/search_anchor.5.dart' + as example; + +void main() { + testWidgets( + 'SearchAnchor with enableTapHandling = false opens view on SearchBar tap', + (WidgetTester tester) async { + await tester.pumpWidget(const example.SearchAnchorCustomSearchBarApp()); + + expect( + find.widgetWithText(AppBar, 'Custom Search Bar Anchor Sample'), + findsOne, + ); + expect(find.text('No item selected'), findsOne); + + await tester.tap(find.byType(SearchBar)); + await tester.pumpAndSettle(); + + for (int i = 0; i < 5; i++) { + expect(find.widgetWithText(ListTile, 'Item $i'), findsOne); + } + + await tester.tap(find.text('Item 2')); + await tester.pumpAndSettle(); + + expect(find.text('Selected item: Item 2'), findsOne); + }, + ); +} diff --git a/packages/material_ui/lib/src/search_anchor.dart b/packages/material_ui/lib/src/search_anchor.dart index 0dcf7ef3123e..057fff322e2c 100644 --- a/packages/material_ui/lib/src/search_anchor.dart +++ b/packages/material_ui/lib/src/search_anchor.dart @@ -83,6 +83,35 @@ typedef ViewBuilder = Widget Function(Iterable suggestions); /// If [builder] returns an Icon, or any un-tappable widgets, we don't have /// to explicitly call [SearchController.openView]. /// +/// ## Tap handling on the anchor +/// +/// By default, [SearchAnchor] wraps the widget returned by [builder] in a +/// [GestureDetector] that calls [SearchController.openView] on tap. This +/// behavior is controlled by [enableTapHandling], which defaults to true. +/// +/// ### When to enable tap handling +/// +/// Keep [enableTapHandling] set to true (the default) when [builder] +/// returns a passive widget, such as an [Icon] or [Text]. The outer +/// [GestureDetector] handles taps and provides tap semantics for accessibility +/// services. +/// +/// ### When to disable tap handling +/// +/// Set [enableTapHandling] to false when the widget returned by [builder] +/// handles its own tap events or provides its own semantics. For example: +/// +/// * When returning a [SearchBar] that handles taps through its own callbacks. +/// * When returning buttons like [IconButton] or other interactive widgets that +/// already call [SearchController.openView]. +/// +/// Setting [enableTapHandling] to false avoids duplicate gesture recognizers +/// in the gesture arena and prevents duplicate tap semantics (which can cause +/// accessibility guideline violations, such as unlabeled tap targets). +/// +/// When using [SearchAnchor.bar], [enableTapHandling] is already set to +/// false because the factory constructor's [SearchBar] handles its own taps. +/// /// The search view route will be popped if the window size is changed and the /// search view route is not in full-screen mode. However, if the search view route /// is in full-screen mode, changing the window size, such as rotating a mobile @@ -139,6 +168,19 @@ typedef ViewBuilder = Widget Function(Iterable suggestions); /// /// /// +/// +/// +/// This example shows how to use a [SearchAnchor] with a custom [SearchBar] as +/// the anchor and sets [enableTapHandling] to false. +/// +// TODO(framework): Replace the following block with a @dartpad directive +// when it's supported. https://github.com/dart-lang/dartdoc/issues/4123 +/// {@macro material_ui.dartpad_guide} +/// +/// {@example /example/lib/search_anchor/search_anchor.5.dart#body} +/// +/// +/// /// See also: /// /// * [SearchBar], a widget that defines a search bar. @@ -181,6 +223,7 @@ class SearchAnchor extends StatefulWidget { this.enabled = true, this.smartDashesType, this.smartQuotesType, + this.enableTapHandling = true, }); /// Create a [SearchAnchor] that has a [SearchBar] which opens a search view. @@ -472,6 +515,16 @@ class SearchAnchor extends StatefulWidget { /// configuration option on a standalone [TextField]. final SmartQuotesType? smartQuotesType; + /// Whether to wrap the widget returned by [builder] with a [GestureDetector] + /// that opens the search view route when tapped. + /// + /// Defaults to true. + /// + /// Set this to false if the widget returned by [builder] handles its own + /// tap gestures (such as a [SearchBar] or [IconButton]) to avoid duplicate + /// gestures and semantics actions. + final bool enableTapHandling; + @override State createState() => _SearchAnchorState(); } @@ -600,19 +653,15 @@ class _SearchAnchorState extends State { @override Widget build(BuildContext context) { + Widget child = widget.builder(context, _searchController); + if (widget.enableTapHandling) { + child = GestureDetector(onTap: _openView, child: child); + } return AnimatedOpacity( key: _anchorKey, opacity: _getOpacity(), duration: _kAnchorFadeDuration, - child: IgnorePointer( - ignoring: !widget.enabled, - child: GestureDetector( - // Avoid providing duplicate semantics actions. - excludeFromSemantics: true, - onTap: _openView, - child: widget.builder(context, _searchController), - ), - ), + child: IgnorePointer(ignoring: !widget.enabled, child: child), ); } } @@ -1319,6 +1368,7 @@ class _SearchAnchorWithSearchBar extends SearchAnchor { super.smartDashesType, super.smartQuotesType, }) : super( + enableTapHandling: false, viewHintText: viewHintText ?? barHintText, headerHeight: viewHeaderHeight, headerTextStyle: viewHeaderTextStyle, diff --git a/packages/material_ui/pending_changelogs/change_2026_08_27_search_bar_semantics.yaml b/packages/material_ui/pending_changelogs/change_2026_08_27_search_bar_semantics.yaml index a353e374d3f8..632048aa99e8 100644 --- a/packages/material_ui/pending_changelogs/change_2026_08_27_search_bar_semantics.yaml +++ b/packages/material_ui/pending_changelogs/change_2026_08_27_search_bar_semantics.yaml @@ -1,3 +1,4 @@ changelog: | - Fixes unlabeled tap target semantics in `SearchBar`. -version: patch + - Adds `enableTapHandling` to `SearchAnchor`. +version: minor diff --git a/packages/material_ui/test/search_anchor_test.dart b/packages/material_ui/test/search_anchor_test.dart index cb7a1fb8454d..48348fcc7460 100644 --- a/packages/material_ui/test/search_anchor_test.dart +++ b/packages/material_ui/test/search_anchor_test.dart @@ -3450,6 +3450,84 @@ void main() { semantics.dispose(); }); + testWidgets('SearchAnchor with enableTapHandling: false meets labeledTapTargetGuideline', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SearchAnchor( + enableTapHandling: false, + builder: (BuildContext context, SearchController controller) { + return SearchBar( + controller: controller, + hintText: 'Search...', + trailing: [ + IconButton(tooltip: 'Clear', icon: const Icon(Icons.clear), onPressed: () {}), + ], + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ), + ); + + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + semantics.dispose(); + }); + + testWidgets('SearchAnchor with enableTapHandling provides tap semantics for passive child', ( + WidgetTester tester, + ) async { + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ), + ); + + expect(semantics, includesNodeWith(actions: [SemanticsAction.tap])); + semantics.dispose(); + }); + + testWidgets('SearchAnchor with enableTapHandling: false does not build GestureDetector', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SearchAnchor( + enableTapHandling: false, + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ), + ); + + expect( + find.descendant(of: find.byType(SearchAnchor), matching: find.byType(GestureDetector)), + findsNothing, + ); + }); + testWidgets('Check SearchBar opacity when disabled', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp(