From 29f2b7a02da4cb8eb67675b2c0a4b42441c71400 Mon Sep 17 00:00:00 2001 From: clragon Date: Thu, 4 Apr 2024 19:47:28 +0200 Subject: [PATCH 1/4] feat: update no response workflow --- .github/workflows/no-response.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/no-response.yml b/.github/workflows/no-response.yml index 6b71d41d..e79ef634 100644 --- a/.github/workflows/no-response.yml +++ b/.github/workflows/no-response.yml @@ -4,7 +4,7 @@ on: issue_comment: types: [created] schedule: - - cron: '30 * * * *' + - cron: '0 1 * * *' permissions: issues: write From bfeff5f99efe6257a5f1677e856fd398556d2667 Mon Sep 17 00:00:00 2001 From: clragon Date: Fri, 16 Aug 2024 17:27:41 +0200 Subject: [PATCH 2/4] feat: switch highlight from focus to custom --- CHANGELOG.md | 4 + .../common/base/suggestions_controller.dart | 51 ++++++ lib/src/common/box/suggestions_box.dart | 22 +-- .../suggestions_box_traversal_connector.dart | 82 --------- lib/src/common/field/suggestions_field.dart | 5 +- ...suggestions_field_highlight_connector.dart | 149 ++++++++++++++++ ...suggestions_field_traversal_connector.dart | 63 ------- lib/src/cupertino/cupertino_defaults.dart | 36 +++- lib/src/material/material_defaults.dart | 24 ++- .../base/suggestions_controller_test.dart | 26 +++ ...gestions_box_traversal_connector_test.dart | 113 ------------ ...stions_field_highlight_connector_test.dart | 164 ++++++++++++++++++ ...stions_field_traversal_connector_test.dart | 108 ------------ 13 files changed, 454 insertions(+), 393 deletions(-) delete mode 100644 lib/src/common/box/suggestions_box_traversal_connector.dart create mode 100644 lib/src/common/field/suggestions_field_highlight_connector.dart delete mode 100644 lib/src/common/field/suggestions_field_traversal_connector.dart delete mode 100644 test/common/box/suggestions_box_traversal_connector_test.dart create mode 100644 test/common/field/suggestions_field_highlight_connector_test.dart delete mode 100644 test/common/field/suggestions_field_traversal_connector_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index fd237167..e407cedc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased +### Added +- Highlighted suggestion in controller + ## 5.2.0 - 2024-02-08 ### Added - force refreshing suggestions with `SuggestionsController.refresh` diff --git a/lib/src/common/base/suggestions_controller.dart b/lib/src/common/base/suggestions_controller.dart index 779543ea..8b781e26 100644 --- a/lib/src/common/base/suggestions_controller.dart +++ b/lib/src/common/base/suggestions_controller.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -52,6 +53,56 @@ class SuggestionsController extends ChangeNotifier { List? _suggestions; + /// The index of the highlighted suggestion in the suggestions box. + int? get highlighted => _highlighted; + set highlighted(int? value) { + if (_highlighted == value) return; + _highlighted = value; + notifyListeners(); + } + + int? _highlighted; + + /// Removes the highlight from the suggestions box. + void unhighlight() => highlighted = null; + + /// Highlights the previous suggestion in the suggestions box. + void highlightPrevious() { + if (highlighted == null) return; + if (highlighted! <= 0) { + highlighted = null; + } else { + int max = suggestions == null ? 0 : suggestions!.length - 1; + highlighted = min(highlighted! - 1, max); + } + } + + /// Highlights the next suggestion in the suggestions box. + void highlightNext() { + if (highlighted == null) { + highlighted = 0; + } else { + int max = suggestions == null ? 0 : suggestions!.length - 1; + highlighted = min(highlighted! + 1, max); + } + } + + /// The highlighted suggestion in the suggestions box. + /// + /// This is the suggestion at the index of [highlighted]. + T? get highlightedSuggestion { + if (highlighted == null) return null; + return suggestions?.elementAtOrNull(highlighted!); + } + + set highlightedSuggestion(T? value) { + if (value == null) { + highlighted = null; + } else { + highlighted = suggestions?.indexOf(value); + } + } + /// A stream of events that occur when the suggestions list should be refreshed. /// /// For internal use only. diff --git a/lib/src/common/box/suggestions_box.dart b/lib/src/common/box/suggestions_box.dart index 2ecab5bb..6df0ff65 100644 --- a/lib/src/common/box/suggestions_box.dart +++ b/lib/src/common/box/suggestions_box.dart @@ -4,7 +4,6 @@ import 'package:flutter_typeahead/src/common/base/types.dart'; import 'package:flutter_typeahead/src/common/box/suggestions_box_animation.dart'; import 'package:flutter_typeahead/src/common/box/suggestions_box_focus_connector.dart'; import 'package:flutter_typeahead/src/common/box/suggestions_box_scroll_injector.dart'; -import 'package:flutter_typeahead/src/common/box/suggestions_box_traversal_connector.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; /// A widget that contains suggestions based on user input. @@ -110,18 +109,15 @@ class SuggestionsBox extends StatelessWidget { controller: scrollController, child: SuggestionsBoxFocusConnector( controller: controller, - child: SuggestionsBoxTraversalConnector( - controller: controller, - child: PointerInterceptor( - child: Builder( - builder: (context) => wrapper( - context, - SuggestionsBoxAnimation( - controller: controller, - transitionBuilder: transitionBuilder, - animationDuration: animationDuration, - child: builder(context), - ), + child: PointerInterceptor( + child: Builder( + builder: (context) => wrapper( + context, + SuggestionsBoxAnimation( + controller: controller, + transitionBuilder: transitionBuilder, + animationDuration: animationDuration, + child: builder(context), ), ), ), diff --git a/lib/src/common/box/suggestions_box_traversal_connector.dart b/lib/src/common/box/suggestions_box_traversal_connector.dart deleted file mode 100644 index f0341a8d..00000000 --- a/lib/src/common/box/suggestions_box_traversal_connector.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_typeahead/src/common/base/connector_widget.dart'; -import 'package:flutter_typeahead/src/common/base/suggestions_controller.dart'; - -/// Enables navigating to the text field from the suggestions box using -/// the keyboard. -class SuggestionsBoxTraversalConnector extends StatefulWidget { - const SuggestionsBoxTraversalConnector({ - super.key, - required this.controller, - required this.child, - }); - - final SuggestionsController controller; - final Widget child; - - @override - State> createState() => - _SuggestionsBoxTraversalConnectorState(); -} - -class _SuggestionsBoxTraversalConnectorState - extends State> { - late final FocusScopeNode focusNode = FocusScopeNode( - onKeyEvent: onKey, - ); - - KeyEventResult onKey(FocusNode node, KeyEvent key) { - if (key is! KeyDownEvent) return KeyEventResult.ignored; - if (widget.controller.effectiveDirection == VerticalDirection.down && - key.logicalKey == LogicalKeyboardKey.arrowUp) { - bool canMove = node.focusInDirection(TraversalDirection.up); - if (!canMove) { - widget.controller.focusField(); - return KeyEventResult.handled; - } - } else if (widget.controller.effectiveDirection == VerticalDirection.up && - key.logicalKey == LogicalKeyboardKey.arrowDown) { - bool canMove = node.focusInDirection(TraversalDirection.down); - if (!canMove) { - widget.controller.focusField(); - return KeyEventResult.handled; - } - } - return KeyEventResult.ignored; - } - - void onControllerFocus() { - if (widget.controller.focusState == SuggestionsFocusState.box) { - if (focusNode.focusedChild == null) { - focusNode.focusInDirection(TraversalDirection.down); - } - } - } - - void onNodeFocus() { - if (focusNode.hasFocus) { - if (focusNode.focusedChild == null) { - widget.controller.unfocus(); - } - } - } - - @override - Widget build(BuildContext context) { - return ConnectorWidget( - value: focusNode, - connect: (value) => value.addListener(onNodeFocus), - disconnect: (value, key) => value.removeListener(onNodeFocus), - child: ConnectorWidget( - value: widget.controller, - connect: (value) => value.addListener(onControllerFocus), - disconnect: (value, key) => value.removeListener(onControllerFocus), - child: FocusScope( - node: focusNode, - child: widget.child, - ), - ), - ); - } -} diff --git a/lib/src/common/field/suggestions_field.dart b/lib/src/common/field/suggestions_field.dart index a353e8f7..8c240b27 100644 --- a/lib/src/common/field/suggestions_field.dart +++ b/lib/src/common/field/suggestions_field.dart @@ -5,11 +5,11 @@ import 'package:flutter_typeahead/src/common/box/suggestions_box.dart'; import 'package:flutter_typeahead/src/common/base/suggestions_controller.dart'; import 'package:flutter_typeahead/src/common/base/types.dart'; import 'package:flutter_typeahead/src/common/field/suggestions_field_focus_connector.dart'; +import 'package:flutter_typeahead/src/common/field/suggestions_field_highlight_connector.dart'; import 'package:flutter_typeahead/src/common/field/suggestions_field_keyboard_connector.dart'; import 'package:flutter_typeahead/src/common/field/suggestions_field_box_connector.dart'; import 'package:flutter_typeahead/src/common/field/suggestions_field_select_connector.dart'; import 'package:flutter_typeahead/src/common/field/suggestions_field_tap_connector.dart'; -import 'package:flutter_typeahead/src/common/field/suggestions_field_traversal_connector.dart'; /// A widget that displays a list of suggestions above or below another widget. class SuggestionsField extends StatefulWidget { @@ -278,9 +278,8 @@ class _SuggestionsFieldState extends State> { child: SuggestionsFieldFocusConnector( controller: controller, focusNode: widget.focusNode, - child: SuggestionsFieldTraversalConnector( + child: SuggestionsFieldHighlightConnector( controller: controller, - focusNode: widget.focusNode, child: SuggestionsFieldBoxConnector( controller: controller, showOnFocus: widget.showOnFocus, diff --git a/lib/src/common/field/suggestions_field_highlight_connector.dart b/lib/src/common/field/suggestions_field_highlight_connector.dart new file mode 100644 index 00000000..c56d5b7c --- /dev/null +++ b/lib/src/common/field/suggestions_field_highlight_connector.dart @@ -0,0 +1,149 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_typeahead/src/common/base/connector_widget.dart'; +import 'package:flutter_typeahead/src/common/base/suggestions_controller.dart'; + +/// Enables changing the highlighted suggestion in a [SuggestionsController] using +/// keyboard shortcuts. +/// +/// Heavily inspired by the [RawAutocomplete] widget. +class SuggestionsFieldHighlightConnector extends StatefulWidget { + const SuggestionsFieldHighlightConnector({ + super.key, + required this.controller, + required this.child, + }); + + final SuggestionsController controller; + final Widget child; + + @override + State> createState() => + _SuggestionsFieldHighlightConnectorState(); +} + +class _SuggestionsFieldHighlightConnectorState + extends State> { + late Map _shortcuts; + + /// Highlights the previous suggestion in the suggestions box. + late final _previousOptionAction = + _ConditionalCallbackAction( + onInvoke: (_) => widget.controller.highlightPrevious()); + + /// Highlights the next suggestion in the suggestions box. + late final _nextOptionAction = + _ConditionalCallbackAction( + onInvoke: (_) => widget.controller.highlightNext(), + ); + + /// Dismisses the suggestions box. + late final _hideOptionsAction = _ConditionalCallbackAction( + onInvoke: (_) { + widget.controller.unhighlight(); + widget.controller.close(retainFocus: true); + return null; + }, + ); + + /// Selects the highlighted suggestion. + late final _selectOptionAction = _ConditionalCallbackAction( + onInvoke: (_) { + int? index = widget.controller.highlighted; + if (index == null) return null; + T? highlighted = widget.controller.suggestions?.elementAtOrNull( + index, + ); + if (highlighted == null) return null; + widget.controller.select(highlighted); + widget.controller.unhighlight(); + return null; + }, + ); + + late final Map _actions = { + SuggestionsPreviousItemIntent: _previousOptionAction, + SuggestionsNextItemIntent: _nextOptionAction, + ActivateIntent: _selectOptionAction, + DismissIntent: _hideOptionsAction, + }; + + @override + void initState() { + super.initState(); + _createShortcuts(); + } + + void _createShortcuts() { + _shortcuts = { + // The shortcuts are different depending on the direction of the suggestions box. + ...switch (widget.controller.effectiveDirection) { + VerticalDirection.up => { + const SingleActivator(LogicalKeyboardKey.arrowUp): + const SuggestionsNextItemIntent(), + const SingleActivator(LogicalKeyboardKey.arrowDown): + const SuggestionsPreviousItemIntent(), + }, + VerticalDirection.down => { + const SingleActivator(LogicalKeyboardKey.arrowUp): + const SuggestionsPreviousItemIntent(), + const SingleActivator(LogicalKeyboardKey.arrowDown): + const SuggestionsNextItemIntent(), + }, + }, + const SingleActivator(LogicalKeyboardKey.enter): const ActivateIntent(), + }; + } + + void _onControllerChange() { + for (final action in _actions.values) { + // When the suggestions box is closed, the actions should be disabled. + action.enabled = widget.controller.isOpen; + } + setState(_createShortcuts); + } + + @override + Widget build(BuildContext context) { + return ConnectorWidget( + value: widget.controller, + connect: (controller) => controller.addListener(_onControllerChange), + disconnect: (controller, _) => + controller.removeListener(_onControllerChange), + child: Shortcuts( + shortcuts: _shortcuts, + child: Actions( + actions: _actions, + child: widget.child, + ), + ), + ); + } +} + +/// An [Intent] to highlight the previous suggestion in the suggestions box. +class SuggestionsPreviousItemIntent extends Intent { + const SuggestionsPreviousItemIntent(); +} + +/// An [Intent] to highlight the next suggestion in the suggestions box. +class SuggestionsNextItemIntent extends Intent { + const SuggestionsNextItemIntent(); +} + +/// A [CallbackAction] that can be enabled or disabled. +/// Directly inspired by the implementation in [RawAutocomplete]. +class _ConditionalCallbackAction extends CallbackAction { + _ConditionalCallbackAction({ + required super.onInvoke, + this.enabled = true, + }); + + bool enabled; + + @override + bool isEnabled(covariant T intent) => enabled; + + @override + bool consumesKey(covariant T intent) => enabled; +} diff --git a/lib/src/common/field/suggestions_field_traversal_connector.dart b/lib/src/common/field/suggestions_field_traversal_connector.dart deleted file mode 100644 index 82ad58ac..00000000 --- a/lib/src/common/field/suggestions_field_traversal_connector.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_typeahead/src/common/base/connector_widget.dart'; -import 'package:flutter_typeahead/src/common/base/suggestions_controller.dart'; - -/// Enables navigating to the suggestions box from the text field using -/// the keyboard. -class SuggestionsFieldTraversalConnector extends StatelessWidget { - const SuggestionsFieldTraversalConnector({ - super.key, - required this.focusNode, - required this.controller, - required this.child, - }); - - final FocusNode focusNode; - final SuggestionsController controller; - final Widget child; - - KeyEventResult onKeyEvent(FocusNode node, KeyEvent key) { - if (key is! KeyDownEvent) return KeyEventResult.ignored; - if (!controller.isOpen) return KeyEventResult.ignored; - if (key.logicalKey == LogicalKeyboardKey.arrowDown) { - if (controller.effectiveDirection == VerticalDirection.down) { - controller.focusBox(); - return KeyEventResult.handled; - } - } else if (key.logicalKey == LogicalKeyboardKey.arrowUp) { - if (controller.effectiveDirection == VerticalDirection.up) { - controller.focusBox(); - return KeyEventResult.handled; - } - } - return KeyEventResult.ignored; - } - - FocusOnKeyEventCallback wrapOnKeyEvent( - FocusOnKeyEventCallback? previousOnKeyEvent, - ) { - return (node, event) { - KeyEventResult result = onKeyEvent(node, event); - if (result == KeyEventResult.ignored && previousOnKeyEvent != null) { - return previousOnKeyEvent(node, event); - } - return result; - }; - } - - @override - Widget build(BuildContext context) { - return ConnectorWidget( - value: focusNode, - connect: (value) { - FocusOnKeyEventCallback? previousOnKeyEvent = focusNode.onKeyEvent; - focusNode.onKeyEvent = wrapOnKeyEvent(previousOnKeyEvent); - return previousOnKeyEvent; - }, - disconnect: (value, previousOnKeyEvent) => - focusNode.onKeyEvent = previousOnKeyEvent, - child: child, - ); - } -} diff --git a/lib/src/cupertino/cupertino_defaults.dart b/lib/src/cupertino/cupertino_defaults.dart index 049f61ef..9ecb60af 100644 --- a/lib/src/cupertino/cupertino_defaults.dart +++ b/lib/src/cupertino/cupertino_defaults.dart @@ -1,4 +1,5 @@ import 'package:flutter/cupertino.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter_typeahead/src/common/base/suggestions_controller.dart'; import 'package:flutter_typeahead/src/common/base/types.dart'; @@ -52,13 +53,34 @@ abstract final class TypeAheadCupertinoDefaults { SuggestionsItemBuilder builder, ) { return (context, item) { - return FocusableActionDetector( - mouseCursor: SystemMouseCursors.click, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - child: builder(context, item), - onTap: () => SuggestionsController.of(context).select(item), - ), + final controller = SuggestionsController.of(context); + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final bool highlighted = controller.highlightedSuggestion == item; + if (highlighted) { + // scroll to the highlighted item + SchedulerBinding.instance.addPostFrameCallback((_) { + Scrollable.ensureVisible(context, alignment: 0.5); + }, debugLabel: 'TypeAheadField.CupertinoDefaults.itemBuilder'); + } + return Container( + decoration: BoxDecoration( + color: highlighted + ? CupertinoColors.systemGrey4.withOpacity(0.5) + : null, + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + child: FocusableActionDetector( + mouseCursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + child: builder(context, item), + onTap: () => SuggestionsController.of(context).select(item), + ), + ), + ); + }, ); }; } diff --git a/lib/src/material/material_defaults.dart b/lib/src/material/material_defaults.dart index 1ccaf8ed..44c4b760 100644 --- a/lib/src/material/material_defaults.dart +++ b/lib/src/material/material_defaults.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter_typeahead/src/common/base/suggestions_controller.dart'; import 'package:flutter_typeahead/src/common/base/types.dart'; @@ -49,10 +50,25 @@ abstract final class TypeAheadMaterialDefaults { SuggestionsItemBuilder builder, ) { return (context, item) { - return InkWell( - focusColor: Theme.of(context).hoverColor, - onTap: () => SuggestionsController.of(context).select(item), - child: builder(context, item), + final controller = SuggestionsController.of(context); + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final bool highlighted = controller.highlightedSuggestion == item; + if (highlighted) { + // scroll to the highlighted item + SchedulerBinding.instance.addPostFrameCallback((_) { + Scrollable.ensureVisible(context, alignment: 0.5); + }, debugLabel: 'TypeAheadField.MaterialDefaults.itemBuilder'); + } + return Container( + color: highlighted ? Theme.of(context).hoverColor : null, + child: InkWell( + onTap: () => SuggestionsController.of(context).select(item), + child: builder(context, item), + ), + ); + }, ); }; } diff --git a/test/common/base/suggestions_controller_test.dart b/test/common/base/suggestions_controller_test.dart index 90c6f01f..e3e70454 100644 --- a/test/common/base/suggestions_controller_test.dart +++ b/test/common/base/suggestions_controller_test.dart @@ -44,6 +44,32 @@ void main() { expect(controller.error, equals('error')); }); + test('sets highlighted index', () { + controller.suggestions = ['a', 'b', 'c']; + expect(controller.highlighted, isNull); + controller.highlighted = 1; + expect(controller.highlighted, equals(1)); + controller.highlightNext(); + expect(controller.highlighted, equals(2)); + controller.highlightPrevious(); + expect(controller.highlighted, equals(1)); + controller.unhighlight(); + expect(controller.highlighted, isNull); + }); + + test('sets highlighted suggestion', () { + controller.suggestions = ['a', 'b', 'c']; + expect(controller.highlightedSuggestion, isNull); + controller.highlightedSuggestion = 'b'; + expect(controller.highlightedSuggestion, equals('b')); + controller.highlightNext(); + expect(controller.highlightedSuggestion, equals('c')); + controller.highlightPrevious(); + expect(controller.highlightedSuggestion, equals('b')); + controller.highlightedSuggestion = null; + expect(controller.highlightedSuggestion, isNull); + }); + test('opens suggestions list', () { bool wasOpened = false; controller.addListener(() => wasOpened = controller.isOpen); diff --git a/test/common/box/suggestions_box_traversal_connector_test.dart b/test/common/box/suggestions_box_traversal_connector_test.dart deleted file mode 100644 index 1d8925a9..00000000 --- a/test/common/box/suggestions_box_traversal_connector_test.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_typeahead/src/common/base/suggestions_controller.dart'; -import 'package:flutter_typeahead/src/common/box/suggestions_box_traversal_connector.dart'; - -void main() { - group('SuggestionsBoxTraversalConnector', () { - late SuggestionsController controller; - - setUp(() { - controller = SuggestionsController(); - }); - - tearDown(() { - controller.dispose(); - }); - - testWidgets('sets field focus when arrow up pressed and direction is down', - (WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - home: Material( - child: SuggestionsBoxTraversalConnector( - controller: controller, - child: const Focus( - autofocus: true, - child: SizedBox(), - ), - ), - ), - ), - ); - - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.pump(); - - expect(controller.focusState, SuggestionsFocusState.field); - }); - - testWidgets('sets field focus when arrow down pressed and direction is up', - (WidgetTester tester) async { - controller.effectiveDirection = VerticalDirection.up; - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: SuggestionsBoxTraversalConnector( - controller: controller, - child: const Focus( - autofocus: true, - child: SizedBox(), - ), - ), - ), - ), - ); - - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - - expect(controller.focusState, SuggestionsFocusState.field); - }); - - testWidgets('focuses first child node when focused', - (WidgetTester tester) async { - FocusNode node = FocusNode(); - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: SuggestionsBoxTraversalConnector( - controller: controller, - child: Focus( - focusNode: node, - child: const SizedBox(), - ), - ), - ), - ), - ); - - controller.focusBox(); - await tester.pump(); - expect(node.hasFocus, isTrue); - }); - - testWidgets('unfocuses node if no child is focused', - (WidgetTester tester) async { - FocusNode node = FocusNode(); - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: SuggestionsBoxTraversalConnector( - controller: controller, - child: Focus( - focusNode: node, - autofocus: true, - child: const SizedBox(), - ), - ), - ), - ), - ); - - node.unfocus(); - await tester.pump(); - - expect(controller.focusState, SuggestionsFocusState.blur); - }); - }); -} diff --git a/test/common/field/suggestions_field_highlight_connector_test.dart b/test/common/field/suggestions_field_highlight_connector_test.dart new file mode 100644 index 00000000..4dfabfa9 --- /dev/null +++ b/test/common/field/suggestions_field_highlight_connector_test.dart @@ -0,0 +1,164 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_typeahead/src/common/base/suggestions_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_typeahead/src/common/field/suggestions_field_highlight_connector.dart'; + +void main() { + group('SuggestionsFieldHighlightConnector', () { + late SuggestionsController controller; + + setUp(() { + controller = SuggestionsController(); + controller.suggestions = ['a', 'b']; + }); + + tearDown(() { + controller.dispose(); + }); + + testWidgets( + 'increments highlighted suggestion when direction down and arrow down key is pressed', + (WidgetTester tester) async { + controller.open(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SuggestionsFieldHighlightConnector( + controller: controller, + child: const Focus( + autofocus: true, + child: SizedBox(), + ), + ), + ), + ), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(controller.highlighted, 0); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + + expect(controller.highlighted, 1); + }); + + testWidgets( + 'decrements highlighted suggestion when direction down and arrow up key is pressed', + (WidgetTester tester) async { + controller.open(); + controller.highlighted = 1; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SuggestionsFieldHighlightConnector( + controller: controller, + child: const Focus( + autofocus: true, + child: SizedBox(), + ), + ), + ), + ), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + expect(controller.highlighted, 0); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + + expect(controller.highlighted, null); + }, + ); + + testWidgets( + 'increments highlighted suggestion when direction up and arrow up key is pressed', + (WidgetTester tester) async { + controller.open(); + controller.effectiveDirection = VerticalDirection.up; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SuggestionsFieldHighlightConnector( + controller: controller, + child: const Focus( + autofocus: true, + child: SizedBox(), + ), + ), + ), + ), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + expect(controller.highlighted, 0); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + + expect(controller.highlighted, 1); + }); + + testWidgets('selects highlighted suggestion when enter key is pressed', + (WidgetTester tester) async { + controller.open(); + controller.highlighted = 1; + String? selected; + + controller.selections.listen((event) { + selected = event; + }); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SuggestionsFieldHighlightConnector( + controller: controller, + child: const Focus( + autofocus: true, + child: SizedBox(), + ), + ), + ), + ), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + + expect(selected, 'b'); + expect(controller.highlighted, null); + }); + + testWidgets('closes suggestions when escape key is pressed', + (WidgetTester tester) async { + controller.open(); + controller.highlighted = 1; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SuggestionsFieldHighlightConnector( + controller: controller, + child: const Focus( + autofocus: true, + child: SizedBox(), + ), + ), + ), + ), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + + expect(controller.isOpen, isFalse); + }); + }); +} diff --git a/test/common/field/suggestions_field_traversal_connector_test.dart b/test/common/field/suggestions_field_traversal_connector_test.dart deleted file mode 100644 index 18ee4f9c..00000000 --- a/test/common/field/suggestions_field_traversal_connector_test.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_typeahead/src/common/base/suggestions_controller.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_typeahead/src/common/field/suggestions_field_traversal_connector.dart'; - -void main() { - group('SuggestionsFieldTraversalConnector', () { - late SuggestionsController controller; - late FocusNode focusNode; - - setUp(() { - controller = SuggestionsController(); - focusNode = FocusNode(); - }); - - tearDown(() { - controller.dispose(); - focusNode.dispose(); - }); - - testWidgets( - 'sets focus to box when direction down and arrow down key is pressed', - (WidgetTester tester) async { - controller.open(); - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: SuggestionsFieldTraversalConnector( - controller: controller, - focusNode: focusNode, - child: Focus( - autofocus: true, - focusNode: focusNode, - child: const SizedBox(), - ), - ), - ), - ), - ); - - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - - expect(controller.focusState, SuggestionsFocusState.box); - }); - - testWidgets( - 'sets focus to box when direction up and arrow up key is pressed', - (WidgetTester tester) async { - controller.open(); - controller.effectiveDirection = VerticalDirection.up; - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: SuggestionsFieldTraversalConnector( - controller: controller, - focusNode: focusNode, - child: Focus( - autofocus: true, - focusNode: focusNode, - child: const SizedBox(), - ), - ), - ), - ), - ); - - await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); - await tester.pump(); - - expect(controller.focusState, SuggestionsFocusState.box); - }); - - testWidgets('proxies focus node key events when ignored by key handler', - (WidgetTester tester) async { - controller.open(); - bool previousOnKeyEventCalled = false; - focusNode.onKeyEvent = (node, event) { - previousOnKeyEventCalled = true; - return KeyEventResult.ignored; - }; - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: SuggestionsFieldTraversalConnector( - controller: controller, - focusNode: focusNode, - child: Focus( - autofocus: true, - focusNode: focusNode, - child: const SizedBox(), - ), - ), - ), - ), - ); - - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pump(); - - expect(previousOnKeyEventCalled, true); - }); - }); -} From eb2f7633c1c58ed24844f809b2c4fa56faeb16e5 Mon Sep 17 00:00:00 2001 From: clragon Date: Fri, 16 Aug 2024 17:28:22 +0200 Subject: [PATCH 3/4] feat: ignore metadata in example --- example/.gitignore | 5 +++-- example/.metadata | 30 ------------------------------ 2 files changed, 3 insertions(+), 32 deletions(-) delete mode 100644 example/.metadata diff --git a/example/.gitignore b/example/.gitignore index da17dee5..5f15ebb7 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -44,10 +44,11 @@ app.*.map.json /android/app/release -# Platform folders +# Platform dependent files /web/ /windows/ /macos/ /linux/ /android/ -/ios/ \ No newline at end of file +/ios/ +.metadata \ No newline at end of file diff --git a/example/.metadata b/example/.metadata deleted file mode 100644 index a072c7d9..00000000 --- a/example/.metadata +++ /dev/null @@ -1,30 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "d211f42860350d914a5ad8102f9ec32764dc6d06" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - - platform: android - create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' From db6ece553f09cfb2650c8115967ae4522d008eea Mon Sep 17 00:00:00 2001 From: clragon Date: Mon, 25 Nov 2024 21:59:57 +0100 Subject: [PATCH 4/4] docs: clarify returning null to hide empty builder --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3229b262..f465f9b6 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,7 @@ Additionally, various changes have been made to the API surface to make the pack - `intercepting`: This is now always true, since it doesnt interfere on mobile platforms and generally has no downsides. - `onSuggestionsBoxToggle`: You can subscribe to the `SuggestionsController` to get notified when the suggestions box is toggled. - `ignoreAccessibleNavigation`: The new `Overlay` code no longer requires to act differently when accessibility is enabled. - - `minCharsForSuggestions`: You can return an empty list from `suggestionsCallback` instead. + - `minCharsForSuggestions`: You can return `null` from `suggestionsCallback` instead. - `animationStart`: You can use the animation in the builder and map it to customise this. - `autoFlipListDirection`: This is now always true. You can use the list builder to disable this behavior. - `getImmediateSuggestions`: You can use the `debounceDuration` to achieve the same effect.