diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 883cea23764..27dc1bc8eb1 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -78,6 +78,7 @@ export 'src/material/drawer_header.dart'; export 'src/material/drawer_theme.dart'; export 'src/material/dropdown.dart'; export 'src/material/dropdown_menu.dart'; +export 'src/material/dropdown_menu_form_field.dart'; export 'src/material/dropdown_menu_theme.dart'; export 'src/material/elevated_button.dart'; export 'src/material/elevated_button_theme.dart'; diff --git a/packages/flutter/lib/src/material/dropdown_menu_form_field.dart b/packages/flutter/lib/src/material/dropdown_menu_form_field.dart new file mode 100644 index 00000000000..33fc4884028 --- /dev/null +++ b/packages/flutter/lib/src/material/dropdown_menu_form_field.dart @@ -0,0 +1,235 @@ +// 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. + +/// @docImport 'text_theme.dart'; +library; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'dropdown_menu.dart'; +import 'input_decorator.dart'; +import 'menu_style.dart'; + +/// A [FormField] that contains a [DropdownMenu]. +/// +/// This is a convenience widget that wraps a [DropdownMenu] widget in a +/// [FormField]. +/// +/// A [Form] ancestor is not required. The [Form] allows one to +/// save, reset, or validate multiple fields at once. To use without a [Form], +/// pass a [GlobalKey] to the constructor and use [GlobalKey.currentState] to +/// save or reset the form field. +/// +/// The `value` parameter maps to [FormField.initialValue]. +/// +/// See also: +/// +/// * [DropdownMenu], which is the underlying text field without the [Form] +/// integration. +class DropdownMenuFormField extends FormField { + /// Creates a [DropdownMenu] widget that is a [FormField]. + /// + /// For a description of the `onSaved`, `validator`, or `autovalidateMode` + /// parameters, see [FormField]. For the rest, see [DropdownMenu]. + DropdownMenuFormField({ + super.key, + bool enabled = true, + double? width, + double? menuHeight, + Widget? leadingIcon, + Widget? trailingIcon, + Widget? label, + String? hintText, + String? helperText, + Widget? selectedTrailingIcon, + bool enableFilter = false, + bool enableSearch = true, + TextInputType? keyboardType, + TextStyle? textStyle, + TextAlign textAlign = TextAlign.start, + InputDecorationTheme? inputDecorationTheme, + MenuStyle? menuStyle, + this.controller, + T? initialSelection, + this.onSelected, + FocusNode? focusNode, + bool? requestFocusOnTap, + EdgeInsetsGeometry? expandedInsets, + Offset? alignmentOffset, + FilterCallback? filterCallback, + SearchCallback? searchCallback, + required this.dropdownMenuEntries, + List? inputFormatters, + DropdownMenuCloseBehavior closeBehavior = DropdownMenuCloseBehavior.all, + int maxLines = 1, + TextInputAction? textInputAction, + super.restorationId, + super.onSaved, + AutovalidateMode autovalidateMode = AutovalidateMode.disabled, + super.validator, + super.forceErrorText, + }) : super( + initialValue: initialSelection, + autovalidateMode: autovalidateMode, + builder: (FormFieldState field) { + final _DropdownMenuFormFieldState state = field as _DropdownMenuFormFieldState; + void onSelectedHandler(T? value) { + field.didChange(value); + onSelected?.call(value); + } + + return UnmanagedRestorationScope( + bucket: field.bucket, + child: DropdownMenu( + restorationId: restorationId, + enabled: enabled, + width: width, + menuHeight: menuHeight, + leadingIcon: leadingIcon, + trailingIcon: trailingIcon, + label: label, + hintText: hintText, + helperText: helperText, + errorText: state.errorText, + selectedTrailingIcon: selectedTrailingIcon, + enableFilter: enableFilter, + enableSearch: enableSearch, + keyboardType: keyboardType, + textStyle: textStyle, + textAlign: textAlign, + inputDecorationTheme: inputDecorationTheme, + menuStyle: menuStyle, + controller: controller, + initialSelection: state.value, + onSelected: onSelectedHandler, + focusNode: focusNode, + requestFocusOnTap: requestFocusOnTap, + expandedInsets: expandedInsets, + alignmentOffset: alignmentOffset, + filterCallback: filterCallback, + searchCallback: searchCallback, + inputFormatters: inputFormatters, + closeBehavior: closeBehavior, + dropdownMenuEntries: dropdownMenuEntries, + maxLines: maxLines, + textInputAction: textInputAction, + ), + ); + }, + ); + + /// The callback is called when a selection is made. + /// + /// Defaults to null. If null, only the text field is updated. + final ValueChanged? onSelected; + + /// Controls the text being edited. + /// + /// If null, this widget will create its own [TextEditingController]. + final TextEditingController? controller; + + /// Descriptions of the menu items in the [DropdownMenuFormField]. + /// + /// This is a required parameter. It is recommended that at least one [DropdownMenuEntry] + /// is provided. If this is an empty list, the menu will be empty and only + /// contain space for padding. + final List> dropdownMenuEntries; + + @override + FormFieldState createState() => _DropdownMenuFormFieldState(); +} + +class _DropdownMenuFormFieldState extends FormFieldState { + DropdownMenuFormField get _dropdownMenuFormField => widget as DropdownMenuFormField; + + RestorableTextEditingController? _restorableController; + + @override + void initState() { + super.initState(); + _createRestorableController(widget.initialValue); + } + + void _createRestorableController(T? initialValue) { + assert(_restorableController == null); + _restorableController = RestorableTextEditingController.fromValue( + TextEditingValue(text: _findLabelByValue(initialValue)), + ); + if (!restorePending) { + _registerRestorableController(); + } + } + + @override + void didUpdateWidget(DropdownMenuFormField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialValue != widget.initialValue && !hasInteractedByUser) { + setValue(widget.initialValue); + } + } + + @override + void dispose() { + _restorableController?.dispose(); + super.dispose(); + } + + @override + void didChange(T? value) { + super.didChange(value); + _dropdownMenuFormField.onSelected?.call(value); + _updateRestorableController(value); + } + + @override + void reset() { + super.reset(); + _dropdownMenuFormField.onSelected?.call(value); + _updateRestorableController(widget.initialValue); + } + + void _updateRestorableController(T? value) { + if (_restorableController != null) { + _restorableController!.value.value = TextEditingValue(text: _findLabelByValue(value)); + } + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + super.restoreState(oldBucket, initialRestore); + if (_restorableController != null) { + _registerRestorableController(); + // Make sure to update the internal [DropdownMenuFieldState] value to sync up with + // text editing controller value if it matches one of the item label. + final T? matchingValue = _findValueByLabel(_restorableController!.value.text); + if (matchingValue != null) { + setValue(matchingValue); + } + } + } + + void _registerRestorableController() { + assert(_restorableController != null); + registerForRestoration(_restorableController!, 'controller'); + } + + T? _findValueByLabel(String label) { + for (final DropdownMenuEntry entry in _dropdownMenuFormField.dropdownMenuEntries) { + if (entry.label == label) { + return entry.value; + } + } + return null; + } + + String _findLabelByValue(T? value) { + for (final DropdownMenuEntry entry in _dropdownMenuFormField.dropdownMenuEntries) { + if (entry.value == value) { + return entry.label; + } + } + return ''; + } +} diff --git a/packages/flutter/test/material/dropdown_menu_form_field_test.dart b/packages/flutter/test/material/dropdown_menu_form_field_test.dart new file mode 100644 index 00000000000..74db5308672 --- /dev/null +++ b/packages/flutter/test/material/dropdown_menu_form_field_test.dart @@ -0,0 +1,1179 @@ +// 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/src/services/text_formatter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +enum MenuItem { + menuItem0('Item 0'), + menuItem1('Item 1'), + menuItem2('Item 2'), + menuItem3('Item 3'); + + const MenuItem(this.label); + final String label; +} + +void main() { + final List> menuEntries = >[]; + + for (final MenuItem value in MenuItem.values) { + final DropdownMenuEntry entry = DropdownMenuEntry( + value: value, + label: value.label, + ); + menuEntries.add(entry); + } + + Finder findMenuItem(MenuItem menuItem) { + // For each menu item there are two MenuItemButton widgets. + // The last one is the real button item in the menu. + // The first one is not visible, it is part of _DropdownMenuBody + // which is used to compute the dropdown width. + return find.widgetWithText(MenuItemButton, menuItem.label).last; + } + + testWidgets('Creates an underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + expect(find.byType(DropdownMenu), findsOne); + }); + + testWidgets('Passes dropdownMenuEntries to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + final DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.dropdownMenuEntries, menuEntries); + }); + + testWidgets('Dropdown menu can be opened and contains all the items', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + for (final MenuItem item in MenuItem.values) { + expect(findMenuItem(item), findsOne); + } + }); + + testWidgets('Passes enabled to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.enabled, true); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField(enabled: false, dropdownMenuEntries: menuEntries), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.enabled, false); + }); + + testWidgets('Passes width to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.width, null); + + const double width = 100.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField(width: width, dropdownMenuEntries: menuEntries), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.width, width); + }); + + testWidgets('Passes menuHeight to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.menuHeight, null); + + const double menuHeight = 100.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + menuHeight: menuHeight, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.menuHeight, menuHeight); + }); + + testWidgets('Passes leadingIcon to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.leadingIcon, null); + + const Icon leadingIcon = Icon(Icons.abc); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + leadingIcon: leadingIcon, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.leadingIcon, leadingIcon); + }); + + testWidgets('Passes trailingIcon to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.trailingIcon, null); + + const Icon trailingIcon = Icon(Icons.abc); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + trailingIcon: trailingIcon, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.trailingIcon, trailingIcon); + }); + + testWidgets('Passes label to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.label, null); + + const Widget label = Text('Label'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField(label: label, dropdownMenuEntries: menuEntries), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.label, label); + }); + + testWidgets('Passes hintText to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.hintText, null); + + const String hintText = 'Hint'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + hintText: hintText, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.hintText, hintText); + }); + + testWidgets('Passes helperText to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.helperText, null); + + const String helperText = 'Hint'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + helperText: helperText, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.helperText, helperText); + }); + + testWidgets('Passes selectedTrailingIcon to underlying DropdownMenu', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.selectedTrailingIcon, null); + + const Icon selectedTrailingIcon = Icon(Icons.abc); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + selectedTrailingIcon: selectedTrailingIcon, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.selectedTrailingIcon, selectedTrailingIcon); + }); + + testWidgets('Passes enableFilter to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.enableFilter, false); + + const bool enableFilter = true; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + enableFilter: enableFilter, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.enableFilter, enableFilter); + }); + + testWidgets('Passes enableSearch to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.enableSearch, true); + + const bool enableSearch = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + enableSearch: enableSearch, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.enableSearch, enableSearch); + }); + + testWidgets('Passes keyboardType to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.keyboardType, null); + + const TextInputType keyboardType = TextInputType.datetime; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + keyboardType: keyboardType, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.keyboardType, keyboardType); + }); + + testWidgets('Passes textStyle to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.textStyle, null); + + const TextStyle textStyle = TextStyle(fontWeight: FontWeight.bold); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + textStyle: textStyle, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.textStyle, textStyle); + }); + + testWidgets('Passes textAlign to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.textAlign, TextAlign.start); + + const TextAlign textAlign = TextAlign.center; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + textAlign: textAlign, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.textAlign, textAlign); + }); + + testWidgets('Passes inputDecorationTheme to underlying DropdownMenu', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.inputDecorationTheme, null); + + const InputDecorationTheme inputDecorationTheme = InputDecorationTheme(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + inputDecorationTheme: inputDecorationTheme, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.inputDecorationTheme, inputDecorationTheme); + }); + + testWidgets('Passes menuStyle to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.menuStyle, null); + + const MenuStyle menuStyle = MenuStyle(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + menuStyle: menuStyle, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.menuStyle, menuStyle); + }); + + testWidgets('Passes controller to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.controller, null); + + final TextEditingController controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + controller: controller, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.controller, controller); + }); + + testWidgets('Passes focusNode to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.focusNode, null); + + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + focusNode: focusNode, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.focusNode, focusNode); + }); + + testWidgets('Passes requestFocusOnTap to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.requestFocusOnTap, null); + + const bool requestFocusOnTap = true; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + requestFocusOnTap: requestFocusOnTap, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.requestFocusOnTap, requestFocusOnTap); + }); + + testWidgets('Passes expandedInsets to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.expandedInsets, null); + + const EdgeInsetsGeometry expandedInsets = EdgeInsets.zero; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + expandedInsets: expandedInsets, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.expandedInsets, expandedInsets); + }); + + testWidgets('Passes alignmentOffset to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.alignmentOffset, null); + + const Offset alignmentOffset = Offset.zero; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + alignmentOffset: alignmentOffset, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.alignmentOffset, alignmentOffset); + }); + + testWidgets('Passes filterCallback to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + enableFilter: true, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.filterCallback, null); + + List> filterCallback( + List> entries, + String filter, + ) { + return entries; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + enableFilter: true, + filterCallback: filterCallback, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.filterCallback, filterCallback); + }); + + testWidgets('Passes searchCallback to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.searchCallback, null); + + int searchCallback(List> entries, String filter) { + return 0; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + searchCallback: searchCallback, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.searchCallback, searchCallback); + }); + + testWidgets('Passes inputFormatters to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.inputFormatters, null); + + final List inputFormatters = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + inputFormatters: inputFormatters, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.inputFormatters, inputFormatters); + }); + + testWidgets('Passes closeBehavior to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.closeBehavior, DropdownMenuCloseBehavior.all); + + const DropdownMenuCloseBehavior closeBehavior = DropdownMenuCloseBehavior.self; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + closeBehavior: closeBehavior, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.closeBehavior, closeBehavior); + }); + + testWidgets('Passes maxLines to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.maxLines, 1); + + const int maxLines = 3; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + maxLines: maxLines, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.maxLines, maxLines); + }); + + testWidgets('Passes textInputAction to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.textInputAction, null); + + const TextInputAction textInputAction = TextInputAction.emergencyCall; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + textInputAction: textInputAction, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.textInputAction, textInputAction); + }); + + testWidgets('Passes restorationId to underlying DropdownMenu', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: DropdownMenuFormField(dropdownMenuEntries: menuEntries)), + ), + ); + + // Check default value. + DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.restorationId, null); + + const String restorationId = 'dropdown_menu'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + restorationId: restorationId, + dropdownMenuEntries: menuEntries, + ), + ), + ), + ); + + expect(find.byType(TextField), findsOne); + + dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.restorationId, restorationId); + }); + + testWidgets('Field state is correcly updated', (WidgetTester tester) async { + final GlobalKey> fieldKey = GlobalKey>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + key: fieldKey, + dropdownMenuEntries: menuEntries, + initialSelection: MenuItem.menuItem0, + ), + ), + ), + ); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + await tester.tap(findMenuItem(MenuItem.menuItem1)); + await tester.pump(); + + expect(fieldKey.currentState!.value, MenuItem.menuItem1); + }); + + testWidgets('onSaved callback is called when the field is outside a Form', ( + WidgetTester tester, + ) async { + final GlobalKey> fieldKey = GlobalKey>(); + + MenuItem? savedValue = MenuItem.menuItem0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + key: fieldKey, + dropdownMenuEntries: menuEntries, + initialSelection: savedValue, + onSaved: (MenuItem? newValue) => savedValue = newValue, + ), + ), + ), + ); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + await tester.tap(findMenuItem(MenuItem.menuItem1)); + await tester.pump(); + + expect(savedValue, MenuItem.menuItem0); + + fieldKey.currentState!.save(); + await tester.pump(); + + expect(savedValue, MenuItem.menuItem1); + }); + + testWidgets('onSaved callback is called when the field is inside a Form', ( + WidgetTester tester, + ) async { + final GlobalKey formKey = GlobalKey(); + + MenuItem? savedValue = MenuItem.menuItem0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Form( + key: formKey, + child: DropdownMenuFormField( + dropdownMenuEntries: menuEntries, + initialSelection: savedValue, + onSaved: (MenuItem? newValue) => savedValue = newValue, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + await tester.tap(findMenuItem(MenuItem.menuItem1)); + await tester.pump(); + + expect(savedValue, MenuItem.menuItem0); + + formKey.currentState!.save(); + await tester.pump(); + + expect(savedValue, MenuItem.menuItem1); + }); + + testWidgets('Field can be reset', (WidgetTester tester) async { + final GlobalKey> fieldKey = GlobalKey>(); + + MenuItem? savedValue = MenuItem.menuItem0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + key: fieldKey, + dropdownMenuEntries: menuEntries, + initialSelection: savedValue, + onSaved: (MenuItem? newValue) => savedValue = newValue, + ), + ), + ), + ); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + await tester.tap(findMenuItem(MenuItem.menuItem1)); + await tester.pump(); + + expect(fieldKey.currentState!.value, MenuItem.menuItem1); + + fieldKey.currentState!.reset(); + await tester.pump(); + + expect(fieldKey.currentState!.value, MenuItem.menuItem0); + }); + + testWidgets('isValid and hasError results are correct', (WidgetTester tester) async { + final GlobalKey> fieldKey = GlobalKey>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + key: fieldKey, + dropdownMenuEntries: menuEntries, + autovalidateMode: AutovalidateMode.always, + ), + ), + ), + ); + + // No validation error. + expect(fieldKey.currentState!.isValid, true); + expect(fieldKey.currentState!.hasError, false); + + const String validationError = 'Required'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + key: fieldKey, + dropdownMenuEntries: menuEntries, + autovalidateMode: AutovalidateMode.always, + validator: (MenuItem? item) => validationError, + ), + ), + ), + ); + + // Validation error. + expect(fieldKey.currentState!.isValid, false); + expect(fieldKey.currentState!.hasError, true); + }); + + testWidgets('Validation result is shown as error text', (WidgetTester tester) async { + final GlobalKey> fieldKey = GlobalKey>(); + + const String validationError = 'Required'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + key: fieldKey, + dropdownMenuEntries: menuEntries, + autovalidateMode: AutovalidateMode.always, + validator: (MenuItem? item) => validationError, + ), + ), + ), + ); + + fieldKey.currentState!.validate(); + await tester.pump(); + + expect(find.text('Required'), findsOneWidget); + + final DropdownMenu dropdownMenu = tester.widget(find.byType(DropdownMenu)); + expect(dropdownMenu.errorText, validationError); + }); + + testWidgets('Initial selection is applied', (WidgetTester tester) async { + final GlobalKey> fieldKey = GlobalKey>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + key: fieldKey, + dropdownMenuEntries: menuEntries, + initialSelection: MenuItem.menuItem0, + ), + ), + ), + ); + + expect(fieldKey.currentState!.value, MenuItem.menuItem0); + }); + + testWidgets( + 'Initial selection is applied when updated and the field has not been updated in-between', + (WidgetTester tester) async { + final GlobalKey> fieldKey = GlobalKey>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + key: fieldKey, + dropdownMenuEntries: menuEntries, + initialSelection: MenuItem.menuItem0, + ), + ), + ), + ); + + expect(fieldKey.currentState!.value, MenuItem.menuItem0); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + key: fieldKey, + dropdownMenuEntries: menuEntries, + initialSelection: MenuItem.menuItem1, + ), + ), + ), + ); + + expect(fieldKey.currentState!.value, MenuItem.menuItem1); + }, + ); + + testWidgets( + 'Initial selection is not applied when updated and the field has been updated in-between', + (WidgetTester tester) async { + final GlobalKey> fieldKey = GlobalKey>(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + key: fieldKey, + dropdownMenuEntries: menuEntries, + initialSelection: MenuItem.menuItem0, + ), + ), + ), + ); + + expect(fieldKey.currentState!.value, MenuItem.menuItem0); + + // Select a different item than the initial one. + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + await tester.tap(findMenuItem(MenuItem.menuItem2)); + await tester.pump(); + + expect(fieldKey.currentState!.value, MenuItem.menuItem2); + + // Update initial selection. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownMenuFormField( + key: fieldKey, + dropdownMenuEntries: menuEntries, + initialSelection: MenuItem.menuItem1, + ), + ), + ), + ); + + // The value selected by the user is preserved. + expect(fieldKey.currentState!.value, MenuItem.menuItem2); + }, + ); + + testWidgets('Selected value is restorable', (WidgetTester tester) async { + final GlobalKey> formFieldState = + GlobalKey>(); + const String restorationId = 'dropdown_menu_form_field'; + + await tester.pumpWidget( + MaterialApp( + restorationScopeId: 'app', + home: Scaffold( + body: DropdownMenuFormField( + key: formFieldState, + dropdownMenuEntries: menuEntries, + initialSelection: MenuItem.menuItem0, + restorationId: restorationId, + ), + ), + ), + ); + + expect(formFieldState.currentState!.value, MenuItem.menuItem0); + + // Select a different item than the initial one. + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + await tester.tap(findMenuItem(MenuItem.menuItem2)); + await tester.pump(); + + expect(formFieldState.currentState!.value, MenuItem.menuItem2); + + // Needed for restoration data to be updated. + await tester.pump(); + + final TestRestorationData data = await tester.getRestorationData(); + await tester.restartAndRestore(); + + expect(formFieldState.currentState!.value, MenuItem.menuItem2); + + formFieldState.currentState!.reset(); + expect(formFieldState.currentState!.value, MenuItem.menuItem0); + + await tester.restoreFrom(data); + await tester.pump(); + + expect(formFieldState.currentState!.value, MenuItem.menuItem2); + }); +}