// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../rendering/mock_canvas.dart'; class User { const User({ required this.email, required this.name, }); final String email; final String name; @override String toString() { return '$name, $email'; } } void main() { const List<String> kOptions = <String>[ 'aardvark', 'bobcat', 'chameleon', 'dingo', 'elephant', 'flamingo', 'goose', 'hippopotamus', 'iguana', 'jaguar', 'koala', 'lemur', 'mouse', 'northern white rhinoceros', ]; const List<User> kOptionsUsers = <User>[ User(name: 'Alice', email: 'alice@example.com'), User(name: 'Bob', email: 'bob@example.com'), User(name: 'Charlie', email: 'charlie123@gmail.com'), ]; testWidgets('can filter and select a list of string options', (WidgetTester tester) async { late String lastSelection; await tester.pumpWidget( MaterialApp( home: Scaffold( body: Autocomplete<String>( onSelected: (String selection) { lastSelection = selection; }, optionsBuilder: (TextEditingValue textEditingValue) { return kOptions.where((String option) { return option.contains(textEditingValue.text.toLowerCase()); }); }, ), ), ), ); // The field is always rendered, but the options are not unless needed. expect(find.byType(TextFormField), findsOneWidget); expect(find.byType(ListView), findsNothing); // Focus the empty field. All the options are displayed. await tester.tap(find.byType(TextFormField)); await tester.pump(); expect(find.byType(ListView), findsOneWidget); ListView list = find.byType(ListView).evaluate().first.widget as ListView; expect(list.semanticChildCount, kOptions.length); // Enter text. The options are filtered by the text. await tester.enterText(find.byType(TextFormField), 'ele'); await tester.pump(); expect(find.byType(TextFormField), findsOneWidget); expect(find.byType(ListView), findsOneWidget); list = find.byType(ListView).evaluate().first.widget as ListView; // 'chameleon' and 'elephant' are displayed. expect(list.semanticChildCount, 2); // Select a option. The options hide and the field updates to show the // selection. await tester.tap(find.byType(InkWell).first); await tester.pump(); expect(find.byType(TextFormField), findsOneWidget); expect(find.byType(ListView), findsNothing); final TextFormField field = find.byType(TextFormField).evaluate().first.widget as TextFormField; expect(field.controller!.text, 'chameleon'); expect(lastSelection, 'chameleon'); // Modify the field text. The options appear again and are filtered. await tester.enterText(find.byType(TextFormField), 'e'); await tester.pump(); expect(find.byType(TextFormField), findsOneWidget); expect(find.byType(ListView), findsOneWidget); list = find.byType(ListView).evaluate().first.widget as ListView; // 'chameleon', 'elephant', 'goose', 'lemur', 'mouse', and // 'northern white rhinoceros' are displayed. expect(list.semanticChildCount, 6); }); testWidgets('can filter and select a list of custom User options', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( home: Scaffold( body: Autocomplete<User>( optionsBuilder: (TextEditingValue textEditingValue) { return kOptionsUsers.where((User option) { return option.toString().contains(textEditingValue.text.toLowerCase()); }); }, ), ), ), ); // The field is always rendered, but the options are not unless needed. expect(find.byType(TextFormField), findsOneWidget); expect(find.byType(ListView), findsNothing); // Focus the empty field. All the options are displayed. await tester.tap(find.byType(TextFormField)); await tester.pump(); expect(find.byType(ListView), findsOneWidget); ListView list = find.byType(ListView).evaluate().first.widget as ListView; expect(list.semanticChildCount, kOptionsUsers.length); // Enter text. The options are filtered by the text. await tester.enterText(find.byType(TextFormField), 'example'); await tester.pump(); expect(find.byType(TextFormField), findsOneWidget); expect(find.byType(ListView), findsOneWidget); list = find.byType(ListView).evaluate().first.widget as ListView; // 'Alice' and 'Bob' are displayed because they have "example.com" emails. expect(list.semanticChildCount, 2); // Select a option. The options hide and the field updates to show the // selection. await tester.tap(find.byType(InkWell).first); await tester.pump(); expect(find.byType(TextFormField), findsOneWidget); expect(find.byType(ListView), findsNothing); final TextFormField field = find.byType(TextFormField).evaluate().first.widget as TextFormField; expect(field.controller!.text, 'Alice, alice@example.com'); // Modify the field text. The options appear again and are filtered. await tester.enterText(find.byType(TextFormField), 'B'); await tester.pump(); expect(find.byType(TextFormField), findsOneWidget); expect(find.byType(ListView), findsOneWidget); list = find.byType(ListView).evaluate().first.widget as ListView; // 'Bob' is displayed. expect(list.semanticChildCount, 1); }); testWidgets('displayStringForOption is displayed in the options', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( home: Scaffold( body: Autocomplete<User>( displayStringForOption: (User option) { return option.name; }, optionsBuilder: (TextEditingValue textEditingValue) { return kOptionsUsers.where((User option) { return option.toString().contains(textEditingValue.text.toLowerCase()); }); }, ), ), ), ); // The field is always rendered, but the options are not unless needed. expect(find.byType(TextFormField), findsOneWidget); expect(find.byType(ListView), findsNothing); // Focus the empty field. All the options are displayed, and the string that // is used comes from displayStringForOption. await tester.tap(find.byType(TextFormField)); await tester.pump(); expect(find.byType(ListView), findsOneWidget); final ListView list = find.byType(ListView).evaluate().first.widget as ListView; expect(list.semanticChildCount, kOptionsUsers.length); for (int i = 0; i < kOptionsUsers.length; i++) { expect(find.text(kOptionsUsers[i].name), findsOneWidget); } // Select a option. The options hide and the field updates to show the // selection. The text in the field is given by displayStringForOption. await tester.tap(find.byType(InkWell).first); await tester.pump(); expect(find.byType(TextFormField), findsOneWidget); expect(find.byType(ListView), findsNothing); final TextFormField field = find.byType(TextFormField).evaluate().first.widget as TextFormField; expect(field.controller!.text, kOptionsUsers.first.name); }); testWidgets('can build a custom field', (WidgetTester tester) async { final GlobalKey fieldKey = GlobalKey(); await tester.pumpWidget( MaterialApp( home: Scaffold( body: Autocomplete<String>( optionsBuilder: (TextEditingValue textEditingValue) { return kOptions.where((String option) { return option.contains(textEditingValue.text.toLowerCase()); }); }, fieldViewBuilder: (BuildContext context, TextEditingController textEditingController, FocusNode focusNode, VoidCallback onFieldSubmitted) { return Container(key: fieldKey); }, ), ), ), ); // The custom field is rendered and not the default TextFormField. expect(find.byKey(fieldKey), findsOneWidget); expect(find.byType(TextFormField), findsNothing); }); testWidgets('can build custom options', (WidgetTester tester) async { final GlobalKey optionsKey = GlobalKey(); await tester.pumpWidget( MaterialApp( home: Scaffold( body: Autocomplete<String>( optionsBuilder: (TextEditingValue textEditingValue) { return kOptions.where((String option) { return option.contains(textEditingValue.text.toLowerCase()); }); }, optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) { return Container(key: optionsKey); }, ), ), ), ); // The default field is rendered but not the options, yet. expect(find.byKey(optionsKey), findsNothing); expect(find.byType(TextFormField), findsOneWidget); // Focus the empty field. The custom options is displayed. await tester.tap(find.byType(TextFormField)); await tester.pump(); expect(find.byKey(optionsKey), findsOneWidget); }); testWidgets('the default Autocomplete options widget has a maximum height of 200', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp(home: Scaffold( body: Autocomplete<String>( optionsBuilder: (TextEditingValue textEditingValue) { return kOptions.where((String option) { return option.contains(textEditingValue.text.toLowerCase()); }); }, ), ))); final Finder listFinder = find.byType(ListView); final Finder inputFinder = find.byType(TextFormField); await tester.tap(inputFinder); await tester.enterText(inputFinder, ''); await tester.pump(); final Size baseSize = tester.getSize(listFinder); final double resultingHeight = baseSize.height; expect(resultingHeight, equals(200)); }); testWidgets('the options height restricts to max desired height', (WidgetTester tester) async { const double desiredHeight = 150.0; await tester.pumpWidget(MaterialApp( home: Scaffold( body: Autocomplete<String>( optionsMaxHeight: desiredHeight, optionsBuilder: (TextEditingValue textEditingValue) { return kOptions.where((String option) { return option.contains(textEditingValue.text.toLowerCase()); }); }, ), ))); /// entering "a" returns 9 items from kOptions so basically the /// height of 9 options would be beyond `desiredHeight=150`, /// so height gets restricted to desiredHeight. final Finder listFinder = find.byType(ListView); final Finder inputFinder = find.byType(TextFormField); await tester.tap(inputFinder); await tester.enterText(inputFinder, 'a'); await tester.pump(); final Size baseSize = tester.getSize(listFinder); final double resultingHeight = baseSize.height; /// expected desired Height =150.0 expect(resultingHeight, equals(desiredHeight)); }); testWidgets('The height of options shrinks to height of resulting items, if less than maxHeight', (WidgetTester tester) async { // Returns a Future with the height of the default [Autocomplete] options widget // after the provided text had been entered into the [Autocomplete] field. Future<double> getDefaultOptionsHeight( WidgetTester tester, String enteredText) async { final Finder listFinder = find.byType(ListView); final Finder inputFinder = find.byType(TextFormField); final TextFormField field = inputFinder.evaluate().first.widget as TextFormField; field.controller!.clear(); await tester.tap(inputFinder); await tester.enterText(inputFinder, enteredText); await tester.pump(); final Size baseSize = tester.getSize(listFinder); return baseSize.height; } const double maxOptionsHeight = 250.0; await tester.pumpWidget(MaterialApp( home: Scaffold( body: Autocomplete<String>( optionsMaxHeight: maxOptionsHeight, optionsBuilder: (TextEditingValue textEditingValue) { return kOptions.where((String option) { return option.contains(textEditingValue.text.toLowerCase()); }); }, ), ))); final Finder listFinder = find.byType(ListView); expect(listFinder, findsNothing); // Entering `a` returns 9 items(height > `maxOptionsHeight`) from the kOptions // so height gets restricted to `maxOptionsHeight =250`. final double nineItemsHeight = await getDefaultOptionsHeight(tester, 'a'); expect(nineItemsHeight, equals(maxOptionsHeight)); // Returns 2 Items (height < `maxOptionsHeight`) // so options height shrinks to 2 Items combined height. final double twoItemsHeight = await getDefaultOptionsHeight(tester, 'el'); expect(twoItemsHeight, lessThan(maxOptionsHeight)); // Returns 1 item (height < `maxOptionsHeight`) from `kOptions` // so options height shrinks to 1 items height. final double oneItemsHeight = await getDefaultOptionsHeight(tester, 'elep'); expect(oneItemsHeight, lessThan(twoItemsHeight)); }); testWidgets('initialValue sets initial text field value', (WidgetTester tester) async { late String lastSelection; await tester.pumpWidget( MaterialApp( home: Scaffold( body: Autocomplete<String>( initialValue: const TextEditingValue(text: 'lem'), onSelected: (String selection) { lastSelection = selection; }, optionsBuilder: (TextEditingValue textEditingValue) { return kOptions.where((String option) { return option.contains(textEditingValue.text.toLowerCase()); }); }, ), ), ), ); // The field is always rendered, but the options are not unless needed. expect(find.byType(TextFormField), findsOneWidget); expect(find.byType(ListView), findsNothing); expect( tester.widget<TextFormField>(find.byType(TextFormField)).controller!.text, 'lem', ); // Focus the empty field. All the options are displayed. await tester.tap(find.byType(TextFormField)); await tester.pump(); expect(find.byType(ListView), findsOneWidget); final ListView list = find.byType(ListView).evaluate().first.widget as ListView; // Displays just one option ('lemur'). expect(list.semanticChildCount, 1); // Select a option. The options hide and the field updates to show the // selection. await tester.tap(find.byType(InkWell).first); await tester.pump(); expect(find.byType(TextFormField), findsOneWidget); expect(find.byType(ListView), findsNothing); final TextFormField field = find.byType(TextFormField).evaluate().first.widget as TextFormField; expect(field.controller!.text, 'lemur'); expect(lastSelection, 'lemur'); }); // Ensures that the option with the given label has a given background color // if given, or no background if color is null. void checkOptionHighlight(WidgetTester tester, String label, Color? color) { final RenderBox renderBox = tester.renderObject<RenderBox>(find.ancestor(matching: find.byType(Container), of: find.text(label))); if (color != null) { // Check to see that the container is painted with the highlighted background color. expect(renderBox, paints..rect(color: color)); } else { // There should only be a paragraph painted. expect(renderBox, paintsExactlyCountTimes(const Symbol('drawRect'), 0)); expect(renderBox, paints..paragraph()); } } testWidgets('keyboard navigation of the options properly highlights the option', (WidgetTester tester) async { const Color highlightColor = Color(0xFF112233); await tester.pumpWidget( MaterialApp( theme: ThemeData.light().copyWith( focusColor: highlightColor, ), home: Scaffold( body: Autocomplete<String>( optionsBuilder: (TextEditingValue textEditingValue) { return kOptions.where((String option) { return option.contains(textEditingValue.text.toLowerCase()); }); }, ), ), ), ); await tester.tap(find.byType(TextFormField)); await tester.enterText(find.byType(TextFormField), 'el'); await tester.pump(); expect(find.byType(ListView), findsOneWidget); final ListView list = find.byType(ListView).evaluate().first.widget as ListView; expect(list.semanticChildCount, 2); // Initially the first option should be highlighted checkOptionHighlight(tester, 'chameleon', highlightColor); checkOptionHighlight(tester, 'elephant', null); // Move the selection down await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); // Highlight should be moved to the second item checkOptionHighlight(tester, 'chameleon', null); checkOptionHighlight(tester, 'elephant', highlightColor); }); testWidgets('keyboard navigation keeps the highlighted option scrolled into view', (WidgetTester tester) async { const Color highlightColor = Color(0xFF112233); await tester.pumpWidget( MaterialApp( theme: ThemeData.light().copyWith( focusColor: highlightColor, ), home: Scaffold( body: Autocomplete<String>( optionsBuilder: (TextEditingValue textEditingValue) { return kOptions.where((String option) { return option.contains(textEditingValue.text.toLowerCase()); }); }, ), ), ), ); await tester.tap(find.byType(TextFormField)); await tester.enterText(find.byType(TextFormField), 'e'); await tester.pump(); expect(find.byType(ListView), findsOneWidget); final ListView list = find.byType(ListView).evaluate().first.widget as ListView; expect(list.semanticChildCount, 6); // Highlighted item should be at the top expect(tester.getTopLeft(find.text('chameleon')).dy, equals(64.0)); checkOptionHighlight(tester, 'chameleon', highlightColor); // Move down the list of options await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); // First item should have scrolled off the top, and not be selected. expect(find.text('chameleon'), findsNothing); // Highlighted item 'lemur' should be centered in the options popup expect(tester.getTopLeft(find.text('mouse')).dy, equals(187.0)); checkOptionHighlight(tester, 'mouse', highlightColor); // The other items on screen should not be selected. checkOptionHighlight(tester, 'goose', null); checkOptionHighlight(tester, 'lemur', null); checkOptionHighlight(tester, 'northern white rhinoceros', null); }); }