diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_find_menu_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_find_menu_test.dart new file mode 100644 index 0000000000000..1603dc793775c --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_find_menu_test.dart @@ -0,0 +1,144 @@ +import 'dart:math'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + String generateRandomString(int len) { + final r = Random(); + return String.fromCharCodes( + List.generate(len, (index) => r.nextInt(33) + 89), + ); + } + + testWidgets( + 'document find menu test', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent(); + + // tap editor to get focus + await tester.tapButton(find.byType(AppFlowyEditor)); + + // set clipboard data + final data = [ + "123456\n", + ...List.generate(100, (_) => "${generateRandomString(50)}\n"), + "1234567\n", + ...List.generate(100, (_) => "${generateRandomString(50)}\n"), + "12345678\n", + ...List.generate(100, (_) => "${generateRandomString(50)}\n"), + ].join(); + await getIt().setData( + ClipboardServiceData( + plainText: data, + ), + ); + + // paste + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: + UniversalPlatform.isLinux || UniversalPlatform.isWindows, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + // go back to beginning of document + // FIXME: Cannot run Ctrl+F unless selection is on screen + await tester.editor + .updateSelection(Selection.collapsed(Position(path: [0]))); + await tester.pumpAndSettle(); + + expect(find.byType(FindAndReplaceMenuWidget), findsNothing); + + // press cmd/ctrl+F to display the find menu + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyF, + isControlPressed: + UniversalPlatform.isLinux || UniversalPlatform.isWindows, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget); + + final textField = find.descendant( + of: find.byType(FindAndReplaceMenuWidget), + matching: find.byType(TextField), + ); + + await tester.enterText( + textField, + "123456", + ); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.text("123456", findRichText: true), + ), + findsOneWidget, + ); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.text("1234567", findRichText: true), + ), + findsOneWidget, + ); + + await tester.showKeyboard(textField); + await tester.idle(); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.text("12345678", findRichText: true), + ), + findsOneWidget, + ); + + // tap next button, go back to beginning of document + await tester.tapButton( + find.descendant( + of: find.byType(FindMenu), + matching: find.byFlowySvg(FlowySvgs.arrow_down_s), + ), + ); + + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.text("123456", findRichText: true), + ), + findsOneWidget, + ); + }, + ); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart index 7ad52cd5a9135..b98c1aad626c0 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart @@ -1,6 +1,7 @@ import 'package:integration_test/integration_test.dart'; import 'document_block_option_test.dart' as document_block_option_test; +import 'document_find_menu_test.dart' as document_find_menu_test; import 'document_inline_page_reference_test.dart' as document_inline_page_reference_test; import 'document_more_actions_test.dart' as document_more_actions_test; @@ -22,5 +23,6 @@ void main() { document_with_file_test.main(); document_shortcuts_test.main(); document_block_option_test.main(); + document_find_menu_test.main(); document_toolbar_test.main(); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index 23be6c299c964..5827506b173dc 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -161,7 +161,16 @@ class _DocumentPageState extends State } return Provider( - create: (_) => SharedEditorContext(), + create: (_) { + final context = SharedEditorContext(); + if (widget.view.name.isEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + context.coverTitleFocusNode.requestFocus(); + }); + } + return context; + }, + dispose: (buildContext, editorContext) => editorContext.dispose(), child: EditorTransactionService( viewId: widget.view.id, editorState: state.editorState!, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index 2f43c2c0b59da..bf4dcb97e0245 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -485,6 +485,7 @@ class _AppFlowyEditorPageState extends State borderRadius: BorderRadius.circular(4), ), child: FindAndReplaceMenuWidget( + showReplaceMenu: showReplaceMenu, editorState: editorState, onDismiss: onDismiss, ), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/find_and_replace/find_and_replace_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/find_and_replace/find_and_replace_menu.dart index e9e120a568fca..0fee679a42172 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/find_and_replace/find_and_replace_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/find_and_replace/find_and_replace_menu.dart @@ -1,62 +1,90 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/text_input.dart'; +import 'package:flutter/material.dart'; class FindAndReplaceMenuWidget extends StatefulWidget { const FindAndReplaceMenuWidget({ super.key, required this.onDismiss, required this.editorState, + required this.showReplaceMenu, }); final EditorState editorState; final VoidCallback onDismiss; + /// Whether to show the replace menu initially + final bool showReplaceMenu; + @override State createState() => _FindAndReplaceMenuWidgetState(); } class _FindAndReplaceMenuWidgetState extends State { - bool showReplaceMenu = false; + late bool showReplaceMenu = widget.showReplaceMenu; + + final findFocusNode = FocusNode(); + final replaceFocusNode = FocusNode(); late SearchServiceV3 searchService = SearchServiceV3( editorState: widget.editorState, ); + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (widget.showReplaceMenu) { + replaceFocusNode.requestFocus(); + } else { + findFocusNode.requestFocus(); + } + }); + } + + @override + void dispose() { + findFocusNode.dispose(); + replaceFocusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: FindMenu( - onDismiss: widget.onDismiss, - editorState: widget.editorState, - searchService: searchService, - onShowReplace: (value) => setState( - () => showReplaceMenu = value, + return TextFieldTapRegion( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: FindMenu( + onDismiss: widget.onDismiss, + editorState: widget.editorState, + searchService: searchService, + focusNode: findFocusNode, + showReplaceMenu: showReplaceMenu, + onToggleShowReplace: () => setState(() { + showReplaceMenu = !showReplaceMenu; + }), ), ), - ), - showReplaceMenu - ? Padding( - padding: const EdgeInsets.only( - bottom: 8.0, - ), - child: ReplaceMenu( - editorState: widget.editorState, - searchService: searchService, - ), - ) - : const SizedBox.shrink(), - ], + if (showReplaceMenu) + Padding( + padding: const EdgeInsets.only( + bottom: 8.0, + ), + child: ReplaceMenu( + editorState: widget.editorState, + searchService: searchService, + focusNode: replaceFocusNode, + ), + ), + ], + ), ); } } @@ -64,29 +92,30 @@ class _FindAndReplaceMenuWidgetState extends State { class FindMenu extends StatefulWidget { const FindMenu({ super.key, - required this.onDismiss, required this.editorState, required this.searchService, - required this.onShowReplace, + required this.showReplaceMenu, + required this.focusNode, + required this.onDismiss, + required this.onToggleShowReplace, }); final EditorState editorState; - final VoidCallback onDismiss; final SearchServiceV3 searchService; - final void Function(bool value) onShowReplace; + + final bool showReplaceMenu; + final FocusNode focusNode; + + final VoidCallback onDismiss; + final void Function() onToggleShowReplace; @override State createState() => _FindMenuState(); } class _FindMenuState extends State { - late final FocusNode findTextFieldFocusNode; - - final findTextEditingController = TextEditingController(); - - String queriedPattern = ''; + final textController = TextEditingController(); - bool showReplaceMenu = false; bool caseSensitive = false; @override @@ -96,11 +125,7 @@ class _FindMenuState extends State { widget.searchService.matchWrappers.addListener(_setState); widget.searchService.currentSelectedIndex.addListener(_setState); - findTextEditingController.addListener(_searchPattern); - - WidgetsBinding.instance.addPostFrameCallback((_) { - findTextFieldFocusNode.requestFocus(); - }); + textController.addListener(_searchPattern); } @override @@ -108,9 +133,7 @@ class _FindMenuState extends State { widget.searchService.matchWrappers.removeListener(_setState); widget.searchService.currentSelectedIndex.removeListener(_setState); widget.searchService.dispose(); - findTextEditingController.removeListener(_searchPattern); - findTextEditingController.dispose(); - findTextFieldFocusNode.dispose(); + textController.dispose(); super.dispose(); } @@ -124,42 +147,36 @@ class _FindMenuState extends State { const HSpace(4.0), // expand/collapse button _FindAndReplaceIcon( - icon: showReplaceMenu + icon: widget.showReplaceMenu ? FlowySvgs.drop_menu_show_s : FlowySvgs.drop_menu_hide_s, tooltipText: '', - onPressed: () { - widget.onShowReplace(!showReplaceMenu); - setState( - () => showReplaceMenu = !showReplaceMenu, - ); - }, + onPressed: widget.onToggleShowReplace, ), const HSpace(4.0), // find text input SizedBox( - width: 150, + width: 200, height: 30, - child: FlowyFormTextInput( - onFocusCreated: (focusNode) { - findTextFieldFocusNode = focusNode; - }, - onEditingComplete: () { + child: TextField( + key: const Key('findTextField'), + focusNode: widget.focusNode, + controller: textController, + style: Theme.of(context).textTheme.bodyMedium, + onSubmitted: (_) { widget.searchService.navigateToMatch(); + // after update selection or navigate to match, the editor - // will request focus, here's a workaround to request the - // focus back to the findTextField - Future.delayed(const Duration(milliseconds: 50), () { - if (context.mounted) { - FocusScope.of(context).requestFocus( - findTextFieldFocusNode, - ); - } - }); + // will request focus, here's a workaround to request the + // focus back to the text field + Future.delayed( + const Duration(milliseconds: 50), + () => widget.focusNode.requestFocus(), + ); }, - controller: findTextEditingController, - hintText: LocaleKeys.findAndReplace_find.tr(), - textAlign: TextAlign.left, + decoration: _buildInputDecoration( + LocaleKeys.findAndReplace_find.tr(), + ), ), ), // the count of matches @@ -210,11 +227,8 @@ class _FindMenuState extends State { } void _searchPattern() { - if (findTextEditingController.text.isEmpty) { - return; - } - widget.searchService.findAndHighlight(findTextEditingController.text); - setState(() => queriedPattern = findTextEditingController.text); + widget.searchService.findAndHighlight(textController.text); + _setState(); } void _setState() { @@ -227,27 +241,24 @@ class ReplaceMenu extends StatefulWidget { super.key, required this.editorState, required this.searchService, - this.localizations, + required this.focusNode, }); final EditorState editorState; - - /// The localizations of the find and replace menu - final FindReplaceLocalizations? localizations; - final SearchServiceV3 searchService; + final FocusNode focusNode; + @override State createState() => _ReplaceMenuState(); } class _ReplaceMenuState extends State { - late final FocusNode replaceTextFieldFocusNode; - final replaceTextEditingController = TextEditingController(); + final textController = TextEditingController(); @override void dispose() { - replaceTextEditingController.dispose(); + textController.dispose(); super.dispose(); } @@ -258,31 +269,26 @@ class _ReplaceMenuState extends State { // placeholder for aligning the replace menu const HSpace(30), SizedBox( - width: 150, + width: 200, height: 30, - child: FlowyFormTextInput( - onFocusCreated: (focusNode) { - replaceTextFieldFocusNode = focusNode; - }, - onEditingComplete: () { - widget.searchService.navigateToMatch(); - // after update selection or navigate to match, the editor - // will request focus, here's a workaround to request the - // focus back to the findTextField - Future.delayed(const Duration(milliseconds: 50), () { - if (context.mounted) { - FocusScope.of(context).requestFocus( - replaceTextFieldFocusNode, - ); - } - }); + child: TextField( + key: const Key('replaceTextField'), + focusNode: widget.focusNode, + controller: textController, + style: Theme.of(context).textTheme.bodyMedium, + onSubmitted: (_) { + _replaceSelectedWord(); + + Future.delayed( + const Duration(milliseconds: 50), + () => widget.focusNode.requestFocus(), + ); }, - controller: replaceTextEditingController, - hintText: LocaleKeys.findAndReplace_replace.tr(), - textAlign: TextAlign.left, + decoration: _buildInputDecoration( + LocaleKeys.findAndReplace_replace.tr(), + ), ), ), - const HSpace(4.0), _FindAndReplaceIcon( onPressed: _replaceSelectedWord, iconBuilder: (_) => const Icon( @@ -299,7 +305,7 @@ class _ReplaceMenuState extends State { ), tooltipText: LocaleKeys.findAndReplace_replaceAll.tr(), onPressed: () => widget.searchService.replaceAllMatches( - replaceTextEditingController.text, + textController.text, ), ), ], @@ -307,7 +313,7 @@ class _ReplaceMenuState extends State { } void _replaceSelectedWord() { - widget.searchService.replaceSelectedWord(replaceTextEditingController.text); + widget.searchService.replaceSelectedWord(textController.text); } } @@ -333,10 +339,20 @@ class _FindAndReplaceIcon extends StatelessWidget { height: 24, onPressed: onPressed, icon: iconBuilder?.call(context) ?? - (icon != null ? FlowySvg(icon!) : const Placeholder()), + (icon != null + ? FlowySvg(icon!, color: Theme.of(context).iconTheme.color) + : const Placeholder()), tooltipText: tooltipText, isSelected: isSelected, iconColorOnHover: Theme.of(context).colorScheme.onSecondary, ); } } + +InputDecoration _buildInputDecoration(String hintText) { + return InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + border: const UnderlineInputBorder(), + hintText: hintText, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart index 72c88b9351d99..f48a1aeec2c1b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart @@ -45,11 +45,10 @@ class _InnerCoverTitle extends StatefulWidget { class _InnerCoverTitleState extends State<_InnerCoverTitle> { final titleTextController = TextEditingController(); - final titleFocusNode = FocusNode(); late final editorContext = context.read(); late final editorState = context.read(); - bool isTitleFocused = false; + late final titleFocusNode = editorContext.coverTitleFocusNode; int lineCount = 1; @override @@ -58,53 +57,32 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> { titleTextController.text = widget.view.name; titleTextController.addListener(_onViewNameChanged); - titleFocusNode.onKeyEvent = _onKeyEvent; - titleFocusNode.addListener(_onTitleFocusChanged); - editorState.selectionNotifier.addListener(_onSelectionChanged); - _requestFocusIfNeeded(widget.view, null); + titleFocusNode + ..onKeyEvent = _onKeyEvent + ..addListener(_onFocusChanged); - editorContext.coverTitleFocusNode = titleFocusNode; + editorState.selectionNotifier.addListener(_onSelectionChanged); } @override void dispose() { - editorContext.coverTitleFocusNode = null; - editorState.selectionNotifier.removeListener(_onSelectionChanged); - - titleTextController.removeListener(_onViewNameChanged); + titleFocusNode + ..onKeyEvent = null + ..removeListener(_onFocusChanged); titleTextController.dispose(); - titleFocusNode.removeListener(_onTitleFocusChanged); - titleFocusNode.dispose(); - + editorState.selectionNotifier.removeListener(_onSelectionChanged); super.dispose(); } void _onSelectionChanged() { // if title is focused and the selection is not null, clear the selection - if (editorState.selection != null && isTitleFocused) { + if (editorState.selection != null && titleFocusNode.hasFocus) { Log.info('title is focused, clear the editor selection'); editorState.selection = null; } } - void _onTitleFocusChanged() { - isTitleFocused = titleFocusNode.hasFocus; - - if (titleFocusNode.hasFocus && editorState.selection != null) { - Log.info('cover title got focus, clear the editor selection'); - editorState.selection = null; - } - - if (isTitleFocused) { - Log.info('cover title got focus, disable keyboard service'); - editorState.service.keyboardService?.disable(); - } else { - Log.info('cover title lost focus, enable keyboard service'); - editorState.service.keyboardService?.enable(); - } - } - @override Widget build(BuildContext context) { final fontStyle = Theme.of(context) @@ -175,6 +153,21 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> { } } + void _onFocusChanged() { + if (titleFocusNode.hasFocus) { + if (editorState.selection != null) { + Log.info('cover title got focus, clear the editor selection'); + editorState.selection = null; + } + + Log.info('cover title got focus, disable keyboard service'); + editorState.service.keyboardService?.disable(); + } else { + Log.info('cover title lost focus, enable keyboard service'); + editorState.service.keyboardService?.enable(); + } + } + void _onViewNameChanged() { Debounce.debounce( 'update view name', diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart index e3b5c2578ea15..8e84d1752399d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart @@ -103,8 +103,6 @@ class _DocumentCoverWidgetState extends State { late final ViewListener viewListener; int retryCount = 0; - final titleTextController = TextEditingController(); - final titleFocusNode = FocusNode(); final isCoverTitleHovered = ValueNotifier(false); late final gestureInterceptor = SelectionGestureInterceptor( @@ -120,7 +118,6 @@ class _DocumentCoverWidgetState extends State { viewIcon = value.isNotEmpty ? value : icon ?? ''; cover = widget.view.cover; view = widget.view; - titleTextController.text = view.name; widget.node.addListener(_reload); widget.editorState.service.selectionService .registerGestureInterceptor(gestureInterceptor); @@ -128,9 +125,6 @@ class _DocumentCoverWidgetState extends State { viewListener = ViewListener(viewId: widget.view.id) ..start( onViewUpdated: (view) { - if (titleTextController.text != view.name) { - titleTextController.text = view.name; - } setState(() { viewIcon = view.icon.value; cover = view.cover; @@ -144,8 +138,6 @@ class _DocumentCoverWidgetState extends State { void dispose() { viewListener.stop(); widget.node.removeListener(_reload); - titleTextController.dispose(); - titleFocusNode.dispose(); isCoverTitleHovered.dispose(); widget.editorState.service.selectionService .unregisterGestureInterceptor(_interceptorKey); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart index 9eefb79051eb3..0d344347baa17 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart @@ -6,9 +6,14 @@ import 'package:flutter/widgets.dart'; /// so we need to use the shared context to get the focus node. /// class SharedEditorContext { - SharedEditorContext(); + SharedEditorContext() : _coverTitleFocusNode = FocusNode(); // The focus node of the cover title. - // It's null when the cover title is not focused. - FocusNode? coverTitleFocusNode; + final FocusNode _coverTitleFocusNode; + + FocusNode get coverTitleFocusNode => _coverTitleFocusNode; + + void dispose() { + _coverTitleFocusNode.dispose(); + } } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart index d5e6282f2e4cf..bc391daf9ef72 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart @@ -7,8 +7,7 @@ import 'package:flutter/services.dart'; import 'package:flowy_infra/size.dart'; class FlowyFormTextInput extends StatelessWidget { - static EdgeInsets kDefaultTextInputPadding = - EdgeInsets.only(bottom: Insets.sm, top: 4); + static EdgeInsets kDefaultTextInputPadding = const EdgeInsets.only(bottom: 2); final String? label; final bool? autoFocus;