From b9fbd90a0381f01d2cc00746bcd51315ef8b2424 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 19 Oct 2023 23:56:12 -0300 Subject: [PATCH 01/36] Implement the dropdown widget Test Update tests Update Focus color Animation Animation --- .../lib/demos/example_editor/_toolbar.dart | 68 ++- .../lib/src/infrastructure/dropdown.dart | 479 ++++++++++++++++++ super_editor/lib/super_editor.dart | 1 + .../test/infrastructure/dropdown_test.dart | 470 +++++++++++++++++ 4 files changed, 983 insertions(+), 35 deletions(-) create mode 100644 super_editor/lib/src/infrastructure/dropdown.dart create mode 100644 super_editor/test/infrastructure/dropdown_test.dart diff --git a/super_editor/example/lib/demos/example_editor/_toolbar.dart b/super_editor/example/lib/demos/example_editor/_toolbar.dart index d90e6d5e7b..5d5d552949 100644 --- a/super_editor/example/lib/demos/example_editor/_toolbar.dart +++ b/super_editor/example/lib/demos/example_editor/_toolbar.dart @@ -224,6 +224,9 @@ class _EditorToolbarState extends State { ), ]); } + + // Rebuild to display the selected item. + setState(() {}); } /// Returns true if the given [_TextType] represents an @@ -440,6 +443,9 @@ class _EditorToolbarState extends State { final selectedNode = widget.document.getNodeById(widget.composer.selection!.extent.nodeId) as ParagraphNode; selectedNode.putMetadataValue('textAlign', newAlignmentValue); + + // Rebuild to display the selected item. + setState(() {}); } /// Returns the localized name for the given [_TextType], e.g., @@ -521,26 +527,30 @@ class _EditorToolbarState extends State { if (_isConvertibleNode()) ...[ Tooltip( message: AppLocalizations.of(context)!.labelTextBlockType, - child: DropdownButton<_TextType>( + child: SuperDropdownButton<_TextType>( + boundaryKey: widget.editorViewportKey, value: _getCurrentTextType(), - items: _TextType.values - .map((textType) => DropdownMenuItem<_TextType>( - value: textType, - child: Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Text(_getTextTypeName(textType)), - ), - )) - .toList(), - icon: const Icon(Icons.arrow_drop_down), - style: const TextStyle( - color: Colors.black, - fontSize: 12, + items: _TextType.values, + itemBuilder: (context, item) => Text( + _getTextTypeName(item), + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), ), - underline: const SizedBox(), - elevation: 0, - itemHeight: 48, + buttonBuilder: (context, item) => Padding( + padding: EdgeInsets.only(left: 8.0), + child: Text( + _getTextTypeName(item!), + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ), + parentFocusNode: widget.editorFocusNode, onChanged: _convertTextToNewType, + focusColor: Colors.yellow, ), ), _buildVerticalDivider(), @@ -584,26 +594,14 @@ class _EditorToolbarState extends State { _buildVerticalDivider(), Tooltip( message: AppLocalizations.of(context)!.labelTextAlignment, - child: DropdownButton( + child: SuperDropdownButton( value: _getCurrentTextAlignment(), - items: [TextAlign.left, TextAlign.center, TextAlign.right, TextAlign.justify] - .map((textAlign) => DropdownMenuItem( - value: textAlign, - child: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Icon(_buildTextAlignIcon(textAlign)), - ), - )) - .toList(), - icon: const Icon(Icons.arrow_drop_down), - style: const TextStyle( - color: Colors.black, - fontSize: 12, - ), - underline: const SizedBox(), - elevation: 0, - itemHeight: 48, + items: [TextAlign.left, TextAlign.center, TextAlign.right, TextAlign.justify], onChanged: _changeAlignment, + boundaryKey: widget.editorViewportKey, + parentFocusNode: widget.editorFocusNode, + itemBuilder: (context, item) => Icon(_buildTextAlignIcon(item)), + buttonBuilder: (context, item) => Icon(_buildTextAlignIcon(item!)), ), ), ], diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart new file mode 100644 index 0000000000..bf095ac4b6 --- /dev/null +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -0,0 +1,479 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A button which displays a dropdown with a vertical list of items. +/// +/// Unlike Flutter `DropdownButton`, which displays the dropdown in a separate route, +/// this widget displays its dropdown in an `Overlay`. By using an `Overlay`, focus can shared +/// with the [parentFocusNode]. This means that when the dropdown requests focus, [parentFocusNode] +/// still has non-primary focus. +/// +/// The dropdown tries to fit all items on the available space. If there isn't enough room, +/// the list of items becomes scrollable with an always visible toolbar. +/// Provide [dropdownContraints] to enforce aditional constraints on the dropdown list. +/// +/// The user can navigate between the options using the arrow keys, select an option with ENTER and +/// close the dropdown with ESC. +class SuperDropdownButton extends StatefulWidget { + const SuperDropdownButton({ + super.key, + required this.items, + required this.value, + required this.onChanged, + required this.itemBuilder, + required this.buttonBuilder, + this.focusColor, + required this.boundaryKey, + required this.parentFocusNode, + this.dropdownContraints, + this.dropdownKey, + }); + + /// The items that will be displayed in the dropdown list. + final List items; + + /// The currently selected value or `null` if no item is selected. + final T? value; + + /// Called when the user selects an item on the dropdown list. + final ValueChanged onChanged; + + /// Builds each item in the dropdown list. + final Widget Function(BuildContext context, T item) itemBuilder; + + /// Builds the button that opens the dropdown. + final Widget Function(BuildContext context, T? item) buttonBuilder; + + /// The background color of the focused list item. + final Color? focusColor; + + /// A [GlobalKey] to a widget that determines the bounds where the dropdown can be displayed. + /// + /// Used to avoid the dropdown to be displayed off-screen. + final GlobalKey boundaryKey; + + /// A [GlobalKey] bound to the dropdown list. + final GlobalKey? dropdownKey; + + /// Constraints applied to the dropdown list. + final BoxConstraints? dropdownContraints; + + /// [FocusNode] which will share focus with the dropdown list. + final FocusNode parentFocusNode; + + @override + State> createState() => SuperDropdownButtonState(); +} + +@visibleForTesting +class SuperDropdownButtonState extends State> with SingleTickerProviderStateMixin { + final DropdownController _dropdownController = DropdownController(); + + @visibleForTesting + ScrollController get scrollController => _scrollController; + final ScrollController _scrollController = ScrollController(); + + @visibleForTesting + int? get focusedIndex => _focusedIndex; + int? _focusedIndex; + + late final AnimationController _animationController; + late final Animation _resizeAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + _resizeAnimation = CurvedAnimation( + parent: _animationController, + // The first half of the animation resizes the dropdown list. + // The other half will fade in the items. + curve: const Interval(0.0, 0.5), + ); + } + + @override + void dispose() { + _dropdownController.dispose(); + _scrollController.dispose(); + + _animationController.dispose(); + super.dispose(); + } + + void _onButtonTap() { + _dropdownController.show(); + _animationController + ..reset() + ..forward(); + + setState(() { + _focusedIndex = null; + }); + } + + /// Called when the user taps an item or presses ENTER with a focused item. + void _submitItem(T item) { + widget.onChanged(item); + _dropdownController.hide(); + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) { + return KeyEventResult.ignored; + } + + if (![ + LogicalKeyboardKey.enter, + LogicalKeyboardKey.numpadEnter, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.escape, + ].contains(event.logicalKey)) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.escape) { + _dropdownController.hide(); + return KeyEventResult.handled; + } + + if (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter) { + if (_focusedIndex == null) { + _dropdownController.hide(); + return KeyEventResult.handled; + } + + _submitItem(widget.items[_focusedIndex!]); + + return KeyEventResult.handled; + } + + int? newFocusedIndex; + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + if (_focusedIndex == null || _focusedIndex! >= widget.items.length - 1) { + // We don't have a focused item or we are at the end of the list. Focus the first item. + newFocusedIndex = 0; + } else { + // Move the focus down. + newFocusedIndex = _focusedIndex! + 1; + } + } + + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (_focusedIndex == null || _focusedIndex! <= 0) { + // We don't have a focused item or we are at the beginning of the list. Focus the last item. + newFocusedIndex = widget.items.length - 1; + } else { + // Move the focus up. + newFocusedIndex = _focusedIndex! - 1; + } + } + + setState(() { + _focusedIndex = newFocusedIndex; + }); + + return KeyEventResult.handled; + } + + @override + Widget build(BuildContext context) { + return RawDropdown( + controller: _dropdownController, + boundaryKey: widget.boundaryKey, + dropdownBuilder: _buildDropDown, + parentFocusNode: widget.parentFocusNode, + onKeyEvent: _onKeyEvent, + child: ConstrainedBox( + // TODO: what value should we use? + constraints: const BoxConstraints(maxHeight: 100), + child: InkWell( + onTap: _onButtonTap, + child: Center( + child: _buildSelectedItem(), + ), + ), + ), + ); + } + + Widget _buildSelectedItem() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: widget.buttonBuilder(context, widget.value), + ), + const Icon(Icons.arrow_drop_down), + ], + ); + } + + Widget _buildDropDown(BuildContext context, LeaderLink link, FollowerBoundary boundary) { + final dropdownItems = []; + + // The fade-in animation start at the middle of the animation. + const fadeAnimationStart = 0.5; + + // The fade-in animation takes half of the animation duration. + const fadeAnimationTotalDuration = 0.5; + + final animationPercentagePerItem = fadeAnimationTotalDuration / (widget.items.length); + + // The duration of the fade-in animation for each list item. + final itemFadeInDuration = fadeAnimationTotalDuration * animationPercentagePerItem; + + for (int i = 0; i < widget.items.length; i++) { + // Computes at which point of the animation each item starts/ends fading in. + final start = clampDouble(fadeAnimationStart + (i + 1) * animationPercentagePerItem, 0.0, 1.0); + final end = clampDouble(start + itemFadeInDuration, 0.0, 1.0); + + dropdownItems.add( + FadeTransition( + opacity: CurvedAnimation( + parent: _animationController, + curve: Interval(start, end), + ), + child: Container( + color: _focusedIndex == i ? widget.focusColor : null, + child: _buildDropDownItem(context, widget.items[i]), + ), + ), + ); + } + + return Follower.withOffset( + link: link, + leaderAnchor: Alignment.center, + followerAnchor: Alignment.center, + boundary: boundary, + showWhenUnlinked: false, + child: ConstrainedBox( + constraints: widget.dropdownContraints ?? const BoxConstraints(), + child: SizeTransition( + sizeFactor: _resizeAnimation, + axisAlignment: 1.0, + fixedCrossAxisSizeFactor: 1.0, + child: Material( + key: widget.dropdownKey, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + scrollbars: false, + overscroll: false, + physics: const ClampingScrollPhysics(), + ), + child: PrimaryScrollController( + controller: _scrollController, + child: Scrollbar( + thumbVisibility: true, + child: SingleChildScrollView( + primary: true, + child: IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + children: dropdownItems, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildDropDownItem(BuildContext context, T item) { + return InkWell( + onTap: () => _submitItem(item), + child: Container( + constraints: const BoxConstraints(minHeight: kMinInteractiveDimension), + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: widget.itemBuilder(context, item), + ), + ), + ); + } +} + +/// A widget which displays a dropdown linked to its child. +/// +/// The dropdown is displayed in an `Overlay` and it can follow the [child] +/// by being wrapped with a [Follower]. The visibility of the dropdown +/// is changed by calling [DropdownController.show] or [DropdownController.hide]. +/// The dropdown is automatically closed when the user taps outside of its bounds. +/// +/// When the dropdown is displayed it requests focus to itself, so the user can +/// interact with the content using the keyboard. The focus is shared +/// with the [parentFocusNode]. Provide [onKeyEvent] to handle key presses +/// when the dropdown is visible. +/// +/// This widget doesn't enforce any style, dropdown position or decoration. +class RawDropdown extends StatefulWidget { + const RawDropdown({ + super.key, + required this.controller, + required this.boundaryKey, + required this.dropdownBuilder, + required this.parentFocusNode, + this.onKeyEvent, + required this.child, + }); + + /// Shows and hides the dropdown. + final DropdownController controller; + + /// Builds the content of the dropdown. + final DropdownBuilder dropdownBuilder; + + /// Called at each key press while the dropdown has focus. + final FocusOnKeyEventCallback? onKeyEvent; + + /// [FocusNode] which will share focus with the dropdown. + final FocusNode parentFocusNode; + + /// A [GlobalKey] to a widget that determines the bounds where the dropdown can be displayed. + /// + /// Used to avoid the dropdown to be displayed off-screen. + final GlobalKey boundaryKey; + + final Widget child; + + @override + State createState() => _RawDropdownState(); +} + +class _RawDropdownState extends State { + final OverlayPortalController _overlayController = OverlayPortalController(); + final LeaderLink _dropdownLink = LeaderLink(); + final FocusNode _dropdownFocusNode = FocusNode(); + + late FollowerBoundary _screenBoundary; + + @override + void initState() { + super.initState(); + + widget.controller.addListener(_onDropdownControllerChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _screenBoundary = WidgetFollowerBoundary( + boundaryKey: widget.boundaryKey, + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + ); + } + + @override + void didUpdateWidget(covariant RawDropdown oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onDropdownControllerChanged); + widget.controller.addListener(_onDropdownControllerChanged); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onDropdownControllerChanged); + _dropdownLink.dispose(); + + super.dispose(); + } + + void _onDropdownControllerChanged() { + if (widget.controller.shouldShow) { + _overlayController.show(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + // Wait until next frame to request focus, so that the parent relationship + // can be established between our focus node and the parent focus node. + _dropdownFocusNode.requestFocus(); + }); + } else { + _overlayController.hide(); + } + } + + void _onTapOutsideOfDropdown(PointerDownEvent e) { + widget.controller.hide(); + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (widget.onKeyEvent != null) { + return widget.onKeyEvent!(node, event); + } + + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + return OverlayPortal( + controller: _overlayController, + overlayChildBuilder: _buildDropdown, + child: Leader( + link: _dropdownLink, + child: widget.child, + ), + ); + } + + Widget _buildDropdown(BuildContext context) { + return TapRegion( + onTapOutside: _onTapOutsideOfDropdown, + child: SuperEditorPopover( + popoverFocusNode: _dropdownFocusNode, + editorFocusNode: widget.parentFocusNode, + onKeyEvent: _onKeyEvent, + child: widget.dropdownBuilder(context, _dropdownLink, _screenBoundary), + ), + ); + } +} + +typedef DropdownBuilder = Widget Function(BuildContext context, LeaderLink link, FollowerBoundary boundary); + +/// Controls the visibility of a dropdown. +class DropdownController with ChangeNotifier { + /// Whether the dropdown should be displayed. + bool get shouldShow => _shouldShow; + bool _shouldShow = false; + + void show() { + if (_shouldShow) { + return; + } + _shouldShow = true; + notifyListeners(); + } + + void hide() { + if (!_shouldShow) { + return; + } + _shouldShow = false; + notifyListeners(); + } + + void toggle() { + if (shouldShow) { + hide(); + } else { + show(); + } + } +} diff --git a/super_editor/lib/super_editor.dart b/super_editor/lib/super_editor.dart index eda3fbf0a4..4b23dbe3da 100644 --- a/super_editor/lib/super_editor.dart +++ b/super_editor/lib/super_editor.dart @@ -87,6 +87,7 @@ export 'src/infrastructure/touch_controls.dart'; export 'src/infrastructure/text_input.dart'; export 'src/infrastructure/viewport_size_reporting.dart'; export 'src/infrastructure/popovers.dart'; +export 'src/infrastructure/dropdown.dart'; // Super Reader export 'src/super_reader/read_only_document_android_touch_interactor.dart'; diff --git a/super_editor/test/infrastructure/dropdown_test.dart b/super_editor/test/infrastructure/dropdown_test.dart new file mode 100644 index 0000000000..ffe9e7c75f --- /dev/null +++ b/super_editor/test/infrastructure/dropdown_test.dart @@ -0,0 +1,470 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; + +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/default_document_editor.dart'; +import 'package:super_editor/src/default_editor/super_editor.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/dropdown.dart'; +import 'package:super_editor/src/infrastructure/text_input.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../super_editor/test_documents.dart'; + +void main() { + group('SuperDropdown', () { + testWidgetsOnAllPlatforms('shows the dropdown list on tap', (tester) async { + final dropdownKey = GlobalKey(); + + await _pumpDropdownTestApp( + tester, + onValueChanged: (s) {}, + dropdownKey: dropdownKey, + ); + + // Ensures the dropdown list isn't displayed. + expect(find.byKey(dropdownKey), findsNothing); + + // Tap the button to show the dropdown. + await tester.tap(find.byType(SuperDropdownButton)); + await tester.pumpAndSettle(); + + // Ensures the dropdown list is displayed. + expect(find.byKey(dropdownKey), findsOneWidget); + }); + + testWidgetsOnAllPlatforms('calls onValueChanged and closes the dropdown list when tapping an item', (tester) async { + String? selectedValue; + + final dropdownKey = GlobalKey(); + await _pumpDropdownTestApp( + tester, + onValueChanged: (s) => selectedValue = s, + dropdownKey: dropdownKey, + ); + + // Ensures the dropdown list isn't displayed. + expect(find.byKey(dropdownKey), findsNothing); + + // Tap the button to show the dropdown. + await tester.tap(find.byType(SuperDropdownButton)); + await tester.pumpAndSettle(); + + // Ensures the dropdown list is displayed. + expect(find.byKey(dropdownKey), findsOneWidget); + + // Taps the first item on the list + await tester.tap(find.text('Item1')); + await tester.pumpAndSettle(); + + // Ensure the tapped item was selected and the dropdown was closed. + expect(selectedValue, 'Item1'); + expect(find.byKey(dropdownKey), findsNothing); + }); + + testWidgetsOnAllPlatforms('closes the dropdown list when tapping outside', (tester) async { + bool onValueChangedCalled = false; + + final dropdownKey = GlobalKey(); + await _pumpDropdownTestApp( + tester, + onValueChanged: (s) => onValueChangedCalled = true, + dropdownKey: dropdownKey, + ); + + // Ensures the dropdown list isn't displayed. + expect(find.byKey(dropdownKey), findsNothing); + + // Tap the button to show the dropdown. + await tester.tap(find.byType(SuperDropdownButton)); + await tester.pumpAndSettle(); + + // Ensures the dropdown list is displayed. + expect(find.byKey(dropdownKey), findsOneWidget); + + // Taps outside of the dropdown. + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + + // Ensures onValueChanged wasn't called and the dropdown list was closed. + expect(onValueChangedCalled, isFalse); + expect(find.byKey(dropdownKey), findsNothing); + }); + + testWidgetsOnAllPlatforms('enforces the given dropdown constraints', (tester) async { + final dropdownKey = GlobalKey(); + + await _pumpDropdownTestApp( + tester, + onValueChanged: (s) {}, + dropdownConstraints: const BoxConstraints(maxHeight: 10), + dropdownKey: dropdownKey, + ); + + // Ensures the dropdown list isn't displayed. + expect(find.byKey(dropdownKey), findsNothing); + + // Tap the button to show the dropdown. + await tester.tap(find.byType(SuperDropdownButton)); + await tester.pumpAndSettle(); + + // Ensures the dropdown list is displayed. + expect(find.byKey(dropdownKey), findsOneWidget); + + // Ensure the maxHeight was honored. + expect(tester.getRect(find.byKey(dropdownKey)).height, 10); + }); + + testWidgetsOnAllPlatforms('dropdown list isn\' scrollable if all items fit on screen', (tester) async { + final dropdownKey = GlobalKey(); + await _pumpDropdownTestApp( + tester, + onValueChanged: (s) {}, + dropdownKey: dropdownKey, + ); + + // Tap the button to show the dropdown. + await tester.tap(find.byType(SuperDropdownButton)); + await tester.pumpAndSettle(); + + // Ensures the dropdown list is displayed. + expect(find.byKey(dropdownKey), findsOneWidget); + + // Ensure the dropdown list isn't scrollable. + final dropdownButonState = + tester.state>(find.byType(SuperDropdownButton)); + expect(dropdownButonState.scrollController.position.maxScrollExtent, 0.0); + }); + + testWidgetsOnAllPlatforms('dropdown list is scrollable if items don\'t fit on screen', (tester) async { + final dropdownKey = GlobalKey(); + await _pumpDropdownTestApp( + tester, + onValueChanged: (s) {}, + dropdownKey: dropdownKey, + dropdownConstraints: const BoxConstraints(maxHeight: 100), + ); + + // Tap the button to show the dropdown. + await tester.tap(find.byType(SuperDropdownButton)); + await tester.pumpAndSettle(); + + // Ensures the dropdown list is displayed. + expect(find.byKey(dropdownKey), findsOneWidget); + + // Ensure the dropdown list is scrollable. + final dropdownButonState = + tester.state>(find.byType(SuperDropdownButton)); + expect(dropdownButonState.scrollController.position.maxScrollExtent, greaterThan(0.0)); + }); + + testWidgetsOnAllPlatforms('moves focus down with DOWN ARROW', (tester) async { + await _pumpDropdownTestApp( + tester, + onValueChanged: (s) => {}, + ); + + // Tap the button to show the dropdown. + await tester.tap(find.byType(SuperDropdownButton)); + await tester.pumpAndSettle(); + + final dropdownButonState = + tester.state>(find.byType(SuperDropdownButton)); + + // Ensure the dropdown is displayed without any focused item. + expect(dropdownButonState.focusedIndex, isNull); + + // Press DOWN ARROW to focus the first item. + await tester.pressDownArrow(); + expect(dropdownButonState.focusedIndex, 0); + + // Press DOWN ARROW to focus the second item. + await tester.pressDownArrow(); + expect(dropdownButonState.focusedIndex, 1); + + // Press DOWN ARROW to focus the third item. + await tester.pressDownArrow(); + expect(dropdownButonState.focusedIndex, 2); + + // Press DOWN ARROW to focus the first item again. + await tester.pressDownArrow(); + expect(dropdownButonState.focusedIndex, 0); + }); + + testWidgetsOnAllPlatforms('moves focus up with UP ARROW', (tester) async { + await _pumpDropdownTestApp( + tester, + onValueChanged: (s) => {}, + ); + + // Tap the button to show the dropdown. + await tester.tap(find.byType(SuperDropdownButton)); + await tester.pumpAndSettle(); + + final dropdownButonState = + tester.state>(find.byType(SuperDropdownButton)); + + // Ensure the dropdown is displayed without any focused item. + expect(dropdownButonState.focusedIndex, isNull); + + // Press UP ARROW to focus the last item. + await tester.pressUpArrow(); + expect(dropdownButonState.focusedIndex, 2); + + // Press UP ARROW to focus the second item. + await tester.pressUpArrow(); + expect(dropdownButonState.focusedIndex, 1); + + // Press UP ARROW to focus the first item. + await tester.pressUpArrow(); + expect(dropdownButonState.focusedIndex, 0); + + // Press UP ARROW to focus the last item again. + await tester.pressUpArrow(); + expect(dropdownButonState.focusedIndex, 2); + }); + + testWidgetsOnAllPlatforms('selects the focused item on ENTER', (tester) async { + String? selectedValue; + + final dropdownKey = GlobalKey(); + await _pumpDropdownTestApp( + tester, + onValueChanged: (s) => selectedValue = s, + ); + + // Tap the button to show the dropdown. + await tester.tap(find.byType(SuperDropdownButton)); + await tester.pumpAndSettle(); + + // Press ARROW DOWN to focus the first item. + await tester.pressDownArrow(); + + // Press ENTER to select the focused item and close the dropdown. + await tester.pressEnter(); + await tester.pump(); + + // Ensure the first item was selected and the dropdown was closed. + expect(selectedValue, 'Item1'); + expect(find.byKey(dropdownKey), findsNothing); + }); + + testWidgetsOnAllPlatforms('closes dropdown list on ENTER', (tester) async { + String? selectedValue; + + final dropdownKey = GlobalKey(); + await _pumpDropdownTestApp( + tester, + onValueChanged: (s) => selectedValue = s, + dropdownKey: dropdownKey, + ); + + // Tap the button to show the dropdown. + await tester.tap(find.byType(SuperDropdownButton)); + await tester.pumpAndSettle(); + + // Press ENTER without a focused item to close the dropdown. + await tester.pressEnter(); + await tester.pump(); + + // Ensure the dropdown was closed and no item was selected. + expect(find.byKey(dropdownKey), findsNothing); + expect(selectedValue, isNull); + }); + + testWidgetsOnAllPlatforms('closes dropdown list on ESC', (tester) async { + String? selectedValue; + + final dropdownKey = GlobalKey(); + await _pumpDropdownTestApp( + tester, + onValueChanged: (s) => selectedValue = s, + dropdownKey: dropdownKey, + ); + + // Tap the button to show the dropdown. + await tester.tap(find.byType(SuperDropdownButton)); + await tester.pumpAndSettle(); + + // Press ARROW DOWN to focus the first item. + await tester.pressDownArrow(); + + // Press ESC to close the dropdown. + await tester.pressEscape(); + await tester.pump(); + + // Ensure the dropdown was closed and no item was selected. + expect(find.byKey(dropdownKey), findsNothing); + expect(selectedValue, isNull); + }); + + testWidgetsOnAllPlatforms('shares focus with SuperEditor', (tester) async { + final editorFocusNode = FocusNode(); + final boundaryKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + key: boundaryKey, + home: _SuperEditorDropdownTestApp( + editorFocusNode: editorFocusNode, + toolbar: SuperDropdownButton( + items: const ['Item1', 'Item2', 'Item3'], + itemBuilder: (context, e) => Text(e), + buttonBuilder: (context, e) => const SizedBox(width: 50), + value: null, + onChanged: (s) => {}, + boundaryKey: boundaryKey, + parentFocusNode: editorFocusNode, + ), + ), + ), + ); + + final documentNode = SuperEditorInspector.findDocument()!.nodes.first; + + // Double tap to select the word "Lorem". + await tester.doubleTapInParagraph(documentNode.id, 1); + + // Ensure the editor has primary focus and the word "Lorem" is selected. + expect(editorFocusNode.hasPrimaryFocus, isTrue); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection( + base: DocumentPosition( + nodeId: documentNode.id, + nodePosition: const TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: documentNode.id, + nodePosition: const TextNodePosition(offset: 5), + ), + ), + ); + + // Tap the button to show the dropdown. + await tester.tap(find.byType(SuperDropdownButton)); + await tester.pumpAndSettle(); + + // Ensure the editor has non-primary focus. + expect(editorFocusNode.hasFocus, true); + expect(editorFocusNode.hasPrimaryFocus, isFalse); + + // Tap at a dropdown list option to close the dropdown. + await tester.tap(find.text('Item2')); + await tester.pumpAndSettle(); + + // Ensure the editor has primary focus again and selection stays the same. + expect(editorFocusNode.hasPrimaryFocus, isTrue); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection( + base: DocumentPosition( + nodeId: documentNode.id, + nodePosition: const TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: documentNode.id, + nodePosition: const TextNodePosition(offset: 5), + ), + ), + ); + }); + }); +} + +/// Pumps a widget tree with a centered [SuperDropdownButton] containing three items. +Future _pumpDropdownTestApp( + WidgetTester tester, { + required void Function(String? value) onValueChanged, + BoxConstraints? dropdownConstraints, + GlobalKey? dropdownKey, +}) async { + final boundaryKey = GlobalKey(); + final focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + key: boundaryKey, + home: Scaffold( + body: Center( + child: Focus( + focusNode: focusNode, + autofocus: true, + child: SuperDropdownButton( + items: const ['Item1', 'Item2', 'Item3'], + itemBuilder: (context, e) => Text(e), + buttonBuilder: (context, e) => const SizedBox(width: 50), + value: null, + onChanged: onValueChanged, + boundaryKey: boundaryKey, + parentFocusNode: focusNode, + dropdownContraints: dropdownConstraints, + dropdownKey: dropdownKey, + ), + ), + ), + ), + ), + ); +} + +/// Displays a [SuperEditor] that fills the available height, containing a single paragraph, +/// and a [toolbar] at the bottom. +class _SuperEditorDropdownTestApp extends StatefulWidget { + const _SuperEditorDropdownTestApp({ + required this.toolbar, + this.editorFocusNode, + }); + + final FocusNode? editorFocusNode; + final Widget toolbar; + + @override + State<_SuperEditorDropdownTestApp> createState() => _SuperEditorDropdownTestAppState(); +} + +class _SuperEditorDropdownTestAppState extends State<_SuperEditorDropdownTestApp> { + late MutableDocument _doc; + late MutableDocumentComposer _composer; + late Editor _docEditor; + + @override + void initState() { + super.initState(); + _doc = singleParagraphDoc(); + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); + } + + @override + void dispose() { + _docEditor.dispose(); + _doc.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + children: [ + Expanded( + child: SuperEditor( + document: _doc, + editor: _docEditor, + composer: _composer, + inputSource: TextInputSource.ime, + focusNode: widget.editorFocusNode, + ), + ), + widget.toolbar, + ], + ), + ); + } +} From 98627415c1f1ddc9b92ed84179927e928a9d8163 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Sat, 21 Oct 2023 09:36:37 -0300 Subject: [PATCH 02/36] PR updates --- .../lib/demos/example_editor/_toolbar.dart | 6 +- .../lib/src/infrastructure/dropdown.dart | 61 ++++++++++--------- ...own_test.dart => super_dropdown_test.dart} | 0 3 files changed, 35 insertions(+), 32 deletions(-) rename super_editor/test/infrastructure/{dropdown_test.dart => super_dropdown_test.dart} (100%) diff --git a/super_editor/example/lib/demos/example_editor/_toolbar.dart b/super_editor/example/lib/demos/example_editor/_toolbar.dart index 5d5d552949..63ce9c0a16 100644 --- a/super_editor/example/lib/demos/example_editor/_toolbar.dart +++ b/super_editor/example/lib/demos/example_editor/_toolbar.dart @@ -528,9 +528,12 @@ class _EditorToolbarState extends State { Tooltip( message: AppLocalizations.of(context)!.labelTextBlockType, child: SuperDropdownButton<_TextType>( + parentFocusNode: widget.editorFocusNode, boundaryKey: widget.editorViewportKey, value: _getCurrentTextType(), items: _TextType.values, + onChanged: _convertTextToNewType, + focusColor: Colors.yellow, itemBuilder: (context, item) => Text( _getTextTypeName(item), style: const TextStyle( @@ -548,9 +551,6 @@ class _EditorToolbarState extends State { ), ), ), - parentFocusNode: widget.editorFocusNode, - onChanged: _convertTextToNewType, - focusColor: Colors.yellow, ), ), _buildVerticalDivider(), diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index bf095ac4b6..d22de166a5 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -20,49 +20,49 @@ import 'package:super_editor/super_editor.dart'; class SuperDropdownButton extends StatefulWidget { const SuperDropdownButton({ super.key, + required this.parentFocusNode, + required this.boundaryKey, required this.items, required this.value, required this.onChanged, required this.itemBuilder, required this.buttonBuilder, this.focusColor, - required this.boundaryKey, - required this.parentFocusNode, this.dropdownContraints, this.dropdownKey, }); - /// The items that will be displayed in the dropdown list. - final List items; + /// [FocusNode] which will share focus with the dropdown list. + final FocusNode parentFocusNode; + + /// A [GlobalKey] to a widget that determines the bounds where the dropdown can be displayed. + /// + /// Used to avoid the dropdown to be displayed off-screen. + final GlobalKey boundaryKey; /// The currently selected value or `null` if no item is selected. final T? value; + /// The items that will be displayed in the dropdown list. + final List items; + /// Called when the user selects an item on the dropdown list. final ValueChanged onChanged; - /// Builds each item in the dropdown list. - final Widget Function(BuildContext context, T item) itemBuilder; - - /// Builds the button that opens the dropdown. - final Widget Function(BuildContext context, T? item) buttonBuilder; - /// The background color of the focused list item. final Color? focusColor; - /// A [GlobalKey] to a widget that determines the bounds where the dropdown can be displayed. - /// - /// Used to avoid the dropdown to be displayed off-screen. - final GlobalKey boundaryKey; - /// A [GlobalKey] bound to the dropdown list. final GlobalKey? dropdownKey; /// Constraints applied to the dropdown list. final BoxConstraints? dropdownContraints; - /// [FocusNode] which will share focus with the dropdown list. - final FocusNode parentFocusNode; + /// Builds each item in the dropdown list. + final Widget Function(BuildContext context, T item) itemBuilder; + + /// Builds the button that opens the dropdown. + final Widget Function(BuildContext context, T? item) buttonBuilder; @override State> createState() => SuperDropdownButtonState(); @@ -70,16 +70,16 @@ class SuperDropdownButton extends StatefulWidget { @visibleForTesting class SuperDropdownButtonState extends State> with SingleTickerProviderStateMixin { + @visibleForTesting + int? get focusedIndex => _focusedIndex; + int? _focusedIndex; + final DropdownController _dropdownController = DropdownController(); @visibleForTesting ScrollController get scrollController => _scrollController; final ScrollController _scrollController = ScrollController(); - @visibleForTesting - int? get focusedIndex => _focusedIndex; - int? _focusedIndex; - late final AnimationController _animationController; late final Animation _resizeAnimation; @@ -253,18 +253,21 @@ class SuperDropdownButtonState extends State> with Sin return Follower.withOffset( link: link, - leaderAnchor: Alignment.center, - followerAnchor: Alignment.center, + leaderAnchor: Alignment.bottomCenter, + followerAnchor: Alignment.topCenter, + offset: const Offset(0, 12), boundary: boundary, showWhenUnlinked: false, child: ConstrainedBox( constraints: widget.dropdownContraints ?? const BoxConstraints(), - child: SizeTransition( - sizeFactor: _resizeAnimation, - axisAlignment: 1.0, - fixedCrossAxisSizeFactor: 1.0, - child: Material( - key: widget.dropdownKey, + child: Material( + key: widget.dropdownKey, + elevation: 8, + borderRadius: BorderRadius.circular(12), + child: SizeTransition( + sizeFactor: _resizeAnimation, + axisAlignment: 1.0, + fixedCrossAxisSizeFactor: 1.0, child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith( scrollbars: false, diff --git a/super_editor/test/infrastructure/dropdown_test.dart b/super_editor/test/infrastructure/super_dropdown_test.dart similarity index 100% rename from super_editor/test/infrastructure/dropdown_test.dart rename to super_editor/test/infrastructure/super_dropdown_test.dart From 7ecbeb5a9f01330c3f99226ae81d5df59b75e4c5 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 26 Oct 2023 21:22:41 -0300 Subject: [PATCH 03/36] Change dropdown alignment --- .../lib/src/infrastructure/dropdown.dart | 74 ++++++++++++++++--- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index d22de166a5..6a252182ec 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -81,7 +81,7 @@ class SuperDropdownButtonState extends State> with Sin final ScrollController _scrollController = ScrollController(); late final AnimationController _animationController; - late final Animation _resizeAnimation; + late final Animation _containerFadeInAnimation; @override void initState() { @@ -91,9 +91,9 @@ class SuperDropdownButtonState extends State> with Sin vsync: this, ); - _resizeAnimation = CurvedAnimation( + _containerFadeInAnimation = CurvedAnimation( parent: _animationController, - // The first half of the animation resizes the dropdown list. + // The first half of the animation fades-in the dropdown list container. // The other half will fade in the items. curve: const Interval(0.0, 0.5), ); @@ -251,11 +251,9 @@ class SuperDropdownButtonState extends State> with Sin ); } - return Follower.withOffset( + return Follower.withAligner( link: link, - leaderAnchor: Alignment.bottomCenter, - followerAnchor: Alignment.topCenter, - offset: const Offset(0, 12), + aligner: _DropdownAligner(boundaryKey: widget.boundaryKey), boundary: boundary, showWhenUnlinked: false, child: ConstrainedBox( @@ -264,10 +262,8 @@ class SuperDropdownButtonState extends State> with Sin key: widget.dropdownKey, elevation: 8, borderRadius: BorderRadius.circular(12), - child: SizeTransition( - sizeFactor: _resizeAnimation, - axisAlignment: 1.0, - fixedCrossAxisSizeFactor: 1.0, + child: FadeTransition( + opacity: _containerFadeInAnimation, child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith( scrollbars: false, @@ -311,6 +307,62 @@ class SuperDropdownButtonState extends State> with Sin } } +/// A [FollowerAligner] to position a dropdown list relative to the dropdown button. +/// +/// The following rules are applied, in order: +/// +/// 1. If there is enough room to display the dropdown list beneath the button, +/// position it below the button. +/// +/// 2. If there is enough room to display the dropdown list above the button, +/// position it above the button. +/// +/// 3. Pin the dropdown list to the bottom of the `RenderBox` bound to [boundaryKey], +/// letting the dropdown list cover the button. +class _DropdownAligner implements FollowerAligner { + _DropdownAligner({required this.boundaryKey}); + + final GlobalKey? boundaryKey; + + @override + FollowerAlignment align(Rect globalLeaderRect, Size followerSize) { + final boundsBox = boundaryKey?.currentContext?.findRenderObject() as RenderBox?; + final bounds = boundsBox != null + ? Rect.fromPoints( + boundsBox.localToGlobal(Offset.zero), + boundsBox.localToGlobal(boundsBox.size.bottomRight(Offset.zero)), + ) + : Rect.largest; + late FollowerAlignment alignment; + + if (globalLeaderRect.bottom + followerSize.height < bounds.bottom) { + // The follower fits below the leader. + alignment = const FollowerAlignment( + leaderAnchor: Alignment.bottomCenter, + followerAnchor: Alignment.topCenter, + followerOffset: Offset(0, 20), + ); + } else if (globalLeaderRect.top - followerSize.height > bounds.top) { + // The follower fits above the leader. + alignment = const FollowerAlignment( + leaderAnchor: Alignment.topCenter, + followerAnchor: Alignment.bottomCenter, + followerOffset: Offset(0, -20), + ); + } else { + // There isn't enough room to fully display the follower below or above the leader. + // Pin the dropdown list to the bottom, letting the follower cover the leader. + alignment = const FollowerAlignment( + leaderAnchor: Alignment.bottomCenter, + followerAnchor: Alignment.topCenter, + followerOffset: Offset(0, 20), + ); + } + + return alignment; + } +} + /// A widget which displays a dropdown linked to its child. /// /// The dropdown is displayed in an `Overlay` and it can follow the [child] From e0ebe87a1466a02c931c0c3c6a5dc220bc321470 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 26 Oct 2023 21:52:01 -0300 Subject: [PATCH 04/36] Simplify animation --- .../lib/src/infrastructure/dropdown.dart | 45 ++++--------------- 1 file changed, 9 insertions(+), 36 deletions(-) diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index 6a252182ec..bd7d0c8aac 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -93,9 +93,7 @@ class SuperDropdownButtonState extends State> with Sin _containerFadeInAnimation = CurvedAnimation( parent: _animationController, - // The first half of the animation fades-in the dropdown list container. - // The other half will fade in the items. - curve: const Interval(0.0, 0.5), + curve: Curves.easeInOut, ); } @@ -219,38 +217,6 @@ class SuperDropdownButtonState extends State> with Sin } Widget _buildDropDown(BuildContext context, LeaderLink link, FollowerBoundary boundary) { - final dropdownItems = []; - - // The fade-in animation start at the middle of the animation. - const fadeAnimationStart = 0.5; - - // The fade-in animation takes half of the animation duration. - const fadeAnimationTotalDuration = 0.5; - - final animationPercentagePerItem = fadeAnimationTotalDuration / (widget.items.length); - - // The duration of the fade-in animation for each list item. - final itemFadeInDuration = fadeAnimationTotalDuration * animationPercentagePerItem; - - for (int i = 0; i < widget.items.length; i++) { - // Computes at which point of the animation each item starts/ends fading in. - final start = clampDouble(fadeAnimationStart + (i + 1) * animationPercentagePerItem, 0.0, 1.0); - final end = clampDouble(start + itemFadeInDuration, 0.0, 1.0); - - dropdownItems.add( - FadeTransition( - opacity: CurvedAnimation( - parent: _animationController, - curve: Interval(start, end), - ), - child: Container( - color: _focusedIndex == i ? widget.focusColor : null, - child: _buildDropDownItem(context, widget.items[i]), - ), - ), - ); - } - return Follower.withAligner( link: link, aligner: _DropdownAligner(boundaryKey: widget.boundaryKey), @@ -262,6 +228,7 @@ class SuperDropdownButtonState extends State> with Sin key: widget.dropdownKey, elevation: 8, borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.hardEdge, child: FadeTransition( opacity: _containerFadeInAnimation, child: ScrollConfiguration( @@ -279,7 +246,13 @@ class SuperDropdownButtonState extends State> with Sin child: IntrinsicWidth( child: Column( mainAxisSize: MainAxisSize.min, - children: dropdownItems, + children: [ + for (int i = 0; i < widget.items.length; i++) + Container( + color: _focusedIndex == i ? widget.focusColor : null, + child: _buildDropDownItem(context, widget.items[i]), + ), + ], ), ), ), From d1b137637e836263640e2c64d1581343588be977 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 26 Oct 2023 21:55:49 -0300 Subject: [PATCH 05/36] Remove unused import --- super_editor/lib/src/infrastructure/dropdown.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index bd7d0c8aac..03914797cd 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; From e3f77b147bc98d5f6d8bec3952060320bd36a793 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 2 Nov 2023 10:09:00 -0300 Subject: [PATCH 06/36] Rename the dropdown widget and update docs --- .../lib/demos/example_editor/_toolbar.dart | 4 +- .../lib/src/infrastructure/dropdown.dart | 52 ++++++++++++++----- .../infrastructure/super_dropdown_test.dart | 42 +++++++-------- 3 files changed, 59 insertions(+), 39 deletions(-) diff --git a/super_editor/example/lib/demos/example_editor/_toolbar.dart b/super_editor/example/lib/demos/example_editor/_toolbar.dart index 63ce9c0a16..2a367b3a8b 100644 --- a/super_editor/example/lib/demos/example_editor/_toolbar.dart +++ b/super_editor/example/lib/demos/example_editor/_toolbar.dart @@ -527,7 +527,7 @@ class _EditorToolbarState extends State { if (_isConvertibleNode()) ...[ Tooltip( message: AppLocalizations.of(context)!.labelTextBlockType, - child: SuperDropdownButton<_TextType>( + child: ItemSelector<_TextType>( parentFocusNode: widget.editorFocusNode, boundaryKey: widget.editorViewportKey, value: _getCurrentTextType(), @@ -594,7 +594,7 @@ class _EditorToolbarState extends State { _buildVerticalDivider(), Tooltip( message: AppLocalizations.of(context)!.labelTextAlignment, - child: SuperDropdownButton( + child: ItemSelector( value: _getCurrentTextAlignment(), items: [TextAlign.left, TextAlign.center, TextAlign.right, TextAlign.justify], onChanged: _changeAlignment, diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index 03914797cd..0fba9ff9e2 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -3,21 +3,32 @@ import 'package:flutter/services.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/super_editor.dart'; -/// A button which displays a dropdown with a vertical list of items. +/// A selection control, which displays a selected item, and upon tap, displays a +/// popover list of available options, from which the user can select a different +/// option. /// -/// Unlike Flutter `DropdownButton`, which displays the dropdown in a separate route, -/// this widget displays its dropdown in an `Overlay`. By using an `Overlay`, focus can shared -/// with the [parentFocusNode]. This means that when the dropdown requests focus, [parentFocusNode] +/// Unlike Flutter `DropdownButton`, which displays the popover list in a separate route, +/// this widget displays its popover list in an `Overlay`. By using an `Overlay`, focus can be shared +/// with the [parentFocusNode]. This means that when the popover list requests focus, [parentFocusNode] /// still has non-primary focus. /// -/// The dropdown tries to fit all items on the available space. If there isn't enough room, -/// the list of items becomes scrollable with an always visible toolbar. -/// Provide [dropdownContraints] to enforce aditional constraints on the dropdown list. +/// The popover list is positioned based on the following rules: /// -/// The user can navigate between the options using the arrow keys, select an option with ENTER and -/// close the dropdown with ESC. -class SuperDropdownButton extends StatefulWidget { - const SuperDropdownButton({ +/// 1. The popover is displayed below the selected item, if there's enough room, or +/// 2. The popover is displayed above the selected item, if there's enough room, or +/// 3. The popover is displayed with its bottom aligned with the bottom of +/// the given boundary, and it covers the selected item. +/// +/// Provide [dropdownContraints] to enforce aditional constraints on the popover list. +/// +/// The popover list includes keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" item selection up/down. +/// * Pressing UP with the first item active moves the active item selection to the last item. +/// * Pressing DOWN with the last item active moves the active item selection to the first item. +/// * Pressing ENTER selects the currently active item and closes the popover list. +class ItemSelector extends StatefulWidget { + const ItemSelector({ super.key, required this.parentFocusNode, required this.boundaryKey, @@ -31,7 +42,20 @@ class SuperDropdownButton extends StatefulWidget { this.dropdownKey, }); - /// [FocusNode] which will share focus with the dropdown list. + /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + /// + /// In Flutter, [FocusNode]s have parents and children. This relationship allows an + /// entire ancestor path to "have focus", but only the lowest level descendant + /// in that path has "primary focus". This path is important because various + /// widgets alter their presentation or behavior based on whether or not they + /// currently have focus, even if they only have "non-primary focus". + /// + /// When the popover list of items is visible, that list will have primary focus. + /// Moreover, because the popover list is built in an `Overlay`, none of your + /// widgets are in the natural focus path for that popover list. Therefore, if you + /// need your widget tree to retain focus while the popover list is visible, then + /// you need to provide the [FocusNode] that the popover list should use as its + /// parent, thereby retaining focus for your widgets. final FocusNode parentFocusNode; /// A [GlobalKey] to a widget that determines the bounds where the dropdown can be displayed. @@ -64,11 +88,11 @@ class SuperDropdownButton extends StatefulWidget { final Widget Function(BuildContext context, T? item) buttonBuilder; @override - State> createState() => SuperDropdownButtonState(); + State> createState() => ItemSelectorState(); } @visibleForTesting -class SuperDropdownButtonState extends State> with SingleTickerProviderStateMixin { +class ItemSelectorState extends State> with SingleTickerProviderStateMixin { @visibleForTesting int? get focusedIndex => _focusedIndex; int? _focusedIndex; diff --git a/super_editor/test/infrastructure/super_dropdown_test.dart b/super_editor/test/infrastructure/super_dropdown_test.dart index ffe9e7c75f..182305cc41 100644 --- a/super_editor/test/infrastructure/super_dropdown_test.dart +++ b/super_editor/test/infrastructure/super_dropdown_test.dart @@ -31,7 +31,7 @@ void main() { expect(find.byKey(dropdownKey), findsNothing); // Tap the button to show the dropdown. - await tester.tap(find.byType(SuperDropdownButton)); + await tester.tap(find.byType(ItemSelector)); await tester.pumpAndSettle(); // Ensures the dropdown list is displayed. @@ -52,7 +52,7 @@ void main() { expect(find.byKey(dropdownKey), findsNothing); // Tap the button to show the dropdown. - await tester.tap(find.byType(SuperDropdownButton)); + await tester.tap(find.byType(ItemSelector)); await tester.pumpAndSettle(); // Ensures the dropdown list is displayed. @@ -81,7 +81,7 @@ void main() { expect(find.byKey(dropdownKey), findsNothing); // Tap the button to show the dropdown. - await tester.tap(find.byType(SuperDropdownButton)); + await tester.tap(find.byType(ItemSelector)); await tester.pumpAndSettle(); // Ensures the dropdown list is displayed. @@ -110,7 +110,7 @@ void main() { expect(find.byKey(dropdownKey), findsNothing); // Tap the button to show the dropdown. - await tester.tap(find.byType(SuperDropdownButton)); + await tester.tap(find.byType(ItemSelector)); await tester.pumpAndSettle(); // Ensures the dropdown list is displayed. @@ -129,15 +129,14 @@ void main() { ); // Tap the button to show the dropdown. - await tester.tap(find.byType(SuperDropdownButton)); + await tester.tap(find.byType(ItemSelector)); await tester.pumpAndSettle(); // Ensures the dropdown list is displayed. expect(find.byKey(dropdownKey), findsOneWidget); // Ensure the dropdown list isn't scrollable. - final dropdownButonState = - tester.state>(find.byType(SuperDropdownButton)); + final dropdownButonState = tester.state>(find.byType(ItemSelector)); expect(dropdownButonState.scrollController.position.maxScrollExtent, 0.0); }); @@ -151,15 +150,14 @@ void main() { ); // Tap the button to show the dropdown. - await tester.tap(find.byType(SuperDropdownButton)); + await tester.tap(find.byType(ItemSelector)); await tester.pumpAndSettle(); // Ensures the dropdown list is displayed. expect(find.byKey(dropdownKey), findsOneWidget); // Ensure the dropdown list is scrollable. - final dropdownButonState = - tester.state>(find.byType(SuperDropdownButton)); + final dropdownButonState = tester.state>(find.byType(ItemSelector)); expect(dropdownButonState.scrollController.position.maxScrollExtent, greaterThan(0.0)); }); @@ -170,11 +168,10 @@ void main() { ); // Tap the button to show the dropdown. - await tester.tap(find.byType(SuperDropdownButton)); + await tester.tap(find.byType(ItemSelector)); await tester.pumpAndSettle(); - final dropdownButonState = - tester.state>(find.byType(SuperDropdownButton)); + final dropdownButonState = tester.state>(find.byType(ItemSelector)); // Ensure the dropdown is displayed without any focused item. expect(dropdownButonState.focusedIndex, isNull); @@ -203,11 +200,10 @@ void main() { ); // Tap the button to show the dropdown. - await tester.tap(find.byType(SuperDropdownButton)); + await tester.tap(find.byType(ItemSelector)); await tester.pumpAndSettle(); - final dropdownButonState = - tester.state>(find.byType(SuperDropdownButton)); + final dropdownButonState = tester.state>(find.byType(ItemSelector)); // Ensure the dropdown is displayed without any focused item. expect(dropdownButonState.focusedIndex, isNull); @@ -239,7 +235,7 @@ void main() { ); // Tap the button to show the dropdown. - await tester.tap(find.byType(SuperDropdownButton)); + await tester.tap(find.byType(ItemSelector)); await tester.pumpAndSettle(); // Press ARROW DOWN to focus the first item. @@ -265,7 +261,7 @@ void main() { ); // Tap the button to show the dropdown. - await tester.tap(find.byType(SuperDropdownButton)); + await tester.tap(find.byType(ItemSelector)); await tester.pumpAndSettle(); // Press ENTER without a focused item to close the dropdown. @@ -288,7 +284,7 @@ void main() { ); // Tap the button to show the dropdown. - await tester.tap(find.byType(SuperDropdownButton)); + await tester.tap(find.byType(ItemSelector)); await tester.pumpAndSettle(); // Press ARROW DOWN to focus the first item. @@ -312,7 +308,7 @@ void main() { key: boundaryKey, home: _SuperEditorDropdownTestApp( editorFocusNode: editorFocusNode, - toolbar: SuperDropdownButton( + toolbar: ItemSelector( items: const ['Item1', 'Item2', 'Item3'], itemBuilder: (context, e) => Text(e), buttonBuilder: (context, e) => const SizedBox(width: 50), @@ -347,7 +343,7 @@ void main() { ); // Tap the button to show the dropdown. - await tester.tap(find.byType(SuperDropdownButton)); + await tester.tap(find.byType(ItemSelector)); await tester.pumpAndSettle(); // Ensure the editor has non-primary focus. @@ -377,7 +373,7 @@ void main() { }); } -/// Pumps a widget tree with a centered [SuperDropdownButton] containing three items. +/// Pumps a widget tree with a centered [ItemSelector] containing three items. Future _pumpDropdownTestApp( WidgetTester tester, { required void Function(String? value) onValueChanged, @@ -395,7 +391,7 @@ Future _pumpDropdownTestApp( child: Focus( focusNode: focusNode, autofocus: true, - child: SuperDropdownButton( + child: ItemSelector( items: const ['Item1', 'Item2', 'Item3'], itemBuilder: (context, e) => Text(e), buttonBuilder: (context, e) => const SizedBox(width: 50), From fb7fffc551edec446f21c0e71e7711014dd478d6 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 2 Nov 2023 11:24:58 -0300 Subject: [PATCH 07/36] PR Updates --- .../lib/demos/example_editor/_toolbar.dart | 8 +- .../lib/src/infrastructure/dropdown.dart | 181 +++++++++++------- .../infrastructure/super_dropdown_test.dart | 28 +-- 3 files changed, 130 insertions(+), 87 deletions(-) diff --git a/super_editor/example/lib/demos/example_editor/_toolbar.dart b/super_editor/example/lib/demos/example_editor/_toolbar.dart index 2a367b3a8b..eb88254701 100644 --- a/super_editor/example/lib/demos/example_editor/_toolbar.dart +++ b/super_editor/example/lib/demos/example_editor/_toolbar.dart @@ -533,7 +533,7 @@ class _EditorToolbarState extends State { value: _getCurrentTextType(), items: _TextType.values, onChanged: _convertTextToNewType, - focusColor: Colors.yellow, + activeItemDecoration: BoxDecoration(color: Colors.yellow), itemBuilder: (context, item) => Text( _getTextTypeName(item), style: const TextStyle( @@ -541,8 +541,8 @@ class _EditorToolbarState extends State { fontSize: 12, ), ), - buttonBuilder: (context, item) => Padding( - padding: EdgeInsets.only(left: 8.0), + selectedItemBuilder: (context, item) => Padding( + padding: EdgeInsets.only(left: 16.0, right: 24), child: Text( _getTextTypeName(item!), style: const TextStyle( @@ -601,7 +601,7 @@ class _EditorToolbarState extends State { boundaryKey: widget.editorViewportKey, parentFocusNode: widget.editorFocusNode, itemBuilder: (context, item) => Icon(_buildTextAlignIcon(item)), - buttonBuilder: (context, item) => Icon(_buildTextAlignIcon(item!)), + selectedItemBuilder: (context, item) => Icon(_buildTextAlignIcon(item!)), ), ), ], diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index 0fba9ff9e2..44887e8e18 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -19,7 +19,10 @@ import 'package:super_editor/super_editor.dart'; /// 3. The popover is displayed with its bottom aligned with the bottom of /// the given boundary, and it covers the selected item. /// -/// Provide [dropdownContraints] to enforce aditional constraints on the popover list. +/// Provide [popoverContraints] to enforce aditional constraints on the popover list. For example: +/// 1. Provide a tight [BoxConstraints] to force the popover list to be a specific size. +/// 2. Provide a [BoxConstraints] with a `maxWidth` to prevent the popover list from being taller +/// than the [BoxConstraints.maxWidth]. /// /// The popover list includes keyboard selection behaviors: /// @@ -36,10 +39,10 @@ class ItemSelector extends StatefulWidget { required this.value, required this.onChanged, required this.itemBuilder, - required this.buttonBuilder, - this.focusColor, - this.dropdownContraints, - this.dropdownKey, + required this.selectedItemBuilder, + this.activeItemDecoration, + this.popoverContraints, + this.popoverKey, }); /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. @@ -58,34 +61,52 @@ class ItemSelector extends StatefulWidget { /// parent, thereby retaining focus for your widgets. final FocusNode parentFocusNode; - /// A [GlobalKey] to a widget that determines the bounds where the dropdown can be displayed. + /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. /// - /// Used to avoid the dropdown to be displayed off-screen. - final GlobalKey boundaryKey; + /// As the popover list follows the selected item, it can be displayed off-screen if this [ItemSelector] + /// is close to the bottom of the screen. + /// + /// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget + /// bound to the [boundaryKey]. + /// + /// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. + final GlobalKey? boundaryKey; /// The currently selected value or `null` if no item is selected. + /// + /// This value is passed to [selectedItemBuilder] to build the visual representation of the selected item. final T? value; - /// The items that will be displayed in the dropdown list. + /// The items that will be displayed in the popover list. + /// + /// For each item, [itemBuilder] is called to build its visual representation. final List items; - /// Called when the user selects an item on the dropdown list. + /// Called when the user selects an item on the popover list. + /// + /// The selection can be performed by: + /// 1. Tapping on an item in the popover list. + /// 2. Pressing ENTER when the popover list has an active item. final ValueChanged onChanged; - /// The background color of the focused list item. - final Color? focusColor; + /// The background color of the active list item. + final BoxDecoration? activeItemDecoration; - /// A [GlobalKey] bound to the dropdown list. - final GlobalKey? dropdownKey; + /// A [GlobalKey] bound to the popover list. + final GlobalKey? popoverKey; - /// Constraints applied to the dropdown list. - final BoxConstraints? dropdownContraints; + /// Constraints applied to the popover list. + final BoxConstraints? popoverContraints; - /// Builds each item in the dropdown list. + /// Builds each item in the popover list. + /// + /// This method is called for each item in [items], to build its visual representation. final Widget Function(BuildContext context, T item) itemBuilder; - /// Builds the button that opens the dropdown. - final Widget Function(BuildContext context, T? item) buttonBuilder; + /// Builds the selected item which, upon tap, opens the popover list. + /// + /// This method is called with the currently selected [value]. + final Widget Function(BuildContext context, T? item) selectedItemBuilder; @override State> createState() => ItemSelectorState(); @@ -94,10 +115,10 @@ class ItemSelector extends StatefulWidget { @visibleForTesting class ItemSelectorState extends State> with SingleTickerProviderStateMixin { @visibleForTesting - int? get focusedIndex => _focusedIndex; - int? _focusedIndex; + int? get activeIndex => _activeIndex; + int? _activeIndex; - final DropdownController _dropdownController = DropdownController(); + final DropdownController _popoverController = DropdownController(); @visibleForTesting ScrollController get scrollController => _scrollController; @@ -122,7 +143,7 @@ class ItemSelectorState extends State> with SingleTickerProvi @override void dispose() { - _dropdownController.dispose(); + _popoverController.dispose(); _scrollController.dispose(); _animationController.dispose(); @@ -130,20 +151,20 @@ class ItemSelectorState extends State> with SingleTickerProvi } void _onButtonTap() { - _dropdownController.show(); + _popoverController.open(); _animationController ..reset() ..forward(); setState(() { - _focusedIndex = null; + _activeIndex = null; }); } - /// Called when the user taps an item or presses ENTER with a focused item. - void _submitItem(T item) { + /// Called when the user taps an item or presses ENTER with an active item. + void _selectItem(T item) { widget.onChanged(item); - _dropdownController.hide(); + _popoverController.close(); } KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { @@ -151,7 +172,7 @@ class ItemSelectorState extends State> with SingleTickerProvi return KeyEventResult.ignored; } - if (![ + if (!const [ LogicalKeyboardKey.enter, LogicalKeyboardKey.numpadEnter, LogicalKeyboardKey.arrowDown, @@ -162,44 +183,46 @@ class ItemSelectorState extends State> with SingleTickerProvi } if (event.logicalKey == LogicalKeyboardKey.escape) { - _dropdownController.hide(); + _popoverController.close(); return KeyEventResult.handled; } if (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter) { - if (_focusedIndex == null) { - _dropdownController.hide(); + if (_activeIndex == null) { + // The user pressed ENTER without an active item. + // Close the popover without changing the selected item. + _popoverController.close(); return KeyEventResult.handled; } - _submitItem(widget.items[_focusedIndex!]); + _selectItem(widget.items[_activeIndex!]); return KeyEventResult.handled; } - int? newFocusedIndex; + int? newActiveIndex; if (event.logicalKey == LogicalKeyboardKey.arrowDown) { - if (_focusedIndex == null || _focusedIndex! >= widget.items.length - 1) { - // We don't have a focused item or we are at the end of the list. Focus the first item. - newFocusedIndex = 0; + if (_activeIndex == null || _activeIndex! >= widget.items.length - 1) { + // We don't have an active item or we are at the end of the list. Activate the first item. + newActiveIndex = 0; } else { - // Move the focus down. - newFocusedIndex = _focusedIndex! + 1; + // Activate the next item. + newActiveIndex = _activeIndex! + 1; } } if (event.logicalKey == LogicalKeyboardKey.arrowUp) { - if (_focusedIndex == null || _focusedIndex! <= 0) { - // We don't have a focused item or we are at the beginning of the list. Focus the last item. - newFocusedIndex = widget.items.length - 1; + if (_activeIndex == null || _activeIndex! <= 0) { + // We don't have an active item or we are at the beginning of the list. Activate the last item. + newActiveIndex = widget.items.length - 1; } else { - // Move the focus up. - newFocusedIndex = _focusedIndex! - 1; + // Activate the previous item. + newActiveIndex = _activeIndex! - 1; } } setState(() { - _focusedIndex = newFocusedIndex; + _activeIndex = newActiveIndex; }); return KeyEventResult.handled; @@ -208,7 +231,7 @@ class ItemSelectorState extends State> with SingleTickerProvi @override Widget build(BuildContext context) { return RawDropdown( - controller: _dropdownController, + controller: _popoverController, boundaryKey: widget.boundaryKey, dropdownBuilder: _buildDropDown, parentFocusNode: widget.parentFocusNode, @@ -227,14 +250,14 @@ class ItemSelectorState extends State> with SingleTickerProvi } Widget _buildSelectedItem() { - return Row( - mainAxisSize: MainAxisSize.min, + return Stack( + alignment: Alignment.centerLeft, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: widget.buttonBuilder(context, widget.value), + widget.selectedItemBuilder(context, widget.value), + const Positioned( + right: 0, + child: Icon(Icons.arrow_drop_down), ), - const Icon(Icons.arrow_drop_down), ], ); } @@ -246,9 +269,9 @@ class ItemSelectorState extends State> with SingleTickerProvi boundary: boundary, showWhenUnlinked: false, child: ConstrainedBox( - constraints: widget.dropdownContraints ?? const BoxConstraints(), + constraints: widget.popoverContraints ?? const BoxConstraints(), child: Material( - key: widget.dropdownKey, + key: widget.popoverKey, elevation: 8, borderRadius: BorderRadius.circular(12), clipBehavior: Clip.hardEdge, @@ -272,7 +295,7 @@ class ItemSelectorState extends State> with SingleTickerProvi children: [ for (int i = 0; i < widget.items.length; i++) Container( - color: _focusedIndex == i ? widget.focusColor : null, + decoration: _activeIndex == i ? widget.activeItemDecoration : null, child: _buildDropDownItem(context, widget.items[i]), ), ], @@ -290,7 +313,7 @@ class ItemSelectorState extends State> with SingleTickerProvi Widget _buildDropDownItem(BuildContext context, T item) { return InkWell( - onTap: () => _submitItem(item), + onTap: () => _selectItem(item), child: Container( constraints: const BoxConstraints(minHeight: kMinInteractiveDimension), alignment: AlignmentDirectional.centerStart, @@ -363,7 +386,7 @@ class _DropdownAligner implements FollowerAligner { /// /// The dropdown is displayed in an `Overlay` and it can follow the [child] /// by being wrapped with a [Follower]. The visibility of the dropdown -/// is changed by calling [DropdownController.show] or [DropdownController.hide]. +/// is changed by calling [DropdownController.open] or [DropdownController.close]. /// The dropdown is automatically closed when the user taps outside of its bounds. /// /// When the dropdown is displayed it requests focus to itself, so the user can @@ -395,10 +418,16 @@ class RawDropdown extends StatefulWidget { /// [FocusNode] which will share focus with the dropdown. final FocusNode parentFocusNode; - /// A [GlobalKey] to a widget that determines the bounds where the dropdown can be displayed. + /// A [GlobalKey] to a widget that determines the bounds where the popover can be displayed. + /// + /// As the popover follows the selected item, it can be displayed off-screen if this [RawDropdown] + /// is close to the bottom of the screen. + /// + /// Passing a [boundaryKey] causes the popover to be confined to the bounds of the widget + /// bound to the [boundaryKey]. /// - /// Used to avoid the dropdown to be displayed off-screen. - final GlobalKey boundaryKey; + /// If `null`, the popover is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. + final GlobalKey? boundaryKey; final Widget child; @@ -423,10 +452,7 @@ class _RawDropdownState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - _screenBoundary = WidgetFollowerBoundary( - boundaryKey: widget.boundaryKey, - devicePixelRatio: MediaQuery.devicePixelRatioOf(context), - ); + _updateFollowerBoundary(); } @override @@ -436,6 +462,9 @@ class _RawDropdownState extends State { oldWidget.controller.removeListener(_onDropdownControllerChanged); widget.controller.addListener(_onDropdownControllerChanged); } + if (oldWidget.boundaryKey != widget.boundaryKey) { + _updateFollowerBoundary(); + } } @override @@ -446,6 +475,20 @@ class _RawDropdownState extends State { super.dispose(); } + void _updateFollowerBoundary() { + if (widget.boundaryKey != null) { + _screenBoundary = WidgetFollowerBoundary( + boundaryKey: widget.boundaryKey, + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + ); + } else { + _screenBoundary = ScreenFollowerBoundary( + screenSize: MediaQuery.sizeOf(context), + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + ); + } + } + void _onDropdownControllerChanged() { if (widget.controller.shouldShow) { _overlayController.show(); @@ -460,7 +503,7 @@ class _RawDropdownState extends State { } void _onTapOutsideOfDropdown(PointerDownEvent e) { - widget.controller.hide(); + widget.controller.close(); } KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { @@ -504,7 +547,7 @@ class DropdownController with ChangeNotifier { bool get shouldShow => _shouldShow; bool _shouldShow = false; - void show() { + void open() { if (_shouldShow) { return; } @@ -512,7 +555,7 @@ class DropdownController with ChangeNotifier { notifyListeners(); } - void hide() { + void close() { if (!_shouldShow) { return; } @@ -522,9 +565,9 @@ class DropdownController with ChangeNotifier { void toggle() { if (shouldShow) { - hide(); + close(); } else { - show(); + open(); } } } diff --git a/super_editor/test/infrastructure/super_dropdown_test.dart b/super_editor/test/infrastructure/super_dropdown_test.dart index 182305cc41..41e30c9a0e 100644 --- a/super_editor/test/infrastructure/super_dropdown_test.dart +++ b/super_editor/test/infrastructure/super_dropdown_test.dart @@ -174,23 +174,23 @@ void main() { final dropdownButonState = tester.state>(find.byType(ItemSelector)); // Ensure the dropdown is displayed without any focused item. - expect(dropdownButonState.focusedIndex, isNull); + expect(dropdownButonState.activeIndex, isNull); // Press DOWN ARROW to focus the first item. await tester.pressDownArrow(); - expect(dropdownButonState.focusedIndex, 0); + expect(dropdownButonState.activeIndex, 0); // Press DOWN ARROW to focus the second item. await tester.pressDownArrow(); - expect(dropdownButonState.focusedIndex, 1); + expect(dropdownButonState.activeIndex, 1); // Press DOWN ARROW to focus the third item. await tester.pressDownArrow(); - expect(dropdownButonState.focusedIndex, 2); + expect(dropdownButonState.activeIndex, 2); // Press DOWN ARROW to focus the first item again. await tester.pressDownArrow(); - expect(dropdownButonState.focusedIndex, 0); + expect(dropdownButonState.activeIndex, 0); }); testWidgetsOnAllPlatforms('moves focus up with UP ARROW', (tester) async { @@ -206,23 +206,23 @@ void main() { final dropdownButonState = tester.state>(find.byType(ItemSelector)); // Ensure the dropdown is displayed without any focused item. - expect(dropdownButonState.focusedIndex, isNull); + expect(dropdownButonState.activeIndex, isNull); // Press UP ARROW to focus the last item. await tester.pressUpArrow(); - expect(dropdownButonState.focusedIndex, 2); + expect(dropdownButonState.activeIndex, 2); // Press UP ARROW to focus the second item. await tester.pressUpArrow(); - expect(dropdownButonState.focusedIndex, 1); + expect(dropdownButonState.activeIndex, 1); // Press UP ARROW to focus the first item. await tester.pressUpArrow(); - expect(dropdownButonState.focusedIndex, 0); + expect(dropdownButonState.activeIndex, 0); // Press UP ARROW to focus the last item again. await tester.pressUpArrow(); - expect(dropdownButonState.focusedIndex, 2); + expect(dropdownButonState.activeIndex, 2); }); testWidgetsOnAllPlatforms('selects the focused item on ENTER', (tester) async { @@ -311,7 +311,7 @@ void main() { toolbar: ItemSelector( items: const ['Item1', 'Item2', 'Item3'], itemBuilder: (context, e) => Text(e), - buttonBuilder: (context, e) => const SizedBox(width: 50), + selectedItemBuilder: (context, e) => const SizedBox(width: 50), value: null, onChanged: (s) => {}, boundaryKey: boundaryKey, @@ -394,13 +394,13 @@ Future _pumpDropdownTestApp( child: ItemSelector( items: const ['Item1', 'Item2', 'Item3'], itemBuilder: (context, e) => Text(e), - buttonBuilder: (context, e) => const SizedBox(width: 50), + selectedItemBuilder: (context, e) => const SizedBox(width: 50), value: null, onChanged: onValueChanged, boundaryKey: boundaryKey, parentFocusNode: focusNode, - dropdownContraints: dropdownConstraints, - dropdownKey: dropdownKey, + popoverContraints: dropdownConstraints, + popoverKey: dropdownKey, ), ), ), From 96eba096b5853da8819f0aac021615e67b55e600 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 2 Nov 2023 11:33:22 -0300 Subject: [PATCH 08/36] Remove required boundaryKey --- super_editor/lib/src/infrastructure/dropdown.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index 44887e8e18..1fc8c828f9 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -34,7 +34,7 @@ class ItemSelector extends StatefulWidget { const ItemSelector({ super.key, required this.parentFocusNode, - required this.boundaryKey, + this.boundaryKey, required this.items, required this.value, required this.onChanged, @@ -399,7 +399,7 @@ class RawDropdown extends StatefulWidget { const RawDropdown({ super.key, required this.controller, - required this.boundaryKey, + this.boundaryKey, required this.dropdownBuilder, required this.parentFocusNode, this.onKeyEvent, From 39a5a55479b442588f2c63c91d36cb40336e25f6 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 2 Nov 2023 11:52:34 -0300 Subject: [PATCH 09/36] Remove constrained box --- super_editor/lib/src/infrastructure/dropdown.dart | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index 1fc8c828f9..bc75dc8b31 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -236,14 +236,10 @@ class ItemSelectorState extends State> with SingleTickerProvi dropdownBuilder: _buildDropDown, parentFocusNode: widget.parentFocusNode, onKeyEvent: _onKeyEvent, - child: ConstrainedBox( - // TODO: what value should we use? - constraints: const BoxConstraints(maxHeight: 100), - child: InkWell( - onTap: _onButtonTap, - child: Center( - child: _buildSelectedItem(), - ), + child: InkWell( + onTap: _onButtonTap, + child: Center( + child: _buildSelectedItem(), ), ), ); From c20c1eb152a116cf337b126c78b1f4bda1a858b4 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 2 Nov 2023 12:17:42 -0300 Subject: [PATCH 10/36] Rebuild the toolbar when document changes --- .../lib/demos/example_editor/_toolbar.dart | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/super_editor/example/lib/demos/example_editor/_toolbar.dart b/super_editor/example/lib/demos/example_editor/_toolbar.dart index eb88254701..e0ba7a906d 100644 --- a/super_editor/example/lib/demos/example_editor/_toolbar.dart +++ b/super_editor/example/lib/demos/example_editor/_toolbar.dart @@ -83,6 +83,8 @@ class _EditorToolbarState extends State { ImeAttributedTextEditingController(controller: SingleLineAttributedTextEditingController(_applyLink)) // ..onPerformActionPressed = _onPerformAction ..text = AttributedText("https://"); + + widget.document.addListener(_onDocumentChanged); } @override @@ -95,14 +97,32 @@ class _EditorToolbarState extends State { ); } + @override + void didUpdateWidget(covariant EditorToolbar oldWidget) { + if (oldWidget.document != widget.document) { + oldWidget.document.removeListener(_onDocumentChanged); + widget.document.addListener(_onDocumentChanged); + } + super.didUpdateWidget(oldWidget); + } + @override void dispose() { _urlFocusNode.dispose(); _urlController!.dispose(); _popoverFocusNode.dispose(); + + widget.document.removeListener(_onDocumentChanged); super.dispose(); } + void _onDocumentChanged(DocumentChangeLog doc) { + // The document has changed. + // Some of the selected node's metadata, for example, the blockType, influences how the toolbar is displayed. + // Reflow the toolbar to acount for the new state of the selected node. + setState(() {}); + } + /// Returns true if the currently selected text node is capable of being /// transformed into a different type text node, returns false if /// multiple nodes are selected, no node is selected, or the selected @@ -224,9 +244,6 @@ class _EditorToolbarState extends State { ), ]); } - - // Rebuild to display the selected item. - setState(() {}); } /// Returns true if the given [_TextType] represents an @@ -443,9 +460,6 @@ class _EditorToolbarState extends State { final selectedNode = widget.document.getNodeById(widget.composer.selection!.extent.nodeId) as ParagraphNode; selectedNode.putMetadataValue('textAlign', newAlignmentValue); - - // Rebuild to display the selected item. - setState(() {}); } /// Returns the localized name for the given [_TextType], e.g., @@ -601,7 +615,12 @@ class _EditorToolbarState extends State { boundaryKey: widget.editorViewportKey, parentFocusNode: widget.editorFocusNode, itemBuilder: (context, item) => Icon(_buildTextAlignIcon(item)), - selectedItemBuilder: (context, item) => Icon(_buildTextAlignIcon(item!)), + selectedItemBuilder: (context, item) => Padding( + padding: EdgeInsets.only(left: 8.0, right: 24), + child: Icon( + _buildTextAlignIcon(item!), + ), + ), ), ), ], From bb1642a4c8ac299e70aab0c4074a98f1c0971490 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 2 Nov 2023 12:43:44 -0300 Subject: [PATCH 11/36] Fix failing tests --- .../infrastructure/super_dropdown_test.dart | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/super_editor/test/infrastructure/super_dropdown_test.dart b/super_editor/test/infrastructure/super_dropdown_test.dart index 41e30c9a0e..3023eac65f 100644 --- a/super_editor/test/infrastructure/super_dropdown_test.dart +++ b/super_editor/test/infrastructure/super_dropdown_test.dart @@ -308,14 +308,17 @@ void main() { key: boundaryKey, home: _SuperEditorDropdownTestApp( editorFocusNode: editorFocusNode, - toolbar: ItemSelector( - items: const ['Item1', 'Item2', 'Item3'], - itemBuilder: (context, e) => Text(e), - selectedItemBuilder: (context, e) => const SizedBox(width: 50), - value: null, - onChanged: (s) => {}, - boundaryKey: boundaryKey, - parentFocusNode: editorFocusNode, + toolbar: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 100), + child: ItemSelector( + items: const ['Item1', 'Item2', 'Item3'], + itemBuilder: (context, e) => Text(e), + selectedItemBuilder: (context, e) => const SizedBox(width: 50), + value: null, + onChanged: (s) => {}, + boundaryKey: boundaryKey, + parentFocusNode: editorFocusNode, + ), ), ), ), @@ -391,16 +394,19 @@ Future _pumpDropdownTestApp( child: Focus( focusNode: focusNode, autofocus: true, - child: ItemSelector( - items: const ['Item1', 'Item2', 'Item3'], - itemBuilder: (context, e) => Text(e), - selectedItemBuilder: (context, e) => const SizedBox(width: 50), - value: null, - onChanged: onValueChanged, - boundaryKey: boundaryKey, - parentFocusNode: focusNode, - popoverContraints: dropdownConstraints, - popoverKey: dropdownKey, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 100), + child: ItemSelector( + items: const ['Item1', 'Item2', 'Item3'], + itemBuilder: (context, e) => Text(e), + selectedItemBuilder: (context, e) => const SizedBox(width: 50), + value: null, + onChanged: onValueChanged, + boundaryKey: boundaryKey, + parentFocusNode: focusNode, + popoverContraints: dropdownConstraints, + popoverKey: dropdownKey, + ), ), ), ), From 790533bbad440ef743ce43b1b4d8b68c86fa8f6f Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 2 Nov 2023 20:01:26 -0300 Subject: [PATCH 12/36] Make parentFocusNode optional --- .../lib/src/infrastructure/dropdown.dart | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index bc75dc8b31..096f24d31a 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -59,7 +59,7 @@ class ItemSelector extends StatefulWidget { /// need your widget tree to retain focus while the popover list is visible, then /// you need to provide the [FocusNode] that the popover list should use as its /// parent, thereby retaining focus for your widgets. - final FocusNode parentFocusNode; + final FocusNode? parentFocusNode; /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. /// @@ -101,12 +101,12 @@ class ItemSelector extends StatefulWidget { /// Builds each item in the popover list. /// /// This method is called for each item in [items], to build its visual representation. - final Widget Function(BuildContext context, T item) itemBuilder; + final ItemBuilder itemBuilder; /// Builds the selected item which, upon tap, opens the popover list. /// /// This method is called with the currently selected [value]. - final Widget Function(BuildContext context, T? item) selectedItemBuilder; + final ItemBuilder selectedItemBuilder; @override State> createState() => ItemSelectorState(); @@ -322,6 +322,8 @@ class ItemSelectorState extends State> with SingleTickerProvi } } +typedef ItemBuilder = Widget Function(BuildContext context, T item); + /// A [FollowerAligner] to position a dropdown list relative to the dropdown button. /// /// The following rules are applied, in order: @@ -397,7 +399,7 @@ class RawDropdown extends StatefulWidget { required this.controller, this.boundaryKey, required this.dropdownBuilder, - required this.parentFocusNode, + this.parentFocusNode, this.onKeyEvent, required this.child, }); @@ -412,7 +414,7 @@ class RawDropdown extends StatefulWidget { final FocusOnKeyEventCallback? onKeyEvent; /// [FocusNode] which will share focus with the dropdown. - final FocusNode parentFocusNode; + final FocusNode? parentFocusNode; /// A [GlobalKey] to a widget that determines the bounds where the popover can be displayed. /// @@ -435,6 +437,7 @@ class _RawDropdownState extends State { final OverlayPortalController _overlayController = OverlayPortalController(); final LeaderLink _dropdownLink = LeaderLink(); final FocusNode _dropdownFocusNode = FocusNode(); + late FocusNode _parentFocusNode; late FollowerBoundary _screenBoundary; @@ -442,6 +445,7 @@ class _RawDropdownState extends State { void initState() { super.initState(); + _parentFocusNode = widget.parentFocusNode ?? FocusNode(); widget.controller.addListener(_onDropdownControllerChanged); } @@ -458,6 +462,15 @@ class _RawDropdownState extends State { oldWidget.controller.removeListener(_onDropdownControllerChanged); widget.controller.addListener(_onDropdownControllerChanged); } + + if (oldWidget.parentFocusNode != widget.parentFocusNode) { + if (oldWidget.parentFocusNode == null) { + _parentFocusNode.dispose(); + } + + _parentFocusNode = widget.parentFocusNode ?? FocusNode(); + } + if (oldWidget.boundaryKey != widget.boundaryKey) { _updateFollowerBoundary(); } @@ -468,6 +481,10 @@ class _RawDropdownState extends State { widget.controller.removeListener(_onDropdownControllerChanged); _dropdownLink.dispose(); + if (widget.parentFocusNode == null) { + _parentFocusNode.dispose(); + } + super.dispose(); } @@ -527,7 +544,7 @@ class _RawDropdownState extends State { onTapOutside: _onTapOutsideOfDropdown, child: SuperEditorPopover( popoverFocusNode: _dropdownFocusNode, - editorFocusNode: widget.parentFocusNode, + editorFocusNode: _parentFocusNode, onKeyEvent: _onKeyEvent, child: widget.dropdownBuilder(context, _dropdownLink, _screenBoundary), ), From 652d76222979f2a21d0837ca9b1e23cbf73000a3 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 2 Nov 2023 20:59:53 -0300 Subject: [PATCH 13/36] Activate the selected item --- .../lib/demos/example_editor/_toolbar.dart | 1 + .../lib/src/infrastructure/dropdown.dart | 22 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/super_editor/example/lib/demos/example_editor/_toolbar.dart b/super_editor/example/lib/demos/example_editor/_toolbar.dart index e0ba7a906d..62b127af19 100644 --- a/super_editor/example/lib/demos/example_editor/_toolbar.dart +++ b/super_editor/example/lib/demos/example_editor/_toolbar.dart @@ -615,6 +615,7 @@ class _EditorToolbarState extends State { boundaryKey: widget.editorViewportKey, parentFocusNode: widget.editorFocusNode, itemBuilder: (context, item) => Icon(_buildTextAlignIcon(item)), + activeItemDecoration: BoxDecoration(color: Colors.yellow), selectedItemBuilder: (context, item) => Padding( padding: EdgeInsets.only(left: 8.0, right: 24), child: Icon( diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index 096f24d31a..243469d47b 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -19,6 +19,13 @@ import 'package:super_editor/super_editor.dart'; /// 3. The popover is displayed with its bottom aligned with the bottom of /// the given boundary, and it covers the selected item. /// +/// The popover list height is based on the following rules: +/// +/// 1. The popover height is constrained by [popoverContraints], if provided, becoming scrollable if +/// there isn't enough room to display all items, or +/// 2. The popover is displayed as tall as all items in the list, if there's enough room, or +/// 3. The popover is displayed as tall as the available space and becomes scrollable. +/// /// Provide [popoverContraints] to enforce aditional constraints on the popover list. For example: /// 1. Provide a tight [BoxConstraints] to force the popover list to be a specific size. /// 2. Provide a [BoxConstraints] with a `maxWidth` to prevent the popover list from being taller @@ -157,7 +164,20 @@ class ItemSelectorState extends State> with SingleTickerProvi ..forward(); setState(() { - _activeIndex = null; + final selectedItem = widget.value; + + if (selectedItem == null) { + _activeIndex = null; + return; + } + + int selectedItemIndex = widget.items.indexOf(selectedItem); + if (selectedItemIndex > -1) { + _activeIndex = selectedItemIndex; + } else { + // A selected item was provided, but it isn't included in the list of items. + _activeIndex = null; + } }); } From d40ac73dfad5d7ea4740ddfad69f68586f33e40b Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 2 Nov 2023 21:20:04 -0300 Subject: [PATCH 14/36] Rebuild fewer widgets when document changes --- .../lib/demos/example_editor/_toolbar.dart | 171 +++++++++++------- 1 file changed, 109 insertions(+), 62 deletions(-) diff --git a/super_editor/example/lib/demos/example_editor/_toolbar.dart b/super_editor/example/lib/demos/example_editor/_toolbar.dart index 62b127af19..f8ce4f916a 100644 --- a/super_editor/example/lib/demos/example_editor/_toolbar.dart +++ b/super_editor/example/lib/demos/example_editor/_toolbar.dart @@ -83,8 +83,6 @@ class _EditorToolbarState extends State { ImeAttributedTextEditingController(controller: SingleLineAttributedTextEditingController(_applyLink)) // ..onPerformActionPressed = _onPerformAction ..text = AttributedText("https://"); - - widget.document.addListener(_onDocumentChanged); } @override @@ -97,32 +95,15 @@ class _EditorToolbarState extends State { ); } - @override - void didUpdateWidget(covariant EditorToolbar oldWidget) { - if (oldWidget.document != widget.document) { - oldWidget.document.removeListener(_onDocumentChanged); - widget.document.addListener(_onDocumentChanged); - } - super.didUpdateWidget(oldWidget); - } - @override void dispose() { _urlFocusNode.dispose(); _urlController!.dispose(); _popoverFocusNode.dispose(); - widget.document.removeListener(_onDocumentChanged); super.dispose(); } - void _onDocumentChanged(DocumentChangeLog doc) { - // The document has changed. - // Some of the selected node's metadata, for example, the blockType, influences how the toolbar is displayed. - // Reflow the toolbar to acount for the new state of the selected node. - setState(() {}); - } - /// Returns true if the currently selected text node is capable of being /// transformed into a different type text node, returns false if /// multiple nodes are selected, no node is selected, or the selected @@ -541,30 +522,35 @@ class _EditorToolbarState extends State { if (_isConvertibleNode()) ...[ Tooltip( message: AppLocalizations.of(context)!.labelTextBlockType, - child: ItemSelector<_TextType>( - parentFocusNode: widget.editorFocusNode, - boundaryKey: widget.editorViewportKey, - value: _getCurrentTextType(), - items: _TextType.values, - onChanged: _convertTextToNewType, - activeItemDecoration: BoxDecoration(color: Colors.yellow), - itemBuilder: (context, item) => Text( - _getTextTypeName(item), - style: const TextStyle( - color: Colors.black, - fontSize: 12, - ), - ), - selectedItemBuilder: (context, item) => Padding( - padding: EdgeInsets.only(left: 16.0, right: 24), - child: Text( - _getTextTypeName(item!), - style: const TextStyle( - color: Colors.black, - fontSize: 12, + child: _DocumentListenableBuilder( + document: widget.document, + builder: (context) { + return ItemSelector<_TextType>( + parentFocusNode: widget.editorFocusNode, + boundaryKey: widget.editorViewportKey, + value: _getCurrentTextType(), + items: _TextType.values, + onChanged: _convertTextToNewType, + activeItemDecoration: BoxDecoration(color: Colors.yellow), + itemBuilder: (context, item) => Text( + _getTextTypeName(item), + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), ), - ), - ), + selectedItemBuilder: (context, item) => Padding( + padding: EdgeInsets.only(left: 16.0, right: 24), + child: Text( + _getTextTypeName(item!), + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ), + ); + }, ), ), _buildVerticalDivider(), @@ -604,27 +590,39 @@ class _EditorToolbarState extends State { ), // Only display alignment controls if the currently selected text // node respects alignment. List items, for example, do not. - if (_isTextAlignable()) ...[ - _buildVerticalDivider(), - Tooltip( - message: AppLocalizations.of(context)!.labelTextAlignment, - child: ItemSelector( - value: _getCurrentTextAlignment(), - items: [TextAlign.left, TextAlign.center, TextAlign.right, TextAlign.justify], - onChanged: _changeAlignment, - boundaryKey: widget.editorViewportKey, - parentFocusNode: widget.editorFocusNode, - itemBuilder: (context, item) => Icon(_buildTextAlignIcon(item)), - activeItemDecoration: BoxDecoration(color: Colors.yellow), - selectedItemBuilder: (context, item) => Padding( - padding: EdgeInsets.only(left: 8.0, right: 24), - child: Icon( - _buildTextAlignIcon(item!), + _DocumentListenableBuilder( + document: widget.document, + builder: (context) { + if (!_isTextAlignable()) { + return const SizedBox(); + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildVerticalDivider(), + Tooltip( + message: AppLocalizations.of(context)!.labelTextAlignment, + child: ItemSelector( + value: _getCurrentTextAlignment(), + items: [TextAlign.left, TextAlign.center, TextAlign.right, TextAlign.justify], + onChanged: _changeAlignment, + boundaryKey: widget.editorViewportKey, + parentFocusNode: widget.editorFocusNode, + itemBuilder: (context, item) => Icon(_buildTextAlignIcon(item)), + activeItemDecoration: BoxDecoration(color: Colors.yellow), + selectedItemBuilder: (context, item) => Padding( + padding: EdgeInsets.only(left: 8.0, right: 24), + child: Icon( + _buildTextAlignIcon(item!), + ), + ), + ), ), - ), - ), - ), - ], + ], + ); + }, + ), + _buildVerticalDivider(), Center( child: IconButton( @@ -907,3 +905,52 @@ class SingleLineAttributedTextEditingController extends AttributedTextEditingCon // line field (#697). } } + +/// A Widget that calls its [builder] whenever the [document] changes. +class _DocumentListenableBuilder extends StatefulWidget { + const _DocumentListenableBuilder({ + required this.document, + required this.builder, + }); + + /// The [document] to listen to. + final Document document; + + final WidgetBuilder builder; + + @override + State<_DocumentListenableBuilder> createState() => _DocumentListenableBuilderState(); +} + +class _DocumentListenableBuilderState extends State<_DocumentListenableBuilder> { + @override + void initState() { + widget.document.addListener(_onDocumentChanged); + super.initState(); + } + + @override + void didUpdateWidget(covariant _DocumentListenableBuilder oldWidget) { + if (oldWidget.document != widget.document) { + oldWidget.document.removeListener(_onDocumentChanged); + widget.document.addListener(_onDocumentChanged); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + widget.document.removeListener(_onDocumentChanged); + super.dispose(); + } + + void _onDocumentChanged(DocumentChangeLog doc) { + // The document has changed. Rebuild. + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return widget.builder(context); + } +} From 7ee05108250eb63f67ab0996c73880ac91ec7f35 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Sat, 11 Nov 2023 13:48:22 -0300 Subject: [PATCH 15/36] Update API --- .../lib/demos/example_editor/_toolbar.dart | 65 +- .../lib/src/infrastructure/dropdown.dart | 803 ++++++++++++------ .../infrastructure/super_dropdown_test.dart | 154 ++-- 3 files changed, 662 insertions(+), 360 deletions(-) diff --git a/super_editor/example/lib/demos/example_editor/_toolbar.dart b/super_editor/example/lib/demos/example_editor/_toolbar.dart index f8ce4f916a..890d22b9e8 100644 --- a/super_editor/example/lib/demos/example_editor/_toolbar.dart +++ b/super_editor/example/lib/demos/example_editor/_toolbar.dart @@ -525,30 +525,28 @@ class _EditorToolbarState extends State { child: _DocumentListenableBuilder( document: widget.document, builder: (context) { - return ItemSelector<_TextType>( + return SuperEditorDemoTextItemSelector( parentFocusNode: widget.editorFocusNode, boundaryKey: widget.editorViewportKey, - value: _getCurrentTextType(), - items: _TextType.values, - onChanged: _convertTextToNewType, - activeItemDecoration: BoxDecoration(color: Colors.yellow), - itemBuilder: (context, item) => Text( - _getTextTypeName(item), - style: const TextStyle( - color: Colors.black, - fontSize: 12, - ), - ), - selectedItemBuilder: (context, item) => Padding( - padding: EdgeInsets.only(left: 16.0, right: 24), - child: Text( - _getTextTypeName(item!), - style: const TextStyle( - color: Colors.black, - fontSize: 12, - ), - ), + value: SuperEditorDemoTextItem( + value: _getCurrentTextType().name, + label: _getTextTypeName(_getCurrentTextType()), ), + items: _TextType.values + .map( + (e) => SuperEditorDemoTextItem( + value: e.name, + label: _getTextTypeName(e), + ), + ) + .toList(), + onSelected: (selectedItem) { + if (selectedItem != null) { + _convertTextToNewType(_TextType.values // + .where((e) => e.name == selectedItem.value) + .first); + } + }, ); }, ), @@ -602,20 +600,21 @@ class _EditorToolbarState extends State { _buildVerticalDivider(), Tooltip( message: AppLocalizations.of(context)!.labelTextAlignment, - child: ItemSelector( - value: _getCurrentTextAlignment(), - items: [TextAlign.left, TextAlign.center, TextAlign.right, TextAlign.justify], - onChanged: _changeAlignment, - boundaryKey: widget.editorViewportKey, + child: SuperEditorDemoIconItemSelector( parentFocusNode: widget.editorFocusNode, - itemBuilder: (context, item) => Icon(_buildTextAlignIcon(item)), - activeItemDecoration: BoxDecoration(color: Colors.yellow), - selectedItemBuilder: (context, item) => Padding( - padding: EdgeInsets.only(left: 8.0, right: 24), - child: Icon( - _buildTextAlignIcon(item!), - ), + boundaryKey: widget.editorViewportKey, + value: SuperEditorDemoIconItem( + value: _getCurrentTextAlignment().name, + icon: _buildTextAlignIcon(_getCurrentTextAlignment()), ), + items: TextAlign.values + .map((e) => SuperEditorDemoIconItem(icon: _buildTextAlignIcon(e), value: e.name)) + .toList(), + onSelected: (selectedItem) { + if (selectedItem != null) { + _changeAlignment(TextAlign.values.firstWhere((e) => e.name == selectedItem.value)); + } + }, ), ), ], diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index 243469d47b..7450bfa578 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -3,6 +3,257 @@ import 'package:flutter/services.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/super_editor.dart'; +/// A selection control, which displays a button with the selected item, and upon tap, displays a +/// popover list of available text options, from which the user can select a different +/// option. +class SuperEditorDemoTextItemSelector extends StatelessWidget { + const SuperEditorDemoTextItemSelector({ + super.key, + this.value, + required this.items, + required this.onSelected, + this.parentFocusNode, + this.boundaryKey, + }); + + /// The currently selected value or `null` if no item is selected. + /// + /// This value is used to build the button. + final SuperEditorDemoTextItem? value; + + /// The items that will be displayed in the popover list. + /// + /// For each item, its [SuperEditorDemoTextItem.label] is displayed. + final List items; + + /// Called when the user selects an item on the popover list. + final void Function(SuperEditorDemoTextItem? value) onSelected; + + /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + final FocusNode? parentFocusNode; + + /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + final GlobalKey? boundaryKey; + + @override + Widget build(BuildContext context) { + return ItemSelectionList( + value: value, + items: items, + buttonBuilder: _buildButton, + itemBuilder: _buildPopoverListItem, + onItemSelected: onSelected, + parentFocusNode: parentFocusNode, + boundaryKey: boundaryKey, + ); + } + + Widget _buildPopoverListItem(BuildContext context, SuperEditorDemoTextItem item, bool isActive, VoidCallback onTap) { + return DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withOpacity(0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: kMinInteractiveDimension), + alignment: AlignmentDirectional.centerStart, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Text( + item.label, + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ), + ), + ); + } + + Widget _buildButton(BuildContext context, SuperEditorDemoTextItem? selectedItem, VoidCallback onTap) { + return PopoverArrowButton( + onTap: onTap, + padding: const EdgeInsets.only(left: 16.0, right: 24), + child: selectedItem == null // + ? const SizedBox() + : Text( + selectedItem.label, + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ); + } +} + +/// An option that is displayed as text by a [SuperEditorDemoTextItemSelector]. +/// +/// Two [SuperEditorDemoTextItem]s are considered to be equal if they have the same [value]. +class SuperEditorDemoTextItem { + const SuperEditorDemoTextItem({ + required this.value, + required this.label, + }); + + /// The value that identifies this item. + final String value; + + /// The text that is displayed. + final String label; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SuperEditorDemoTextItem && runtimeType == other.runtimeType && value == other.value; + + @override + int get hashCode => value.hashCode; +} + +/// A selection control, which displays a button with the selected item, and upon tap, displays a +/// popover list of available icons, from which the user can select a different option. +class SuperEditorDemoIconItemSelector extends StatelessWidget { + const SuperEditorDemoIconItemSelector({ + super.key, + this.value, + required this.items, + required this.onSelected, + this.parentFocusNode, + this.boundaryKey, + }); + + /// The currently selected value or `null` if no item is selected. + /// + /// This value is used to build the button. + final SuperEditorDemoIconItem? value; + + /// The items that will be displayed in the popover list. + /// + /// For each item, its [SuperEditorDemoIconItem.icon] is displayed. + final List items; + + /// Called when the user selects an item on the popover list. + final void Function(SuperEditorDemoIconItem? value) onSelected; + + /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + final FocusNode? parentFocusNode; + + /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + final GlobalKey? boundaryKey; + + @override + Widget build(BuildContext context) { + return ItemSelectionList( + value: value, + items: items, + buttonBuilder: _buildButton, + itemBuilder: _buildItem, + onItemSelected: onSelected, + parentFocusNode: parentFocusNode, + boundaryKey: boundaryKey, + ); + } + + Widget _buildItem(BuildContext context, SuperEditorDemoIconItem item, bool isActive, VoidCallback onTap) { + return DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withOpacity(0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: kMinInteractiveDimension), + alignment: AlignmentDirectional.centerStart, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Icon(item.icon), + ), + ), + ); + } + + Widget _buildButton(BuildContext context, SuperEditorDemoIconItem? selectedItem, VoidCallback onTap) { + return PopoverArrowButton( + onTap: onTap, + padding: const EdgeInsets.only(left: 8.0, right: 24), + child: selectedItem == null // + ? const SizedBox() + : Icon(selectedItem.icon), + ); + } +} + +/// An option that is displayed as an icon by a [SuperEditorDemoIconItemSelector]. +/// +/// Two [SuperEditorDemoIconItem]s are considered to be equal if they have the same [value]. +class SuperEditorDemoIconItem { + const SuperEditorDemoIconItem({ + required this.icon, + required this.value, + }); + + /// The value that identifies this item. + final String value; + + /// The icon that is displayed. + final IconData icon; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SuperEditorDemoIconItem && runtimeType == other.runtimeType && value == other.value; + + @override + int get hashCode => value.hashCode; +} + +/// A button with a center-left aligned [child] and a right aligned arrow icon. +/// +/// The arrow is displayed above the [child]. +class PopoverArrowButton extends StatelessWidget { + const PopoverArrowButton({ + super.key, + required this.onTap, + this.padding, + this.child, + }); + + /// Called when the user taps the button. + final VoidCallback onTap; + + /// Padding around the [child]. + final EdgeInsets? padding; + + /// The Widget displayed inside this button. + /// + /// If `null`, only the arrow is displayed. + final Widget? child; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Center( + child: Stack( + alignment: Alignment.centerLeft, + children: [ + if (child != null) // + Padding( + padding: padding ?? EdgeInsets.zero, + child: child, + ), + const Positioned( + right: 0, + child: Icon(Icons.arrow_drop_down), + ), + ], + ), + ), + ); + } +} + /// A selection control, which displays a selected item, and upon tap, displays a /// popover list of available options, from which the user can select a different /// option. @@ -21,15 +272,14 @@ import 'package:super_editor/super_editor.dart'; /// /// The popover list height is based on the following rules: /// -/// 1. The popover height is constrained by [popoverContraints], if provided, becoming scrollable if -/// there isn't enough room to display all items, or +/// 1. The popover height is constrained by [popoverGeometry.contraints], if provided, +/// becoming scrollable if there isn't enough room to display all items, or /// 2. The popover is displayed as tall as all items in the list, if there's enough room, or /// 3. The popover is displayed as tall as the available space and becomes scrollable. /// -/// Provide [popoverContraints] to enforce aditional constraints on the popover list. For example: -/// 1. Provide a tight [BoxConstraints] to force the popover list to be a specific size. -/// 2. Provide a [BoxConstraints] with a `maxWidth` to prevent the popover list from being taller -/// than the [BoxConstraints.maxWidth]. +/// Provide a [popoverGeometry] to control the size and position of the popover. The popover +/// is first sized given the [PopoverGeometry.constraints] and then positioned using the +/// [PopoverGeometry.align]. /// /// The popover list includes keyboard selection behaviors: /// @@ -37,21 +287,63 @@ import 'package:super_editor/super_editor.dart'; /// * Pressing UP with the first item active moves the active item selection to the last item. /// * Pressing DOWN with the last item active moves the active item selection to the first item. /// * Pressing ENTER selects the currently active item and closes the popover list. -class ItemSelector extends StatefulWidget { - const ItemSelector({ +class ItemSelectionList extends StatefulWidget { + const ItemSelectionList({ super.key, - required this.parentFocusNode, - this.boundaryKey, - required this.items, required this.value, - required this.onChanged, + required this.items, + required this.buttonBuilder, required this.itemBuilder, - required this.selectedItemBuilder, - this.activeItemDecoration, - this.popoverContraints, - this.popoverKey, + this.onItemActivated, + required this.onItemSelected, + this.popoverGeometry, + this.parentFocusNode, + this.boundaryKey, }); + /// The currently selected value or `null` if no item is selected. + /// + /// This value is passed to [buttonBuilder] to build the visual representation of the selected item. + final T? value; + + /// The items that will be displayed in the popover list. + /// + /// For each item, [itemBuilder] is called to build its visual representation. + final List items; + + /// Builds the selected item which, upon tap, opens the popover list. + /// + /// This method is called with the currently selected [value]. + /// + /// The provided `onTap` must be called when the button is tapped. + final PopoverListButtonBuilder buttonBuilder; + + /// Builds each item in the popover list. + /// + /// This method is called for each item in [items], to build its visual representation. + /// + /// The provided `onTap` must be called when the item is tapped. + final PopoverListItemBuilder itemBuilder; + + /// Called when the user activates an item on the popover list. + /// + /// The activation can be performed by: + /// 1. Opening the popover, when the selected item is activate. + /// 2. Pressing UP ARROW or DOWN ARROW. + final ValueChanged? onItemActivated; + + /// Called when the user selects an item on the popover list. + /// + /// The selection can be performed by: + /// 1. Tapping on an item in the popover list. + /// 2. Pressing ENTER when the popover list has an active item. + final ValueChanged onItemSelected; + + /// Controls the size and position of the popover. + /// + /// The popover is first sized, then positioned. + final PopoverGeometry? popoverGeometry; + /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. /// /// In Flutter, [FocusNode]s have parents and children. This relationship allows an @@ -70,7 +362,7 @@ class ItemSelector extends StatefulWidget { /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. /// - /// As the popover list follows the selected item, it can be displayed off-screen if this [ItemSelector] + /// As the popover list follows the selected item, it can be displayed off-screen if this [ItemSelectionList] /// is close to the bottom of the screen. /// /// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget @@ -79,89 +371,30 @@ class ItemSelector extends StatefulWidget { /// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. final GlobalKey? boundaryKey; - /// The currently selected value or `null` if no item is selected. - /// - /// This value is passed to [selectedItemBuilder] to build the visual representation of the selected item. - final T? value; - - /// The items that will be displayed in the popover list. - /// - /// For each item, [itemBuilder] is called to build its visual representation. - final List items; - - /// Called when the user selects an item on the popover list. - /// - /// The selection can be performed by: - /// 1. Tapping on an item in the popover list. - /// 2. Pressing ENTER when the popover list has an active item. - final ValueChanged onChanged; - - /// The background color of the active list item. - final BoxDecoration? activeItemDecoration; - - /// A [GlobalKey] bound to the popover list. - final GlobalKey? popoverKey; - - /// Constraints applied to the popover list. - final BoxConstraints? popoverContraints; - - /// Builds each item in the popover list. - /// - /// This method is called for each item in [items], to build its visual representation. - final ItemBuilder itemBuilder; - - /// Builds the selected item which, upon tap, opens the popover list. - /// - /// This method is called with the currently selected [value]. - final ItemBuilder selectedItemBuilder; - @override - State> createState() => ItemSelectorState(); + State> createState() => ItemSelectionListState(); } @visibleForTesting -class ItemSelectorState extends State> with SingleTickerProviderStateMixin { - @visibleForTesting - int? get activeIndex => _activeIndex; +class ItemSelectionListState extends State> with SingleTickerProviderStateMixin { int? _activeIndex; - final DropdownController _popoverController = DropdownController(); + final PopoverController _popoverController = PopoverController(); @visibleForTesting ScrollController get scrollController => _scrollController; final ScrollController _scrollController = ScrollController(); - late final AnimationController _animationController; - late final Animation _containerFadeInAnimation; - - @override - void initState() { - super.initState(); - _animationController = AnimationController( - duration: const Duration(milliseconds: 300), - vsync: this, - ); - - _containerFadeInAnimation = CurvedAnimation( - parent: _animationController, - curve: Curves.easeInOut, - ); - } - @override void dispose() { _popoverController.dispose(); _scrollController.dispose(); - _animationController.dispose(); super.dispose(); } void _onButtonTap() { _popoverController.open(); - _animationController - ..reset() - ..forward(); setState(() { final selectedItem = widget.value; @@ -174,6 +407,7 @@ class ItemSelectorState extends State> with SingleTickerProvi int selectedItemIndex = widget.items.indexOf(selectedItem); if (selectedItemIndex > -1) { _activeIndex = selectedItemIndex; + widget.onItemActivated?.call(widget.items[selectedItemIndex]); } else { // A selected item was provided, but it isn't included in the list of items. _activeIndex = null; @@ -183,7 +417,7 @@ class ItemSelectorState extends State> with SingleTickerProvi /// Called when the user taps an item or presses ENTER with an active item. void _selectItem(T item) { - widget.onChanged(item); + widget.onItemSelected(item); _popoverController.close(); } @@ -243,6 +477,9 @@ class ItemSelectorState extends State> with SingleTickerProvi setState(() { _activeIndex = newActiveIndex; + if (_activeIndex != null) { + widget.onItemActivated?.call(widget.items[_activeIndex!]); + } }); return KeyEventResult.handled; @@ -250,74 +487,43 @@ class ItemSelectorState extends State> with SingleTickerProvi @override Widget build(BuildContext context) { - return RawDropdown( + return PopoverScaffold( controller: _popoverController, + buttonBuilder: (context) => widget.buttonBuilder(context, widget.value, _onButtonTap), + popoverBuilder: _buildPopover, + popoverGeometry: widget.popoverGeometry ?? const PopoverGeometry(), + onKeyEvent: _onKeyEvent, boundaryKey: widget.boundaryKey, - dropdownBuilder: _buildDropDown, parentFocusNode: widget.parentFocusNode, - onKeyEvent: _onKeyEvent, - child: InkWell( - onTap: _onButtonTap, - child: Center( - child: _buildSelectedItem(), - ), - ), ); } - Widget _buildSelectedItem() { - return Stack( - alignment: Alignment.centerLeft, - children: [ - widget.selectedItemBuilder(context, widget.value), - const Positioned( - right: 0, - child: Icon(Icons.arrow_drop_down), + Widget _buildPopover(BuildContext context) { + return PopoverShape( + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + scrollbars: false, + overscroll: false, + physics: const ClampingScrollPhysics(), ), - ], - ); - } - - Widget _buildDropDown(BuildContext context, LeaderLink link, FollowerBoundary boundary) { - return Follower.withAligner( - link: link, - aligner: _DropdownAligner(boundaryKey: widget.boundaryKey), - boundary: boundary, - showWhenUnlinked: false, - child: ConstrainedBox( - constraints: widget.popoverContraints ?? const BoxConstraints(), - child: Material( - key: widget.popoverKey, - elevation: 8, - borderRadius: BorderRadius.circular(12), - clipBehavior: Clip.hardEdge, - child: FadeTransition( - opacity: _containerFadeInAnimation, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - scrollbars: false, - overscroll: false, - physics: const ClampingScrollPhysics(), - ), - child: PrimaryScrollController( - controller: _scrollController, - child: Scrollbar( - thumbVisibility: true, - child: SingleChildScrollView( - primary: true, - child: IntrinsicWidth( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (int i = 0; i < widget.items.length; i++) - Container( - decoration: _activeIndex == i ? widget.activeItemDecoration : null, - child: _buildDropDownItem(context, widget.items[i]), - ), - ], + child: PrimaryScrollController( + controller: _scrollController, + child: Scrollbar( + thumbVisibility: true, + child: SingleChildScrollView( + primary: true, + child: IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < widget.items.length; i++) // + widget.itemBuilder( + context, + widget.items[i], + i == _activeIndex, + () => _selectItem(widget.items[i]), ), - ), - ), + ], ), ), ), @@ -326,119 +532,107 @@ class ItemSelectorState extends State> with SingleTickerProvi ), ); } +} - Widget _buildDropDownItem(BuildContext context, T item) { - return InkWell( - onTap: () => _selectItem(item), - child: Container( - constraints: const BoxConstraints(minHeight: kMinInteractiveDimension), - alignment: AlignmentDirectional.centerStart, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20.0), - child: widget.itemBuilder(context, item), - ), - ), - ); - } +/// A rounded rectangle shape with a fade-in transition. +class PopoverShape extends StatefulWidget { + const PopoverShape({ + super.key, + required this.child, + }); + + final Widget child; + + @override + State createState() => _PopoverShapeState(); } -typedef ItemBuilder = Widget Function(BuildContext context, T item); +class _PopoverShapeState extends State with SingleTickerProviderStateMixin { + late final AnimationController _animationController; + late final Animation _containerFadeInAnimation; -/// A [FollowerAligner] to position a dropdown list relative to the dropdown button. -/// -/// The following rules are applied, in order: -/// -/// 1. If there is enough room to display the dropdown list beneath the button, -/// position it below the button. -/// -/// 2. If there is enough room to display the dropdown list above the button, -/// position it above the button. -/// -/// 3. Pin the dropdown list to the bottom of the `RenderBox` bound to [boundaryKey], -/// letting the dropdown list cover the button. -class _DropdownAligner implements FollowerAligner { - _DropdownAligner({required this.boundaryKey}); + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); - final GlobalKey? boundaryKey; + _containerFadeInAnimation = CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ); + + _animationController.forward(); + } @override - FollowerAlignment align(Rect globalLeaderRect, Size followerSize) { - final boundsBox = boundaryKey?.currentContext?.findRenderObject() as RenderBox?; - final bounds = boundsBox != null - ? Rect.fromPoints( - boundsBox.localToGlobal(Offset.zero), - boundsBox.localToGlobal(boundsBox.size.bottomRight(Offset.zero)), - ) - : Rect.largest; - late FollowerAlignment alignment; - - if (globalLeaderRect.bottom + followerSize.height < bounds.bottom) { - // The follower fits below the leader. - alignment = const FollowerAlignment( - leaderAnchor: Alignment.bottomCenter, - followerAnchor: Alignment.topCenter, - followerOffset: Offset(0, 20), - ); - } else if (globalLeaderRect.top - followerSize.height > bounds.top) { - // The follower fits above the leader. - alignment = const FollowerAlignment( - leaderAnchor: Alignment.topCenter, - followerAnchor: Alignment.bottomCenter, - followerOffset: Offset(0, -20), - ); - } else { - // There isn't enough room to fully display the follower below or above the leader. - // Pin the dropdown list to the bottom, letting the follower cover the leader. - alignment = const FollowerAlignment( - leaderAnchor: Alignment.bottomCenter, - followerAnchor: Alignment.topCenter, - followerOffset: Offset(0, 20), - ); - } + void dispose() { + _animationController.dispose(); + super.dispose(); + } - return alignment; + @override + Widget build(BuildContext context) { + return Material( + elevation: 8, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.hardEdge, + child: FadeTransition( + opacity: _containerFadeInAnimation, + child: widget.child, + ), + ); } } -/// A widget which displays a dropdown linked to its child. +/// A widget which displays a button built in [buttonBuilder] with a popover +/// which follows the button. /// -/// The dropdown is displayed in an `Overlay` and it can follow the [child] -/// by being wrapped with a [Follower]. The visibility of the dropdown -/// is changed by calling [DropdownController.open] or [DropdownController.close]. -/// The dropdown is automatically closed when the user taps outside of its bounds. +/// The popover is displayed in an `Overlay` and its visibility is changed by calling +/// [PopoverController.open] or [PopoverController.close]. The popover is automatically closed +/// when the user taps outside of its bounds. /// -/// When the dropdown is displayed it requests focus to itself, so the user can +/// When the popover is displayed it requests focus to itself, so the user can /// interact with the content using the keyboard. The focus is shared /// with the [parentFocusNode]. Provide [onKeyEvent] to handle key presses -/// when the dropdown is visible. -/// -/// This widget doesn't enforce any style, dropdown position or decoration. -class RawDropdown extends StatefulWidget { - const RawDropdown({ +/// when the popover is visible. +class PopoverScaffold extends StatefulWidget { + const PopoverScaffold({ super.key, required this.controller, - this.boundaryKey, - required this.dropdownBuilder, - this.parentFocusNode, + required this.buttonBuilder, + required this.popoverBuilder, + this.popoverGeometry = const PopoverGeometry(), this.onKeyEvent, - required this.child, + this.parentFocusNode, + this.boundaryKey, }); - /// Shows and hides the dropdown. - final DropdownController controller; + /// Shows and hides the popover. + final PopoverController controller; - /// Builds the content of the dropdown. - final DropdownBuilder dropdownBuilder; + /// Builds a button that is always displayed. + final WidgetBuilder buttonBuilder; - /// Called at each key press while the dropdown has focus. + /// Builds the content of the popover. + final WidgetBuilder popoverBuilder; + + /// Controls the size and position of the popover. + /// + /// The popover is first sized, then positioned. + final PopoverGeometry popoverGeometry; + + /// Called at each key press while the popover has focus. final FocusOnKeyEventCallback? onKeyEvent; - /// [FocusNode] which will share focus with the dropdown. + /// [FocusNode] which will share focus with the popover. final FocusNode? parentFocusNode; /// A [GlobalKey] to a widget that determines the bounds where the popover can be displayed. /// - /// As the popover follows the selected item, it can be displayed off-screen if this [RawDropdown] + /// As the popover follows the selected item, it can be displayed off-screen if this [PopoverScaffold] /// is close to the bottom of the screen. /// /// Passing a [boundaryKey] causes the popover to be confined to the bounds of the widget @@ -447,16 +641,14 @@ class RawDropdown extends StatefulWidget { /// If `null`, the popover is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. final GlobalKey? boundaryKey; - final Widget child; - @override - State createState() => _RawDropdownState(); + State createState() => _PopoverScaffoldState(); } -class _RawDropdownState extends State { +class _PopoverScaffoldState extends State { final OverlayPortalController _overlayController = OverlayPortalController(); - final LeaderLink _dropdownLink = LeaderLink(); - final FocusNode _dropdownFocusNode = FocusNode(); + final LeaderLink _popoverLink = LeaderLink(); + final FocusNode _popoverFocusNode = FocusNode(); late FocusNode _parentFocusNode; late FollowerBoundary _screenBoundary; @@ -466,7 +658,7 @@ class _RawDropdownState extends State { super.initState(); _parentFocusNode = widget.parentFocusNode ?? FocusNode(); - widget.controller.addListener(_onDropdownControllerChanged); + widget.controller.addListener(_onPopoverControllerChanged); } @override @@ -476,11 +668,11 @@ class _RawDropdownState extends State { } @override - void didUpdateWidget(covariant RawDropdown oldWidget) { + void didUpdateWidget(covariant PopoverScaffold oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_onDropdownControllerChanged); - widget.controller.addListener(_onDropdownControllerChanged); + oldWidget.controller.removeListener(_onPopoverControllerChanged); + widget.controller.addListener(_onPopoverControllerChanged); } if (oldWidget.parentFocusNode != widget.parentFocusNode) { @@ -498,8 +690,8 @@ class _RawDropdownState extends State { @override void dispose() { - widget.controller.removeListener(_onDropdownControllerChanged); - _dropdownLink.dispose(); + widget.controller.removeListener(_onPopoverControllerChanged); + _popoverLink.dispose(); if (widget.parentFocusNode == null) { _parentFocusNode.dispose(); @@ -522,13 +714,13 @@ class _RawDropdownState extends State { } } - void _onDropdownControllerChanged() { + void _onPopoverControllerChanged() { if (widget.controller.shouldShow) { _overlayController.show(); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { // Wait until next frame to request focus, so that the parent relationship // can be established between our focus node and the parent focus node. - _dropdownFocusNode.requestFocus(); + _popoverFocusNode.requestFocus(); }); } else { _overlayController.hide(); @@ -553,8 +745,8 @@ class _RawDropdownState extends State { controller: _overlayController, overlayChildBuilder: _buildDropdown, child: Leader( - link: _dropdownLink, - child: widget.child, + link: _popoverLink, + child: widget.buttonBuilder(context), ), ); } @@ -563,20 +755,29 @@ class _RawDropdownState extends State { return TapRegion( onTapOutside: _onTapOutsideOfDropdown, child: SuperEditorPopover( - popoverFocusNode: _dropdownFocusNode, + popoverFocusNode: _popoverFocusNode, editorFocusNode: _parentFocusNode, onKeyEvent: _onKeyEvent, - child: widget.dropdownBuilder(context, _dropdownLink, _screenBoundary), + child: Follower.withAligner( + link: _popoverLink, + boundary: _screenBoundary, + aligner: _DelegateAligner( + delegate: widget.popoverGeometry.align, + boundaryKey: widget.boundaryKey, + ), + child: ConstrainedBox( + constraints: widget.popoverGeometry.constraints ?? const BoxConstraints(), + child: widget.popoverBuilder(context), + ), + ), ), ); } } -typedef DropdownBuilder = Widget Function(BuildContext context, LeaderLink link, FollowerBoundary boundary); - -/// Controls the visibility of a dropdown. -class DropdownController with ChangeNotifier { - /// Whether the dropdown should be displayed. +/// Controls the visibility of a popover. +class PopoverController with ChangeNotifier { + /// Whether the popover should be displayed. bool get shouldShow => _shouldShow; bool _shouldShow = false; @@ -604,3 +805,109 @@ class DropdownController with ChangeNotifier { } } } + +/// Controls the size and position of a popover. +class PopoverGeometry { + const PopoverGeometry({ + this.align = defaultPopoverAligner, + this.constraints, + }); + + /// Positions the popover. + /// + /// If the `boundaryKey` is non-`null`, the popover must be positioned within the bounds of + /// the `RenderBox` bound to `boundaryKey`. + final PopoverAligner align; + + /// [BoxConstraints] applied to the popover. + /// + /// If `null`, the popover can use all the available space. + final BoxConstraints? constraints; +} + +/// A [FollowerAligner] which uses a [delegate] to align a [Follower]. +class _DelegateAligner implements FollowerAligner { + _DelegateAligner({ + required this.delegate, + this.boundaryKey, + }); + + /// Called to determine the position of the [Follower]. + final PopoverAligner delegate; + + /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + /// + /// If non-`null`, the [FollowerAlignment] returned by the [delegate] must be within the bounds of its `RenderBox`. + final GlobalKey? boundaryKey; + + @override + FollowerAlignment align(Rect globalLeaderRect, Size followerSize) { + return delegate(globalLeaderRect, followerSize, boundaryKey); + } +} + +/// Computes the position of a popover list relative to the dropdown button. +/// +/// The following rules are applied, in order: +/// +/// 1. If there is enough room to display the dropdown list beneath the button, +/// position it below the button. +/// +/// 2. If there is enough room to display the dropdown list above the button, +/// position it above the button. +/// +/// 3. Pin the dropdown list to the bottom of the `RenderBox` bound to [boundaryKey], +/// letting the dropdown list cover the button. +FollowerAlignment defaultPopoverAligner(Rect globalLeaderRect, Size followerSize, GlobalKey? boundaryKey) { + final boundsBox = boundaryKey?.currentContext?.findRenderObject() as RenderBox?; + final bounds = boundsBox != null + ? Rect.fromPoints( + boundsBox.localToGlobal(Offset.zero), + boundsBox.localToGlobal(boundsBox.size.bottomRight(Offset.zero)), + ) + : Rect.largest; + late FollowerAlignment alignment; + + if (globalLeaderRect.bottom + followerSize.height < bounds.bottom) { + // The follower fits below the leader. + alignment = const FollowerAlignment( + leaderAnchor: Alignment.bottomCenter, + followerAnchor: Alignment.topCenter, + followerOffset: Offset(0, 20), + ); + } else if (globalLeaderRect.top - followerSize.height > bounds.top) { + // The follower fits above the leader. + alignment = const FollowerAlignment( + leaderAnchor: Alignment.topCenter, + followerAnchor: Alignment.bottomCenter, + followerOffset: Offset(0, -20), + ); + } else { + // There isn't enough room to fully display the follower below or above the leader. + // Pin the dropdown list to the bottom, letting the follower cover the leader. + alignment = const FollowerAlignment( + leaderAnchor: Alignment.bottomCenter, + followerAnchor: Alignment.topCenter, + followerOffset: Offset(0, 20), + ); + } + + return alignment; +} + +/// A function to align a Widget following a leader Widget. +/// +/// If a [boundaryKey] is given, the alignment must be within the bounds of its `RenderBox`. +typedef PopoverAligner = FollowerAlignment Function(Rect globalLeaderRect, Size followerSize, GlobalKey? boundaryKey); + +/// Builds a popover list item. +/// +/// [isActive] is `true` if [item] is the currently active item on the list, or `false` otherwise. +/// +/// The provided [onTap] must be called when the button is tapped. +typedef PopoverListItemBuilder = Widget Function(BuildContext context, T item, bool isActive, VoidCallback onTap); + +/// Builds a button is an [ItemSelectionList]. +/// +/// The provided [onTap] must be called when the button is tapped. +typedef PopoverListButtonBuilder = Widget Function(BuildContext context, T? selectedItem, VoidCallback onTap); diff --git a/super_editor/test/infrastructure/super_dropdown_test.dart b/super_editor/test/infrastructure/super_dropdown_test.dart index 3023eac65f..c078449522 100644 --- a/super_editor/test/infrastructure/super_dropdown_test.dart +++ b/super_editor/test/infrastructure/super_dropdown_test.dart @@ -19,44 +19,39 @@ import '../super_editor/test_documents.dart'; void main() { group('SuperDropdown', () { testWidgetsOnAllPlatforms('shows the dropdown list on tap', (tester) async { - final dropdownKey = GlobalKey(); - await _pumpDropdownTestApp( tester, onValueChanged: (s) {}, - dropdownKey: dropdownKey, ); // Ensures the dropdown list isn't displayed. - expect(find.byKey(dropdownKey), findsNothing); + expect(find.byType(PopoverShape), findsNothing); // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelector)); + await tester.tap(find.byType(ItemSelectionList)); await tester.pumpAndSettle(); // Ensures the dropdown list is displayed. - expect(find.byKey(dropdownKey), findsOneWidget); + expect(find.byType(PopoverShape), findsOneWidget); }); - testWidgetsOnAllPlatforms('calls onValueChanged and closes the dropdown list when tapping an item', (tester) async { + testWidgetsOnAllPlatforms('calls onSelected and closes the dropdown list when tapping an item', (tester) async { String? selectedValue; - final dropdownKey = GlobalKey(); await _pumpDropdownTestApp( tester, onValueChanged: (s) => selectedValue = s, - dropdownKey: dropdownKey, ); // Ensures the dropdown list isn't displayed. - expect(find.byKey(dropdownKey), findsNothing); + expect(find.byType(PopoverShape), findsNothing); // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelector)); + await tester.tap(find.byType(ItemSelectionList)); await tester.pumpAndSettle(); // Ensures the dropdown list is displayed. - expect(find.byKey(dropdownKey), findsOneWidget); + expect(find.byType(PopoverShape), findsOneWidget); // Taps the first item on the list await tester.tap(find.text('Item1')); @@ -64,28 +59,26 @@ void main() { // Ensure the tapped item was selected and the dropdown was closed. expect(selectedValue, 'Item1'); - expect(find.byKey(dropdownKey), findsNothing); + expect(find.byType(PopoverShape), findsNothing); }); testWidgetsOnAllPlatforms('closes the dropdown list when tapping outside', (tester) async { bool onValueChangedCalled = false; - final dropdownKey = GlobalKey(); await _pumpDropdownTestApp( tester, onValueChanged: (s) => onValueChangedCalled = true, - dropdownKey: dropdownKey, ); // Ensures the dropdown list isn't displayed. - expect(find.byKey(dropdownKey), findsNothing); + expect(find.byType(PopoverShape), findsNothing); // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelector)); + await tester.tap(find.byType(ItemSelectionList)); await tester.pumpAndSettle(); // Ensures the dropdown list is displayed. - expect(find.byKey(dropdownKey), findsOneWidget); + expect(find.byType(PopoverShape), findsOneWidget); // Taps outside of the dropdown. await tester.tapAt(Offset.zero); @@ -93,149 +86,144 @@ void main() { // Ensures onValueChanged wasn't called and the dropdown list was closed. expect(onValueChangedCalled, isFalse); - expect(find.byKey(dropdownKey), findsNothing); + expect(find.byType(PopoverShape), findsNothing); }); testWidgetsOnAllPlatforms('enforces the given dropdown constraints', (tester) async { - final dropdownKey = GlobalKey(); - await _pumpDropdownTestApp( tester, onValueChanged: (s) {}, - dropdownConstraints: const BoxConstraints(maxHeight: 10), - dropdownKey: dropdownKey, + popoverGeometry: PopoverGeometry( + constraints: const BoxConstraints(maxHeight: 10), + ), ); // Ensures the dropdown list isn't displayed. - expect(find.byKey(dropdownKey), findsNothing); + expect(find.byType(PopoverShape), findsNothing); // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelector)); + await tester.tap(find.byType(ItemSelectionList)); await tester.pumpAndSettle(); // Ensures the dropdown list is displayed. - expect(find.byKey(dropdownKey), findsOneWidget); + expect(find.byType(PopoverShape), findsOneWidget); // Ensure the maxHeight was honored. - expect(tester.getRect(find.byKey(dropdownKey)).height, 10); + expect(tester.getRect(find.byType(PopoverShape)).height, 10); }); testWidgetsOnAllPlatforms('dropdown list isn\' scrollable if all items fit on screen', (tester) async { - final dropdownKey = GlobalKey(); await _pumpDropdownTestApp( tester, onValueChanged: (s) {}, - dropdownKey: dropdownKey, ); // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelector)); + await tester.tap(find.byType(ItemSelectionList)); await tester.pumpAndSettle(); // Ensures the dropdown list is displayed. - expect(find.byKey(dropdownKey), findsOneWidget); + expect(find.byType(PopoverShape), findsOneWidget); // Ensure the dropdown list isn't scrollable. - final dropdownButonState = tester.state>(find.byType(ItemSelector)); + final dropdownButonState = tester.state>(find.byType(ItemSelectionList)); expect(dropdownButonState.scrollController.position.maxScrollExtent, 0.0); }); testWidgetsOnAllPlatforms('dropdown list is scrollable if items don\'t fit on screen', (tester) async { - final dropdownKey = GlobalKey(); await _pumpDropdownTestApp( tester, onValueChanged: (s) {}, - dropdownKey: dropdownKey, - dropdownConstraints: const BoxConstraints(maxHeight: 100), + popoverGeometry: PopoverGeometry(constraints: const BoxConstraints(maxHeight: 50)), ); // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelector)); + await tester.tap(find.byType(ItemSelectionList)); await tester.pumpAndSettle(); // Ensures the dropdown list is displayed. - expect(find.byKey(dropdownKey), findsOneWidget); + expect(find.byType(PopoverShape), findsOneWidget); // Ensure the dropdown list is scrollable. - final dropdownButonState = tester.state>(find.byType(ItemSelector)); + final dropdownButonState = tester.state>(find.byType(ItemSelectionList)); expect(dropdownButonState.scrollController.position.maxScrollExtent, greaterThan(0.0)); }); testWidgetsOnAllPlatforms('moves focus down with DOWN ARROW', (tester) async { + String? activeItem; await _pumpDropdownTestApp( tester, onValueChanged: (s) => {}, + onActivate: (s) => activeItem = s, ); // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelector)); + await tester.tap(find.byType(ItemSelectionList)); await tester.pumpAndSettle(); - final dropdownButonState = tester.state>(find.byType(ItemSelector)); - // Ensure the dropdown is displayed without any focused item. - expect(dropdownButonState.activeIndex, isNull); + expect(activeItem, isNull); // Press DOWN ARROW to focus the first item. await tester.pressDownArrow(); - expect(dropdownButonState.activeIndex, 0); + expect(activeItem, 'Item1'); // Press DOWN ARROW to focus the second item. await tester.pressDownArrow(); - expect(dropdownButonState.activeIndex, 1); + expect(activeItem, 'Item2'); // Press DOWN ARROW to focus the third item. await tester.pressDownArrow(); - expect(dropdownButonState.activeIndex, 2); + expect(activeItem, 'Item3'); // Press DOWN ARROW to focus the first item again. await tester.pressDownArrow(); - expect(dropdownButonState.activeIndex, 0); + expect(activeItem, 'Item1'); }); testWidgetsOnAllPlatforms('moves focus up with UP ARROW', (tester) async { + String? activeItem; + await _pumpDropdownTestApp( tester, onValueChanged: (s) => {}, + onActivate: (s) => activeItem = s, ); // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelector)); + await tester.tap(find.byType(ItemSelectionList)); await tester.pumpAndSettle(); - final dropdownButonState = tester.state>(find.byType(ItemSelector)); - // Ensure the dropdown is displayed without any focused item. - expect(dropdownButonState.activeIndex, isNull); + expect(activeItem, isNull); // Press UP ARROW to focus the last item. await tester.pressUpArrow(); - expect(dropdownButonState.activeIndex, 2); + expect(activeItem, 'Item3'); // Press UP ARROW to focus the second item. await tester.pressUpArrow(); - expect(dropdownButonState.activeIndex, 1); + expect(activeItem, 'Item2'); // Press UP ARROW to focus the first item. await tester.pressUpArrow(); - expect(dropdownButonState.activeIndex, 0); + expect(activeItem, 'Item1'); // Press UP ARROW to focus the last item again. await tester.pressUpArrow(); - expect(dropdownButonState.activeIndex, 2); + expect(activeItem, 'Item3'); }); testWidgetsOnAllPlatforms('selects the focused item on ENTER', (tester) async { String? selectedValue; - final dropdownKey = GlobalKey(); await _pumpDropdownTestApp( tester, onValueChanged: (s) => selectedValue = s, ); // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelector)); + await tester.tap(find.byType(ItemSelectionList)); await tester.pumpAndSettle(); // Press ARROW DOWN to focus the first item. @@ -247,21 +235,19 @@ void main() { // Ensure the first item was selected and the dropdown was closed. expect(selectedValue, 'Item1'); - expect(find.byKey(dropdownKey), findsNothing); + expect(find.byType(PopoverShape), findsNothing); }); testWidgetsOnAllPlatforms('closes dropdown list on ENTER', (tester) async { String? selectedValue; - final dropdownKey = GlobalKey(); await _pumpDropdownTestApp( tester, onValueChanged: (s) => selectedValue = s, - dropdownKey: dropdownKey, ); // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelector)); + await tester.tap(find.byType(ItemSelectionList)); await tester.pumpAndSettle(); // Press ENTER without a focused item to close the dropdown. @@ -269,22 +255,20 @@ void main() { await tester.pump(); // Ensure the dropdown was closed and no item was selected. - expect(find.byKey(dropdownKey), findsNothing); + expect(find.byType(PopoverShape), findsNothing); expect(selectedValue, isNull); }); testWidgetsOnAllPlatforms('closes dropdown list on ESC', (tester) async { String? selectedValue; - final dropdownKey = GlobalKey(); await _pumpDropdownTestApp( tester, onValueChanged: (s) => selectedValue = s, - dropdownKey: dropdownKey, ); // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelector)); + await tester.tap(find.byType(ItemSelectionList)); await tester.pumpAndSettle(); // Press ARROW DOWN to focus the first item. @@ -295,7 +279,7 @@ void main() { await tester.pump(); // Ensure the dropdown was closed and no item was selected. - expect(find.byKey(dropdownKey), findsNothing); + expect(find.byType(PopoverShape), findsNothing); expect(selectedValue, isNull); }); @@ -310,12 +294,18 @@ void main() { editorFocusNode: editorFocusNode, toolbar: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 100), - child: ItemSelector( + child: ItemSelectionList( items: const ['Item1', 'Item2', 'Item3'], - itemBuilder: (context, e) => Text(e), - selectedItemBuilder: (context, e) => const SizedBox(width: 50), + itemBuilder: (context, e, isActive, onTap) => TextButton( + onPressed: onTap, + child: Text(e), + ), + buttonBuilder: (context, e, onTap) => ElevatedButton( + onPressed: onTap, + child: const SizedBox(width: 50), + ), value: null, - onChanged: (s) => {}, + onItemSelected: (s) => {}, boundaryKey: boundaryKey, parentFocusNode: editorFocusNode, ), @@ -346,7 +336,7 @@ void main() { ); // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelector)); + await tester.tap(find.byType(ItemSelectionList)); await tester.pumpAndSettle(); // Ensure the editor has non-primary focus. @@ -376,12 +366,12 @@ void main() { }); } -/// Pumps a widget tree with a centered [ItemSelector] containing three items. +/// Pumps a widget tree with a centered [ItemSelectionList] containing three items. Future _pumpDropdownTestApp( WidgetTester tester, { required void Function(String? value) onValueChanged, - BoxConstraints? dropdownConstraints, - GlobalKey? dropdownKey, + void Function(String? value)? onActivate, + PopoverGeometry? popoverGeometry, }) async { final boundaryKey = GlobalKey(); final focusNode = FocusNode(); @@ -396,16 +386,22 @@ Future _pumpDropdownTestApp( autofocus: true, child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 100), - child: ItemSelector( + child: ItemSelectionList( items: const ['Item1', 'Item2', 'Item3'], - itemBuilder: (context, e) => Text(e), - selectedItemBuilder: (context, e) => const SizedBox(width: 50), + itemBuilder: (context, e, isActive, onTap) => TextButton( + onPressed: onTap, + child: Text(e), + ), + buttonBuilder: (context, e, onTap) => ElevatedButton( + onPressed: onTap, + child: const SizedBox(width: 50), + ), value: null, - onChanged: onValueChanged, + onItemActivated: onActivate, + onItemSelected: onValueChanged, boundaryKey: boundaryKey, parentFocusNode: focusNode, - popoverContraints: dropdownConstraints, - popoverKey: dropdownKey, + popoverGeometry: popoverGeometry, ), ), ), From 4389096278341f60d43372fdb7ab174b8bc68ddf Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Sat, 11 Nov 2023 14:31:07 -0300 Subject: [PATCH 16/36] Implement autoscroll --- .../lib/src/infrastructure/dropdown.dart | 78 +++++++++++++++---- 1 file changed, 63 insertions(+), 15 deletions(-) diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index 7450bfa578..5fafbea693 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; import 'package:super_editor/super_editor.dart'; /// A selection control, which displays a button with the selected item, and upon tap, displays a @@ -385,6 +386,11 @@ class ItemSelectionListState extends State> with SingleT ScrollController get scrollController => _scrollController; final ScrollController _scrollController = ScrollController(); + final GlobalKey _scrollableKey = GlobalKey(); + + /// Holds keys to each item on the list. + final List _itemKeys = []; + @override void dispose() { _popoverController.dispose(); @@ -406,8 +412,9 @@ class ItemSelectionListState extends State> with SingleT int selectedItemIndex = widget.items.indexOf(selectedItem); if (selectedItemIndex > -1) { - _activeIndex = selectedItemIndex; - widget.onItemActivated?.call(widget.items[selectedItemIndex]); + // We just opened the popover. + // Jump to the active item without animation. + _activateItem(selectedItemIndex, Duration.zero); } else { // A selected item was provided, but it isn't included in the list of items. _activeIndex = null; @@ -421,6 +428,40 @@ class ItemSelectionListState extends State> with SingleT _popoverController.close(); } + /// Activates the item at [itemIndex] and ensure it's visible on screen. + void _activateItem(int? itemIndex, Duration animationDuration) { + _activeIndex = itemIndex; + if (itemIndex != null) { + widget.onItemActivated?.call(widget.items[itemIndex]); + } + + // Scrolls on the next frame to let the popover to be + // laid-out first, so we can access its RenderBox. + onNextFrame((timeStamp) { + _ensureActiveItemIsVisible(animationDuration); + }); + } + + /// Scrolls the popover scrollable to display the selected item. + void _ensureActiveItemIsVisible(Duration animationDuration) { + if (_activeIndex == null) { + return; + } + + final key = _itemKeys[_activeIndex!]; + + final childRenderBox = key.currentContext?.findRenderObject() as RenderBox?; + if (childRenderBox == null) { + return; + } + + childRenderBox.showOnScreen( + rect: Offset.zero & childRenderBox.size, + duration: animationDuration, + curve: Curves.easeIn, + ); + } + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent) { return KeyEventResult.ignored; @@ -476,10 +517,7 @@ class ItemSelectionListState extends State> with SingleT } setState(() { - _activeIndex = newActiveIndex; - if (_activeIndex != null) { - widget.onItemActivated?.call(widget.items[_activeIndex!]); - } + _activateItem(newActiveIndex, const Duration(milliseconds: 100)); }); return KeyEventResult.handled; @@ -499,6 +537,23 @@ class ItemSelectionListState extends State> with SingleT } Widget _buildPopover(BuildContext context) { + final children = []; + _itemKeys.clear(); + + for (int i = 0; i < widget.items.length; i++) { + final key = GlobalKey(); + children.add(Container( + key: key, + child: widget.itemBuilder( + context, + widget.items[i], + i == _activeIndex, + () => _selectItem(widget.items[i]), + ), + )); + _itemKeys.add(key); + } + return PopoverShape( child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith( @@ -511,19 +566,12 @@ class ItemSelectionListState extends State> with SingleT child: Scrollbar( thumbVisibility: true, child: SingleChildScrollView( + key: _scrollableKey, primary: true, child: IntrinsicWidth( child: Column( mainAxisSize: MainAxisSize.min, - children: [ - for (int i = 0; i < widget.items.length; i++) // - widget.itemBuilder( - context, - widget.items[i], - i == _activeIndex, - () => _selectItem(widget.items[i]), - ), - ], + children: children, ), ), ), From 1522043efd9b1752a18527e555ea9c3aa930d135 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Sat, 11 Nov 2023 14:55:22 -0300 Subject: [PATCH 17/36] Update import --- super_editor/lib/src/infrastructure/dropdown.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index 5fafbea693..9bb1bb853e 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_pipeline.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/super_editor.dart'; /// A selection control, which displays a button with the selected item, and upon tap, displays a From d799dc48eba7b46a668135a14a5990f37dfdf26e Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Mon, 13 Nov 2023 21:18:16 -0300 Subject: [PATCH 18/36] API updates --- .../lib/src/infrastructure/dropdown.dart | 362 +++++++++++------- 1 file changed, 230 insertions(+), 132 deletions(-) diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index 9bb1bb853e..bb314eda11 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -2,12 +2,37 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/platforms/mac/mac_ime.dart'; import 'package:super_editor/super_editor.dart'; /// A selection control, which displays a button with the selected item, and upon tap, displays a /// popover list of available text options, from which the user can select a different /// option. -class SuperEditorDemoTextItemSelector extends StatelessWidget { +/// +/// Unlike Flutter `DropdownButton`, which displays the popover list in a separate route, +/// this widget displays its popover list in an `Overlay`. By using an `Overlay`, focus can be shared +/// with the [parentFocusNode]. This means that when the popover list requests focus, [parentFocusNode] +/// still has non-primary focus. +/// +/// The popover list is positioned based on the following rules: +/// +/// 1. The popover is displayed below the selected item, if there's enough room, or +/// 2. The popover is displayed above the selected item, if there's enough room, or +/// 3. The popover is displayed with its bottom aligned with the bottom of +/// the given boundary, and it covers the selected item. +/// +/// The popover list height is based on the following rules: +/// +/// 1. The popover is displayed as tall as all items in the list, if there's enough room, or +/// 2. The popover is displayed as tall as the available space and becomes scrollable. +/// +/// The popover list includes keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" item selection up/down. +/// * Pressing UP with the first item active moves the active item selection to the last item. +/// * Pressing DOWN with the last item active moves the active item selection to the first item. +/// * Pressing ENTER selects the currently active item and closes the popover list. +class SuperEditorDemoTextItemSelector extends StatefulWidget { const SuperEditorDemoTextItemSelector({ super.key, this.value, @@ -31,21 +56,68 @@ class SuperEditorDemoTextItemSelector extends StatelessWidget { final void Function(SuperEditorDemoTextItem? value) onSelected; /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + /// + /// In Flutter, [FocusNode]s have parents and children. This relationship allows an + /// entire ancestor path to "have focus", but only the lowest level descendant + /// in that path has "primary focus". This path is important because various + /// widgets alter their presentation or behavior based on whether or not they + /// currently have focus, even if they only have "non-primary focus". + /// + /// When the popover list of items is visible, that list will have primary focus. + /// Moreover, because the popover list is built in an `Overlay`, none of your + /// widgets are in the natural focus path for that popover list. Therefore, if you + /// need your widget tree to retain focus while the popover list is visible, then + /// you need to provide the [FocusNode] that the popover list should use as its + /// parent, thereby retaining focus for your widgets. final FocusNode? parentFocusNode; /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + /// + /// As the popover list follows the selected item, it can be displayed off-screen if this [SuperEditorDemoTextItemSelector] + /// is close to the bottom of the screen. + /// + /// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget + /// bound to the [boundaryKey]. + /// + /// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. final GlobalKey? boundaryKey; + @override + State createState() => _SuperEditorDemoTextItemSelectorState(); +} + +class _SuperEditorDemoTextItemSelectorState extends State { + /// Shows and hides the popover. + final PopoverController _popoverController = PopoverController(); + + /// The [FocusNode] of the popover list. + final FocusNode _popoverFocusNode = FocusNode(); + + @override + void dispose() { + _popoverController.dispose(); + _popoverFocusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return ItemSelectionList( - value: value, - items: items, + return PopoverScaffold( + controller: _popoverController, buttonBuilder: _buildButton, - itemBuilder: _buildPopoverListItem, - onItemSelected: onSelected, - parentFocusNode: parentFocusNode, - boundaryKey: boundaryKey, + popoverFocusNode: _popoverFocusNode, + boundaryKey: widget.boundaryKey, + popoverBuilder: (context) => PopoverShape( + child: ItemSelectionList( + value: widget.value, + items: widget.items, + itemBuilder: _buildPopoverListItem, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + focusNode: _popoverFocusNode, + parentFocusNode: widget.parentFocusNode, + ), + ), ); } @@ -72,14 +144,14 @@ class SuperEditorDemoTextItemSelector extends StatelessWidget { ); } - Widget _buildButton(BuildContext context, SuperEditorDemoTextItem? selectedItem, VoidCallback onTap) { + Widget _buildButton(BuildContext context) { return PopoverArrowButton( - onTap: onTap, + onTap: () => _popoverController.open(), padding: const EdgeInsets.only(left: 16.0, right: 24), - child: selectedItem == null // + child: widget.value == null // ? const SizedBox() : Text( - selectedItem.label, + widget.value!.label, style: const TextStyle( color: Colors.black, fontSize: 12, @@ -87,6 +159,11 @@ class SuperEditorDemoTextItemSelector extends StatelessWidget { ), ); } + + void _onItemSelected(SuperEditorDemoTextItem? value) { + _popoverController.close(); + widget.onSelected(value); + } } /// An option that is displayed as text by a [SuperEditorDemoTextItemSelector]. @@ -115,7 +192,31 @@ class SuperEditorDemoTextItem { /// A selection control, which displays a button with the selected item, and upon tap, displays a /// popover list of available icons, from which the user can select a different option. -class SuperEditorDemoIconItemSelector extends StatelessWidget { +/// +/// Unlike Flutter `DropdownButton`, which displays the popover list in a separate route, +/// this widget displays its popover list in an `Overlay`. By using an `Overlay`, focus can be shared +/// with the [parentFocusNode]. This means that when the popover list requests focus, [parentFocusNode] +/// still has non-primary focus. +/// +/// The popover list is positioned based on the following rules: +/// +/// 1. The popover is displayed below the selected item, if there's enough room, or +/// 2. The popover is displayed above the selected item, if there's enough room, or +/// 3. The popover is displayed with its bottom aligned with the bottom of +/// the given boundary, and it covers the selected item. +/// +/// The popover list height is based on the following rules: +/// +/// 1. The popover is displayed as tall as all items in the list, if there's enough room, or +/// 2. The popover is displayed as tall as the available space and becomes scrollable. +/// +/// The popover list includes keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" item selection up/down. +/// * Pressing UP with the first item active moves the active item selection to the last item. +/// * Pressing DOWN with the last item active moves the active item selection to the first item. +/// * Pressing ENTER selects the currently active item and closes the popover list. +class SuperEditorDemoIconItemSelector extends StatefulWidget { const SuperEditorDemoIconItemSelector({ super.key, this.value, @@ -139,21 +240,67 @@ class SuperEditorDemoIconItemSelector extends StatelessWidget { final void Function(SuperEditorDemoIconItem? value) onSelected; /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + /// + /// In Flutter, [FocusNode]s have parents and children. This relationship allows an + /// entire ancestor path to "have focus", but only the lowest level descendant + /// in that path has "primary focus". This path is important because various + /// widgets alter their presentation or behavior based on whether or not they + /// currently have focus, even if they only have "non-primary focus". + /// + /// When the popover list of items is visible, that list will have primary focus. + /// Moreover, because the popover list is built in an `Overlay`, none of your + /// widgets are in the natural focus path for that popover list. Therefore, if you + /// need your widget tree to retain focus while the popover list is visible, then + /// you need to provide the [FocusNode] that the popover list should use as its + /// parent, thereby retaining focus for your widgets. final FocusNode? parentFocusNode; /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + /// + /// As the popover list follows the selected item, it can be displayed off-screen if this [SuperEditorDemoIconItemSelector] + /// is close to the bottom of the screen. + /// + /// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget + /// bound to the [boundaryKey]. + /// + /// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. final GlobalKey? boundaryKey; + @override + State createState() => _SuperEditorDemoIconItemSelectorState(); +} + +class _SuperEditorDemoIconItemSelectorState extends State { + /// Shows and hides the popover. + final PopoverController _popoverController = PopoverController(); + + /// The [FocusNode] of the popover list. + final FocusNode _popoverFocusNode = FocusNode(); + + @override + void dispose() { + _popoverController.dispose(); + _popoverFocusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return ItemSelectionList( - value: value, - items: items, + return PopoverScaffold( + controller: _popoverController, buttonBuilder: _buildButton, - itemBuilder: _buildItem, - onItemSelected: onSelected, - parentFocusNode: parentFocusNode, - boundaryKey: boundaryKey, + popoverFocusNode: _popoverFocusNode, + popoverBuilder: (context) => PopoverShape( + child: ItemSelectionList( + value: widget.value, + items: widget.items, + itemBuilder: _buildItem, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + parentFocusNode: widget.parentFocusNode, + focusNode: _popoverFocusNode, + ), + ), ); } @@ -174,15 +321,20 @@ class SuperEditorDemoIconItemSelector extends StatelessWidget { ); } - Widget _buildButton(BuildContext context, SuperEditorDemoIconItem? selectedItem, VoidCallback onTap) { + Widget _buildButton(BuildContext context) { return PopoverArrowButton( - onTap: onTap, + onTap: () => _popoverController.open(), padding: const EdgeInsets.only(left: 8.0, right: 24), - child: selectedItem == null // + child: widget.value == null // ? const SizedBox() - : Icon(selectedItem.icon), + : Icon(widget.value!.icon), ); } + + void _onItemSelected(SuperEditorDemoIconItem? value) { + _popoverController.close(); + widget.onSelected(value); + } } /// An option that is displayed as an icon by a [SuperEditorDemoIconItemSelector]. @@ -278,9 +430,7 @@ class PopoverArrowButton extends StatelessWidget { /// 2. The popover is displayed as tall as all items in the list, if there's enough room, or /// 3. The popover is displayed as tall as the available space and becomes scrollable. /// -/// Provide a [popoverGeometry] to control the size and position of the popover. The popover -/// is first sized given the [PopoverGeometry.constraints] and then positioned using the -/// [PopoverGeometry.align]. + /// /// The popover list includes keyboard selection behaviors: /// @@ -293,13 +443,12 @@ class ItemSelectionList extends StatefulWidget { super.key, required this.value, required this.items, - required this.buttonBuilder, required this.itemBuilder, this.onItemActivated, required this.onItemSelected, - this.popoverGeometry, + this.onCancel, + this.focusNode, this.parentFocusNode, - this.boundaryKey, }); /// The currently selected value or `null` if no item is selected. @@ -312,13 +461,6 @@ class ItemSelectionList extends StatefulWidget { /// For each item, [itemBuilder] is called to build its visual representation. final List items; - /// Builds the selected item which, upon tap, opens the popover list. - /// - /// This method is called with the currently selected [value]. - /// - /// The provided `onTap` must be called when the button is tapped. - final PopoverListButtonBuilder buttonBuilder; - /// Builds each item in the popover list. /// /// This method is called for each item in [items], to build its visual representation. @@ -340,37 +482,14 @@ class ItemSelectionList extends StatefulWidget { /// 2. Pressing ENTER when the popover list has an active item. final ValueChanged onItemSelected; - /// Controls the size and position of the popover. - /// - /// The popover is first sized, then positioned. - final PopoverGeometry? popoverGeometry; + /// Called when the user presses ESCAPE. + final VoidCallback? onCancel; - /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. - /// - /// In Flutter, [FocusNode]s have parents and children. This relationship allows an - /// entire ancestor path to "have focus", but only the lowest level descendant - /// in that path has "primary focus". This path is important because various - /// widgets alter their presentation or behavior based on whether or not they - /// currently have focus, even if they only have "non-primary focus". - /// - /// When the popover list of items is visible, that list will have primary focus. - /// Moreover, because the popover list is built in an `Overlay`, none of your - /// widgets are in the natural focus path for that popover list. Therefore, if you - /// need your widget tree to retain focus while the popover list is visible, then - /// you need to provide the [FocusNode] that the popover list should use as its - /// parent, thereby retaining focus for your widgets. - final FocusNode? parentFocusNode; + /// The [FocusNode] of the list. + final FocusNode? focusNode; - /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. - /// - /// As the popover list follows the selected item, it can be displayed off-screen if this [ItemSelectionList] - /// is close to the bottom of the screen. - /// - /// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget - /// bound to the [boundaryKey]. - /// - /// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. - final GlobalKey? boundaryKey; + /// The [FocusNode], to which the list's [FocusNode] will be added as a child. + final FocusNode? parentFocusNode; @override State> createState() => ItemSelectionListState(); @@ -380,8 +499,6 @@ class ItemSelectionList extends StatefulWidget { class ItemSelectionListState extends State> with SingleTickerProviderStateMixin { int? _activeIndex; - final PopoverController _popoverController = PopoverController(); - @visibleForTesting ScrollController get scrollController => _scrollController; final ScrollController _scrollController = ScrollController(); @@ -391,41 +508,41 @@ class ItemSelectionListState extends State> with SingleT /// Holds keys to each item on the list. final List _itemKeys = []; + @override + void initState() { + super.initState(); + _activateSelectedItem(); + } + @override void dispose() { - _popoverController.dispose(); _scrollController.dispose(); super.dispose(); } - void _onButtonTap() { - _popoverController.open(); + void _activateSelectedItem() { + final selectedItem = widget.value; - setState(() { - final selectedItem = widget.value; - - if (selectedItem == null) { - _activeIndex = null; - return; - } + if (selectedItem == null) { + _activeIndex = null; + return; + } - int selectedItemIndex = widget.items.indexOf(selectedItem); - if (selectedItemIndex > -1) { - // We just opened the popover. - // Jump to the active item without animation. - _activateItem(selectedItemIndex, Duration.zero); - } else { - // A selected item was provided, but it isn't included in the list of items. - _activeIndex = null; - } - }); + int selectedItemIndex = widget.items.indexOf(selectedItem); + if (selectedItemIndex > -1) { + // We just opened the popover. + // Jump to the active item without animation. + _activateItem(selectedItemIndex, Duration.zero); + } else { + // A selected item was provided, but it isn't included in the list of items. + _activeIndex = null; + } } /// Called when the user taps an item or presses ENTER with an active item. - void _selectItem(T item) { + void _selectItem(T? item) { widget.onItemSelected(item); - _popoverController.close(); } /// Activates the item at [itemIndex] and ensure it's visible on screen. @@ -478,15 +595,14 @@ class ItemSelectionListState extends State> with SingleT } if (event.logicalKey == LogicalKeyboardKey.escape) { - _popoverController.close(); + widget.onCancel?.call(); return KeyEventResult.handled; } if (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter) { if (_activeIndex == null) { // The user pressed ENTER without an active item. - // Close the popover without changing the selected item. - _popoverController.close(); + _selectItem(null); return KeyEventResult.handled; } @@ -525,18 +641,6 @@ class ItemSelectionListState extends State> with SingleT @override Widget build(BuildContext context) { - return PopoverScaffold( - controller: _popoverController, - buttonBuilder: (context) => widget.buttonBuilder(context, widget.value, _onButtonTap), - popoverBuilder: _buildPopover, - popoverGeometry: widget.popoverGeometry ?? const PopoverGeometry(), - onKeyEvent: _onKeyEvent, - boundaryKey: widget.boundaryKey, - parentFocusNode: widget.parentFocusNode, - ); - } - - Widget _buildPopover(BuildContext context) { final children = []; _itemKeys.clear(); @@ -554,7 +658,10 @@ class ItemSelectionListState extends State> with SingleT _itemKeys.add(key); } - return PopoverShape( + return Focus( + focusNode: widget.focusNode, + parentNode: widget.parentFocusNode, + onKeyEvent: _onKeyEvent, child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith( scrollbars: false, @@ -642,10 +749,12 @@ class _PopoverShapeState extends State with SingleTickerProviderSt /// [PopoverController.open] or [PopoverController.close]. The popover is automatically closed /// when the user taps outside of its bounds. /// +/// Provide a [popoverGeometry] to control the size and position of the popover. The popover +/// is first sized given the [PopoverGeometry.constraints] and then positioned using the +/// [PopoverGeometry.align]. +/// /// When the popover is displayed it requests focus to itself, so the user can -/// interact with the content using the keyboard. The focus is shared -/// with the [parentFocusNode]. Provide [onKeyEvent] to handle key presses -/// when the popover is visible. +/// interact with the content using the keyboard. class PopoverScaffold extends StatefulWidget { const PopoverScaffold({ super.key, @@ -654,7 +763,7 @@ class PopoverScaffold extends StatefulWidget { required this.popoverBuilder, this.popoverGeometry = const PopoverGeometry(), this.onKeyEvent, - this.parentFocusNode, + this.popoverFocusNode, this.boundaryKey, }); @@ -676,7 +785,7 @@ class PopoverScaffold extends StatefulWidget { final FocusOnKeyEventCallback? onKeyEvent; /// [FocusNode] which will share focus with the popover. - final FocusNode? parentFocusNode; + final FocusNode? popoverFocusNode; /// A [GlobalKey] to a widget that determines the bounds where the popover can be displayed. /// @@ -696,8 +805,7 @@ class PopoverScaffold extends StatefulWidget { class _PopoverScaffoldState extends State { final OverlayPortalController _overlayController = OverlayPortalController(); final LeaderLink _popoverLink = LeaderLink(); - final FocusNode _popoverFocusNode = FocusNode(); - late FocusNode _parentFocusNode; + late FocusNode _popoverFocusNode; late FollowerBoundary _screenBoundary; @@ -705,7 +813,7 @@ class _PopoverScaffoldState extends State { void initState() { super.initState(); - _parentFocusNode = widget.parentFocusNode ?? FocusNode(); + _popoverFocusNode = widget.popoverFocusNode ?? FocusNode(); widget.controller.addListener(_onPopoverControllerChanged); } @@ -723,12 +831,12 @@ class _PopoverScaffoldState extends State { widget.controller.addListener(_onPopoverControllerChanged); } - if (oldWidget.parentFocusNode != widget.parentFocusNode) { - if (oldWidget.parentFocusNode == null) { - _parentFocusNode.dispose(); + if (oldWidget.popoverFocusNode != widget.popoverFocusNode) { + if (oldWidget.popoverFocusNode == null) { + _popoverFocusNode.dispose(); } - _parentFocusNode = widget.parentFocusNode ?? FocusNode(); + _popoverFocusNode = widget.popoverFocusNode ?? FocusNode(); } if (oldWidget.boundaryKey != widget.boundaryKey) { @@ -741,8 +849,8 @@ class _PopoverScaffoldState extends State { widget.controller.removeListener(_onPopoverControllerChanged); _popoverLink.dispose(); - if (widget.parentFocusNode == null) { - _parentFocusNode.dispose(); + if (widget.popoverFocusNode == null) { + _popoverFocusNode.dispose(); } super.dispose(); @@ -779,14 +887,6 @@ class _PopoverScaffoldState extends State { widget.controller.close(); } - KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { - if (widget.onKeyEvent != null) { - return widget.onKeyEvent!(node, event); - } - - return KeyEventResult.ignored; - } - @override Widget build(BuildContext context) { return OverlayPortal( @@ -802,10 +902,8 @@ class _PopoverScaffoldState extends State { Widget _buildDropdown(BuildContext context) { return TapRegion( onTapOutside: _onTapOutsideOfDropdown, - child: SuperEditorPopover( - popoverFocusNode: _popoverFocusNode, - editorFocusNode: _parentFocusNode, - onKeyEvent: _onKeyEvent, + child: Actions( + actions: disabledMacIntents, child: Follower.withAligner( link: _popoverLink, boundary: _screenBoundary, From 6edb852354e1de269679a9eaf8c5effe612674f5 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Mon, 13 Nov 2023 21:18:26 -0300 Subject: [PATCH 19/36] Temporarily disable tests --- .../infrastructure/super_dropdown_test.dart | 936 +++++++++--------- 1 file changed, 468 insertions(+), 468 deletions(-) diff --git a/super_editor/test/infrastructure/super_dropdown_test.dart b/super_editor/test/infrastructure/super_dropdown_test.dart index c078449522..8315186299 100644 --- a/super_editor/test/infrastructure/super_dropdown_test.dart +++ b/super_editor/test/infrastructure/super_dropdown_test.dart @@ -1,468 +1,468 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_test_robots/flutter_test_robots.dart'; -import 'package:flutter_test_runners/flutter_test_runners.dart'; - -import 'package:super_editor/src/core/document.dart'; -import 'package:super_editor/src/core/document_composer.dart'; -import 'package:super_editor/src/core/document_selection.dart'; -import 'package:super_editor/src/core/editor.dart'; -import 'package:super_editor/src/default_editor/default_document_editor.dart'; -import 'package:super_editor/src/default_editor/super_editor.dart'; -import 'package:super_editor/src/default_editor/text.dart'; -import 'package:super_editor/src/infrastructure/dropdown.dart'; -import 'package:super_editor/src/infrastructure/text_input.dart'; -import 'package:super_editor/super_editor_test.dart'; - -import '../super_editor/test_documents.dart'; - -void main() { - group('SuperDropdown', () { - testWidgetsOnAllPlatforms('shows the dropdown list on tap', (tester) async { - await _pumpDropdownTestApp( - tester, - onValueChanged: (s) {}, - ); - - // Ensures the dropdown list isn't displayed. - expect(find.byType(PopoverShape), findsNothing); - - // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelectionList)); - await tester.pumpAndSettle(); - - // Ensures the dropdown list is displayed. - expect(find.byType(PopoverShape), findsOneWidget); - }); - - testWidgetsOnAllPlatforms('calls onSelected and closes the dropdown list when tapping an item', (tester) async { - String? selectedValue; - - await _pumpDropdownTestApp( - tester, - onValueChanged: (s) => selectedValue = s, - ); - - // Ensures the dropdown list isn't displayed. - expect(find.byType(PopoverShape), findsNothing); - - // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelectionList)); - await tester.pumpAndSettle(); - - // Ensures the dropdown list is displayed. - expect(find.byType(PopoverShape), findsOneWidget); - - // Taps the first item on the list - await tester.tap(find.text('Item1')); - await tester.pumpAndSettle(); - - // Ensure the tapped item was selected and the dropdown was closed. - expect(selectedValue, 'Item1'); - expect(find.byType(PopoverShape), findsNothing); - }); - - testWidgetsOnAllPlatforms('closes the dropdown list when tapping outside', (tester) async { - bool onValueChangedCalled = false; - - await _pumpDropdownTestApp( - tester, - onValueChanged: (s) => onValueChangedCalled = true, - ); - - // Ensures the dropdown list isn't displayed. - expect(find.byType(PopoverShape), findsNothing); - - // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelectionList)); - await tester.pumpAndSettle(); - - // Ensures the dropdown list is displayed. - expect(find.byType(PopoverShape), findsOneWidget); - - // Taps outside of the dropdown. - await tester.tapAt(Offset.zero); - await tester.pumpAndSettle(); - - // Ensures onValueChanged wasn't called and the dropdown list was closed. - expect(onValueChangedCalled, isFalse); - expect(find.byType(PopoverShape), findsNothing); - }); - - testWidgetsOnAllPlatforms('enforces the given dropdown constraints', (tester) async { - await _pumpDropdownTestApp( - tester, - onValueChanged: (s) {}, - popoverGeometry: PopoverGeometry( - constraints: const BoxConstraints(maxHeight: 10), - ), - ); - - // Ensures the dropdown list isn't displayed. - expect(find.byType(PopoverShape), findsNothing); - - // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelectionList)); - await tester.pumpAndSettle(); - - // Ensures the dropdown list is displayed. - expect(find.byType(PopoverShape), findsOneWidget); - - // Ensure the maxHeight was honored. - expect(tester.getRect(find.byType(PopoverShape)).height, 10); - }); - - testWidgetsOnAllPlatforms('dropdown list isn\' scrollable if all items fit on screen', (tester) async { - await _pumpDropdownTestApp( - tester, - onValueChanged: (s) {}, - ); - - // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelectionList)); - await tester.pumpAndSettle(); - - // Ensures the dropdown list is displayed. - expect(find.byType(PopoverShape), findsOneWidget); - - // Ensure the dropdown list isn't scrollable. - final dropdownButonState = tester.state>(find.byType(ItemSelectionList)); - expect(dropdownButonState.scrollController.position.maxScrollExtent, 0.0); - }); - - testWidgetsOnAllPlatforms('dropdown list is scrollable if items don\'t fit on screen', (tester) async { - await _pumpDropdownTestApp( - tester, - onValueChanged: (s) {}, - popoverGeometry: PopoverGeometry(constraints: const BoxConstraints(maxHeight: 50)), - ); - - // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelectionList)); - await tester.pumpAndSettle(); - - // Ensures the dropdown list is displayed. - expect(find.byType(PopoverShape), findsOneWidget); - - // Ensure the dropdown list is scrollable. - final dropdownButonState = tester.state>(find.byType(ItemSelectionList)); - expect(dropdownButonState.scrollController.position.maxScrollExtent, greaterThan(0.0)); - }); - - testWidgetsOnAllPlatforms('moves focus down with DOWN ARROW', (tester) async { - String? activeItem; - await _pumpDropdownTestApp( - tester, - onValueChanged: (s) => {}, - onActivate: (s) => activeItem = s, - ); - - // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelectionList)); - await tester.pumpAndSettle(); - - // Ensure the dropdown is displayed without any focused item. - expect(activeItem, isNull); - - // Press DOWN ARROW to focus the first item. - await tester.pressDownArrow(); - expect(activeItem, 'Item1'); - - // Press DOWN ARROW to focus the second item. - await tester.pressDownArrow(); - expect(activeItem, 'Item2'); - - // Press DOWN ARROW to focus the third item. - await tester.pressDownArrow(); - expect(activeItem, 'Item3'); - - // Press DOWN ARROW to focus the first item again. - await tester.pressDownArrow(); - expect(activeItem, 'Item1'); - }); - - testWidgetsOnAllPlatforms('moves focus up with UP ARROW', (tester) async { - String? activeItem; - - await _pumpDropdownTestApp( - tester, - onValueChanged: (s) => {}, - onActivate: (s) => activeItem = s, - ); - - // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelectionList)); - await tester.pumpAndSettle(); - - // Ensure the dropdown is displayed without any focused item. - expect(activeItem, isNull); - - // Press UP ARROW to focus the last item. - await tester.pressUpArrow(); - expect(activeItem, 'Item3'); - - // Press UP ARROW to focus the second item. - await tester.pressUpArrow(); - expect(activeItem, 'Item2'); - - // Press UP ARROW to focus the first item. - await tester.pressUpArrow(); - expect(activeItem, 'Item1'); - - // Press UP ARROW to focus the last item again. - await tester.pressUpArrow(); - expect(activeItem, 'Item3'); - }); - - testWidgetsOnAllPlatforms('selects the focused item on ENTER', (tester) async { - String? selectedValue; - - await _pumpDropdownTestApp( - tester, - onValueChanged: (s) => selectedValue = s, - ); - - // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelectionList)); - await tester.pumpAndSettle(); - - // Press ARROW DOWN to focus the first item. - await tester.pressDownArrow(); - - // Press ENTER to select the focused item and close the dropdown. - await tester.pressEnter(); - await tester.pump(); - - // Ensure the first item was selected and the dropdown was closed. - expect(selectedValue, 'Item1'); - expect(find.byType(PopoverShape), findsNothing); - }); - - testWidgetsOnAllPlatforms('closes dropdown list on ENTER', (tester) async { - String? selectedValue; - - await _pumpDropdownTestApp( - tester, - onValueChanged: (s) => selectedValue = s, - ); - - // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelectionList)); - await tester.pumpAndSettle(); - - // Press ENTER without a focused item to close the dropdown. - await tester.pressEnter(); - await tester.pump(); - - // Ensure the dropdown was closed and no item was selected. - expect(find.byType(PopoverShape), findsNothing); - expect(selectedValue, isNull); - }); - - testWidgetsOnAllPlatforms('closes dropdown list on ESC', (tester) async { - String? selectedValue; - - await _pumpDropdownTestApp( - tester, - onValueChanged: (s) => selectedValue = s, - ); - - // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelectionList)); - await tester.pumpAndSettle(); - - // Press ARROW DOWN to focus the first item. - await tester.pressDownArrow(); - - // Press ESC to close the dropdown. - await tester.pressEscape(); - await tester.pump(); - - // Ensure the dropdown was closed and no item was selected. - expect(find.byType(PopoverShape), findsNothing); - expect(selectedValue, isNull); - }); - - testWidgetsOnAllPlatforms('shares focus with SuperEditor', (tester) async { - final editorFocusNode = FocusNode(); - final boundaryKey = GlobalKey(); - - await tester.pumpWidget( - MaterialApp( - key: boundaryKey, - home: _SuperEditorDropdownTestApp( - editorFocusNode: editorFocusNode, - toolbar: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 100), - child: ItemSelectionList( - items: const ['Item1', 'Item2', 'Item3'], - itemBuilder: (context, e, isActive, onTap) => TextButton( - onPressed: onTap, - child: Text(e), - ), - buttonBuilder: (context, e, onTap) => ElevatedButton( - onPressed: onTap, - child: const SizedBox(width: 50), - ), - value: null, - onItemSelected: (s) => {}, - boundaryKey: boundaryKey, - parentFocusNode: editorFocusNode, - ), - ), - ), - ), - ); - - final documentNode = SuperEditorInspector.findDocument()!.nodes.first; - - // Double tap to select the word "Lorem". - await tester.doubleTapInParagraph(documentNode.id, 1); - - // Ensure the editor has primary focus and the word "Lorem" is selected. - expect(editorFocusNode.hasPrimaryFocus, isTrue); - expect( - SuperEditorInspector.findDocumentSelection(), - DocumentSelection( - base: DocumentPosition( - nodeId: documentNode.id, - nodePosition: const TextNodePosition(offset: 0), - ), - extent: DocumentPosition( - nodeId: documentNode.id, - nodePosition: const TextNodePosition(offset: 5), - ), - ), - ); - - // Tap the button to show the dropdown. - await tester.tap(find.byType(ItemSelectionList)); - await tester.pumpAndSettle(); - - // Ensure the editor has non-primary focus. - expect(editorFocusNode.hasFocus, true); - expect(editorFocusNode.hasPrimaryFocus, isFalse); - - // Tap at a dropdown list option to close the dropdown. - await tester.tap(find.text('Item2')); - await tester.pumpAndSettle(); - - // Ensure the editor has primary focus again and selection stays the same. - expect(editorFocusNode.hasPrimaryFocus, isTrue); - expect( - SuperEditorInspector.findDocumentSelection(), - DocumentSelection( - base: DocumentPosition( - nodeId: documentNode.id, - nodePosition: const TextNodePosition(offset: 0), - ), - extent: DocumentPosition( - nodeId: documentNode.id, - nodePosition: const TextNodePosition(offset: 5), - ), - ), - ); - }); - }); -} - -/// Pumps a widget tree with a centered [ItemSelectionList] containing three items. -Future _pumpDropdownTestApp( - WidgetTester tester, { - required void Function(String? value) onValueChanged, - void Function(String? value)? onActivate, - PopoverGeometry? popoverGeometry, -}) async { - final boundaryKey = GlobalKey(); - final focusNode = FocusNode(); - - await tester.pumpWidget( - MaterialApp( - key: boundaryKey, - home: Scaffold( - body: Center( - child: Focus( - focusNode: focusNode, - autofocus: true, - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 100), - child: ItemSelectionList( - items: const ['Item1', 'Item2', 'Item3'], - itemBuilder: (context, e, isActive, onTap) => TextButton( - onPressed: onTap, - child: Text(e), - ), - buttonBuilder: (context, e, onTap) => ElevatedButton( - onPressed: onTap, - child: const SizedBox(width: 50), - ), - value: null, - onItemActivated: onActivate, - onItemSelected: onValueChanged, - boundaryKey: boundaryKey, - parentFocusNode: focusNode, - popoverGeometry: popoverGeometry, - ), - ), - ), - ), - ), - ), - ); -} - -/// Displays a [SuperEditor] that fills the available height, containing a single paragraph, -/// and a [toolbar] at the bottom. -class _SuperEditorDropdownTestApp extends StatefulWidget { - const _SuperEditorDropdownTestApp({ - required this.toolbar, - this.editorFocusNode, - }); - - final FocusNode? editorFocusNode; - final Widget toolbar; - - @override - State<_SuperEditorDropdownTestApp> createState() => _SuperEditorDropdownTestAppState(); -} - -class _SuperEditorDropdownTestAppState extends State<_SuperEditorDropdownTestApp> { - late MutableDocument _doc; - late MutableDocumentComposer _composer; - late Editor _docEditor; - - @override - void initState() { - super.initState(); - _doc = singleParagraphDoc(); - _composer = MutableDocumentComposer(); - _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); - } - - @override - void dispose() { - _docEditor.dispose(); - _doc.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Column( - children: [ - Expanded( - child: SuperEditor( - document: _doc, - editor: _docEditor, - composer: _composer, - inputSource: TextInputSource.ime, - focusNode: widget.editorFocusNode, - ), - ), - widget.toolbar, - ], - ), - ); - } -} +// import 'package:flutter/material.dart'; +// import 'package:flutter_test/flutter_test.dart'; +// import 'package:flutter_test_robots/flutter_test_robots.dart'; +// import 'package:flutter_test_runners/flutter_test_runners.dart'; + +// import 'package:super_editor/src/core/document.dart'; +// import 'package:super_editor/src/core/document_composer.dart'; +// import 'package:super_editor/src/core/document_selection.dart'; +// import 'package:super_editor/src/core/editor.dart'; +// import 'package:super_editor/src/default_editor/default_document_editor.dart'; +// import 'package:super_editor/src/default_editor/super_editor.dart'; +// import 'package:super_editor/src/default_editor/text.dart'; +// import 'package:super_editor/src/infrastructure/dropdown.dart'; +// import 'package:super_editor/src/infrastructure/text_input.dart'; +// import 'package:super_editor/super_editor_test.dart'; + +// import '../super_editor/test_documents.dart'; + +// void main() { +// group('SuperDropdown', () { +// testWidgetsOnAllPlatforms('shows the dropdown list on tap', (tester) async { +// await _pumpDropdownTestApp( +// tester, +// onValueChanged: (s) {}, +// ); + +// // Ensures the dropdown list isn't displayed. +// expect(find.byType(PopoverShape), findsNothing); + +// // Tap the button to show the dropdown. +// await tester.tap(find.byType(ItemSelectionList)); +// await tester.pumpAndSettle(); + +// // Ensures the dropdown list is displayed. +// expect(find.byType(PopoverShape), findsOneWidget); +// }); + +// testWidgetsOnAllPlatforms('calls onSelected and closes the dropdown list when tapping an item', (tester) async { +// String? selectedValue; + +// await _pumpDropdownTestApp( +// tester, +// onValueChanged: (s) => selectedValue = s, +// ); + +// // Ensures the dropdown list isn't displayed. +// expect(find.byType(PopoverShape), findsNothing); + +// // Tap the button to show the dropdown. +// await tester.tap(find.byType(ItemSelectionList)); +// await tester.pumpAndSettle(); + +// // Ensures the dropdown list is displayed. +// expect(find.byType(PopoverShape), findsOneWidget); + +// // Taps the first item on the list +// await tester.tap(find.text('Item1')); +// await tester.pumpAndSettle(); + +// // Ensure the tapped item was selected and the dropdown was closed. +// expect(selectedValue, 'Item1'); +// expect(find.byType(PopoverShape), findsNothing); +// }); + +// testWidgetsOnAllPlatforms('closes the dropdown list when tapping outside', (tester) async { +// bool onValueChangedCalled = false; + +// await _pumpDropdownTestApp( +// tester, +// onValueChanged: (s) => onValueChangedCalled = true, +// ); + +// // Ensures the dropdown list isn't displayed. +// expect(find.byType(PopoverShape), findsNothing); + +// // Tap the button to show the dropdown. +// await tester.tap(find.byType(ItemSelectionList)); +// await tester.pumpAndSettle(); + +// // Ensures the dropdown list is displayed. +// expect(find.byType(PopoverShape), findsOneWidget); + +// // Taps outside of the dropdown. +// await tester.tapAt(Offset.zero); +// await tester.pumpAndSettle(); + +// // Ensures onValueChanged wasn't called and the dropdown list was closed. +// expect(onValueChangedCalled, isFalse); +// expect(find.byType(PopoverShape), findsNothing); +// }); + +// testWidgetsOnAllPlatforms('enforces the given dropdown constraints', (tester) async { +// await _pumpDropdownTestApp( +// tester, +// onValueChanged: (s) {}, +// popoverGeometry: PopoverGeometry( +// constraints: const BoxConstraints(maxHeight: 10), +// ), +// ); + +// // Ensures the dropdown list isn't displayed. +// expect(find.byType(PopoverShape), findsNothing); + +// // Tap the button to show the dropdown. +// await tester.tap(find.byType(ItemSelectionList)); +// await tester.pumpAndSettle(); + +// // Ensures the dropdown list is displayed. +// expect(find.byType(PopoverShape), findsOneWidget); + +// // Ensure the maxHeight was honored. +// expect(tester.getRect(find.byType(PopoverShape)).height, 10); +// }); + +// testWidgetsOnAllPlatforms('dropdown list isn\' scrollable if all items fit on screen', (tester) async { +// await _pumpDropdownTestApp( +// tester, +// onValueChanged: (s) {}, +// ); + +// // Tap the button to show the dropdown. +// await tester.tap(find.byType(ItemSelectionList)); +// await tester.pumpAndSettle(); + +// // Ensures the dropdown list is displayed. +// expect(find.byType(PopoverShape), findsOneWidget); + +// // Ensure the dropdown list isn't scrollable. +// final dropdownButonState = tester.state>(find.byType(ItemSelectionList)); +// expect(dropdownButonState.scrollController.position.maxScrollExtent, 0.0); +// }); + +// testWidgetsOnAllPlatforms('dropdown list is scrollable if items don\'t fit on screen', (tester) async { +// await _pumpDropdownTestApp( +// tester, +// onValueChanged: (s) {}, +// popoverGeometry: PopoverGeometry(constraints: const BoxConstraints(maxHeight: 50)), +// ); + +// // Tap the button to show the dropdown. +// await tester.tap(find.byType(ItemSelectionList)); +// await tester.pumpAndSettle(); + +// // Ensures the dropdown list is displayed. +// expect(find.byType(PopoverShape), findsOneWidget); + +// // Ensure the dropdown list is scrollable. +// final dropdownButonState = tester.state>(find.byType(ItemSelectionList)); +// expect(dropdownButonState.scrollController.position.maxScrollExtent, greaterThan(0.0)); +// }); + +// testWidgetsOnAllPlatforms('moves focus down with DOWN ARROW', (tester) async { +// String? activeItem; +// await _pumpDropdownTestApp( +// tester, +// onValueChanged: (s) => {}, +// onActivate: (s) => activeItem = s, +// ); + +// // Tap the button to show the dropdown. +// await tester.tap(find.byType(ItemSelectionList)); +// await tester.pumpAndSettle(); + +// // Ensure the dropdown is displayed without any focused item. +// expect(activeItem, isNull); + +// // Press DOWN ARROW to focus the first item. +// await tester.pressDownArrow(); +// expect(activeItem, 'Item1'); + +// // Press DOWN ARROW to focus the second item. +// await tester.pressDownArrow(); +// expect(activeItem, 'Item2'); + +// // Press DOWN ARROW to focus the third item. +// await tester.pressDownArrow(); +// expect(activeItem, 'Item3'); + +// // Press DOWN ARROW to focus the first item again. +// await tester.pressDownArrow(); +// expect(activeItem, 'Item1'); +// }); + +// testWidgetsOnAllPlatforms('moves focus up with UP ARROW', (tester) async { +// String? activeItem; + +// await _pumpDropdownTestApp( +// tester, +// onValueChanged: (s) => {}, +// onActivate: (s) => activeItem = s, +// ); + +// // Tap the button to show the dropdown. +// await tester.tap(find.byType(ItemSelectionList)); +// await tester.pumpAndSettle(); + +// // Ensure the dropdown is displayed without any focused item. +// expect(activeItem, isNull); + +// // Press UP ARROW to focus the last item. +// await tester.pressUpArrow(); +// expect(activeItem, 'Item3'); + +// // Press UP ARROW to focus the second item. +// await tester.pressUpArrow(); +// expect(activeItem, 'Item2'); + +// // Press UP ARROW to focus the first item. +// await tester.pressUpArrow(); +// expect(activeItem, 'Item1'); + +// // Press UP ARROW to focus the last item again. +// await tester.pressUpArrow(); +// expect(activeItem, 'Item3'); +// }); + +// testWidgetsOnAllPlatforms('selects the focused item on ENTER', (tester) async { +// String? selectedValue; + +// await _pumpDropdownTestApp( +// tester, +// onValueChanged: (s) => selectedValue = s, +// ); + +// // Tap the button to show the dropdown. +// await tester.tap(find.byType(ItemSelectionList)); +// await tester.pumpAndSettle(); + +// // Press ARROW DOWN to focus the first item. +// await tester.pressDownArrow(); + +// // Press ENTER to select the focused item and close the dropdown. +// await tester.pressEnter(); +// await tester.pump(); + +// // Ensure the first item was selected and the dropdown was closed. +// expect(selectedValue, 'Item1'); +// expect(find.byType(PopoverShape), findsNothing); +// }); + +// testWidgetsOnAllPlatforms('closes dropdown list on ENTER', (tester) async { +// String? selectedValue; + +// await _pumpDropdownTestApp( +// tester, +// onValueChanged: (s) => selectedValue = s, +// ); + +// // Tap the button to show the dropdown. +// await tester.tap(find.byType(ItemSelectionList)); +// await tester.pumpAndSettle(); + +// // Press ENTER without a focused item to close the dropdown. +// await tester.pressEnter(); +// await tester.pump(); + +// // Ensure the dropdown was closed and no item was selected. +// expect(find.byType(PopoverShape), findsNothing); +// expect(selectedValue, isNull); +// }); + +// testWidgetsOnAllPlatforms('closes dropdown list on ESC', (tester) async { +// String? selectedValue; + +// await _pumpDropdownTestApp( +// tester, +// onValueChanged: (s) => selectedValue = s, +// ); + +// // Tap the button to show the dropdown. +// await tester.tap(find.byType(ItemSelectionList)); +// await tester.pumpAndSettle(); + +// // Press ARROW DOWN to focus the first item. +// await tester.pressDownArrow(); + +// // Press ESC to close the dropdown. +// await tester.pressEscape(); +// await tester.pump(); + +// // Ensure the dropdown was closed and no item was selected. +// expect(find.byType(PopoverShape), findsNothing); +// expect(selectedValue, isNull); +// }); + +// testWidgetsOnAllPlatforms('shares focus with SuperEditor', (tester) async { +// final editorFocusNode = FocusNode(); +// final boundaryKey = GlobalKey(); + +// await tester.pumpWidget( +// MaterialApp( +// key: boundaryKey, +// home: _SuperEditorDropdownTestApp( +// editorFocusNode: editorFocusNode, +// toolbar: ConstrainedBox( +// constraints: const BoxConstraints(maxHeight: 100), +// child: ItemSelectionList( +// items: const ['Item1', 'Item2', 'Item3'], +// itemBuilder: (context, e, isActive, onTap) => TextButton( +// onPressed: onTap, +// child: Text(e), +// ), +// buttonBuilder: (context, e, onTap) => ElevatedButton( +// onPressed: onTap, +// child: const SizedBox(width: 50), +// ), +// value: null, +// onItemSelected: (s) => {}, +// boundaryKey: boundaryKey, +// parentFocusNode: editorFocusNode, +// ), +// ), +// ), +// ), +// ); + +// final documentNode = SuperEditorInspector.findDocument()!.nodes.first; + +// // Double tap to select the word "Lorem". +// await tester.doubleTapInParagraph(documentNode.id, 1); + +// // Ensure the editor has primary focus and the word "Lorem" is selected. +// expect(editorFocusNode.hasPrimaryFocus, isTrue); +// expect( +// SuperEditorInspector.findDocumentSelection(), +// DocumentSelection( +// base: DocumentPosition( +// nodeId: documentNode.id, +// nodePosition: const TextNodePosition(offset: 0), +// ), +// extent: DocumentPosition( +// nodeId: documentNode.id, +// nodePosition: const TextNodePosition(offset: 5), +// ), +// ), +// ); + +// // Tap the button to show the dropdown. +// await tester.tap(find.byType(ItemSelectionList)); +// await tester.pumpAndSettle(); + +// // Ensure the editor has non-primary focus. +// expect(editorFocusNode.hasFocus, true); +// expect(editorFocusNode.hasPrimaryFocus, isFalse); + +// // Tap at a dropdown list option to close the dropdown. +// await tester.tap(find.text('Item2')); +// await tester.pumpAndSettle(); + +// // Ensure the editor has primary focus again and selection stays the same. +// expect(editorFocusNode.hasPrimaryFocus, isTrue); +// expect( +// SuperEditorInspector.findDocumentSelection(), +// DocumentSelection( +// base: DocumentPosition( +// nodeId: documentNode.id, +// nodePosition: const TextNodePosition(offset: 0), +// ), +// extent: DocumentPosition( +// nodeId: documentNode.id, +// nodePosition: const TextNodePosition(offset: 5), +// ), +// ), +// ); +// }); +// }); +// } + +// /// Pumps a widget tree with a centered [ItemSelectionList] containing three items. +// Future _pumpDropdownTestApp( +// WidgetTester tester, { +// required void Function(String? value) onValueChanged, +// void Function(String? value)? onActivate, +// PopoverGeometry? popoverGeometry, +// }) async { +// final boundaryKey = GlobalKey(); +// final focusNode = FocusNode(); + +// await tester.pumpWidget( +// MaterialApp( +// key: boundaryKey, +// home: Scaffold( +// body: Center( +// child: Focus( +// focusNode: focusNode, +// autofocus: true, +// child: ConstrainedBox( +// constraints: const BoxConstraints(maxHeight: 100), +// child: ItemSelectionList( +// items: const ['Item1', 'Item2', 'Item3'], +// itemBuilder: (context, e, isActive, onTap) => TextButton( +// onPressed: onTap, +// child: Text(e), +// ), +// buttonBuilder: (context, e, onTap) => ElevatedButton( +// onPressed: onTap, +// child: const SizedBox(width: 50), +// ), +// value: null, +// onItemActivated: onActivate, +// onItemSelected: onValueChanged, +// boundaryKey: boundaryKey, +// parentFocusNode: focusNode, +// popoverGeometry: popoverGeometry, +// ), +// ), +// ), +// ), +// ), +// ), +// ); +// } + +// /// Displays a [SuperEditor] that fills the available height, containing a single paragraph, +// /// and a [toolbar] at the bottom. +// class _SuperEditorDropdownTestApp extends StatefulWidget { +// const _SuperEditorDropdownTestApp({ +// required this.toolbar, +// this.editorFocusNode, +// }); + +// final FocusNode? editorFocusNode; +// final Widget toolbar; + +// @override +// State<_SuperEditorDropdownTestApp> createState() => _SuperEditorDropdownTestAppState(); +// } + +// class _SuperEditorDropdownTestAppState extends State<_SuperEditorDropdownTestApp> { +// late MutableDocument _doc; +// late MutableDocumentComposer _composer; +// late Editor _docEditor; + +// @override +// void initState() { +// super.initState(); +// _doc = singleParagraphDoc(); +// _composer = MutableDocumentComposer(); +// _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); +// } + +// @override +// void dispose() { +// _docEditor.dispose(); +// _doc.dispose(); +// super.dispose(); +// } + +// @override +// Widget build(BuildContext context) { +// return Scaffold( +// body: Column( +// children: [ +// Expanded( +// child: SuperEditor( +// document: _doc, +// editor: _docEditor, +// composer: _composer, +// inputSource: TextInputSource.ime, +// focusNode: widget.editorFocusNode, +// ), +// ), +// widget.toolbar, +// ], +// ), +// ); +// } +// } From f2179377036d11b31390aa1a16dbd348074c8543 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Wed, 15 Nov 2023 19:06:27 -0300 Subject: [PATCH 20/36] PR updates --- .../lib/demos/example_editor/_toolbar.dart | 197 ++++----- .../super_editor_item_selector.dart | 404 ++++++++++++++++++ .../lib/src/infrastructure/dropdown.dart | 404 +----------------- 3 files changed, 492 insertions(+), 513 deletions(-) create mode 100644 super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart diff --git a/super_editor/example/lib/demos/example_editor/_toolbar.dart b/super_editor/example/lib/demos/example_editor/_toolbar.dart index 890d22b9e8..e7155281ab 100644 --- a/super_editor/example/lib/demos/example_editor/_toolbar.dart +++ b/super_editor/example/lib/demos/example_editor/_toolbar.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:example/demos/infrastructure/super_editor_item_selector.dart'; import 'package:example/logging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -70,6 +71,9 @@ class _EditorToolbarState extends State { late FocusNode _urlFocusNode; ImeAttributedTextEditingController? _urlController; + SuperEditorDemoTextItem? _selectedBlockType; + SuperEditorDemoIconItem? _selectedAlignment; + @override void initState() { super.initState(); @@ -93,6 +97,21 @@ class _EditorToolbarState extends State { boundaryKey: widget.editorViewportKey, devicePixelRatio: MediaQuery.devicePixelRatioOf(context), ); + + // We assign the selected block type here instead of initState + // because we need to access `AppLocalizations.of`, which isn't + // available in initState. + final currentBlockType = _getCurrentTextType(); + _selectedBlockType = SuperEditorDemoTextItem( + value: currentBlockType.name, + label: _getTextTypeName(currentBlockType), + ); + + final currentAlignment = _getCurrentTextAlignment(); + _selectedAlignment = SuperEditorDemoIconItem( + value: currentAlignment.name, + icon: _buildTextAlignIcon(currentAlignment), + ); } @override @@ -470,6 +489,28 @@ class _EditorToolbarState extends State { } } + /// Called when the user selects a block type on the toolbar. + void _onBlockTypeSelected(SuperEditorDemoTextItem? selectedItem) { + if (selectedItem != null) { + setState(() { + _selectedBlockType = selectedItem; + _convertTextToNewType(_TextType.values // + .where((e) => e.name == selectedItem.value) + .first); + }); + } + } + + /// Called when the user selects an alignment on the toolbar. + void _onAlignmentSelected(SuperEditorDemoIconItem? selectedItem) { + if (selectedItem != null) { + setState(() { + _selectedAlignment = selectedItem; + _changeAlignment(TextAlign.values.firstWhere((e) => e.name == selectedItem.value)); + }); + } + } + @override Widget build(BuildContext context) { return BuildInOrder( @@ -522,34 +563,7 @@ class _EditorToolbarState extends State { if (_isConvertibleNode()) ...[ Tooltip( message: AppLocalizations.of(context)!.labelTextBlockType, - child: _DocumentListenableBuilder( - document: widget.document, - builder: (context) { - return SuperEditorDemoTextItemSelector( - parentFocusNode: widget.editorFocusNode, - boundaryKey: widget.editorViewportKey, - value: SuperEditorDemoTextItem( - value: _getCurrentTextType().name, - label: _getTextTypeName(_getCurrentTextType()), - ), - items: _TextType.values - .map( - (e) => SuperEditorDemoTextItem( - value: e.name, - label: _getTextTypeName(e), - ), - ) - .toList(), - onSelected: (selectedItem) { - if (selectedItem != null) { - _convertTextToNewType(_TextType.values // - .where((e) => e.name == selectedItem.value) - .first); - } - }, - ); - }, - ), + child: _buildBlockTypeSelector(), ), _buildVerticalDivider(), ], @@ -588,39 +602,17 @@ class _EditorToolbarState extends State { ), // Only display alignment controls if the currently selected text // node respects alignment. List items, for example, do not. - _DocumentListenableBuilder( - document: widget.document, - builder: (context) { - if (!_isTextAlignable()) { - return const SizedBox(); - } - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildVerticalDivider(), - Tooltip( - message: AppLocalizations.of(context)!.labelTextAlignment, - child: SuperEditorDemoIconItemSelector( - parentFocusNode: widget.editorFocusNode, - boundaryKey: widget.editorViewportKey, - value: SuperEditorDemoIconItem( - value: _getCurrentTextAlignment().name, - icon: _buildTextAlignIcon(_getCurrentTextAlignment()), - ), - items: TextAlign.values - .map((e) => SuperEditorDemoIconItem(icon: _buildTextAlignIcon(e), value: e.name)) - .toList(), - onSelected: (selectedItem) { - if (selectedItem != null) { - _changeAlignment(TextAlign.values.firstWhere((e) => e.name == selectedItem.value)); - } - }, - ), - ), - ], - ); - }, - ), + if (_isTextAlignable()) // + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildVerticalDivider(), + Tooltip( + message: AppLocalizations.of(context)!.labelTextAlignment, + child: _buildAlignmentSelector(), + ), + ], + ), _buildVerticalDivider(), Center( @@ -638,6 +630,40 @@ class _EditorToolbarState extends State { ); } + Widget _buildAlignmentSelector() { + return SuperEditorDemoIconItemSelector( + parentFocusNode: widget.editorFocusNode, + boundaryKey: widget.editorViewportKey, + value: _selectedAlignment, + items: TextAlign.values + .map( + (e) => SuperEditorDemoIconItem( + icon: _buildTextAlignIcon(e), + value: e.name, + ), + ) + .toList(), + onSelected: _onAlignmentSelected, + ); + } + + Widget _buildBlockTypeSelector() { + return SuperEditorDemoTextItemSelector( + parentFocusNode: widget.editorFocusNode, + boundaryKey: widget.editorViewportKey, + value: _selectedBlockType, + items: _TextType.values + .map( + (e) => SuperEditorDemoTextItem( + value: e.name, + label: _getTextTypeName(e), + ), + ) + .toList(), + onSelected: _onBlockTypeSelected, + ); + } + Widget _buildUrlField() { return Material( shape: const StadiumBorder(), @@ -904,52 +930,3 @@ class SingleLineAttributedTextEditingController extends AttributedTextEditingCon // line field (#697). } } - -/// A Widget that calls its [builder] whenever the [document] changes. -class _DocumentListenableBuilder extends StatefulWidget { - const _DocumentListenableBuilder({ - required this.document, - required this.builder, - }); - - /// The [document] to listen to. - final Document document; - - final WidgetBuilder builder; - - @override - State<_DocumentListenableBuilder> createState() => _DocumentListenableBuilderState(); -} - -class _DocumentListenableBuilderState extends State<_DocumentListenableBuilder> { - @override - void initState() { - widget.document.addListener(_onDocumentChanged); - super.initState(); - } - - @override - void didUpdateWidget(covariant _DocumentListenableBuilder oldWidget) { - if (oldWidget.document != widget.document) { - oldWidget.document.removeListener(_onDocumentChanged); - widget.document.addListener(_onDocumentChanged); - } - super.didUpdateWidget(oldWidget); - } - - @override - void dispose() { - widget.document.removeListener(_onDocumentChanged); - super.dispose(); - } - - void _onDocumentChanged(DocumentChangeLog doc) { - // The document has changed. Rebuild. - setState(() {}); - } - - @override - Widget build(BuildContext context) { - return widget.builder(context); - } -} diff --git a/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart b/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart new file mode 100644 index 0000000000..915ddd9618 --- /dev/null +++ b/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart @@ -0,0 +1,404 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A selection control, which displays a button with the selected item, and upon tap, displays a +/// popover list of available text options, from which the user can select a different +/// option. +/// +/// Unlike Flutter `DropdownButton`, which displays the popover list in a separate route, +/// this widget displays its popover list in an `Overlay`. By using an `Overlay`, focus can be shared +/// with the [parentFocusNode]. This means that when the popover list requests focus, [parentFocusNode] +/// still has non-primary focus. +/// +/// The popover list is positioned based on the following rules: +/// +/// 1. The popover is displayed below the selected item, if there's enough room, or +/// 2. The popover is displayed above the selected item, if there's enough room, or +/// 3. The popover is displayed with its bottom aligned with the bottom of +/// the given boundary, and it covers the selected item. +/// +/// The popover list height is based on the following rules: +/// +/// 1. The popover is displayed as tall as all items in the list, if there's enough room, or +/// 2. The popover is displayed as tall as the available space and becomes scrollable. +/// +/// The popover list includes keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" item selection up/down. +/// * Pressing UP with the first item active moves the active item selection to the last item. +/// * Pressing DOWN with the last item active moves the active item selection to the first item. +/// * Pressing ENTER selects the currently active item and closes the popover list. +class SuperEditorDemoTextItemSelector extends StatefulWidget { + const SuperEditorDemoTextItemSelector({ + super.key, + this.parentFocusNode, + this.boundaryKey, + this.value, + required this.items, + required this.onSelected, + }); + + /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + /// + /// In Flutter, [FocusNode]s have parents and children. This relationship allows an + /// entire ancestor path to "have focus", but only the lowest level descendant + /// in that path has "primary focus". This path is important because various + /// widgets alter their presentation or behavior based on whether or not they + /// currently have focus, even if they only have "non-primary focus". + /// + /// When the popover list of items is visible, that list will have primary focus. + /// Moreover, because the popover list is built in an `Overlay`, none of your + /// widgets are in the natural focus path for that popover list. Therefore, if you + /// need your widget tree to retain focus while the popover list is visible, then + /// you need to provide the [FocusNode] that the popover list should use as its + /// parent, thereby retaining focus for your widgets. + final FocusNode? parentFocusNode; + + /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + /// + /// As the popover list follows the selected item, it can be displayed off-screen if this [SuperEditorDemoTextItemSelector] + /// is close to the bottom of the screen. + /// + /// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget + /// bound to the [boundaryKey]. + /// + /// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. + final GlobalKey? boundaryKey; + + /// The currently selected value or `null` if no item is selected. + /// + /// This value is used to build the button. + final SuperEditorDemoTextItem? value; + + /// The items that will be displayed in the popover list. + /// + /// For each item, its [SuperEditorDemoTextItem.label] is displayed. + final List items; + + /// Called when the user selects an item on the popover list. + final void Function(SuperEditorDemoTextItem? value) onSelected; + + @override + State createState() => _SuperEditorDemoTextItemSelectorState(); +} + +class _SuperEditorDemoTextItemSelectorState extends State { + /// Shows and hides the popover. + final PopoverController _popoverController = PopoverController(); + + /// The [FocusNode] of the popover list. + final FocusNode _popoverFocusNode = FocusNode(); + + @override + void dispose() { + _popoverController.dispose(); + _popoverFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopoverScaffold( + controller: _popoverController, + buttonBuilder: _buildButton, + popoverFocusNode: _popoverFocusNode, + boundaryKey: widget.boundaryKey, + popoverBuilder: (context) => PopoverShape( + child: ItemSelectionList( + value: widget.value, + items: widget.items, + itemBuilder: _buildPopoverListItem, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + focusNode: _popoverFocusNode, + parentFocusNode: widget.parentFocusNode, + ), + ), + ); + } + + Widget _buildPopoverListItem(BuildContext context, SuperEditorDemoTextItem item, bool isActive, VoidCallback onTap) { + return DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withOpacity(0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: kMinInteractiveDimension), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Text( + item.label, + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ), + ), + ); + } + + Widget _buildButton(BuildContext context) { + return SuperEditorPopoverButton( + onTap: () => _popoverController.open(), + padding: const EdgeInsets.only(left: 16.0, right: 24), + child: widget.value == null // + ? const SizedBox() + : Text( + widget.value!.label, + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ); + } + + void _onItemSelected(SuperEditorDemoTextItem? value) { + _popoverController.close(); + widget.onSelected(value); + } +} + +/// An option that is displayed as text by a [SuperEditorDemoTextItemSelector]. +/// +/// Two [SuperEditorDemoTextItem]s are considered to be equal if they have the same [value]. +class SuperEditorDemoTextItem { + const SuperEditorDemoTextItem({ + required this.value, + required this.label, + }); + + /// The value that identifies this item. + final String value; + + /// The text that is displayed. + final String label; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SuperEditorDemoTextItem && runtimeType == other.runtimeType && value == other.value; + + @override + int get hashCode => value.hashCode; +} + +/// A selection control, which displays a button with the selected item, and upon tap, displays a +/// popover list of available icons, from which the user can select a different option. +/// +/// Unlike Flutter `DropdownButton`, which displays the popover list in a separate route, +/// this widget displays its popover list in an `Overlay`. By using an `Overlay`, focus can be shared +/// with the [parentFocusNode]. This means that when the popover list requests focus, [parentFocusNode] +/// still has non-primary focus. +/// +/// The popover list is positioned based on the following rules: +/// +/// 1. The popover is displayed below the selected item, if there's enough room, or +/// 2. The popover is displayed above the selected item, if there's enough room, or +/// 3. The popover is displayed with its bottom aligned with the bottom of +/// the given boundary, and it covers the selected item. +/// +/// The popover list height is based on the following rules: +/// +/// 1. The popover is displayed as tall as all items in the list, if there's enough room, or +/// 2. The popover is displayed as tall as the available space and becomes scrollable. +/// +/// The popover list includes keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" item selection up/down. +/// * Pressing UP with the first item active moves the active item selection to the last item. +/// * Pressing DOWN with the last item active moves the active item selection to the first item. +/// * Pressing ENTER selects the currently active item and closes the popover list. +class SuperEditorDemoIconItemSelector extends StatefulWidget { + const SuperEditorDemoIconItemSelector({ + super.key, + this.parentFocusNode, + this.boundaryKey, + this.value, + required this.items, + required this.onSelected, + }); + + /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + /// + /// In Flutter, [FocusNode]s have parents and children. This relationship allows an + /// entire ancestor path to "have focus", but only the lowest level descendant + /// in that path has "primary focus". This path is important because various + /// widgets alter their presentation or behavior based on whether or not they + /// currently have focus, even if they only have "non-primary focus". + /// + /// When the popover list of items is visible, that list will have primary focus. + /// Moreover, because the popover list is built in an `Overlay`, none of your + /// widgets are in the natural focus path for that popover list. Therefore, if you + /// need your widget tree to retain focus while the popover list is visible, then + /// you need to provide the [FocusNode] that the popover list should use as its + /// parent, thereby retaining focus for your widgets. + final FocusNode? parentFocusNode; + + /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + /// + /// As the popover list follows the selected item, it can be displayed off-screen if this [SuperEditorDemoIconItemSelector] + /// is close to the bottom of the screen. + /// + /// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget + /// bound to the [boundaryKey]. + /// + /// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. + final GlobalKey? boundaryKey; + + /// The currently selected value or `null` if no item is selected. + /// + /// This value is used to build the button. + final SuperEditorDemoIconItem? value; + + /// The items that will be displayed in the popover list. + /// + /// For each item, its [SuperEditorDemoIconItem.icon] is displayed. + final List items; + + /// Called when the user selects an item on the popover list. + final void Function(SuperEditorDemoIconItem? value) onSelected; + + @override + State createState() => _SuperEditorDemoIconItemSelectorState(); +} + +class _SuperEditorDemoIconItemSelectorState extends State { + /// Shows and hides the popover. + final PopoverController _popoverController = PopoverController(); + + /// The [FocusNode] of the popover list. + final FocusNode _popoverFocusNode = FocusNode(); + + @override + void dispose() { + _popoverController.dispose(); + _popoverFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopoverScaffold( + controller: _popoverController, + buttonBuilder: _buildButton, + popoverFocusNode: _popoverFocusNode, + popoverBuilder: (context) => PopoverShape( + child: ItemSelectionList( + value: widget.value, + items: widget.items, + itemBuilder: _buildItem, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + parentFocusNode: widget.parentFocusNode, + focusNode: _popoverFocusNode, + ), + ), + ); + } + + Widget _buildItem(BuildContext context, SuperEditorDemoIconItem item, bool isActive, VoidCallback onTap) { + return DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withOpacity(0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: kMinInteractiveDimension), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Icon(item.icon), + ), + ), + ); + } + + Widget _buildButton(BuildContext context) { + return SuperEditorPopoverButton( + onTap: () => _popoverController.open(), + padding: const EdgeInsets.only(left: 8.0, right: 24), + child: widget.value == null // + ? const SizedBox() + : Icon(widget.value!.icon), + ); + } + + void _onItemSelected(SuperEditorDemoIconItem? value) { + _popoverController.close(); + widget.onSelected(value); + } +} + +/// An option that is displayed as an icon by a [SuperEditorDemoIconItemSelector]. +/// +/// Two [SuperEditorDemoIconItem]s are considered to be equal if they have the same [value]. +class SuperEditorDemoIconItem { + const SuperEditorDemoIconItem({ + required this.icon, + required this.value, + }); + + /// The value that identifies this item. + final String value; + + /// The icon that is displayed. + final IconData icon; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SuperEditorDemoIconItem && runtimeType == other.runtimeType && value == other.value; + + @override + int get hashCode => value.hashCode; +} + +/// A button with a center-left aligned [child] and a right aligned arrow icon. +/// +/// The arrow is displayed above the [child]. +class SuperEditorPopoverButton extends StatelessWidget { + const SuperEditorPopoverButton({ + super.key, + required this.onTap, + this.padding, + this.child, + }); + + /// Called when the user taps the button. + final VoidCallback onTap; + + /// Padding around the [child]. + final EdgeInsets? padding; + + /// The Widget displayed inside this button. + /// + /// If `null`, only the arrow is displayed. + final Widget? child; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Center( + child: Stack( + alignment: Alignment.centerLeft, + children: [ + if (child != null) // + Padding( + padding: padding ?? EdgeInsets.zero, + child: child, + ), + const Positioned( + right: 0, + child: Icon(Icons.arrow_drop_down), + ), + ], + ), + ), + ); + } +} diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index bb314eda11..b589f72188 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -5,408 +5,6 @@ import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/platforms/mac/mac_ime.dart'; import 'package:super_editor/super_editor.dart'; -/// A selection control, which displays a button with the selected item, and upon tap, displays a -/// popover list of available text options, from which the user can select a different -/// option. -/// -/// Unlike Flutter `DropdownButton`, which displays the popover list in a separate route, -/// this widget displays its popover list in an `Overlay`. By using an `Overlay`, focus can be shared -/// with the [parentFocusNode]. This means that when the popover list requests focus, [parentFocusNode] -/// still has non-primary focus. -/// -/// The popover list is positioned based on the following rules: -/// -/// 1. The popover is displayed below the selected item, if there's enough room, or -/// 2. The popover is displayed above the selected item, if there's enough room, or -/// 3. The popover is displayed with its bottom aligned with the bottom of -/// the given boundary, and it covers the selected item. -/// -/// The popover list height is based on the following rules: -/// -/// 1. The popover is displayed as tall as all items in the list, if there's enough room, or -/// 2. The popover is displayed as tall as the available space and becomes scrollable. -/// -/// The popover list includes keyboard selection behaviors: -/// -/// * Pressing UP/DOWN moves the "active" item selection up/down. -/// * Pressing UP with the first item active moves the active item selection to the last item. -/// * Pressing DOWN with the last item active moves the active item selection to the first item. -/// * Pressing ENTER selects the currently active item and closes the popover list. -class SuperEditorDemoTextItemSelector extends StatefulWidget { - const SuperEditorDemoTextItemSelector({ - super.key, - this.value, - required this.items, - required this.onSelected, - this.parentFocusNode, - this.boundaryKey, - }); - - /// The currently selected value or `null` if no item is selected. - /// - /// This value is used to build the button. - final SuperEditorDemoTextItem? value; - - /// The items that will be displayed in the popover list. - /// - /// For each item, its [SuperEditorDemoTextItem.label] is displayed. - final List items; - - /// Called when the user selects an item on the popover list. - final void Function(SuperEditorDemoTextItem? value) onSelected; - - /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. - /// - /// In Flutter, [FocusNode]s have parents and children. This relationship allows an - /// entire ancestor path to "have focus", but only the lowest level descendant - /// in that path has "primary focus". This path is important because various - /// widgets alter their presentation or behavior based on whether or not they - /// currently have focus, even if they only have "non-primary focus". - /// - /// When the popover list of items is visible, that list will have primary focus. - /// Moreover, because the popover list is built in an `Overlay`, none of your - /// widgets are in the natural focus path for that popover list. Therefore, if you - /// need your widget tree to retain focus while the popover list is visible, then - /// you need to provide the [FocusNode] that the popover list should use as its - /// parent, thereby retaining focus for your widgets. - final FocusNode? parentFocusNode; - - /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. - /// - /// As the popover list follows the selected item, it can be displayed off-screen if this [SuperEditorDemoTextItemSelector] - /// is close to the bottom of the screen. - /// - /// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget - /// bound to the [boundaryKey]. - /// - /// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. - final GlobalKey? boundaryKey; - - @override - State createState() => _SuperEditorDemoTextItemSelectorState(); -} - -class _SuperEditorDemoTextItemSelectorState extends State { - /// Shows and hides the popover. - final PopoverController _popoverController = PopoverController(); - - /// The [FocusNode] of the popover list. - final FocusNode _popoverFocusNode = FocusNode(); - - @override - void dispose() { - _popoverController.dispose(); - _popoverFocusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return PopoverScaffold( - controller: _popoverController, - buttonBuilder: _buildButton, - popoverFocusNode: _popoverFocusNode, - boundaryKey: widget.boundaryKey, - popoverBuilder: (context) => PopoverShape( - child: ItemSelectionList( - value: widget.value, - items: widget.items, - itemBuilder: _buildPopoverListItem, - onItemSelected: _onItemSelected, - onCancel: () => _popoverController.close(), - focusNode: _popoverFocusNode, - parentFocusNode: widget.parentFocusNode, - ), - ), - ); - } - - Widget _buildPopoverListItem(BuildContext context, SuperEditorDemoTextItem item, bool isActive, VoidCallback onTap) { - return DecoratedBox( - decoration: BoxDecoration( - color: isActive ? Colors.grey.withOpacity(0.2) : Colors.transparent, - ), - child: InkWell( - onTap: onTap, - child: Container( - constraints: const BoxConstraints(minHeight: kMinInteractiveDimension), - alignment: AlignmentDirectional.centerStart, - padding: const EdgeInsets.symmetric(horizontal: 20.0), - child: Text( - item.label, - style: const TextStyle( - color: Colors.black, - fontSize: 12, - ), - ), - ), - ), - ); - } - - Widget _buildButton(BuildContext context) { - return PopoverArrowButton( - onTap: () => _popoverController.open(), - padding: const EdgeInsets.only(left: 16.0, right: 24), - child: widget.value == null // - ? const SizedBox() - : Text( - widget.value!.label, - style: const TextStyle( - color: Colors.black, - fontSize: 12, - ), - ), - ); - } - - void _onItemSelected(SuperEditorDemoTextItem? value) { - _popoverController.close(); - widget.onSelected(value); - } -} - -/// An option that is displayed as text by a [SuperEditorDemoTextItemSelector]. -/// -/// Two [SuperEditorDemoTextItem]s are considered to be equal if they have the same [value]. -class SuperEditorDemoTextItem { - const SuperEditorDemoTextItem({ - required this.value, - required this.label, - }); - - /// The value that identifies this item. - final String value; - - /// The text that is displayed. - final String label; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SuperEditorDemoTextItem && runtimeType == other.runtimeType && value == other.value; - - @override - int get hashCode => value.hashCode; -} - -/// A selection control, which displays a button with the selected item, and upon tap, displays a -/// popover list of available icons, from which the user can select a different option. -/// -/// Unlike Flutter `DropdownButton`, which displays the popover list in a separate route, -/// this widget displays its popover list in an `Overlay`. By using an `Overlay`, focus can be shared -/// with the [parentFocusNode]. This means that when the popover list requests focus, [parentFocusNode] -/// still has non-primary focus. -/// -/// The popover list is positioned based on the following rules: -/// -/// 1. The popover is displayed below the selected item, if there's enough room, or -/// 2. The popover is displayed above the selected item, if there's enough room, or -/// 3. The popover is displayed with its bottom aligned with the bottom of -/// the given boundary, and it covers the selected item. -/// -/// The popover list height is based on the following rules: -/// -/// 1. The popover is displayed as tall as all items in the list, if there's enough room, or -/// 2. The popover is displayed as tall as the available space and becomes scrollable. -/// -/// The popover list includes keyboard selection behaviors: -/// -/// * Pressing UP/DOWN moves the "active" item selection up/down. -/// * Pressing UP with the first item active moves the active item selection to the last item. -/// * Pressing DOWN with the last item active moves the active item selection to the first item. -/// * Pressing ENTER selects the currently active item and closes the popover list. -class SuperEditorDemoIconItemSelector extends StatefulWidget { - const SuperEditorDemoIconItemSelector({ - super.key, - this.value, - required this.items, - required this.onSelected, - this.parentFocusNode, - this.boundaryKey, - }); - - /// The currently selected value or `null` if no item is selected. - /// - /// This value is used to build the button. - final SuperEditorDemoIconItem? value; - - /// The items that will be displayed in the popover list. - /// - /// For each item, its [SuperEditorDemoIconItem.icon] is displayed. - final List items; - - /// Called when the user selects an item on the popover list. - final void Function(SuperEditorDemoIconItem? value) onSelected; - - /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. - /// - /// In Flutter, [FocusNode]s have parents and children. This relationship allows an - /// entire ancestor path to "have focus", but only the lowest level descendant - /// in that path has "primary focus". This path is important because various - /// widgets alter their presentation or behavior based on whether or not they - /// currently have focus, even if they only have "non-primary focus". - /// - /// When the popover list of items is visible, that list will have primary focus. - /// Moreover, because the popover list is built in an `Overlay`, none of your - /// widgets are in the natural focus path for that popover list. Therefore, if you - /// need your widget tree to retain focus while the popover list is visible, then - /// you need to provide the [FocusNode] that the popover list should use as its - /// parent, thereby retaining focus for your widgets. - final FocusNode? parentFocusNode; - - /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. - /// - /// As the popover list follows the selected item, it can be displayed off-screen if this [SuperEditorDemoIconItemSelector] - /// is close to the bottom of the screen. - /// - /// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget - /// bound to the [boundaryKey]. - /// - /// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. - final GlobalKey? boundaryKey; - - @override - State createState() => _SuperEditorDemoIconItemSelectorState(); -} - -class _SuperEditorDemoIconItemSelectorState extends State { - /// Shows and hides the popover. - final PopoverController _popoverController = PopoverController(); - - /// The [FocusNode] of the popover list. - final FocusNode _popoverFocusNode = FocusNode(); - - @override - void dispose() { - _popoverController.dispose(); - _popoverFocusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return PopoverScaffold( - controller: _popoverController, - buttonBuilder: _buildButton, - popoverFocusNode: _popoverFocusNode, - popoverBuilder: (context) => PopoverShape( - child: ItemSelectionList( - value: widget.value, - items: widget.items, - itemBuilder: _buildItem, - onItemSelected: _onItemSelected, - onCancel: () => _popoverController.close(), - parentFocusNode: widget.parentFocusNode, - focusNode: _popoverFocusNode, - ), - ), - ); - } - - Widget _buildItem(BuildContext context, SuperEditorDemoIconItem item, bool isActive, VoidCallback onTap) { - return DecoratedBox( - decoration: BoxDecoration( - color: isActive ? Colors.grey.withOpacity(0.2) : Colors.transparent, - ), - child: InkWell( - onTap: onTap, - child: Container( - constraints: const BoxConstraints(minHeight: kMinInteractiveDimension), - alignment: AlignmentDirectional.centerStart, - padding: const EdgeInsets.symmetric(horizontal: 20.0), - child: Icon(item.icon), - ), - ), - ); - } - - Widget _buildButton(BuildContext context) { - return PopoverArrowButton( - onTap: () => _popoverController.open(), - padding: const EdgeInsets.only(left: 8.0, right: 24), - child: widget.value == null // - ? const SizedBox() - : Icon(widget.value!.icon), - ); - } - - void _onItemSelected(SuperEditorDemoIconItem? value) { - _popoverController.close(); - widget.onSelected(value); - } -} - -/// An option that is displayed as an icon by a [SuperEditorDemoIconItemSelector]. -/// -/// Two [SuperEditorDemoIconItem]s are considered to be equal if they have the same [value]. -class SuperEditorDemoIconItem { - const SuperEditorDemoIconItem({ - required this.icon, - required this.value, - }); - - /// The value that identifies this item. - final String value; - - /// The icon that is displayed. - final IconData icon; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SuperEditorDemoIconItem && runtimeType == other.runtimeType && value == other.value; - - @override - int get hashCode => value.hashCode; -} - -/// A button with a center-left aligned [child] and a right aligned arrow icon. -/// -/// The arrow is displayed above the [child]. -class PopoverArrowButton extends StatelessWidget { - const PopoverArrowButton({ - super.key, - required this.onTap, - this.padding, - this.child, - }); - - /// Called when the user taps the button. - final VoidCallback onTap; - - /// Padding around the [child]. - final EdgeInsets? padding; - - /// The Widget displayed inside this button. - /// - /// If `null`, only the arrow is displayed. - final Widget? child; - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - child: Center( - child: Stack( - alignment: Alignment.centerLeft, - children: [ - if (child != null) // - Padding( - padding: padding ?? EdgeInsets.zero, - child: child, - ), - const Positioned( - right: 0, - child: Icon(Icons.arrow_drop_down), - ), - ], - ), - ), - ); - } -} - /// A selection control, which displays a selected item, and upon tap, displays a /// popover list of available options, from which the user can select a different /// option. @@ -952,7 +550,7 @@ class PopoverController with ChangeNotifier { } } -/// Controls the size and position of a popover. +/// The offset and size of a popover. class PopoverGeometry { const PopoverGeometry({ this.align = defaultPopoverAligner, From 82be137b662c96b0cdab583824869d93aabf049e Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Mon, 27 Nov 2023 19:13:47 -0300 Subject: [PATCH 21/36] Replace _DelegateAligner with FunctionalAligner --- super_editor/example/pubspec.yaml | 2 ++ .../lib/src/infrastructure/dropdown.dart | 27 +++---------------- super_editor/pubspec.yaml | 2 ++ 3 files changed, 7 insertions(+), 24 deletions(-) diff --git a/super_editor/example/pubspec.yaml b/super_editor/example/pubspec.yaml index b5ea317dab..2c344f911a 100644 --- a/super_editor/example/pubspec.yaml +++ b/super_editor/example/pubspec.yaml @@ -67,6 +67,8 @@ dependency_overrides: path: ../../super_text_layout attributed_text: path: ../../attributed_text + follow_the_leader: + git: https://github.com/Flutter-Bounty-Hunters/follow_the_leader dev_dependencies: flutter_test: diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index b589f72188..68ec8f28ab 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -505,9 +505,9 @@ class _PopoverScaffoldState extends State { child: Follower.withAligner( link: _popoverLink, boundary: _screenBoundary, - aligner: _DelegateAligner( - delegate: widget.popoverGeometry.align, - boundaryKey: widget.boundaryKey, + aligner: FunctionalAligner( + delegate: (globalLeaderRect, followerSize) => + widget.popoverGeometry.align(globalLeaderRect, followerSize, widget.boundaryKey), ), child: ConstrainedBox( constraints: widget.popoverGeometry.constraints ?? const BoxConstraints(), @@ -569,27 +569,6 @@ class PopoverGeometry { final BoxConstraints? constraints; } -/// A [FollowerAligner] which uses a [delegate] to align a [Follower]. -class _DelegateAligner implements FollowerAligner { - _DelegateAligner({ - required this.delegate, - this.boundaryKey, - }); - - /// Called to determine the position of the [Follower]. - final PopoverAligner delegate; - - /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. - /// - /// If non-`null`, the [FollowerAlignment] returned by the [delegate] must be within the bounds of its `RenderBox`. - final GlobalKey? boundaryKey; - - @override - FollowerAlignment align(Rect globalLeaderRect, Size followerSize) { - return delegate(globalLeaderRect, followerSize, boundaryKey); - } -} - /// Computes the position of a popover list relative to the dropdown button. /// /// The following rules are applied, in order: diff --git a/super_editor/pubspec.yaml b/super_editor/pubspec.yaml index 4e976fec80..f9b18ba322 100644 --- a/super_editor/pubspec.yaml +++ b/super_editor/pubspec.yaml @@ -45,6 +45,8 @@ dependency_overrides: path: ../attributed_text super_text_layout: path: ../super_text_layout + follow_the_leader: + git: https://github.com/Flutter-Bounty-Hunters/follow_the_leader # # flutter_test_robots: # git: From c0642f35de80563287267c0702de063bfca8477c Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Mon, 27 Nov 2023 20:09:13 -0300 Subject: [PATCH 22/36] Update docs --- super_editor/lib/src/infrastructure/dropdown.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index 68ec8f28ab..3cdade13d9 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -340,8 +340,7 @@ class _PopoverShapeState extends State with SingleTickerProviderSt } } -/// A widget which displays a button built in [buttonBuilder] with a popover -/// which follows the button. +/// A widget used to build UI controls that display a following popover. /// /// The popover is displayed in an `Overlay` and its visibility is changed by calling /// [PopoverController.open] or [PopoverController.close]. The popover is automatically closed From 692a1b58c031f1679c4abb1891fa03e44412cb59 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Mon, 4 Dec 2023 09:33:25 -0300 Subject: [PATCH 23/36] Update dependencies --- super_editor/example/pubspec.yaml | 4 +--- super_editor/pubspec.yaml | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/super_editor/example/pubspec.yaml b/super_editor/example/pubspec.yaml index 2c344f911a..833e68d4da 100644 --- a/super_editor/example/pubspec.yaml +++ b/super_editor/example/pubspec.yaml @@ -53,7 +53,7 @@ dependencies: git: url: https://github.com/superlistapp/super_editor.git path: super_text_layout - follow_the_leader: ^0.0.4+6 + follow_the_leader: ^0.0.4+7 overlord: ^0.0.3+4 dependency_overrides: @@ -67,8 +67,6 @@ dependency_overrides: path: ../../super_text_layout attributed_text: path: ../../attributed_text - follow_the_leader: - git: https://github.com/Flutter-Bounty-Hunters/follow_the_leader dev_dependencies: flutter_test: diff --git a/super_editor/pubspec.yaml b/super_editor/pubspec.yaml index f9b18ba322..ae1d75083c 100644 --- a/super_editor/pubspec.yaml +++ b/super_editor/pubspec.yaml @@ -24,7 +24,7 @@ dependencies: attributed_text: ^0.2.2 characters: ^1.2.0 collection: ^1.15.0 - follow_the_leader: ^0.0.4+6 + follow_the_leader: ^0.0.4+7 http: ">=0.13.1 <2.0.0" linkify: ^5.0.0 logging: ^1.0.1 @@ -45,8 +45,6 @@ dependency_overrides: path: ../attributed_text super_text_layout: path: ../super_text_layout - follow_the_leader: - git: https://github.com/Flutter-Bounty-Hunters/follow_the_leader # # flutter_test_robots: # git: From e8f023d1a16d7c622b9c3fe37b67bce97d9954b6 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Fri, 8 Dec 2023 13:16:53 -0300 Subject: [PATCH 24/36] PR updates --- .../lib/demos/example_editor/_toolbar.dart | 50 +- .../super_editor_item_selector.dart | 82 ++-- .../lib/src/infrastructure/dropdown.dart | 453 ++++++++---------- 3 files changed, 280 insertions(+), 305 deletions(-) diff --git a/super_editor/example/lib/demos/example_editor/_toolbar.dart b/super_editor/example/lib/demos/example_editor/_toolbar.dart index e7155281ab..ae1d9f55dd 100644 --- a/super_editor/example/lib/demos/example_editor/_toolbar.dart +++ b/super_editor/example/lib/demos/example_editor/_toolbar.dart @@ -102,16 +102,20 @@ class _EditorToolbarState extends State { // because we need to access `AppLocalizations.of`, which isn't // available in initState. final currentBlockType = _getCurrentTextType(); - _selectedBlockType = SuperEditorDemoTextItem( - value: currentBlockType.name, - label: _getTextTypeName(currentBlockType), - ); + _selectedBlockType = currentBlockType != null + ? SuperEditorDemoTextItem( + id: currentBlockType.name, + label: _getTextTypeName(currentBlockType), + ) + : null; final currentAlignment = _getCurrentTextAlignment(); - _selectedAlignment = SuperEditorDemoIconItem( - value: currentAlignment.name, - icon: _buildTextAlignIcon(currentAlignment), - ); + _selectedAlignment = currentAlignment != null + ? SuperEditorDemoIconItem( + id: currentAlignment.name, + icon: _buildTextAlignIcon(currentAlignment), + ) + : null; } @override @@ -139,8 +143,8 @@ class _EditorToolbarState extends State { /// Returns the block type of the currently selected text node. /// - /// Throws an exception if the currently selected node is not a text node. - _TextType _getCurrentTextType() { + /// Returns `null` if the currently selected node is not a text node. + _TextType? _getCurrentTextType() { final selectedNode = widget.document.getNodeById(widget.composer.selection!.extent.nodeId); if (selectedNode is ParagraphNode) { final type = selectedNode.getMetadataValue('blockType'); @@ -159,14 +163,14 @@ class _EditorToolbarState extends State { } else if (selectedNode is ListItemNode) { return selectedNode.type == ListItemType.ordered ? _TextType.orderedListItem : _TextType.unorderedListItem; } else { - throw Exception('Invalid node type: $selectedNode'); + return null; } } /// Returns the text alignment of the currently selected text node. /// - /// Throws an exception if the currently selected node is not a text node. - TextAlign _getCurrentTextAlignment() { + /// Returns `null` if the currently selected node is not a text node. + TextAlign? _getCurrentTextAlignment() { final selectedNode = widget.document.getNodeById(widget.composer.selection!.extent.nodeId); if (selectedNode is ParagraphNode) { final align = selectedNode.getMetadataValue('textAlign'); @@ -183,7 +187,7 @@ class _EditorToolbarState extends State { return TextAlign.left; } } else { - throw Exception('Alignment does not apply to node of type: $selectedNode'); + return null; } } @@ -495,7 +499,7 @@ class _EditorToolbarState extends State { setState(() { _selectedBlockType = selectedItem; _convertTextToNewType(_TextType.values // - .where((e) => e.name == selectedItem.value) + .where((e) => e.name == selectedItem.id) .first); }); } @@ -506,7 +510,7 @@ class _EditorToolbarState extends State { if (selectedItem != null) { setState(() { _selectedAlignment = selectedItem; - _changeAlignment(TextAlign.values.firstWhere((e) => e.name == selectedItem.value)); + _changeAlignment(TextAlign.values.firstWhere((e) => e.name == selectedItem.id)); }); } } @@ -637,9 +641,9 @@ class _EditorToolbarState extends State { value: _selectedAlignment, items: TextAlign.values .map( - (e) => SuperEditorDemoIconItem( - icon: _buildTextAlignIcon(e), - value: e.name, + (alignment) => SuperEditorDemoIconItem( + icon: _buildTextAlignIcon(alignment), + id: alignment.name, ), ) .toList(), @@ -651,12 +655,12 @@ class _EditorToolbarState extends State { return SuperEditorDemoTextItemSelector( parentFocusNode: widget.editorFocusNode, boundaryKey: widget.editorViewportKey, - value: _selectedBlockType, + id: _selectedBlockType, items: _TextType.values .map( - (e) => SuperEditorDemoTextItem( - value: e.name, - label: _getTextTypeName(e), + (blockType) => SuperEditorDemoTextItem( + id: blockType.name, + label: _getTextTypeName(blockType), ), ) .toList(), diff --git a/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart b/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart index 915ddd9618..09c81f8afa 100644 --- a/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart +++ b/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart @@ -33,7 +33,7 @@ class SuperEditorDemoTextItemSelector extends StatefulWidget { super.key, this.parentFocusNode, this.boundaryKey, - this.value, + this.id, required this.items, required this.onSelected, }); @@ -68,7 +68,7 @@ class SuperEditorDemoTextItemSelector extends StatefulWidget { /// The currently selected value or `null` if no item is selected. /// /// This value is used to build the button. - final SuperEditorDemoTextItem? value; + final SuperEditorDemoTextItem? id; /// The items that will be displayed in the popover list. /// @@ -96,6 +96,11 @@ class _SuperEditorDemoTextItemSelectorState extends State PopoverShape( child: ItemSelectionList( - value: widget.value, + focusNode: _popoverFocusNode, + parentFocusNode: widget.parentFocusNode, + value: widget.id, items: widget.items, itemBuilder: _buildPopoverListItem, onItemSelected: _onItemSelected, onCancel: () => _popoverController.close(), - focusNode: _popoverFocusNode, - parentFocusNode: widget.parentFocusNode, ), ), ); } + Widget _buildButton(BuildContext context) { + return SuperEditorPopoverButton( + padding: const EdgeInsets.only(left: 16.0, right: 24), + onTap: () => _popoverController.open(), + child: widget.id == null // + ? const SizedBox() + : Text( + widget.id!.label, + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ); + } + Widget _buildPopoverListItem(BuildContext context, SuperEditorDemoTextItem item, bool isActive, VoidCallback onTap) { return DecoratedBox( decoration: BoxDecoration( @@ -139,51 +160,29 @@ class _SuperEditorDemoTextItemSelectorState extends State _popoverController.open(), - padding: const EdgeInsets.only(left: 16.0, right: 24), - child: widget.value == null // - ? const SizedBox() - : Text( - widget.value!.label, - style: const TextStyle( - color: Colors.black, - fontSize: 12, - ), - ), - ); - } - - void _onItemSelected(SuperEditorDemoTextItem? value) { - _popoverController.close(); - widget.onSelected(value); - } } /// An option that is displayed as text by a [SuperEditorDemoTextItemSelector]. /// -/// Two [SuperEditorDemoTextItem]s are considered to be equal if they have the same [value]. +/// Two [SuperEditorDemoTextItem]s are considered to be equal if they have the same [id]. class SuperEditorDemoTextItem { const SuperEditorDemoTextItem({ - required this.value, + required this.id, required this.label, }); /// The value that identifies this item. - final String value; + final String id; /// The text that is displayed. final String label; @override bool operator ==(Object other) => - identical(this, other) || - other is SuperEditorDemoTextItem && runtimeType == other.runtimeType && value == other.value; + identical(this, other) || other is SuperEditorDemoTextItem && runtimeType == other.runtimeType && id == other.id; @override - int get hashCode => value.hashCode; + int get hashCode => id.hashCode; } /// A selection control, which displays a button with the selected item, and upon tap, displays a @@ -335,26 +334,25 @@ class _SuperEditorDemoIconItemSelectorState extends State - identical(this, other) || - other is SuperEditorDemoIconItem && runtimeType == other.runtimeType && value == other.value; + identical(this, other) || other is SuperEditorDemoIconItem && runtimeType == other.runtimeType && id == other.id; @override - int get hashCode => value.hashCode; + int get hashCode => id.hashCode; } /// A button with a center-left aligned [child] and a right aligned arrow icon. @@ -363,17 +361,17 @@ class SuperEditorDemoIconItem { class SuperEditorPopoverButton extends StatelessWidget { const SuperEditorPopoverButton({ super.key, - required this.onTap, this.padding, + required this.onTap, this.child, }); - /// Called when the user taps the button. - final VoidCallback onTap; - /// Padding around the [child]. final EdgeInsets? padding; + /// Called when the user taps the button. + final VoidCallback onTap; + /// The Widget displayed inside this button. /// /// If `null`, only the arrow is displayed. diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index 3cdade13d9..31f761b6b3 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -2,35 +2,177 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; -import 'package:super_editor/src/infrastructure/platforms/mac/mac_ime.dart'; import 'package:super_editor/super_editor.dart'; -/// A selection control, which displays a selected item, and upon tap, displays a -/// popover list of available options, from which the user can select a different -/// option. +/// A scaffold, which builds a popover selection system, comprised of a button and a popover +/// that's positioned near the button. /// -/// Unlike Flutter `DropdownButton`, which displays the popover list in a separate route, -/// this widget displays its popover list in an `Overlay`. By using an `Overlay`, focus can be shared -/// with the [parentFocusNode]. This means that when the popover list requests focus, [parentFocusNode] -/// still has non-primary focus. +/// Unlike Flutter `DropdownButton`, which displays the popover in a separate route, +/// this widget displays its popover in an `Overlay`. By using an `Overlay`, focus can be shared +/// between the popover's `FocusNode` and an arbitrary parent `FocusNode`. /// -/// The popover list is positioned based on the following rules: +/// The popover visibility is changed by calling [PopoverController.open] or [PopoverController.close]. +/// The popover is automatically closed when the user taps outside of its bounds. /// -/// 1. The popover is displayed below the selected item, if there's enough room, or -/// 2. The popover is displayed above the selected item, if there's enough room, or -/// 3. The popover is displayed with its bottom aligned with the bottom of -/// the given boundary, and it covers the selected item. -/// -/// The popover list height is based on the following rules: -/// -/// 1. The popover height is constrained by [popoverGeometry.contraints], if provided, -/// becoming scrollable if there isn't enough room to display all items, or -/// 2. The popover is displayed as tall as all items in the list, if there's enough room, or -/// 3. The popover is displayed as tall as the available space and becomes scrollable. +/// Provide a [popoverGeometry] to control the size and position of the popover. The popover +/// is first sized given the [PopoverGeometry.constraints] and then positioned using the +/// [PopoverGeometry.align]. /// +/// When the popover is displayed it requests focus to itself, so the user can +/// interact with the content using the keyboard. +class PopoverScaffold extends StatefulWidget { + const PopoverScaffold({ + super.key, + required this.controller, + required this.buttonBuilder, + required this.popoverBuilder, + this.popoverGeometry = const PopoverGeometry(), + this.popoverFocusNode, + this.boundaryKey, + }); + + /// Shows and hides the popover. + final PopoverController controller; + + /// Builds a button that is always displayed. + final WidgetBuilder buttonBuilder; + + /// Builds the content of the popover. + final WidgetBuilder popoverBuilder; + + /// Controls the size and position of the popover. + /// + /// The popover is first sized, then positioned. + final PopoverGeometry popoverGeometry; + + /// The [FocusNode] which is bound to the popover. + /// + /// Focus will be requested to this [FocusNode] when the popover is displayed. + final FocusNode? popoverFocusNode; + + /// A [GlobalKey] to a widget that determines the bounds where the popover can be displayed. + /// + /// Passing a [boundaryKey] causes the popover to be confined to the bounds of the widget + /// bound to the [boundaryKey]. + /// + /// If `null`, the popover is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. + final GlobalKey? boundaryKey; + + @override + State createState() => _PopoverScaffoldState(); +} + +class _PopoverScaffoldState extends State { + final OverlayPortalController _overlayController = OverlayPortalController(); + final LeaderLink _popoverLink = LeaderLink(); + + late FollowerBoundary _screenBoundary; + + @override + void initState() { + super.initState(); + + widget.controller.addListener(_onPopoverControllerChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateFollowerBoundary(); + } + + @override + void didUpdateWidget(covariant PopoverScaffold oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onPopoverControllerChanged); + widget.controller.addListener(_onPopoverControllerChanged); + } + + if (oldWidget.boundaryKey != widget.boundaryKey) { + _updateFollowerBoundary(); + } + } + @override + void dispose() { + widget.controller.removeListener(_onPopoverControllerChanged); + _popoverLink.dispose(); + + super.dispose(); + } + + void _updateFollowerBoundary() { + if (widget.boundaryKey != null) { + _screenBoundary = WidgetFollowerBoundary( + boundaryKey: widget.boundaryKey, + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + ); + } else { + _screenBoundary = ScreenFollowerBoundary( + screenSize: MediaQuery.sizeOf(context), + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + ); + } + } + + void _onPopoverControllerChanged() { + if (widget.controller.shouldShow) { + _overlayController.show(); + if (widget.popoverFocusNode != null) { + onNextFrame((timeStamp) { + widget.popoverFocusNode!.requestFocus(); + }); + } + } else { + _overlayController.hide(); + } + } + + void _onTapOutsideOfDropdown(PointerDownEvent e) { + widget.controller.close(); + } + + @override + Widget build(BuildContext context) { + return OverlayPortal( + controller: _overlayController, + overlayChildBuilder: _buildDropdown, + child: Leader( + link: _popoverLink, + child: widget.buttonBuilder(context), + ), + ); + } + + Widget _buildDropdown(BuildContext context) { + return TapRegion( + onTapOutside: _onTapOutsideOfDropdown, + child: Actions( + actions: disabledMacIntents, + child: Follower.withAligner( + link: _popoverLink, + boundary: _screenBoundary, + aligner: FunctionalAligner( + delegate: (globalLeaderRect, followerSize) => + widget.popoverGeometry.align(globalLeaderRect, followerSize, widget.boundaryKey), + ), + child: ConstrainedBox( + constraints: widget.popoverGeometry.constraints ?? const BoxConstraints(), + child: widget.popoverBuilder(context), + ), + ), + ), + ); + } +} + +/// A list where the user can navigate between its items and select one of them. +/// +/// This widget shares focus with its [parentFocusNode]. This means that when the list requests focus, +/// [parentFocusNode] still has non-primary focus. /// -/// The popover list includes keyboard selection behaviors: +/// Includes the following keyboard selection behaviors: /// /// * Pressing UP/DOWN moves the "active" item selection up/down. /// * Pressing UP with the first item active moves the active item selection to the last item. @@ -50,8 +192,6 @@ class ItemSelectionList extends StatefulWidget { }); /// The currently selected value or `null` if no item is selected. - /// - /// This value is passed to [buttonBuilder] to build the visual representation of the selected item. final T? value; /// The items that will be displayed in the popover list. @@ -87,6 +227,12 @@ class ItemSelectionList extends StatefulWidget { final FocusNode? focusNode; /// The [FocusNode], to which the list's [FocusNode] will be added as a child. + /// + /// In Flutter, [FocusNode]s have parents and children. This relationship allows an + /// entire ancestor path to "have focus", but only the lowest level descendant + /// in that path has "primary focus". This path is important because various + /// widgets alter their presentation or behavior based on whether or not they + /// currently have focus, even if they only have "non-primary focus". final FocusNode? parentFocusNode; @override @@ -95,17 +241,18 @@ class ItemSelectionList extends StatefulWidget { @visibleForTesting class ItemSelectionListState extends State> with SingleTickerProviderStateMixin { - int? _activeIndex; + final GlobalKey _scrollableKey = GlobalKey(); @visibleForTesting - ScrollController get scrollController => _scrollController; - final ScrollController _scrollController = ScrollController(); - - final GlobalKey _scrollableKey = GlobalKey(); + final ScrollController scrollController = ScrollController(); /// Holds keys to each item on the list. + /// + /// Used to scroll the list to reveal the active item. final List _itemKeys = []; + int? _activeIndex; + @override void initState() { super.initState(); @@ -114,7 +261,7 @@ class ItemSelectionListState extends State> with SingleT @override void dispose() { - _scrollController.dispose(); + scrollController.dispose(); super.dispose(); } @@ -128,37 +275,40 @@ class ItemSelectionListState extends State> with SingleT } int selectedItemIndex = widget.items.indexOf(selectedItem); - if (selectedItemIndex > -1) { - // We just opened the popover. - // Jump to the active item without animation. - _activateItem(selectedItemIndex, Duration.zero); - } else { + if (selectedItemIndex < 0) { // A selected item was provided, but it isn't included in the list of items. _activeIndex = null; + return; } - } - /// Called when the user taps an item or presses ENTER with an active item. - void _selectItem(T? item) { - widget.onItemSelected(item); + // We just opened the popover. + // Jump to the active item without animation. + _activateItem(selectedItemIndex, animationDuration: Duration.zero); } /// Activates the item at [itemIndex] and ensure it's visible on screen. - void _activateItem(int? itemIndex, Duration animationDuration) { + /// + /// The active item is selected when the user presses ENTER. + void _activateItem(int? itemIndex, {required Duration animationDuration}) { _activeIndex = itemIndex; if (itemIndex != null) { widget.onItemActivated?.call(widget.items[itemIndex]); } - // Scrolls on the next frame to let the popover to be - // laid-out first, so we can access its RenderBox. + // This method might be called before the widget was rendered. + // For example, when the widget is created with a selected item, + // this item is immediately activated, before the rendering pipeline is + // executed. Therefore, the RenderBox won't be available at the same frame. + // + // Scrolls on the next frame to let the popover be laid-out first, + // so we can access its RenderBox. onNextFrame((timeStamp) { - _ensureActiveItemIsVisible(animationDuration); + _scrollToShowActiveItem(animationDuration); }); } /// Scrolls the popover scrollable to display the selected item. - void _ensureActiveItemIsVisible(Duration animationDuration) { + void _scrollToShowActiveItem(Duration animationDuration) { if (_activeIndex == null) { return; } @@ -178,7 +328,7 @@ class ItemSelectionListState extends State> with SingleT } KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return KeyEventResult.ignored; } @@ -200,15 +350,17 @@ class ItemSelectionListState extends State> with SingleT if (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter) { if (_activeIndex == null) { // The user pressed ENTER without an active item. - _selectItem(null); + // Clear the selected item. + widget.onItemSelected(null); return KeyEventResult.handled; } - _selectItem(widget.items[_activeIndex!]); + widget.onItemSelected(widget.items[_activeIndex!]); return KeyEventResult.handled; } + // The user pressed an arrow key. Update the active item. int? newActiveIndex; if (event.logicalKey == LogicalKeyboardKey.arrowDown) { if (_activeIndex == null || _activeIndex! >= widget.items.length - 1) { @@ -231,7 +383,7 @@ class ItemSelectionListState extends State> with SingleT } setState(() { - _activateItem(newActiveIndex, const Duration(milliseconds: 100)); + _activateItem(newActiveIndex, animationDuration: const Duration(milliseconds: 100)); }); return KeyEventResult.handled; @@ -239,23 +391,11 @@ class ItemSelectionListState extends State> with SingleT @override Widget build(BuildContext context) { - final children = []; _itemKeys.clear(); for (int i = 0; i < widget.items.length; i++) { - final key = GlobalKey(); - children.add(Container( - key: key, - child: widget.itemBuilder( - context, - widget.items[i], - i == _activeIndex, - () => _selectItem(widget.items[i]), - ), - )); - _itemKeys.add(key); + _itemKeys.add(GlobalKey()); } - return Focus( focusNode: widget.focusNode, parentNode: widget.parentFocusNode, @@ -267,7 +407,7 @@ class ItemSelectionListState extends State> with SingleT physics: const ClampingScrollPhysics(), ), child: PrimaryScrollController( - controller: _scrollController, + controller: scrollController, child: Scrollbar( thumbVisibility: true, child: SingleChildScrollView( @@ -276,7 +416,18 @@ class ItemSelectionListState extends State> with SingleT child: IntrinsicWidth( child: Column( mainAxisSize: MainAxisSize.min, - children: children, + children: [ + for (int i = 0; i < widget.items.length; i++) + KeyedSubtree( + key: _itemKeys[i], + child: widget.itemBuilder( + context, + widget.items[i], + i == _activeIndex, + () => widget.onItemSelected(widget.items[i]), + ), + ), + ], ), ), ), @@ -340,184 +491,6 @@ class _PopoverShapeState extends State with SingleTickerProviderSt } } -/// A widget used to build UI controls that display a following popover. -/// -/// The popover is displayed in an `Overlay` and its visibility is changed by calling -/// [PopoverController.open] or [PopoverController.close]. The popover is automatically closed -/// when the user taps outside of its bounds. -/// -/// Provide a [popoverGeometry] to control the size and position of the popover. The popover -/// is first sized given the [PopoverGeometry.constraints] and then positioned using the -/// [PopoverGeometry.align]. -/// -/// When the popover is displayed it requests focus to itself, so the user can -/// interact with the content using the keyboard. -class PopoverScaffold extends StatefulWidget { - const PopoverScaffold({ - super.key, - required this.controller, - required this.buttonBuilder, - required this.popoverBuilder, - this.popoverGeometry = const PopoverGeometry(), - this.onKeyEvent, - this.popoverFocusNode, - this.boundaryKey, - }); - - /// Shows and hides the popover. - final PopoverController controller; - - /// Builds a button that is always displayed. - final WidgetBuilder buttonBuilder; - - /// Builds the content of the popover. - final WidgetBuilder popoverBuilder; - - /// Controls the size and position of the popover. - /// - /// The popover is first sized, then positioned. - final PopoverGeometry popoverGeometry; - - /// Called at each key press while the popover has focus. - final FocusOnKeyEventCallback? onKeyEvent; - - /// [FocusNode] which will share focus with the popover. - final FocusNode? popoverFocusNode; - - /// A [GlobalKey] to a widget that determines the bounds where the popover can be displayed. - /// - /// As the popover follows the selected item, it can be displayed off-screen if this [PopoverScaffold] - /// is close to the bottom of the screen. - /// - /// Passing a [boundaryKey] causes the popover to be confined to the bounds of the widget - /// bound to the [boundaryKey]. - /// - /// If `null`, the popover is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. - final GlobalKey? boundaryKey; - - @override - State createState() => _PopoverScaffoldState(); -} - -class _PopoverScaffoldState extends State { - final OverlayPortalController _overlayController = OverlayPortalController(); - final LeaderLink _popoverLink = LeaderLink(); - late FocusNode _popoverFocusNode; - - late FollowerBoundary _screenBoundary; - - @override - void initState() { - super.initState(); - - _popoverFocusNode = widget.popoverFocusNode ?? FocusNode(); - widget.controller.addListener(_onPopoverControllerChanged); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _updateFollowerBoundary(); - } - - @override - void didUpdateWidget(covariant PopoverScaffold oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_onPopoverControllerChanged); - widget.controller.addListener(_onPopoverControllerChanged); - } - - if (oldWidget.popoverFocusNode != widget.popoverFocusNode) { - if (oldWidget.popoverFocusNode == null) { - _popoverFocusNode.dispose(); - } - - _popoverFocusNode = widget.popoverFocusNode ?? FocusNode(); - } - - if (oldWidget.boundaryKey != widget.boundaryKey) { - _updateFollowerBoundary(); - } - } - - @override - void dispose() { - widget.controller.removeListener(_onPopoverControllerChanged); - _popoverLink.dispose(); - - if (widget.popoverFocusNode == null) { - _popoverFocusNode.dispose(); - } - - super.dispose(); - } - - void _updateFollowerBoundary() { - if (widget.boundaryKey != null) { - _screenBoundary = WidgetFollowerBoundary( - boundaryKey: widget.boundaryKey, - devicePixelRatio: MediaQuery.devicePixelRatioOf(context), - ); - } else { - _screenBoundary = ScreenFollowerBoundary( - screenSize: MediaQuery.sizeOf(context), - devicePixelRatio: MediaQuery.devicePixelRatioOf(context), - ); - } - } - - void _onPopoverControllerChanged() { - if (widget.controller.shouldShow) { - _overlayController.show(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - // Wait until next frame to request focus, so that the parent relationship - // can be established between our focus node and the parent focus node. - _popoverFocusNode.requestFocus(); - }); - } else { - _overlayController.hide(); - } - } - - void _onTapOutsideOfDropdown(PointerDownEvent e) { - widget.controller.close(); - } - - @override - Widget build(BuildContext context) { - return OverlayPortal( - controller: _overlayController, - overlayChildBuilder: _buildDropdown, - child: Leader( - link: _popoverLink, - child: widget.buttonBuilder(context), - ), - ); - } - - Widget _buildDropdown(BuildContext context) { - return TapRegion( - onTapOutside: _onTapOutsideOfDropdown, - child: Actions( - actions: disabledMacIntents, - child: Follower.withAligner( - link: _popoverLink, - boundary: _screenBoundary, - aligner: FunctionalAligner( - delegate: (globalLeaderRect, followerSize) => - widget.popoverGeometry.align(globalLeaderRect, followerSize, widget.boundaryKey), - ), - child: ConstrainedBox( - constraints: widget.popoverGeometry.constraints ?? const BoxConstraints(), - child: widget.popoverBuilder(context), - ), - ), - ), - ); - } -} - /// Controls the visibility of a popover. class PopoverController with ChangeNotifier { /// Whether the popover should be displayed. From ef1f34e1f0ed30033ddaa7a03a1956b938b85822 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Fri, 8 Dec 2023 20:30:57 -0300 Subject: [PATCH 25/36] PR updates --- .../lib/demos/example_editor/_toolbar.dart | 48 +++++++------------ 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/super_editor/example/lib/demos/example_editor/_toolbar.dart b/super_editor/example/lib/demos/example_editor/_toolbar.dart index ae1d9f55dd..ebd644b54c 100644 --- a/super_editor/example/lib/demos/example_editor/_toolbar.dart +++ b/super_editor/example/lib/demos/example_editor/_toolbar.dart @@ -71,9 +71,6 @@ class _EditorToolbarState extends State { late FocusNode _urlFocusNode; ImeAttributedTextEditingController? _urlController; - SuperEditorDemoTextItem? _selectedBlockType; - SuperEditorDemoIconItem? _selectedAlignment; - @override void initState() { super.initState(); @@ -97,25 +94,6 @@ class _EditorToolbarState extends State { boundaryKey: widget.editorViewportKey, devicePixelRatio: MediaQuery.devicePixelRatioOf(context), ); - - // We assign the selected block type here instead of initState - // because we need to access `AppLocalizations.of`, which isn't - // available in initState. - final currentBlockType = _getCurrentTextType(); - _selectedBlockType = currentBlockType != null - ? SuperEditorDemoTextItem( - id: currentBlockType.name, - label: _getTextTypeName(currentBlockType), - ) - : null; - - final currentAlignment = _getCurrentTextAlignment(); - _selectedAlignment = currentAlignment != null - ? SuperEditorDemoIconItem( - id: currentAlignment.name, - icon: _buildTextAlignIcon(currentAlignment), - ) - : null; } @override @@ -143,8 +121,8 @@ class _EditorToolbarState extends State { /// Returns the block type of the currently selected text node. /// - /// Returns `null` if the currently selected node is not a text node. - _TextType? _getCurrentTextType() { + /// Throws an exception if the currently selected node is not a text node. + _TextType _getCurrentTextType() { final selectedNode = widget.document.getNodeById(widget.composer.selection!.extent.nodeId); if (selectedNode is ParagraphNode) { final type = selectedNode.getMetadataValue('blockType'); @@ -163,14 +141,14 @@ class _EditorToolbarState extends State { } else if (selectedNode is ListItemNode) { return selectedNode.type == ListItemType.ordered ? _TextType.orderedListItem : _TextType.unorderedListItem; } else { - return null; + throw Exception('Alignment does not apply to node of type: $selectedNode'); } } /// Returns the text alignment of the currently selected text node. /// - /// Returns `null` if the currently selected node is not a text node. - TextAlign? _getCurrentTextAlignment() { + /// Throws an exception if the currently selected node is not a text node. + TextAlign _getCurrentTextAlignment() { final selectedNode = widget.document.getNodeById(widget.composer.selection!.extent.nodeId); if (selectedNode is ParagraphNode) { final align = selectedNode.getMetadataValue('textAlign'); @@ -187,7 +165,7 @@ class _EditorToolbarState extends State { return TextAlign.left; } } else { - return null; + throw Exception('Invalid node type: $selectedNode'); } } @@ -497,7 +475,6 @@ class _EditorToolbarState extends State { void _onBlockTypeSelected(SuperEditorDemoTextItem? selectedItem) { if (selectedItem != null) { setState(() { - _selectedBlockType = selectedItem; _convertTextToNewType(_TextType.values // .where((e) => e.name == selectedItem.id) .first); @@ -509,7 +486,6 @@ class _EditorToolbarState extends State { void _onAlignmentSelected(SuperEditorDemoIconItem? selectedItem) { if (selectedItem != null) { setState(() { - _selectedAlignment = selectedItem; _changeAlignment(TextAlign.values.firstWhere((e) => e.name == selectedItem.id)); }); } @@ -635,10 +611,14 @@ class _EditorToolbarState extends State { } Widget _buildAlignmentSelector() { + final alignment = _getCurrentTextAlignment(); return SuperEditorDemoIconItemSelector( parentFocusNode: widget.editorFocusNode, boundaryKey: widget.editorViewportKey, - value: _selectedAlignment, + value: SuperEditorDemoIconItem( + id: alignment.name, + icon: _buildTextAlignIcon(alignment), + ), items: TextAlign.values .map( (alignment) => SuperEditorDemoIconItem( @@ -652,10 +632,14 @@ class _EditorToolbarState extends State { } Widget _buildBlockTypeSelector() { + final currentBlockType = _getCurrentTextType(); return SuperEditorDemoTextItemSelector( parentFocusNode: widget.editorFocusNode, boundaryKey: widget.editorViewportKey, - id: _selectedBlockType, + id: SuperEditorDemoTextItem( + id: currentBlockType.name, + label: _getTextTypeName(currentBlockType), + ), items: _TextType.values .map( (blockType) => SuperEditorDemoTextItem( From e602f200f42701a16d8f272017ee62ba133dcc48 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Fri, 8 Dec 2023 20:37:48 -0300 Subject: [PATCH 26/36] Rename dropdown to popover and add onTapOutSide configuration --- .../lib/src/infrastructure/dropdown.dart | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index 31f761b6b3..b2e880aadb 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -29,6 +29,7 @@ class PopoverScaffold extends StatefulWidget { this.popoverGeometry = const PopoverGeometry(), this.popoverFocusNode, this.boundaryKey, + this.onTapOutSide, }); /// Shows and hides the popover. @@ -58,6 +59,11 @@ class PopoverScaffold extends StatefulWidget { /// If `null`, the popover is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. final GlobalKey? boundaryKey; + /// Called when the user taps outside of the popover. + /// + /// If `null`, tapping outside closes the popover. + final VoidCallback? onTapOutSide; + @override State createState() => _PopoverScaffoldState(); } @@ -129,15 +135,19 @@ class _PopoverScaffoldState extends State { } } - void _onTapOutsideOfDropdown(PointerDownEvent e) { - widget.controller.close(); + void _onTapOutsideOfPopover(PointerDownEvent e) { + if (widget.onTapOutSide != null) { + widget.onTapOutSide!(); + } else { + widget.controller.close(); + } } @override Widget build(BuildContext context) { return OverlayPortal( controller: _overlayController, - overlayChildBuilder: _buildDropdown, + overlayChildBuilder: _buildPopover, child: Leader( link: _popoverLink, child: widget.buttonBuilder(context), @@ -145,9 +155,9 @@ class _PopoverScaffoldState extends State { ); } - Widget _buildDropdown(BuildContext context) { + Widget _buildPopover(BuildContext context) { return TapRegion( - onTapOutside: _onTapOutsideOfDropdown, + onTapOutside: _onTapOutsideOfPopover, child: Actions( actions: disabledMacIntents, child: Follower.withAligner( @@ -579,7 +589,7 @@ FollowerAlignment defaultPopoverAligner(Rect globalLeaderRect, Size followerSize ); } else { // There isn't enough room to fully display the follower below or above the leader. - // Pin the dropdown list to the bottom, letting the follower cover the leader. + // Pin the popover list to the bottom, letting the follower cover the leader. alignment = const FollowerAlignment( leaderAnchor: Alignment.bottomCenter, followerAnchor: Alignment.topCenter, From ad8eb108174d0924f7c9ae3d68e1c494f7405cd0 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Fri, 8 Dec 2023 20:41:55 -0300 Subject: [PATCH 27/36] Default popover tap outside --- super_editor/lib/src/infrastructure/dropdown.dart | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart index b2e880aadb..5c267fef6f 100644 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ b/super_editor/lib/src/infrastructure/dropdown.dart @@ -29,7 +29,7 @@ class PopoverScaffold extends StatefulWidget { this.popoverGeometry = const PopoverGeometry(), this.popoverFocusNode, this.boundaryKey, - this.onTapOutSide, + this.onTapOutside = _defaultPopoverOnTapOutside, }); /// Shows and hides the popover. @@ -62,7 +62,7 @@ class PopoverScaffold extends StatefulWidget { /// Called when the user taps outside of the popover. /// /// If `null`, tapping outside closes the popover. - final VoidCallback? onTapOutSide; + final void Function(PopoverController) onTapOutside; @override State createState() => _PopoverScaffoldState(); @@ -136,11 +136,7 @@ class _PopoverScaffoldState extends State { } void _onTapOutsideOfPopover(PointerDownEvent e) { - if (widget.onTapOutSide != null) { - widget.onTapOutSide!(); - } else { - widget.controller.close(); - } + widget.onTapOutside(widget.controller); } @override @@ -551,6 +547,11 @@ class PopoverGeometry { final BoxConstraints? constraints; } +/// Closes the popover when tapping outside. +void _defaultPopoverOnTapOutside(PopoverController controller) { + controller.close(); +} + /// Computes the position of a popover list relative to the dropdown button. /// /// The following rules are applied, in order: From 3395d45309a3f166bc4442227e323d3eaa1fe761 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Fri, 8 Dec 2023 20:48:14 -0300 Subject: [PATCH 28/36] Split files --- .../super_editor_item_selector.dart | 4 +- .../src/infrastructure/default_popovers.dart | 55 ++ .../lib/src/infrastructure/dropdown.dart | 619 ------------------ .../src/infrastructure/popover_scaffold.dart | 282 ++++++++ .../src/infrastructure/selectable_list.dart | 287 ++++++++ super_editor/lib/super_editor.dart | 4 +- 6 files changed, 629 insertions(+), 622 deletions(-) create mode 100644 super_editor/lib/src/infrastructure/default_popovers.dart delete mode 100644 super_editor/lib/src/infrastructure/dropdown.dart create mode 100644 super_editor/lib/src/infrastructure/popover_scaffold.dart create mode 100644 super_editor/lib/src/infrastructure/selectable_list.dart diff --git a/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart b/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart index 09c81f8afa..e8d046c969 100644 --- a/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart +++ b/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart @@ -108,7 +108,7 @@ class _SuperEditorDemoTextItemSelectorState extends State PopoverShape( + popoverBuilder: (context) => RoundedRectanglePopoverAppearance( child: ItemSelectionList( focusNode: _popoverFocusNode, parentFocusNode: widget.parentFocusNode, @@ -285,7 +285,7 @@ class _SuperEditorDemoIconItemSelectorState extends State PopoverShape( + popoverBuilder: (context) => RoundedRectanglePopoverAppearance( child: ItemSelectionList( value: widget.value, items: widget.items, diff --git a/super_editor/lib/src/infrastructure/default_popovers.dart b/super_editor/lib/src/infrastructure/default_popovers.dart new file mode 100644 index 0000000000..06d88322d2 --- /dev/null +++ b/super_editor/lib/src/infrastructure/default_popovers.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +/// A rounded rectangle shape with a fade-in transition. +class RoundedRectanglePopoverAppearance extends StatefulWidget { + const RoundedRectanglePopoverAppearance({ + super.key, + required this.child, + }); + + final Widget child; + + @override + State createState() => _RoundedRectanglePopoverAppearanceState(); +} + +class _RoundedRectanglePopoverAppearanceState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animationController; + late final Animation _containerFadeInAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + _containerFadeInAnimation = CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ); + + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( + elevation: 8, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.hardEdge, + child: FadeTransition( + opacity: _containerFadeInAnimation, + child: widget.child, + ), + ); + } +} diff --git a/super_editor/lib/src/infrastructure/dropdown.dart b/super_editor/lib/src/infrastructure/dropdown.dart deleted file mode 100644 index 5c267fef6f..0000000000 --- a/super_editor/lib/src/infrastructure/dropdown.dart +++ /dev/null @@ -1,619 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:follow_the_leader/follow_the_leader.dart'; -import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; -import 'package:super_editor/super_editor.dart'; - -/// A scaffold, which builds a popover selection system, comprised of a button and a popover -/// that's positioned near the button. -/// -/// Unlike Flutter `DropdownButton`, which displays the popover in a separate route, -/// this widget displays its popover in an `Overlay`. By using an `Overlay`, focus can be shared -/// between the popover's `FocusNode` and an arbitrary parent `FocusNode`. -/// -/// The popover visibility is changed by calling [PopoverController.open] or [PopoverController.close]. -/// The popover is automatically closed when the user taps outside of its bounds. -/// -/// Provide a [popoverGeometry] to control the size and position of the popover. The popover -/// is first sized given the [PopoverGeometry.constraints] and then positioned using the -/// [PopoverGeometry.align]. -/// -/// When the popover is displayed it requests focus to itself, so the user can -/// interact with the content using the keyboard. -class PopoverScaffold extends StatefulWidget { - const PopoverScaffold({ - super.key, - required this.controller, - required this.buttonBuilder, - required this.popoverBuilder, - this.popoverGeometry = const PopoverGeometry(), - this.popoverFocusNode, - this.boundaryKey, - this.onTapOutside = _defaultPopoverOnTapOutside, - }); - - /// Shows and hides the popover. - final PopoverController controller; - - /// Builds a button that is always displayed. - final WidgetBuilder buttonBuilder; - - /// Builds the content of the popover. - final WidgetBuilder popoverBuilder; - - /// Controls the size and position of the popover. - /// - /// The popover is first sized, then positioned. - final PopoverGeometry popoverGeometry; - - /// The [FocusNode] which is bound to the popover. - /// - /// Focus will be requested to this [FocusNode] when the popover is displayed. - final FocusNode? popoverFocusNode; - - /// A [GlobalKey] to a widget that determines the bounds where the popover can be displayed. - /// - /// Passing a [boundaryKey] causes the popover to be confined to the bounds of the widget - /// bound to the [boundaryKey]. - /// - /// If `null`, the popover is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. - final GlobalKey? boundaryKey; - - /// Called when the user taps outside of the popover. - /// - /// If `null`, tapping outside closes the popover. - final void Function(PopoverController) onTapOutside; - - @override - State createState() => _PopoverScaffoldState(); -} - -class _PopoverScaffoldState extends State { - final OverlayPortalController _overlayController = OverlayPortalController(); - final LeaderLink _popoverLink = LeaderLink(); - - late FollowerBoundary _screenBoundary; - - @override - void initState() { - super.initState(); - - widget.controller.addListener(_onPopoverControllerChanged); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _updateFollowerBoundary(); - } - - @override - void didUpdateWidget(covariant PopoverScaffold oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_onPopoverControllerChanged); - widget.controller.addListener(_onPopoverControllerChanged); - } - - if (oldWidget.boundaryKey != widget.boundaryKey) { - _updateFollowerBoundary(); - } - } - - @override - void dispose() { - widget.controller.removeListener(_onPopoverControllerChanged); - _popoverLink.dispose(); - - super.dispose(); - } - - void _updateFollowerBoundary() { - if (widget.boundaryKey != null) { - _screenBoundary = WidgetFollowerBoundary( - boundaryKey: widget.boundaryKey, - devicePixelRatio: MediaQuery.devicePixelRatioOf(context), - ); - } else { - _screenBoundary = ScreenFollowerBoundary( - screenSize: MediaQuery.sizeOf(context), - devicePixelRatio: MediaQuery.devicePixelRatioOf(context), - ); - } - } - - void _onPopoverControllerChanged() { - if (widget.controller.shouldShow) { - _overlayController.show(); - if (widget.popoverFocusNode != null) { - onNextFrame((timeStamp) { - widget.popoverFocusNode!.requestFocus(); - }); - } - } else { - _overlayController.hide(); - } - } - - void _onTapOutsideOfPopover(PointerDownEvent e) { - widget.onTapOutside(widget.controller); - } - - @override - Widget build(BuildContext context) { - return OverlayPortal( - controller: _overlayController, - overlayChildBuilder: _buildPopover, - child: Leader( - link: _popoverLink, - child: widget.buttonBuilder(context), - ), - ); - } - - Widget _buildPopover(BuildContext context) { - return TapRegion( - onTapOutside: _onTapOutsideOfPopover, - child: Actions( - actions: disabledMacIntents, - child: Follower.withAligner( - link: _popoverLink, - boundary: _screenBoundary, - aligner: FunctionalAligner( - delegate: (globalLeaderRect, followerSize) => - widget.popoverGeometry.align(globalLeaderRect, followerSize, widget.boundaryKey), - ), - child: ConstrainedBox( - constraints: widget.popoverGeometry.constraints ?? const BoxConstraints(), - child: widget.popoverBuilder(context), - ), - ), - ), - ); - } -} - -/// A list where the user can navigate between its items and select one of them. -/// -/// This widget shares focus with its [parentFocusNode]. This means that when the list requests focus, -/// [parentFocusNode] still has non-primary focus. -/// -/// Includes the following keyboard selection behaviors: -/// -/// * Pressing UP/DOWN moves the "active" item selection up/down. -/// * Pressing UP with the first item active moves the active item selection to the last item. -/// * Pressing DOWN with the last item active moves the active item selection to the first item. -/// * Pressing ENTER selects the currently active item and closes the popover list. -class ItemSelectionList extends StatefulWidget { - const ItemSelectionList({ - super.key, - required this.value, - required this.items, - required this.itemBuilder, - this.onItemActivated, - required this.onItemSelected, - this.onCancel, - this.focusNode, - this.parentFocusNode, - }); - - /// The currently selected value or `null` if no item is selected. - final T? value; - - /// The items that will be displayed in the popover list. - /// - /// For each item, [itemBuilder] is called to build its visual representation. - final List items; - - /// Builds each item in the popover list. - /// - /// This method is called for each item in [items], to build its visual representation. - /// - /// The provided `onTap` must be called when the item is tapped. - final PopoverListItemBuilder itemBuilder; - - /// Called when the user activates an item on the popover list. - /// - /// The activation can be performed by: - /// 1. Opening the popover, when the selected item is activate. - /// 2. Pressing UP ARROW or DOWN ARROW. - final ValueChanged? onItemActivated; - - /// Called when the user selects an item on the popover list. - /// - /// The selection can be performed by: - /// 1. Tapping on an item in the popover list. - /// 2. Pressing ENTER when the popover list has an active item. - final ValueChanged onItemSelected; - - /// Called when the user presses ESCAPE. - final VoidCallback? onCancel; - - /// The [FocusNode] of the list. - final FocusNode? focusNode; - - /// The [FocusNode], to which the list's [FocusNode] will be added as a child. - /// - /// In Flutter, [FocusNode]s have parents and children. This relationship allows an - /// entire ancestor path to "have focus", but only the lowest level descendant - /// in that path has "primary focus". This path is important because various - /// widgets alter their presentation or behavior based on whether or not they - /// currently have focus, even if they only have "non-primary focus". - final FocusNode? parentFocusNode; - - @override - State> createState() => ItemSelectionListState(); -} - -@visibleForTesting -class ItemSelectionListState extends State> with SingleTickerProviderStateMixin { - final GlobalKey _scrollableKey = GlobalKey(); - - @visibleForTesting - final ScrollController scrollController = ScrollController(); - - /// Holds keys to each item on the list. - /// - /// Used to scroll the list to reveal the active item. - final List _itemKeys = []; - - int? _activeIndex; - - @override - void initState() { - super.initState(); - _activateSelectedItem(); - } - - @override - void dispose() { - scrollController.dispose(); - - super.dispose(); - } - - void _activateSelectedItem() { - final selectedItem = widget.value; - - if (selectedItem == null) { - _activeIndex = null; - return; - } - - int selectedItemIndex = widget.items.indexOf(selectedItem); - if (selectedItemIndex < 0) { - // A selected item was provided, but it isn't included in the list of items. - _activeIndex = null; - return; - } - - // We just opened the popover. - // Jump to the active item without animation. - _activateItem(selectedItemIndex, animationDuration: Duration.zero); - } - - /// Activates the item at [itemIndex] and ensure it's visible on screen. - /// - /// The active item is selected when the user presses ENTER. - void _activateItem(int? itemIndex, {required Duration animationDuration}) { - _activeIndex = itemIndex; - if (itemIndex != null) { - widget.onItemActivated?.call(widget.items[itemIndex]); - } - - // This method might be called before the widget was rendered. - // For example, when the widget is created with a selected item, - // this item is immediately activated, before the rendering pipeline is - // executed. Therefore, the RenderBox won't be available at the same frame. - // - // Scrolls on the next frame to let the popover be laid-out first, - // so we can access its RenderBox. - onNextFrame((timeStamp) { - _scrollToShowActiveItem(animationDuration); - }); - } - - /// Scrolls the popover scrollable to display the selected item. - void _scrollToShowActiveItem(Duration animationDuration) { - if (_activeIndex == null) { - return; - } - - final key = _itemKeys[_activeIndex!]; - - final childRenderBox = key.currentContext?.findRenderObject() as RenderBox?; - if (childRenderBox == null) { - return; - } - - childRenderBox.showOnScreen( - rect: Offset.zero & childRenderBox.size, - duration: animationDuration, - curve: Curves.easeIn, - ); - } - - KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { - return KeyEventResult.ignored; - } - - if (!const [ - LogicalKeyboardKey.enter, - LogicalKeyboardKey.numpadEnter, - LogicalKeyboardKey.arrowDown, - LogicalKeyboardKey.arrowUp, - LogicalKeyboardKey.escape, - ].contains(event.logicalKey)) { - return KeyEventResult.ignored; - } - - if (event.logicalKey == LogicalKeyboardKey.escape) { - widget.onCancel?.call(); - return KeyEventResult.handled; - } - - if (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter) { - if (_activeIndex == null) { - // The user pressed ENTER without an active item. - // Clear the selected item. - widget.onItemSelected(null); - return KeyEventResult.handled; - } - - widget.onItemSelected(widget.items[_activeIndex!]); - - return KeyEventResult.handled; - } - - // The user pressed an arrow key. Update the active item. - int? newActiveIndex; - if (event.logicalKey == LogicalKeyboardKey.arrowDown) { - if (_activeIndex == null || _activeIndex! >= widget.items.length - 1) { - // We don't have an active item or we are at the end of the list. Activate the first item. - newActiveIndex = 0; - } else { - // Activate the next item. - newActiveIndex = _activeIndex! + 1; - } - } - - if (event.logicalKey == LogicalKeyboardKey.arrowUp) { - if (_activeIndex == null || _activeIndex! <= 0) { - // We don't have an active item or we are at the beginning of the list. Activate the last item. - newActiveIndex = widget.items.length - 1; - } else { - // Activate the previous item. - newActiveIndex = _activeIndex! - 1; - } - } - - setState(() { - _activateItem(newActiveIndex, animationDuration: const Duration(milliseconds: 100)); - }); - - return KeyEventResult.handled; - } - - @override - Widget build(BuildContext context) { - _itemKeys.clear(); - - for (int i = 0; i < widget.items.length; i++) { - _itemKeys.add(GlobalKey()); - } - return Focus( - focusNode: widget.focusNode, - parentNode: widget.parentFocusNode, - onKeyEvent: _onKeyEvent, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - scrollbars: false, - overscroll: false, - physics: const ClampingScrollPhysics(), - ), - child: PrimaryScrollController( - controller: scrollController, - child: Scrollbar( - thumbVisibility: true, - child: SingleChildScrollView( - key: _scrollableKey, - primary: true, - child: IntrinsicWidth( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (int i = 0; i < widget.items.length; i++) - KeyedSubtree( - key: _itemKeys[i], - child: widget.itemBuilder( - context, - widget.items[i], - i == _activeIndex, - () => widget.onItemSelected(widget.items[i]), - ), - ), - ], - ), - ), - ), - ), - ), - ), - ); - } -} - -/// A rounded rectangle shape with a fade-in transition. -class PopoverShape extends StatefulWidget { - const PopoverShape({ - super.key, - required this.child, - }); - - final Widget child; - - @override - State createState() => _PopoverShapeState(); -} - -class _PopoverShapeState extends State with SingleTickerProviderStateMixin { - late final AnimationController _animationController; - late final Animation _containerFadeInAnimation; - - @override - void initState() { - super.initState(); - _animationController = AnimationController( - duration: const Duration(milliseconds: 300), - vsync: this, - ); - - _containerFadeInAnimation = CurvedAnimation( - parent: _animationController, - curve: Curves.easeInOut, - ); - - _animationController.forward(); - } - - @override - void dispose() { - _animationController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Material( - elevation: 8, - borderRadius: BorderRadius.circular(12), - clipBehavior: Clip.hardEdge, - child: FadeTransition( - opacity: _containerFadeInAnimation, - child: widget.child, - ), - ); - } -} - -/// Controls the visibility of a popover. -class PopoverController with ChangeNotifier { - /// Whether the popover should be displayed. - bool get shouldShow => _shouldShow; - bool _shouldShow = false; - - void open() { - if (_shouldShow) { - return; - } - _shouldShow = true; - notifyListeners(); - } - - void close() { - if (!_shouldShow) { - return; - } - _shouldShow = false; - notifyListeners(); - } - - void toggle() { - if (shouldShow) { - close(); - } else { - open(); - } - } -} - -/// The offset and size of a popover. -class PopoverGeometry { - const PopoverGeometry({ - this.align = defaultPopoverAligner, - this.constraints, - }); - - /// Positions the popover. - /// - /// If the `boundaryKey` is non-`null`, the popover must be positioned within the bounds of - /// the `RenderBox` bound to `boundaryKey`. - final PopoverAligner align; - - /// [BoxConstraints] applied to the popover. - /// - /// If `null`, the popover can use all the available space. - final BoxConstraints? constraints; -} - -/// Closes the popover when tapping outside. -void _defaultPopoverOnTapOutside(PopoverController controller) { - controller.close(); -} - -/// Computes the position of a popover list relative to the dropdown button. -/// -/// The following rules are applied, in order: -/// -/// 1. If there is enough room to display the dropdown list beneath the button, -/// position it below the button. -/// -/// 2. If there is enough room to display the dropdown list above the button, -/// position it above the button. -/// -/// 3. Pin the dropdown list to the bottom of the `RenderBox` bound to [boundaryKey], -/// letting the dropdown list cover the button. -FollowerAlignment defaultPopoverAligner(Rect globalLeaderRect, Size followerSize, GlobalKey? boundaryKey) { - final boundsBox = boundaryKey?.currentContext?.findRenderObject() as RenderBox?; - final bounds = boundsBox != null - ? Rect.fromPoints( - boundsBox.localToGlobal(Offset.zero), - boundsBox.localToGlobal(boundsBox.size.bottomRight(Offset.zero)), - ) - : Rect.largest; - late FollowerAlignment alignment; - - if (globalLeaderRect.bottom + followerSize.height < bounds.bottom) { - // The follower fits below the leader. - alignment = const FollowerAlignment( - leaderAnchor: Alignment.bottomCenter, - followerAnchor: Alignment.topCenter, - followerOffset: Offset(0, 20), - ); - } else if (globalLeaderRect.top - followerSize.height > bounds.top) { - // The follower fits above the leader. - alignment = const FollowerAlignment( - leaderAnchor: Alignment.topCenter, - followerAnchor: Alignment.bottomCenter, - followerOffset: Offset(0, -20), - ); - } else { - // There isn't enough room to fully display the follower below or above the leader. - // Pin the popover list to the bottom, letting the follower cover the leader. - alignment = const FollowerAlignment( - leaderAnchor: Alignment.bottomCenter, - followerAnchor: Alignment.topCenter, - followerOffset: Offset(0, 20), - ); - } - - return alignment; -} - -/// A function to align a Widget following a leader Widget. -/// -/// If a [boundaryKey] is given, the alignment must be within the bounds of its `RenderBox`. -typedef PopoverAligner = FollowerAlignment Function(Rect globalLeaderRect, Size followerSize, GlobalKey? boundaryKey); - -/// Builds a popover list item. -/// -/// [isActive] is `true` if [item] is the currently active item on the list, or `false` otherwise. -/// -/// The provided [onTap] must be called when the button is tapped. -typedef PopoverListItemBuilder = Widget Function(BuildContext context, T item, bool isActive, VoidCallback onTap); - -/// Builds a button is an [ItemSelectionList]. -/// -/// The provided [onTap] must be called when the button is tapped. -typedef PopoverListButtonBuilder = Widget Function(BuildContext context, T? selectedItem, VoidCallback onTap); diff --git a/super_editor/lib/src/infrastructure/popover_scaffold.dart b/super_editor/lib/src/infrastructure/popover_scaffold.dart new file mode 100644 index 0000000000..645fbc908a --- /dev/null +++ b/super_editor/lib/src/infrastructure/popover_scaffold.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/platforms/mac/mac_ime.dart'; + +/// A scaffold, which builds a popover selection system, comprised of a button and a popover +/// that's positioned near the button. +/// +/// Unlike Flutter `DropdownButton`, which displays the popover in a separate route, +/// this widget displays its popover in an `Overlay`. By using an `Overlay`, focus can be shared +/// between the popover's `FocusNode` and an arbitrary parent `FocusNode`. +/// +/// The popover visibility is changed by calling [PopoverController.open] or [PopoverController.close]. +/// The popover is automatically closed when the user taps outside of its bounds. +/// +/// Provide a [popoverGeometry] to control the size and position of the popover. The popover +/// is first sized given the [PopoverGeometry.constraints] and then positioned using the +/// [PopoverGeometry.align]. +/// +/// When the popover is displayed it requests focus to itself, so the user can +/// interact with the content using the keyboard. +class PopoverScaffold extends StatefulWidget { + const PopoverScaffold({ + super.key, + required this.controller, + required this.buttonBuilder, + required this.popoverBuilder, + this.popoverGeometry = const PopoverGeometry(), + this.popoverFocusNode, + this.boundaryKey, + this.onTapOutside = closePopoverOnTapOutside, + }); + + /// Shows and hides the popover. + final PopoverController controller; + + /// Builds a button that is always displayed. + final WidgetBuilder buttonBuilder; + + /// Builds the content of the popover. + final WidgetBuilder popoverBuilder; + + /// Controls the size and position of the popover. + /// + /// The popover is first sized, then positioned. + final PopoverGeometry popoverGeometry; + + /// The [FocusNode] which is bound to the popover. + /// + /// Focus will be requested to this [FocusNode] when the popover is displayed. + final FocusNode? popoverFocusNode; + + /// A [GlobalKey] to a widget that determines the bounds where the popover can be displayed. + /// + /// Passing a [boundaryKey] causes the popover to be confined to the bounds of the widget + /// bound to the [boundaryKey]. + /// + /// If `null`, the popover is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. + final GlobalKey? boundaryKey; + + /// Called when the user taps outside of the popover. + /// + /// If `null`, tapping outside closes the popover. + final void Function(PopoverController) onTapOutside; + + @override + State createState() => _PopoverScaffoldState(); +} + +class _PopoverScaffoldState extends State { + final OverlayPortalController _overlayController = OverlayPortalController(); + final LeaderLink _popoverLink = LeaderLink(); + + late FollowerBoundary _screenBoundary; + + @override + void initState() { + super.initState(); + + widget.controller.addListener(_onPopoverControllerChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateFollowerBoundary(); + } + + @override + void didUpdateWidget(covariant PopoverScaffold oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onPopoverControllerChanged); + widget.controller.addListener(_onPopoverControllerChanged); + } + + if (oldWidget.boundaryKey != widget.boundaryKey) { + _updateFollowerBoundary(); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onPopoverControllerChanged); + _popoverLink.dispose(); + + super.dispose(); + } + + void _updateFollowerBoundary() { + if (widget.boundaryKey != null) { + _screenBoundary = WidgetFollowerBoundary( + boundaryKey: widget.boundaryKey, + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + ); + } else { + _screenBoundary = ScreenFollowerBoundary( + screenSize: MediaQuery.sizeOf(context), + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + ); + } + } + + void _onPopoverControllerChanged() { + if (widget.controller.shouldShow) { + _overlayController.show(); + if (widget.popoverFocusNode != null) { + onNextFrame((timeStamp) { + widget.popoverFocusNode!.requestFocus(); + }); + } + } else { + _overlayController.hide(); + } + } + + void _onTapOutsideOfPopover(PointerDownEvent e) { + widget.onTapOutside(widget.controller); + } + + @override + Widget build(BuildContext context) { + return OverlayPortal( + controller: _overlayController, + overlayChildBuilder: _buildPopover, + child: Leader( + link: _popoverLink, + child: widget.buttonBuilder(context), + ), + ); + } + + Widget _buildPopover(BuildContext context) { + return TapRegion( + onTapOutside: _onTapOutsideOfPopover, + child: Actions( + actions: disabledMacIntents, + child: Follower.withAligner( + link: _popoverLink, + boundary: _screenBoundary, + aligner: FunctionalAligner( + delegate: (globalLeaderRect, followerSize) => + widget.popoverGeometry.align(globalLeaderRect, followerSize, widget.boundaryKey), + ), + child: ConstrainedBox( + constraints: widget.popoverGeometry.constraints ?? const BoxConstraints(), + child: widget.popoverBuilder(context), + ), + ), + ), + ); + } +} + +/// Controls the visibility of a popover. +class PopoverController with ChangeNotifier { + /// Whether the popover should be displayed. + bool get shouldShow => _shouldShow; + bool _shouldShow = false; + + void open() { + if (_shouldShow) { + return; + } + _shouldShow = true; + notifyListeners(); + } + + void close() { + if (!_shouldShow) { + return; + } + _shouldShow = false; + notifyListeners(); + } + + void toggle() { + if (shouldShow) { + close(); + } else { + open(); + } + } +} + +/// The offset and size of a popover. +class PopoverGeometry { + const PopoverGeometry({ + this.align = defaultPopoverAligner, + this.constraints, + }); + + /// Positions the popover. + /// + /// If the `boundaryKey` is non-`null`, the popover must be positioned within the bounds of + /// the `RenderBox` bound to `boundaryKey`. + final PopoverAligner align; + + /// [BoxConstraints] applied to the popover. + /// + /// If `null`, the popover can use all the available space. + final BoxConstraints? constraints; +} + +/// Closes the popover when tapping outside. +void closePopoverOnTapOutside(PopoverController controller) { + controller.close(); +} + +/// Computes the position of a popover list relative to the dropdown button. +/// +/// The following rules are applied, in order: +/// +/// 1. If there is enough room to display the dropdown list beneath the button, +/// position it below the button. +/// +/// 2. If there is enough room to display the dropdown list above the button, +/// position it above the button. +/// +/// 3. Pin the dropdown list to the bottom of the `RenderBox` bound to [boundaryKey], +/// letting the dropdown list cover the button. +FollowerAlignment defaultPopoverAligner(Rect globalLeaderRect, Size followerSize, GlobalKey? boundaryKey) { + final boundsBox = boundaryKey?.currentContext?.findRenderObject() as RenderBox?; + final bounds = boundsBox != null + ? Rect.fromPoints( + boundsBox.localToGlobal(Offset.zero), + boundsBox.localToGlobal(boundsBox.size.bottomRight(Offset.zero)), + ) + : Rect.largest; + late FollowerAlignment alignment; + + if (globalLeaderRect.bottom + followerSize.height < bounds.bottom) { + // The follower fits below the leader. + alignment = const FollowerAlignment( + leaderAnchor: Alignment.bottomCenter, + followerAnchor: Alignment.topCenter, + followerOffset: Offset(0, 20), + ); + } else if (globalLeaderRect.top - followerSize.height > bounds.top) { + // The follower fits above the leader. + alignment = const FollowerAlignment( + leaderAnchor: Alignment.topCenter, + followerAnchor: Alignment.bottomCenter, + followerOffset: Offset(0, -20), + ); + } else { + // There isn't enough room to fully display the follower below or above the leader. + // Pin the popover list to the bottom, letting the follower cover the leader. + alignment = const FollowerAlignment( + leaderAnchor: Alignment.bottomCenter, + followerAnchor: Alignment.topCenter, + followerOffset: Offset(0, 20), + ); + } + + return alignment; +} + +/// A function to align a Widget following a leader Widget. +/// +/// If a [boundaryKey] is given, the alignment must be within the bounds of its `RenderBox`. +typedef PopoverAligner = FollowerAlignment Function(Rect globalLeaderRect, Size followerSize, GlobalKey? boundaryKey); diff --git a/super_editor/lib/src/infrastructure/selectable_list.dart b/super_editor/lib/src/infrastructure/selectable_list.dart new file mode 100644 index 0000000000..98170c62bf --- /dev/null +++ b/super_editor/lib/src/infrastructure/selectable_list.dart @@ -0,0 +1,287 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A list where the user can navigate between its items and select one of them. +/// +/// This widget shares focus with its [parentFocusNode]. This means that when the list requests focus, +/// [parentFocusNode] still has non-primary focus. +/// +/// Includes the following keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" item selection up/down. +/// * Pressing UP with the first item active moves the active item selection to the last item. +/// * Pressing DOWN with the last item active moves the active item selection to the first item. +/// * Pressing ENTER selects the currently active item and closes the popover list. +class ItemSelectionList extends StatefulWidget { + const ItemSelectionList({ + super.key, + required this.value, + required this.items, + required this.itemBuilder, + this.onItemActivated, + required this.onItemSelected, + this.onCancel, + this.focusNode, + this.parentFocusNode, + }); + + /// The currently selected value or `null` if no item is selected. + final T? value; + + /// The items that will be displayed in the popover list. + /// + /// For each item, [itemBuilder] is called to build its visual representation. + final List items; + + /// Builds each item in the popover list. + /// + /// This method is called for each item in [items], to build its visual representation. + /// + /// The provided `onTap` must be called when the item is tapped. + final PopoverListItemBuilder itemBuilder; + + /// Called when the user activates an item on the popover list. + /// + /// The activation can be performed by: + /// 1. Opening the popover, when the selected item is activate. + /// 2. Pressing UP ARROW or DOWN ARROW. + final ValueChanged? onItemActivated; + + /// Called when the user selects an item on the popover list. + /// + /// The selection can be performed by: + /// 1. Tapping on an item in the popover list. + /// 2. Pressing ENTER when the popover list has an active item. + final ValueChanged onItemSelected; + + /// Called when the user presses ESCAPE. + final VoidCallback? onCancel; + + /// The [FocusNode] of the list. + final FocusNode? focusNode; + + /// The [FocusNode], to which the list's [FocusNode] will be added as a child. + /// + /// In Flutter, [FocusNode]s have parents and children. This relationship allows an + /// entire ancestor path to "have focus", but only the lowest level descendant + /// in that path has "primary focus". This path is important because various + /// widgets alter their presentation or behavior based on whether or not they + /// currently have focus, even if they only have "non-primary focus". + final FocusNode? parentFocusNode; + + @override + State> createState() => ItemSelectionListState(); +} + +@visibleForTesting +class ItemSelectionListState extends State> with SingleTickerProviderStateMixin { + final GlobalKey _scrollableKey = GlobalKey(); + + @visibleForTesting + final ScrollController scrollController = ScrollController(); + + /// Holds keys to each item on the list. + /// + /// Used to scroll the list to reveal the active item. + final List _itemKeys = []; + + int? _activeIndex; + + @override + void initState() { + super.initState(); + _activateSelectedItem(); + } + + @override + void dispose() { + scrollController.dispose(); + + super.dispose(); + } + + void _activateSelectedItem() { + final selectedItem = widget.value; + + if (selectedItem == null) { + _activeIndex = null; + return; + } + + int selectedItemIndex = widget.items.indexOf(selectedItem); + if (selectedItemIndex < 0) { + // A selected item was provided, but it isn't included in the list of items. + _activeIndex = null; + return; + } + + // We just opened the popover. + // Jump to the active item without animation. + _activateItem(selectedItemIndex, animationDuration: Duration.zero); + } + + /// Activates the item at [itemIndex] and ensure it's visible on screen. + /// + /// The active item is selected when the user presses ENTER. + void _activateItem(int? itemIndex, {required Duration animationDuration}) { + _activeIndex = itemIndex; + if (itemIndex != null) { + widget.onItemActivated?.call(widget.items[itemIndex]); + } + + // This method might be called before the widget was rendered. + // For example, when the widget is created with a selected item, + // this item is immediately activated, before the rendering pipeline is + // executed. Therefore, the RenderBox won't be available at the same frame. + // + // Scrolls on the next frame to let the popover be laid-out first, + // so we can access its RenderBox. + onNextFrame((timeStamp) { + _scrollToShowActiveItem(animationDuration); + }); + } + + /// Scrolls the popover scrollable to display the selected item. + void _scrollToShowActiveItem(Duration animationDuration) { + if (_activeIndex == null) { + return; + } + + final key = _itemKeys[_activeIndex!]; + + final childRenderBox = key.currentContext?.findRenderObject() as RenderBox?; + if (childRenderBox == null) { + return; + } + + childRenderBox.showOnScreen( + rect: Offset.zero & childRenderBox.size, + duration: animationDuration, + curve: Curves.easeIn, + ); + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + if (!const [ + LogicalKeyboardKey.enter, + LogicalKeyboardKey.numpadEnter, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.escape, + ].contains(event.logicalKey)) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.escape) { + widget.onCancel?.call(); + return KeyEventResult.handled; + } + + if (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter) { + if (_activeIndex == null) { + // The user pressed ENTER without an active item. + // Clear the selected item. + widget.onItemSelected(null); + return KeyEventResult.handled; + } + + widget.onItemSelected(widget.items[_activeIndex!]); + + return KeyEventResult.handled; + } + + // The user pressed an arrow key. Update the active item. + int? newActiveIndex; + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + if (_activeIndex == null || _activeIndex! >= widget.items.length - 1) { + // We don't have an active item or we are at the end of the list. Activate the first item. + newActiveIndex = 0; + } else { + // Activate the next item. + newActiveIndex = _activeIndex! + 1; + } + } + + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (_activeIndex == null || _activeIndex! <= 0) { + // We don't have an active item or we are at the beginning of the list. Activate the last item. + newActiveIndex = widget.items.length - 1; + } else { + // Activate the previous item. + newActiveIndex = _activeIndex! - 1; + } + } + + setState(() { + _activateItem(newActiveIndex, animationDuration: const Duration(milliseconds: 100)); + }); + + return KeyEventResult.handled; + } + + @override + Widget build(BuildContext context) { + _itemKeys.clear(); + + for (int i = 0; i < widget.items.length; i++) { + _itemKeys.add(GlobalKey()); + } + return Focus( + focusNode: widget.focusNode, + parentNode: widget.parentFocusNode, + onKeyEvent: _onKeyEvent, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + scrollbars: false, + overscroll: false, + physics: const ClampingScrollPhysics(), + ), + child: PrimaryScrollController( + controller: scrollController, + child: Scrollbar( + thumbVisibility: true, + child: SingleChildScrollView( + key: _scrollableKey, + primary: true, + child: IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < widget.items.length; i++) + KeyedSubtree( + key: _itemKeys[i], + child: widget.itemBuilder( + context, + widget.items[i], + i == _activeIndex, + () => widget.onItemSelected(widget.items[i]), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +/// Builds a popover list item. +/// +/// [isActive] is `true` if [item] is the currently active item on the list, or `false` otherwise. +/// +/// The provided [onTap] must be called when the button is tapped. +typedef PopoverListItemBuilder = Widget Function(BuildContext context, T item, bool isActive, VoidCallback onTap); + +/// Builds a button is an [ItemSelectionList]. +/// +/// The provided [onTap] must be called when the button is tapped. +typedef PopoverListButtonBuilder = Widget Function(BuildContext context, T? selectedItem, VoidCallback onTap); diff --git a/super_editor/lib/super_editor.dart b/super_editor/lib/super_editor.dart index 4b23dbe3da..9f3464a62f 100644 --- a/super_editor/lib/super_editor.dart +++ b/super_editor/lib/super_editor.dart @@ -87,7 +87,9 @@ export 'src/infrastructure/touch_controls.dart'; export 'src/infrastructure/text_input.dart'; export 'src/infrastructure/viewport_size_reporting.dart'; export 'src/infrastructure/popovers.dart'; -export 'src/infrastructure/dropdown.dart'; +export 'src/infrastructure/popover_scaffold.dart'; +export 'src/infrastructure/selectable_list.dart'; +export 'src/infrastructure/default_popovers.dart'; // Super Reader export 'src/super_reader/read_only_document_android_touch_interactor.dart'; From 4d22abfb984307e0a84c20d01ad962aae44bfd6c Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Fri, 8 Dec 2023 20:53:52 -0300 Subject: [PATCH 29/36] PR updates --- .../src/infrastructure/popover_scaffold.dart | 20 +++++++++---------- .../src/infrastructure/selectable_list.dart | 9 ++------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/super_editor/lib/src/infrastructure/popover_scaffold.dart b/super_editor/lib/src/infrastructure/popover_scaffold.dart index 645fbc908a..ddefe59a6c 100644 --- a/super_editor/lib/src/infrastructure/popover_scaffold.dart +++ b/super_editor/lib/src/infrastructure/popover_scaffold.dart @@ -28,7 +28,7 @@ class PopoverScaffold extends StatefulWidget { this.popoverGeometry = const PopoverGeometry(), this.popoverFocusNode, this.boundaryKey, - this.onTapOutside = closePopoverOnTapOutside, + this.onTapOutside = _PopoverScaffoldState.closePopoverOnTapOutside, }); /// Shows and hides the popover. @@ -73,6 +73,11 @@ class _PopoverScaffoldState extends State { late FollowerBoundary _screenBoundary; + /// Closes the popover when tapping outside. + static void closePopoverOnTapOutside(PopoverController controller) { + controller.close(); + } + @override void initState() { super.initState(); @@ -222,10 +227,10 @@ class PopoverGeometry { final BoxConstraints? constraints; } -/// Closes the popover when tapping outside. -void closePopoverOnTapOutside(PopoverController controller) { - controller.close(); -} +/// A function to align a Widget following a leader Widget. +/// +/// If a [boundaryKey] is given, the alignment must be within the bounds of its `RenderBox`. +typedef PopoverAligner = FollowerAlignment Function(Rect globalLeaderRect, Size followerSize, GlobalKey? boundaryKey); /// Computes the position of a popover list relative to the dropdown button. /// @@ -275,8 +280,3 @@ FollowerAlignment defaultPopoverAligner(Rect globalLeaderRect, Size followerSize return alignment; } - -/// A function to align a Widget following a leader Widget. -/// -/// If a [boundaryKey] is given, the alignment must be within the bounds of its `RenderBox`. -typedef PopoverAligner = FollowerAlignment Function(Rect globalLeaderRect, Size followerSize, GlobalKey? boundaryKey); diff --git a/super_editor/lib/src/infrastructure/selectable_list.dart b/super_editor/lib/src/infrastructure/selectable_list.dart index 98170c62bf..9ddad93b81 100644 --- a/super_editor/lib/src/infrastructure/selectable_list.dart +++ b/super_editor/lib/src/infrastructure/selectable_list.dart @@ -40,7 +40,7 @@ class ItemSelectionList extends StatefulWidget { /// This method is called for each item in [items], to build its visual representation. /// /// The provided `onTap` must be called when the item is tapped. - final PopoverListItemBuilder itemBuilder; + final SelectableListItemBuilder itemBuilder; /// Called when the user activates an item on the popover list. /// @@ -279,9 +279,4 @@ class ItemSelectionListState extends State> with SingleT /// [isActive] is `true` if [item] is the currently active item on the list, or `false` otherwise. /// /// The provided [onTap] must be called when the button is tapped. -typedef PopoverListItemBuilder = Widget Function(BuildContext context, T item, bool isActive, VoidCallback onTap); - -/// Builds a button is an [ItemSelectionList]. -/// -/// The provided [onTap] must be called when the button is tapped. -typedef PopoverListButtonBuilder = Widget Function(BuildContext context, T? selectedItem, VoidCallback onTap); +typedef SelectableListItemBuilder = Widget Function(BuildContext context, T item, bool isActive, VoidCallback onTap); From bd4ed5d657447e43cf380bf7a7380da38dbc9e44 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Mon, 11 Dec 2023 10:17:02 -0300 Subject: [PATCH 30/36] Move focus sharing to the scaffold --- .../super_editor_item_selector.dart | 4 ++-- .../src/infrastructure/popover_scaffold.dart | 24 ++++++++++++++++++- .../src/infrastructure/selectable_list.dart | 14 ----------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart b/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart index e8d046c969..f47b0807b8 100644 --- a/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart +++ b/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart @@ -107,11 +107,11 @@ class _SuperEditorDemoTextItemSelectorState extends State RoundedRectanglePopoverAppearance( child: ItemSelectionList( focusNode: _popoverFocusNode, - parentFocusNode: widget.parentFocusNode, value: widget.id, items: widget.items, itemBuilder: _buildPopoverListItem, @@ -285,6 +285,7 @@ class _SuperEditorDemoIconItemSelectorState extends State RoundedRectanglePopoverAppearance( child: ItemSelectionList( value: widget.value, @@ -292,7 +293,6 @@ class _SuperEditorDemoIconItemSelectorState extends State _popoverController.close(), - parentFocusNode: widget.parentFocusNode, focusNode: _popoverFocusNode, ), ), diff --git a/super_editor/lib/src/infrastructure/popover_scaffold.dart b/super_editor/lib/src/infrastructure/popover_scaffold.dart index ddefe59a6c..35918974fa 100644 --- a/super_editor/lib/src/infrastructure/popover_scaffold.dart +++ b/super_editor/lib/src/infrastructure/popover_scaffold.dart @@ -27,6 +27,7 @@ class PopoverScaffold extends StatefulWidget { required this.popoverBuilder, this.popoverGeometry = const PopoverGeometry(), this.popoverFocusNode, + this.parentFocusNode, this.boundaryKey, this.onTapOutside = _PopoverScaffoldState.closePopoverOnTapOutside, }); @@ -50,6 +51,22 @@ class PopoverScaffold extends StatefulWidget { /// Focus will be requested to this [FocusNode] when the popover is displayed. final FocusNode? popoverFocusNode; + /// The [FocusNode], to which the popover [FocusNode] will be added as a child. + /// + /// In Flutter, [FocusNode]s have parents and children. This relationship allows an + /// entire ancestor path to "have focus", but only the lowest level descendant + /// in that path has "primary focus". This path is important because various + /// widgets alter their presentation or behavior based on whether or not they + /// currently have focus, even if they only have "non-primary focus". + /// + /// When the popover is visible, it will have primary focus. + /// Moreover, because the popover is built in an `Overlay`, none of your + /// widgets are in the natural focus path for that popover. Therefore, if you + /// need your widget tree to retain focus while the popover is visible, then + /// you need to provide the [FocusNode] that the popover should use as its + /// parent, thereby retaining focus for your widgets. + final FocusNode? parentFocusNode; + /// A [GlobalKey] to a widget that determines the bounds where the popover can be displayed. /// /// Passing a [boundaryKey] causes the popover to be confined to the bounds of the widget @@ -70,6 +87,7 @@ class PopoverScaffold extends StatefulWidget { class _PopoverScaffoldState extends State { final OverlayPortalController _overlayController = OverlayPortalController(); final LeaderLink _popoverLink = LeaderLink(); + final FocusNode _scaffoldFocusNode = FocusNode(); late FollowerBoundary _screenBoundary; @@ -108,6 +126,7 @@ class _PopoverScaffoldState extends State { void dispose() { widget.controller.removeListener(_onPopoverControllerChanged); _popoverLink.dispose(); + _scaffoldFocusNode.dispose(); super.dispose(); } @@ -169,7 +188,10 @@ class _PopoverScaffoldState extends State { ), child: ConstrainedBox( constraints: widget.popoverGeometry.constraints ?? const BoxConstraints(), - child: widget.popoverBuilder(context), + child: Focus( + parentNode: widget.parentFocusNode, + child: widget.popoverBuilder(context), + ), ), ), ), diff --git a/super_editor/lib/src/infrastructure/selectable_list.dart b/super_editor/lib/src/infrastructure/selectable_list.dart index 9ddad93b81..14c74c8248 100644 --- a/super_editor/lib/src/infrastructure/selectable_list.dart +++ b/super_editor/lib/src/infrastructure/selectable_list.dart @@ -5,9 +5,6 @@ import 'package:super_editor/super_editor.dart'; /// A list where the user can navigate between its items and select one of them. /// -/// This widget shares focus with its [parentFocusNode]. This means that when the list requests focus, -/// [parentFocusNode] still has non-primary focus. -/// /// Includes the following keyboard selection behaviors: /// /// * Pressing UP/DOWN moves the "active" item selection up/down. @@ -24,7 +21,6 @@ class ItemSelectionList extends StatefulWidget { required this.onItemSelected, this.onCancel, this.focusNode, - this.parentFocusNode, }); /// The currently selected value or `null` if no item is selected. @@ -62,15 +58,6 @@ class ItemSelectionList extends StatefulWidget { /// The [FocusNode] of the list. final FocusNode? focusNode; - /// The [FocusNode], to which the list's [FocusNode] will be added as a child. - /// - /// In Flutter, [FocusNode]s have parents and children. This relationship allows an - /// entire ancestor path to "have focus", but only the lowest level descendant - /// in that path has "primary focus". This path is important because various - /// widgets alter their presentation or behavior based on whether or not they - /// currently have focus, even if they only have "non-primary focus". - final FocusNode? parentFocusNode; - @override State> createState() => ItemSelectionListState(); } @@ -234,7 +221,6 @@ class ItemSelectionListState extends State> with SingleT } return Focus( focusNode: widget.focusNode, - parentNode: widget.parentFocusNode, onKeyEvent: _onKeyEvent, child: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith( From 9d2a42f3c346566e880c512f76a5c6579dd2dc3f Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Wed, 13 Dec 2023 08:27:57 -0300 Subject: [PATCH 31/36] Add tests --- .../test/infrastructure/popover_test.dart | 440 ++++++++++++++++ .../infrastructure/super_dropdown_test.dart | 468 ------------------ 2 files changed, 440 insertions(+), 468 deletions(-) create mode 100644 super_editor/test/infrastructure/popover_test.dart delete mode 100644 super_editor/test/infrastructure/super_dropdown_test.dart diff --git a/super_editor/test/infrastructure/popover_test.dart b/super_editor/test/infrastructure/popover_test.dart new file mode 100644 index 0000000000..0381fa14c7 --- /dev/null +++ b/super_editor/test/infrastructure/popover_test.dart @@ -0,0 +1,440 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; + +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/default_document_editor.dart'; +import 'package:super_editor/src/default_editor/super_editor.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/default_popovers.dart'; +import 'package:super_editor/src/infrastructure/popover_scaffold.dart'; +import 'package:super_editor/src/infrastructure/selectable_list.dart'; +import 'package:super_editor/src/infrastructure/text_input.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../super_editor/test_documents.dart'; + +void main() { + group('PopoverScaffold', () { + testWidgetsOnAllPlatforms('opens and closes the popover when requested', (tester) async { + final popoverController = PopoverController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PopoverScaffold( + controller: popoverController, + buttonBuilder: (context) => const SizedBox(), + popoverBuilder: (context) => const RoundedRectanglePopoverAppearance( + child: SizedBox(), + ), + ), + ), + ), + ); + + // Ensure the popover isn't displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsNothing); + + // Show the popover. + popoverController.open(); + await tester.pump(); + + // Ensure the popover is displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsOneWidget); + + // Close the popover. + popoverController.close(); + await tester.pump(); + + // Ensure the popover was closed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsNothing); + }); + + testWidgetsOnAllPlatforms('closes the popover when tapping outside', (tester) async { + final popoverController = PopoverController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 100), + child: PopoverScaffold( + controller: popoverController, + buttonBuilder: (context) => const SizedBox(), + popoverBuilder: (context) => const RoundedRectanglePopoverAppearance( + child: SizedBox(), + ), + ), + ), + ), + ), + ), + ); + + // Ensure the popover isn't displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsNothing); + + // Show the popover. + popoverController.open(); + await tester.pumpAndSettle(); + + // Ensure the popover is displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsOneWidget); + + // Taps outside of the popover. + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + + // Ensure the popover was closed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsNothing); + }); + + testWidgetsOnAllPlatforms('enforces the given popover geometry', (tester) async { + final popoverController = PopoverController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopoverScaffold( + controller: popoverController, + popoverGeometry: const PopoverGeometry( + constraints: BoxConstraints(maxHeight: 10), + ), + buttonBuilder: (context) => const SizedBox(), + popoverBuilder: (context) => const RoundedRectanglePopoverAppearance( + child: SizedBox(height: 100), + ), + ), + ), + ), + ), + ); + + // Ensure the popover isn't displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsNothing); + + // Show the popover. + popoverController.open(); + await tester.pumpAndSettle(); + + // Ensure the popover is displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsOneWidget); + + // Ensure the maxHeight was honored. + expect(tester.getRect(find.byType(RoundedRectanglePopoverAppearance)).height, 10); + }); + + testWidgetsOnAllPlatforms('shares focus with SuperEditor', (tester) async { + final editorFocusNode = FocusNode(); + final popoverFocusNode = FocusNode(); + final popoverController = PopoverController(); + + await tester.pumpWidget( + MaterialApp( + home: _SuperEditorDropdownTestApp( + editorFocusNode: editorFocusNode, + toolbar: PopoverScaffold( + controller: popoverController, + parentFocusNode: editorFocusNode, + popoverFocusNode: popoverFocusNode, + buttonBuilder: (context) => const SizedBox(), + popoverBuilder: (context) => Focus( + focusNode: popoverFocusNode, + child: const SizedBox(), + ), + ), + ), + ), + ); + + final documentNode = SuperEditorInspector.findDocument()!.nodes.first; + + // Double tap to select the word "Lorem". + await tester.doubleTapInParagraph(documentNode.id, 1); + + // Ensure the editor has primary focus and the word "Lorem" is selected. + expect(editorFocusNode.hasPrimaryFocus, isTrue); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection( + base: DocumentPosition( + nodeId: documentNode.id, + nodePosition: const TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: documentNode.id, + nodePosition: const TextNodePosition(offset: 5), + ), + ), + ); + + // Show the popover. + popoverController.open(); + await tester.pumpAndSettle(); + + // Ensure the editor has non-primary focus. + expect(editorFocusNode.hasFocus, true); + expect(editorFocusNode.hasPrimaryFocus, isFalse); + + // Close the popover. + popoverController.close(); + await tester.pump(); + + // Ensure the editor has primary focus again and selection stays the same. + expect(editorFocusNode.hasPrimaryFocus, isTrue); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection( + base: DocumentPosition( + nodeId: documentNode.id, + nodePosition: const TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: documentNode.id, + nodePosition: const TextNodePosition(offset: 5), + ), + ), + ); + }); + }); + + group('ItemSelectionList', () { + testWidgetsOnAllPlatforms('changes active item down with DOWN ARROW', (tester) async { + String? activeItem; + + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) => {}, + onItemActivated: (s) => activeItem = s, + ); + + // Ensure the popover is displayed without any active item. + expect(activeItem, isNull); + + // Press DOWN ARROW to activate the first item. + await tester.pressDownArrow(); + expect(activeItem, 'Item1'); + + // Press DOWN ARROW to activate the second item. + await tester.pressDownArrow(); + expect(activeItem, 'Item2'); + + // Press DOWN ARROW to activate the third item. + await tester.pressDownArrow(); + expect(activeItem, 'Item3'); + + // Press DOWN ARROW to activate the first item again. + await tester.pressDownArrow(); + expect(activeItem, 'Item1'); + }); + + testWidgetsOnAllPlatforms('changes active item up with UP ARROW', (tester) async { + String? activeItem; + + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) => {}, + onItemActivated: (s) => activeItem = s, + ); + + // Ensure the popover is displayed without any activate item. + expect(activeItem, isNull); + + // Press UP ARROW to activate the last item. + await tester.pressUpArrow(); + expect(activeItem, 'Item3'); + + // Press UP ARROW to activate the second item. + await tester.pressUpArrow(); + expect(activeItem, 'Item2'); + + // Press UP ARROW to activate the first item. + await tester.pressUpArrow(); + expect(activeItem, 'Item1'); + + // Press UP ARROW to activate the last item again. + await tester.pressUpArrow(); + expect(activeItem, 'Item3'); + }); + + testWidgetsOnAllPlatforms('selects the active item on ENTER', (tester) async { + String? selectedValue; + + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) => selectedValue = s, + ); + + // Press ARROW DOWN to activate the first item. + await tester.pressDownArrow(); + + // Press ENTER to select the active item. + await tester.pressEnter(); + await tester.pump(); + + // Ensure the first item was selected. + expect(selectedValue, 'Item1'); + }); + + testWidgetsOnAllPlatforms('clears selected item on ENTER without an active item', (tester) async { + String? selectedValue = ''; + + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) => selectedValue = s, + ); + + // Press ENTER without an active item. + await tester.pressEnter(); + await tester.pump(); + + // Ensure the selected item was set to null. + expect(selectedValue, isNull); + }); + + testWidgetsOnAllPlatforms('calls onCancel on ESC', (tester) async { + String? selectedValue; + bool isCanceled = false; + + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) => selectedValue = s, + onCancel: () => isCanceled = true, + ); + + // Press ARROW DOWN to activate the first item. + await tester.pressDownArrow(); + + // Press ESC to cancel. + await tester.pressEscape(); + await tester.pump(); + + // Ensure onCancel was called and no item was selected. + expect(isCanceled, true); + expect(selectedValue, isNull); + }); + + testWidgetsOnAllPlatforms('isn\'t scrollable if all items fit on screen', (tester) async { + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) {}, + ); + + // Ensure the list isn't scrollable. + final dropdownButonState = tester.state>(find.byType(ItemSelectionList)); + expect(dropdownButonState.scrollController.position.maxScrollExtent, 0.0); + }); + + testWidgetsOnAllPlatforms('is scrollable if items don\'t fit on screen', (tester) async { + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) {}, + constraints: const BoxConstraints(maxHeight: 50), + ); + + // Ensure the list is scrollable. + final dropdownButonState = tester.state>(find.byType(ItemSelectionList)); + expect(dropdownButonState.scrollController.position.maxScrollExtent, greaterThan(0.0)); + }); + }); +} + +/// Pumps a widget tree with a [ItemSelectionList] containing three items and +/// immediately requests focus to it. +Future _pumpItemSelectionListTestApp( + WidgetTester tester, { + required void Function(String? value) onItemSelected, + void Function(String? value)? onItemActivated, + VoidCallback? onCancel, + BoxConstraints? constraints, +}) async { + final focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConstrainedBox( + constraints: constraints ?? const BoxConstraints(), + child: ItemSelectionList( + focusNode: focusNode, + value: null, + items: const ['Item1', 'Item2', 'Item3'], + onItemSelected: onItemSelected, + onItemActivated: onItemActivated, + onCancel: onCancel, + itemBuilder: (context, item, isActive, onTap) => TextButton( + onPressed: onTap, + child: Text(item), + ), + ), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); +} + +/// Displays a [SuperEditor] that fills the available height, containing a single paragraph, +/// and a [toolbar] at the bottom. +class _SuperEditorDropdownTestApp extends StatefulWidget { + const _SuperEditorDropdownTestApp({ + required this.toolbar, + this.editorFocusNode, + }); + + final FocusNode? editorFocusNode; + final Widget toolbar; + + @override + State<_SuperEditorDropdownTestApp> createState() => _SuperEditorDropdownTestAppState(); +} + +class _SuperEditorDropdownTestAppState extends State<_SuperEditorDropdownTestApp> { + late MutableDocument _doc; + late MutableDocumentComposer _composer; + late Editor _docEditor; + + @override + void initState() { + super.initState(); + _doc = singleParagraphDoc(); + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); + } + + @override + void dispose() { + _docEditor.dispose(); + _doc.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + children: [ + Expanded( + child: SuperEditor( + document: _doc, + editor: _docEditor, + composer: _composer, + inputSource: TextInputSource.ime, + focusNode: widget.editorFocusNode, + ), + ), + widget.toolbar, + ], + ), + ); + } +} diff --git a/super_editor/test/infrastructure/super_dropdown_test.dart b/super_editor/test/infrastructure/super_dropdown_test.dart deleted file mode 100644 index 8315186299..0000000000 --- a/super_editor/test/infrastructure/super_dropdown_test.dart +++ /dev/null @@ -1,468 +0,0 @@ -// import 'package:flutter/material.dart'; -// import 'package:flutter_test/flutter_test.dart'; -// import 'package:flutter_test_robots/flutter_test_robots.dart'; -// import 'package:flutter_test_runners/flutter_test_runners.dart'; - -// import 'package:super_editor/src/core/document.dart'; -// import 'package:super_editor/src/core/document_composer.dart'; -// import 'package:super_editor/src/core/document_selection.dart'; -// import 'package:super_editor/src/core/editor.dart'; -// import 'package:super_editor/src/default_editor/default_document_editor.dart'; -// import 'package:super_editor/src/default_editor/super_editor.dart'; -// import 'package:super_editor/src/default_editor/text.dart'; -// import 'package:super_editor/src/infrastructure/dropdown.dart'; -// import 'package:super_editor/src/infrastructure/text_input.dart'; -// import 'package:super_editor/super_editor_test.dart'; - -// import '../super_editor/test_documents.dart'; - -// void main() { -// group('SuperDropdown', () { -// testWidgetsOnAllPlatforms('shows the dropdown list on tap', (tester) async { -// await _pumpDropdownTestApp( -// tester, -// onValueChanged: (s) {}, -// ); - -// // Ensures the dropdown list isn't displayed. -// expect(find.byType(PopoverShape), findsNothing); - -// // Tap the button to show the dropdown. -// await tester.tap(find.byType(ItemSelectionList)); -// await tester.pumpAndSettle(); - -// // Ensures the dropdown list is displayed. -// expect(find.byType(PopoverShape), findsOneWidget); -// }); - -// testWidgetsOnAllPlatforms('calls onSelected and closes the dropdown list when tapping an item', (tester) async { -// String? selectedValue; - -// await _pumpDropdownTestApp( -// tester, -// onValueChanged: (s) => selectedValue = s, -// ); - -// // Ensures the dropdown list isn't displayed. -// expect(find.byType(PopoverShape), findsNothing); - -// // Tap the button to show the dropdown. -// await tester.tap(find.byType(ItemSelectionList)); -// await tester.pumpAndSettle(); - -// // Ensures the dropdown list is displayed. -// expect(find.byType(PopoverShape), findsOneWidget); - -// // Taps the first item on the list -// await tester.tap(find.text('Item1')); -// await tester.pumpAndSettle(); - -// // Ensure the tapped item was selected and the dropdown was closed. -// expect(selectedValue, 'Item1'); -// expect(find.byType(PopoverShape), findsNothing); -// }); - -// testWidgetsOnAllPlatforms('closes the dropdown list when tapping outside', (tester) async { -// bool onValueChangedCalled = false; - -// await _pumpDropdownTestApp( -// tester, -// onValueChanged: (s) => onValueChangedCalled = true, -// ); - -// // Ensures the dropdown list isn't displayed. -// expect(find.byType(PopoverShape), findsNothing); - -// // Tap the button to show the dropdown. -// await tester.tap(find.byType(ItemSelectionList)); -// await tester.pumpAndSettle(); - -// // Ensures the dropdown list is displayed. -// expect(find.byType(PopoverShape), findsOneWidget); - -// // Taps outside of the dropdown. -// await tester.tapAt(Offset.zero); -// await tester.pumpAndSettle(); - -// // Ensures onValueChanged wasn't called and the dropdown list was closed. -// expect(onValueChangedCalled, isFalse); -// expect(find.byType(PopoverShape), findsNothing); -// }); - -// testWidgetsOnAllPlatforms('enforces the given dropdown constraints', (tester) async { -// await _pumpDropdownTestApp( -// tester, -// onValueChanged: (s) {}, -// popoverGeometry: PopoverGeometry( -// constraints: const BoxConstraints(maxHeight: 10), -// ), -// ); - -// // Ensures the dropdown list isn't displayed. -// expect(find.byType(PopoverShape), findsNothing); - -// // Tap the button to show the dropdown. -// await tester.tap(find.byType(ItemSelectionList)); -// await tester.pumpAndSettle(); - -// // Ensures the dropdown list is displayed. -// expect(find.byType(PopoverShape), findsOneWidget); - -// // Ensure the maxHeight was honored. -// expect(tester.getRect(find.byType(PopoverShape)).height, 10); -// }); - -// testWidgetsOnAllPlatforms('dropdown list isn\' scrollable if all items fit on screen', (tester) async { -// await _pumpDropdownTestApp( -// tester, -// onValueChanged: (s) {}, -// ); - -// // Tap the button to show the dropdown. -// await tester.tap(find.byType(ItemSelectionList)); -// await tester.pumpAndSettle(); - -// // Ensures the dropdown list is displayed. -// expect(find.byType(PopoverShape), findsOneWidget); - -// // Ensure the dropdown list isn't scrollable. -// final dropdownButonState = tester.state>(find.byType(ItemSelectionList)); -// expect(dropdownButonState.scrollController.position.maxScrollExtent, 0.0); -// }); - -// testWidgetsOnAllPlatforms('dropdown list is scrollable if items don\'t fit on screen', (tester) async { -// await _pumpDropdownTestApp( -// tester, -// onValueChanged: (s) {}, -// popoverGeometry: PopoverGeometry(constraints: const BoxConstraints(maxHeight: 50)), -// ); - -// // Tap the button to show the dropdown. -// await tester.tap(find.byType(ItemSelectionList)); -// await tester.pumpAndSettle(); - -// // Ensures the dropdown list is displayed. -// expect(find.byType(PopoverShape), findsOneWidget); - -// // Ensure the dropdown list is scrollable. -// final dropdownButonState = tester.state>(find.byType(ItemSelectionList)); -// expect(dropdownButonState.scrollController.position.maxScrollExtent, greaterThan(0.0)); -// }); - -// testWidgetsOnAllPlatforms('moves focus down with DOWN ARROW', (tester) async { -// String? activeItem; -// await _pumpDropdownTestApp( -// tester, -// onValueChanged: (s) => {}, -// onActivate: (s) => activeItem = s, -// ); - -// // Tap the button to show the dropdown. -// await tester.tap(find.byType(ItemSelectionList)); -// await tester.pumpAndSettle(); - -// // Ensure the dropdown is displayed without any focused item. -// expect(activeItem, isNull); - -// // Press DOWN ARROW to focus the first item. -// await tester.pressDownArrow(); -// expect(activeItem, 'Item1'); - -// // Press DOWN ARROW to focus the second item. -// await tester.pressDownArrow(); -// expect(activeItem, 'Item2'); - -// // Press DOWN ARROW to focus the third item. -// await tester.pressDownArrow(); -// expect(activeItem, 'Item3'); - -// // Press DOWN ARROW to focus the first item again. -// await tester.pressDownArrow(); -// expect(activeItem, 'Item1'); -// }); - -// testWidgetsOnAllPlatforms('moves focus up with UP ARROW', (tester) async { -// String? activeItem; - -// await _pumpDropdownTestApp( -// tester, -// onValueChanged: (s) => {}, -// onActivate: (s) => activeItem = s, -// ); - -// // Tap the button to show the dropdown. -// await tester.tap(find.byType(ItemSelectionList)); -// await tester.pumpAndSettle(); - -// // Ensure the dropdown is displayed without any focused item. -// expect(activeItem, isNull); - -// // Press UP ARROW to focus the last item. -// await tester.pressUpArrow(); -// expect(activeItem, 'Item3'); - -// // Press UP ARROW to focus the second item. -// await tester.pressUpArrow(); -// expect(activeItem, 'Item2'); - -// // Press UP ARROW to focus the first item. -// await tester.pressUpArrow(); -// expect(activeItem, 'Item1'); - -// // Press UP ARROW to focus the last item again. -// await tester.pressUpArrow(); -// expect(activeItem, 'Item3'); -// }); - -// testWidgetsOnAllPlatforms('selects the focused item on ENTER', (tester) async { -// String? selectedValue; - -// await _pumpDropdownTestApp( -// tester, -// onValueChanged: (s) => selectedValue = s, -// ); - -// // Tap the button to show the dropdown. -// await tester.tap(find.byType(ItemSelectionList)); -// await tester.pumpAndSettle(); - -// // Press ARROW DOWN to focus the first item. -// await tester.pressDownArrow(); - -// // Press ENTER to select the focused item and close the dropdown. -// await tester.pressEnter(); -// await tester.pump(); - -// // Ensure the first item was selected and the dropdown was closed. -// expect(selectedValue, 'Item1'); -// expect(find.byType(PopoverShape), findsNothing); -// }); - -// testWidgetsOnAllPlatforms('closes dropdown list on ENTER', (tester) async { -// String? selectedValue; - -// await _pumpDropdownTestApp( -// tester, -// onValueChanged: (s) => selectedValue = s, -// ); - -// // Tap the button to show the dropdown. -// await tester.tap(find.byType(ItemSelectionList)); -// await tester.pumpAndSettle(); - -// // Press ENTER without a focused item to close the dropdown. -// await tester.pressEnter(); -// await tester.pump(); - -// // Ensure the dropdown was closed and no item was selected. -// expect(find.byType(PopoverShape), findsNothing); -// expect(selectedValue, isNull); -// }); - -// testWidgetsOnAllPlatforms('closes dropdown list on ESC', (tester) async { -// String? selectedValue; - -// await _pumpDropdownTestApp( -// tester, -// onValueChanged: (s) => selectedValue = s, -// ); - -// // Tap the button to show the dropdown. -// await tester.tap(find.byType(ItemSelectionList)); -// await tester.pumpAndSettle(); - -// // Press ARROW DOWN to focus the first item. -// await tester.pressDownArrow(); - -// // Press ESC to close the dropdown. -// await tester.pressEscape(); -// await tester.pump(); - -// // Ensure the dropdown was closed and no item was selected. -// expect(find.byType(PopoverShape), findsNothing); -// expect(selectedValue, isNull); -// }); - -// testWidgetsOnAllPlatforms('shares focus with SuperEditor', (tester) async { -// final editorFocusNode = FocusNode(); -// final boundaryKey = GlobalKey(); - -// await tester.pumpWidget( -// MaterialApp( -// key: boundaryKey, -// home: _SuperEditorDropdownTestApp( -// editorFocusNode: editorFocusNode, -// toolbar: ConstrainedBox( -// constraints: const BoxConstraints(maxHeight: 100), -// child: ItemSelectionList( -// items: const ['Item1', 'Item2', 'Item3'], -// itemBuilder: (context, e, isActive, onTap) => TextButton( -// onPressed: onTap, -// child: Text(e), -// ), -// buttonBuilder: (context, e, onTap) => ElevatedButton( -// onPressed: onTap, -// child: const SizedBox(width: 50), -// ), -// value: null, -// onItemSelected: (s) => {}, -// boundaryKey: boundaryKey, -// parentFocusNode: editorFocusNode, -// ), -// ), -// ), -// ), -// ); - -// final documentNode = SuperEditorInspector.findDocument()!.nodes.first; - -// // Double tap to select the word "Lorem". -// await tester.doubleTapInParagraph(documentNode.id, 1); - -// // Ensure the editor has primary focus and the word "Lorem" is selected. -// expect(editorFocusNode.hasPrimaryFocus, isTrue); -// expect( -// SuperEditorInspector.findDocumentSelection(), -// DocumentSelection( -// base: DocumentPosition( -// nodeId: documentNode.id, -// nodePosition: const TextNodePosition(offset: 0), -// ), -// extent: DocumentPosition( -// nodeId: documentNode.id, -// nodePosition: const TextNodePosition(offset: 5), -// ), -// ), -// ); - -// // Tap the button to show the dropdown. -// await tester.tap(find.byType(ItemSelectionList)); -// await tester.pumpAndSettle(); - -// // Ensure the editor has non-primary focus. -// expect(editorFocusNode.hasFocus, true); -// expect(editorFocusNode.hasPrimaryFocus, isFalse); - -// // Tap at a dropdown list option to close the dropdown. -// await tester.tap(find.text('Item2')); -// await tester.pumpAndSettle(); - -// // Ensure the editor has primary focus again and selection stays the same. -// expect(editorFocusNode.hasPrimaryFocus, isTrue); -// expect( -// SuperEditorInspector.findDocumentSelection(), -// DocumentSelection( -// base: DocumentPosition( -// nodeId: documentNode.id, -// nodePosition: const TextNodePosition(offset: 0), -// ), -// extent: DocumentPosition( -// nodeId: documentNode.id, -// nodePosition: const TextNodePosition(offset: 5), -// ), -// ), -// ); -// }); -// }); -// } - -// /// Pumps a widget tree with a centered [ItemSelectionList] containing three items. -// Future _pumpDropdownTestApp( -// WidgetTester tester, { -// required void Function(String? value) onValueChanged, -// void Function(String? value)? onActivate, -// PopoverGeometry? popoverGeometry, -// }) async { -// final boundaryKey = GlobalKey(); -// final focusNode = FocusNode(); - -// await tester.pumpWidget( -// MaterialApp( -// key: boundaryKey, -// home: Scaffold( -// body: Center( -// child: Focus( -// focusNode: focusNode, -// autofocus: true, -// child: ConstrainedBox( -// constraints: const BoxConstraints(maxHeight: 100), -// child: ItemSelectionList( -// items: const ['Item1', 'Item2', 'Item3'], -// itemBuilder: (context, e, isActive, onTap) => TextButton( -// onPressed: onTap, -// child: Text(e), -// ), -// buttonBuilder: (context, e, onTap) => ElevatedButton( -// onPressed: onTap, -// child: const SizedBox(width: 50), -// ), -// value: null, -// onItemActivated: onActivate, -// onItemSelected: onValueChanged, -// boundaryKey: boundaryKey, -// parentFocusNode: focusNode, -// popoverGeometry: popoverGeometry, -// ), -// ), -// ), -// ), -// ), -// ), -// ); -// } - -// /// Displays a [SuperEditor] that fills the available height, containing a single paragraph, -// /// and a [toolbar] at the bottom. -// class _SuperEditorDropdownTestApp extends StatefulWidget { -// const _SuperEditorDropdownTestApp({ -// required this.toolbar, -// this.editorFocusNode, -// }); - -// final FocusNode? editorFocusNode; -// final Widget toolbar; - -// @override -// State<_SuperEditorDropdownTestApp> createState() => _SuperEditorDropdownTestAppState(); -// } - -// class _SuperEditorDropdownTestAppState extends State<_SuperEditorDropdownTestApp> { -// late MutableDocument _doc; -// late MutableDocumentComposer _composer; -// late Editor _docEditor; - -// @override -// void initState() { -// super.initState(); -// _doc = singleParagraphDoc(); -// _composer = MutableDocumentComposer(); -// _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); -// } - -// @override -// void dispose() { -// _docEditor.dispose(); -// _doc.dispose(); -// super.dispose(); -// } - -// @override -// Widget build(BuildContext context) { -// return Scaffold( -// body: Column( -// children: [ -// Expanded( -// child: SuperEditor( -// document: _doc, -// editor: _docEditor, -// composer: _composer, -// inputSource: TextInputSource.ime, -// focusNode: widget.editorFocusNode, -// ), -// ), -// widget.toolbar, -// ], -// ), -// ); -// } -// } From 6de054d718be190f99a3949fd7aa5d3c71264158 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 21 Dec 2023 19:31:58 -0300 Subject: [PATCH 32/36] Test updates --- .../src/infrastructure/selectable_list.dart | 6 +- .../test/infrastructure/popover_test.dart | 108 ++++++++---------- 2 files changed, 53 insertions(+), 61 deletions(-) diff --git a/super_editor/lib/src/infrastructure/selectable_list.dart b/super_editor/lib/src/infrastructure/selectable_list.dart index 14c74c8248..68f6edf525 100644 --- a/super_editor/lib/src/infrastructure/selectable_list.dart +++ b/super_editor/lib/src/infrastructure/selectable_list.dart @@ -10,7 +10,7 @@ import 'package:super_editor/super_editor.dart'; /// * Pressing UP/DOWN moves the "active" item selection up/down. /// * Pressing UP with the first item active moves the active item selection to the last item. /// * Pressing DOWN with the last item active moves the active item selection to the first item. -/// * Pressing ENTER selects the currently active item and closes the popover list. +/// * Pressing ENTER selects the currently active item. class ItemSelectionList extends StatefulWidget { const ItemSelectionList({ super.key, @@ -260,9 +260,11 @@ class ItemSelectionListState extends State> with SingleT } } -/// Builds a popover list item. +/// Builds a list item. /// /// [isActive] is `true` if [item] is the currently active item on the list, or `false` otherwise. /// +/// The active item is the currently focused item in the list, which can be selected by pressing ENTER. +/// /// The provided [onTap] must be called when the button is tapped. typedef SelectableListItemBuilder = Widget Function(BuildContext context, T item, bool isActive, VoidCallback onTap); diff --git a/super_editor/test/infrastructure/popover_test.dart b/super_editor/test/infrastructure/popover_test.dart index 0381fa14c7..3f47fa54d6 100644 --- a/super_editor/test/infrastructure/popover_test.dart +++ b/super_editor/test/infrastructure/popover_test.dart @@ -2,19 +2,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_robots/flutter_test_robots.dart'; import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; -import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_composer.dart'; -import 'package:super_editor/src/core/document_selection.dart'; import 'package:super_editor/src/core/editor.dart'; import 'package:super_editor/src/default_editor/default_document_editor.dart'; import 'package:super_editor/src/default_editor/super_editor.dart'; -import 'package:super_editor/src/default_editor/text.dart'; import 'package:super_editor/src/infrastructure/default_popovers.dart'; import 'package:super_editor/src/infrastructure/popover_scaffold.dart'; import 'package:super_editor/src/infrastructure/selectable_list.dart'; import 'package:super_editor/src/infrastructure/text_input.dart'; -import 'package:super_editor/super_editor_test.dart'; import '../super_editor/test_documents.dart'; @@ -96,6 +93,7 @@ void main() { }); testWidgetsOnAllPlatforms('enforces the given popover geometry', (tester) async { + final buttonKey = GlobalKey(); final popoverController = PopoverController(); await tester.pumpWidget( @@ -104,12 +102,17 @@ void main() { body: Center( child: PopoverScaffold( controller: popoverController, - popoverGeometry: const PopoverGeometry( - constraints: BoxConstraints(maxHeight: 10), + popoverGeometry: PopoverGeometry( + constraints: const BoxConstraints(maxHeight: 300), + align: (globalLeaderRect, followerSize, boundaryKey) => const FollowerAlignment( + leaderAnchor: Alignment.topRight, + followerAnchor: Alignment.topLeft, + followerOffset: Offset(10, 10), + ), ), - buttonBuilder: (context) => const SizedBox(), + buttonBuilder: (context) => SizedBox(key: buttonKey), popoverBuilder: (context) => const RoundedRectanglePopoverAppearance( - child: SizedBox(height: 100), + child: SizedBox(height: 500), ), ), ), @@ -127,81 +130,68 @@ void main() { // Ensure the popover is displayed. expect(find.byType(RoundedRectanglePopoverAppearance), findsOneWidget); - // Ensure the maxHeight was honored. - expect(tester.getRect(find.byType(RoundedRectanglePopoverAppearance)).height, 10); + final buttonRect = tester.getRect(find.byKey(buttonKey)); + final popoverRect = tester.getRect(find.byType(RoundedRectanglePopoverAppearance)); + + // Ensure the given geometry was honored. + expect(popoverRect.height, 300); + expect(popoverRect.top, buttonRect.top + 10); + expect(popoverRect.left, buttonRect.right + 10); }); - testWidgetsOnAllPlatforms('shares focus with SuperEditor', (tester) async { - final editorFocusNode = FocusNode(); + testWidgetsOnAllPlatforms('shares focus with other widgets', (tester) async { + final parentFocusNode = FocusNode(); final popoverFocusNode = FocusNode(); + final popoverController = PopoverController(); + final overLayControler = OverlayPortalController(); await tester.pumpWidget( MaterialApp( - home: _SuperEditorDropdownTestApp( - editorFocusNode: editorFocusNode, - toolbar: PopoverScaffold( - controller: popoverController, - parentFocusNode: editorFocusNode, - popoverFocusNode: popoverFocusNode, - buttonBuilder: (context) => const SizedBox(), - popoverBuilder: (context) => Focus( - focusNode: popoverFocusNode, - child: const SizedBox(), + home: Scaffold( + body: Focus( + focusNode: parentFocusNode, + child: OverlayPortal( + controller: overLayControler, + overlayChildBuilder: (context) => PopoverScaffold( + controller: popoverController, + parentFocusNode: parentFocusNode, + popoverFocusNode: popoverFocusNode, + buttonBuilder: (context) => const SizedBox(), + popoverBuilder: (context) => Focus( + focusNode: popoverFocusNode, + child: const SizedBox(), + ), + ), ), ), ), ), ); - final documentNode = SuperEditorInspector.findDocument()!.nodes.first; - - // Double tap to select the word "Lorem". - await tester.doubleTapInParagraph(documentNode.id, 1); + // Focus the parent node. + parentFocusNode.requestFocus(); + await tester.pump(); + expect(parentFocusNode.hasPrimaryFocus, true); - // Ensure the editor has primary focus and the word "Lorem" is selected. - expect(editorFocusNode.hasPrimaryFocus, isTrue); - expect( - SuperEditorInspector.findDocumentSelection(), - DocumentSelection( - base: DocumentPosition( - nodeId: documentNode.id, - nodePosition: const TextNodePosition(offset: 0), - ), - extent: DocumentPosition( - nodeId: documentNode.id, - nodePosition: const TextNodePosition(offset: 5), - ), - ), - ); + // Show the overlay. + overLayControler.show(); + await tester.pumpAndSettle(); // Show the popover. popoverController.open(); await tester.pumpAndSettle(); - // Ensure the editor has non-primary focus. - expect(editorFocusNode.hasFocus, true); - expect(editorFocusNode.hasPrimaryFocus, isFalse); + // Ensure the parent node has non-primary focus. + expect(parentFocusNode.hasFocus, true); + expect(parentFocusNode.hasPrimaryFocus, isFalse); // Close the popover. popoverController.close(); await tester.pump(); - // Ensure the editor has primary focus again and selection stays the same. - expect(editorFocusNode.hasPrimaryFocus, isTrue); - expect( - SuperEditorInspector.findDocumentSelection(), - DocumentSelection( - base: DocumentPosition( - nodeId: documentNode.id, - nodePosition: const TextNodePosition(offset: 0), - ), - extent: DocumentPosition( - nodeId: documentNode.id, - nodePosition: const TextNodePosition(offset: 5), - ), - ), - ); + // Ensure the parent node has primary focus again. + expect(parentFocusNode.hasPrimaryFocus, true); }); }); From d986e6aee05deb6aee17b311e19cd3820a1088aa Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 21 Dec 2023 19:34:48 -0300 Subject: [PATCH 33/36] Split test files --- .../test/infrastructure/popover_test.dart | 242 ------------------ .../infrastructure/selectable_list_test.dart | 184 +++++++++++++ 2 files changed, 184 insertions(+), 242 deletions(-) create mode 100644 super_editor/test/infrastructure/selectable_list_test.dart diff --git a/super_editor/test/infrastructure/popover_test.dart b/super_editor/test/infrastructure/popover_test.dart index 3f47fa54d6..3cb395fd55 100644 --- a/super_editor/test/infrastructure/popover_test.dart +++ b/super_editor/test/infrastructure/popover_test.dart @@ -1,19 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_test_robots/flutter_test_robots.dart'; import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:follow_the_leader/follow_the_leader.dart'; -import 'package:super_editor/src/core/document_composer.dart'; -import 'package:super_editor/src/core/editor.dart'; -import 'package:super_editor/src/default_editor/default_document_editor.dart'; -import 'package:super_editor/src/default_editor/super_editor.dart'; import 'package:super_editor/src/infrastructure/default_popovers.dart'; import 'package:super_editor/src/infrastructure/popover_scaffold.dart'; -import 'package:super_editor/src/infrastructure/selectable_list.dart'; -import 'package:super_editor/src/infrastructure/text_input.dart'; - -import '../super_editor/test_documents.dart'; void main() { group('PopoverScaffold', () { @@ -194,237 +185,4 @@ void main() { expect(parentFocusNode.hasPrimaryFocus, true); }); }); - - group('ItemSelectionList', () { - testWidgetsOnAllPlatforms('changes active item down with DOWN ARROW', (tester) async { - String? activeItem; - - await _pumpItemSelectionListTestApp( - tester, - onItemSelected: (s) => {}, - onItemActivated: (s) => activeItem = s, - ); - - // Ensure the popover is displayed without any active item. - expect(activeItem, isNull); - - // Press DOWN ARROW to activate the first item. - await tester.pressDownArrow(); - expect(activeItem, 'Item1'); - - // Press DOWN ARROW to activate the second item. - await tester.pressDownArrow(); - expect(activeItem, 'Item2'); - - // Press DOWN ARROW to activate the third item. - await tester.pressDownArrow(); - expect(activeItem, 'Item3'); - - // Press DOWN ARROW to activate the first item again. - await tester.pressDownArrow(); - expect(activeItem, 'Item1'); - }); - - testWidgetsOnAllPlatforms('changes active item up with UP ARROW', (tester) async { - String? activeItem; - - await _pumpItemSelectionListTestApp( - tester, - onItemSelected: (s) => {}, - onItemActivated: (s) => activeItem = s, - ); - - // Ensure the popover is displayed without any activate item. - expect(activeItem, isNull); - - // Press UP ARROW to activate the last item. - await tester.pressUpArrow(); - expect(activeItem, 'Item3'); - - // Press UP ARROW to activate the second item. - await tester.pressUpArrow(); - expect(activeItem, 'Item2'); - - // Press UP ARROW to activate the first item. - await tester.pressUpArrow(); - expect(activeItem, 'Item1'); - - // Press UP ARROW to activate the last item again. - await tester.pressUpArrow(); - expect(activeItem, 'Item3'); - }); - - testWidgetsOnAllPlatforms('selects the active item on ENTER', (tester) async { - String? selectedValue; - - await _pumpItemSelectionListTestApp( - tester, - onItemSelected: (s) => selectedValue = s, - ); - - // Press ARROW DOWN to activate the first item. - await tester.pressDownArrow(); - - // Press ENTER to select the active item. - await tester.pressEnter(); - await tester.pump(); - - // Ensure the first item was selected. - expect(selectedValue, 'Item1'); - }); - - testWidgetsOnAllPlatforms('clears selected item on ENTER without an active item', (tester) async { - String? selectedValue = ''; - - await _pumpItemSelectionListTestApp( - tester, - onItemSelected: (s) => selectedValue = s, - ); - - // Press ENTER without an active item. - await tester.pressEnter(); - await tester.pump(); - - // Ensure the selected item was set to null. - expect(selectedValue, isNull); - }); - - testWidgetsOnAllPlatforms('calls onCancel on ESC', (tester) async { - String? selectedValue; - bool isCanceled = false; - - await _pumpItemSelectionListTestApp( - tester, - onItemSelected: (s) => selectedValue = s, - onCancel: () => isCanceled = true, - ); - - // Press ARROW DOWN to activate the first item. - await tester.pressDownArrow(); - - // Press ESC to cancel. - await tester.pressEscape(); - await tester.pump(); - - // Ensure onCancel was called and no item was selected. - expect(isCanceled, true); - expect(selectedValue, isNull); - }); - - testWidgetsOnAllPlatforms('isn\'t scrollable if all items fit on screen', (tester) async { - await _pumpItemSelectionListTestApp( - tester, - onItemSelected: (s) {}, - ); - - // Ensure the list isn't scrollable. - final dropdownButonState = tester.state>(find.byType(ItemSelectionList)); - expect(dropdownButonState.scrollController.position.maxScrollExtent, 0.0); - }); - - testWidgetsOnAllPlatforms('is scrollable if items don\'t fit on screen', (tester) async { - await _pumpItemSelectionListTestApp( - tester, - onItemSelected: (s) {}, - constraints: const BoxConstraints(maxHeight: 50), - ); - - // Ensure the list is scrollable. - final dropdownButonState = tester.state>(find.byType(ItemSelectionList)); - expect(dropdownButonState.scrollController.position.maxScrollExtent, greaterThan(0.0)); - }); - }); -} - -/// Pumps a widget tree with a [ItemSelectionList] containing three items and -/// immediately requests focus to it. -Future _pumpItemSelectionListTestApp( - WidgetTester tester, { - required void Function(String? value) onItemSelected, - void Function(String? value)? onItemActivated, - VoidCallback? onCancel, - BoxConstraints? constraints, -}) async { - final focusNode = FocusNode(); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: ConstrainedBox( - constraints: constraints ?? const BoxConstraints(), - child: ItemSelectionList( - focusNode: focusNode, - value: null, - items: const ['Item1', 'Item2', 'Item3'], - onItemSelected: onItemSelected, - onItemActivated: onItemActivated, - onCancel: onCancel, - itemBuilder: (context, item, isActive, onTap) => TextButton( - onPressed: onTap, - child: Text(item), - ), - ), - ), - ), - ), - ); - - focusNode.requestFocus(); - await tester.pump(); -} - -/// Displays a [SuperEditor] that fills the available height, containing a single paragraph, -/// and a [toolbar] at the bottom. -class _SuperEditorDropdownTestApp extends StatefulWidget { - const _SuperEditorDropdownTestApp({ - required this.toolbar, - this.editorFocusNode, - }); - - final FocusNode? editorFocusNode; - final Widget toolbar; - - @override - State<_SuperEditorDropdownTestApp> createState() => _SuperEditorDropdownTestAppState(); -} - -class _SuperEditorDropdownTestAppState extends State<_SuperEditorDropdownTestApp> { - late MutableDocument _doc; - late MutableDocumentComposer _composer; - late Editor _docEditor; - - @override - void initState() { - super.initState(); - _doc = singleParagraphDoc(); - _composer = MutableDocumentComposer(); - _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); - } - - @override - void dispose() { - _docEditor.dispose(); - _doc.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Column( - children: [ - Expanded( - child: SuperEditor( - document: _doc, - editor: _docEditor, - composer: _composer, - inputSource: TextInputSource.ime, - focusNode: widget.editorFocusNode, - ), - ), - widget.toolbar, - ], - ), - ); - } } diff --git a/super_editor/test/infrastructure/selectable_list_test.dart b/super_editor/test/infrastructure/selectable_list_test.dart new file mode 100644 index 0000000000..4ca2cc131d --- /dev/null +++ b/super_editor/test/infrastructure/selectable_list_test.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/selectable_list.dart'; + +void main() { + group('ItemSelectionList', () { + testWidgetsOnAllPlatforms('changes active item down with DOWN ARROW', (tester) async { + String? activeItem; + + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) => {}, + onItemActivated: (s) => activeItem = s, + ); + + // Ensure the popover is displayed without any active item. + expect(activeItem, isNull); + + // Press DOWN ARROW to activate the first item. + await tester.pressDownArrow(); + expect(activeItem, 'Item1'); + + // Press DOWN ARROW to activate the second item. + await tester.pressDownArrow(); + expect(activeItem, 'Item2'); + + // Press DOWN ARROW to activate the third item. + await tester.pressDownArrow(); + expect(activeItem, 'Item3'); + + // Press DOWN ARROW to activate the first item again. + await tester.pressDownArrow(); + expect(activeItem, 'Item1'); + }); + + testWidgetsOnAllPlatforms('changes active item up with UP ARROW', (tester) async { + String? activeItem; + + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) => {}, + onItemActivated: (s) => activeItem = s, + ); + + // Ensure the popover is displayed without any activate item. + expect(activeItem, isNull); + + // Press UP ARROW to activate the last item. + await tester.pressUpArrow(); + expect(activeItem, 'Item3'); + + // Press UP ARROW to activate the second item. + await tester.pressUpArrow(); + expect(activeItem, 'Item2'); + + // Press UP ARROW to activate the first item. + await tester.pressUpArrow(); + expect(activeItem, 'Item1'); + + // Press UP ARROW to activate the last item again. + await tester.pressUpArrow(); + expect(activeItem, 'Item3'); + }); + + testWidgetsOnAllPlatforms('selects the active item on ENTER', (tester) async { + String? selectedValue; + + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) => selectedValue = s, + ); + + // Press ARROW DOWN to activate the first item. + await tester.pressDownArrow(); + + // Press ENTER to select the active item. + await tester.pressEnter(); + await tester.pump(); + + // Ensure the first item was selected. + expect(selectedValue, 'Item1'); + }); + + testWidgetsOnAllPlatforms('clears selected item on ENTER without an active item', (tester) async { + String? selectedValue = ''; + + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) => selectedValue = s, + ); + + // Press ENTER without an active item. + await tester.pressEnter(); + await tester.pump(); + + // Ensure the selected item was set to null. + expect(selectedValue, isNull); + }); + + testWidgetsOnAllPlatforms('calls onCancel on ESC', (tester) async { + String? selectedValue; + bool isCanceled = false; + + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) => selectedValue = s, + onCancel: () => isCanceled = true, + ); + + // Press ARROW DOWN to activate the first item. + await tester.pressDownArrow(); + + // Press ESC to cancel. + await tester.pressEscape(); + await tester.pump(); + + // Ensure onCancel was called and no item was selected. + expect(isCanceled, true); + expect(selectedValue, isNull); + }); + + testWidgetsOnAllPlatforms('isn\'t scrollable if all items fit on screen', (tester) async { + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) {}, + ); + + // Ensure the list isn't scrollable. + final dropdownButonState = tester.state>(find.byType(ItemSelectionList)); + expect(dropdownButonState.scrollController.position.maxScrollExtent, 0.0); + }); + + testWidgetsOnAllPlatforms('is scrollable if items don\'t fit on screen', (tester) async { + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) {}, + constraints: const BoxConstraints(maxHeight: 50), + ); + + // Ensure the list is scrollable. + final dropdownButonState = tester.state>(find.byType(ItemSelectionList)); + expect(dropdownButonState.scrollController.position.maxScrollExtent, greaterThan(0.0)); + }); + }); +} + +/// Pumps a widget tree with a [ItemSelectionList] containing three items and +/// immediately requests focus to it. +Future _pumpItemSelectionListTestApp( + WidgetTester tester, { + required void Function(String? value) onItemSelected, + void Function(String? value)? onItemActivated, + VoidCallback? onCancel, + BoxConstraints? constraints, +}) async { + final focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConstrainedBox( + constraints: constraints ?? const BoxConstraints(), + child: ItemSelectionList( + focusNode: focusNode, + value: null, + items: const ['Item1', 'Item2', 'Item3'], + onItemSelected: onItemSelected, + onItemActivated: onItemActivated, + onCancel: onCancel, + itemBuilder: (context, item, isActive, onTap) => TextButton( + onPressed: onTap, + child: Text(item), + ), + ), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); +} From e306b88813bdef8c295128f71ca18416d4d6f45a Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Sun, 24 Dec 2023 09:09:31 -0300 Subject: [PATCH 34/36] Add tests for default popover geometry --- .../src/infrastructure/popover_scaffold.dart | 24 +- .../test/infrastructure/popover_test.dart | 307 +++++++++++++++++- 2 files changed, 320 insertions(+), 11 deletions(-) diff --git a/super_editor/lib/src/infrastructure/popover_scaffold.dart b/super_editor/lib/src/infrastructure/popover_scaffold.dart index 35918974fa..c82d40f0ba 100644 --- a/super_editor/lib/src/infrastructure/popover_scaffold.dart +++ b/super_editor/lib/src/infrastructure/popover_scaffold.dart @@ -89,6 +89,7 @@ class _PopoverScaffoldState extends State { final LeaderLink _popoverLink = LeaderLink(); final FocusNode _scaffoldFocusNode = FocusNode(); + late Size _screenSize; late FollowerBoundary _screenBoundary; /// Closes the popover when tapping outside. @@ -132,6 +133,7 @@ class _PopoverScaffoldState extends State { } void _updateFollowerBoundary() { + _screenSize = MediaQuery.sizeOf(context); if (widget.boundaryKey != null) { _screenBoundary = WidgetFollowerBoundary( boundaryKey: widget.boundaryKey, @@ -139,7 +141,7 @@ class _PopoverScaffoldState extends State { ); } else { _screenBoundary = ScreenFollowerBoundary( - screenSize: MediaQuery.sizeOf(context), + screenSize: _screenSize, devicePixelRatio: MediaQuery.devicePixelRatioOf(context), ); } @@ -184,7 +186,7 @@ class _PopoverScaffoldState extends State { boundary: _screenBoundary, aligner: FunctionalAligner( delegate: (globalLeaderRect, followerSize) => - widget.popoverGeometry.align(globalLeaderRect, followerSize, widget.boundaryKey), + widget.popoverGeometry.align(globalLeaderRect, followerSize, _screenSize, widget.boundaryKey), ), child: ConstrainedBox( constraints: widget.popoverGeometry.constraints ?? const BoxConstraints(), @@ -252,28 +254,30 @@ class PopoverGeometry { /// A function to align a Widget following a leader Widget. /// /// If a [boundaryKey] is given, the alignment must be within the bounds of its `RenderBox`. -typedef PopoverAligner = FollowerAlignment Function(Rect globalLeaderRect, Size followerSize, GlobalKey? boundaryKey); +typedef PopoverAligner = FollowerAlignment Function( + Rect globalLeaderRect, Size followerSize, Size screenSize, GlobalKey? boundaryKey); -/// Computes the position of a popover list relative to the dropdown button. +/// Computes the position of a popover relative to the button. /// /// The following rules are applied, in order: /// -/// 1. If there is enough room to display the dropdown list beneath the button, +/// 1. If there is enough room to display the popover beneath the button, /// position it below the button. /// -/// 2. If there is enough room to display the dropdown list above the button, +/// 2. If there is enough room to display the popover above the button, /// position it above the button. /// -/// 3. Pin the dropdown list to the bottom of the `RenderBox` bound to [boundaryKey], -/// letting the dropdown list cover the button. -FollowerAlignment defaultPopoverAligner(Rect globalLeaderRect, Size followerSize, GlobalKey? boundaryKey) { +/// 3. Pin the popover to the bottom of the `RenderBox` bound to [boundaryKey], +/// letting the popover cover the button. +FollowerAlignment defaultPopoverAligner( + Rect globalLeaderRect, Size followerSize, Size screenSize, GlobalKey? boundaryKey) { final boundsBox = boundaryKey?.currentContext?.findRenderObject() as RenderBox?; final bounds = boundsBox != null ? Rect.fromPoints( boundsBox.localToGlobal(Offset.zero), boundsBox.localToGlobal(boundsBox.size.bottomRight(Offset.zero)), ) - : Rect.largest; + : Offset.zero & screenSize; late FollowerAlignment alignment; if (globalLeaderRect.bottom + followerSize.height < bounds.bottom) { diff --git a/super_editor/test/infrastructure/popover_test.dart b/super_editor/test/infrastructure/popover_test.dart index 3cb395fd55..4031cf6996 100644 --- a/super_editor/test/infrastructure/popover_test.dart +++ b/super_editor/test/infrastructure/popover_test.dart @@ -95,7 +95,7 @@ void main() { controller: popoverController, popoverGeometry: PopoverGeometry( constraints: const BoxConstraints(maxHeight: 300), - align: (globalLeaderRect, followerSize, boundaryKey) => const FollowerAlignment( + align: (globalLeaderRect, followerSize, screenSize, boundaryKey) => const FollowerAlignment( leaderAnchor: Alignment.topRight, followerAnchor: Alignment.topLeft, followerOffset: Offset(10, 10), @@ -130,6 +130,311 @@ void main() { expect(popoverRect.left, buttonRect.right + 10); }); + group('default popover geometry', () { + group('with a boundary key', () { + testWidgetsOnAllPlatforms('positions the popover below button if there is room', (tester) async { + final boundaryKey = GlobalKey(); + final buttonKey = GlobalKey(); + final popoverController = PopoverController(); + + // Pump a tree with a popover that fits below the button. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + key: boundaryKey, + height: 600, + child: Stack( + children: [ + Positioned( + top: 0, + child: PopoverScaffold( + controller: popoverController, + boundaryKey: boundaryKey, + buttonBuilder: (context) => SizedBox( + key: buttonKey, + height: 50, + ), + popoverBuilder: (context) => const RoundedRectanglePopoverAppearance( + child: SizedBox(height: 500), + ), + ), + ), + ], + ), + ), + ), + ), + ); + + // Ensure the popover isn't displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsNothing); + + // Show the popover. + popoverController.open(); + await tester.pumpAndSettle(); + + // Ensure the popover is displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsOneWidget); + + final buttonRect = tester.getRect(find.byKey(buttonKey)); + final popoverRect = tester.getRect(find.byType(RoundedRectanglePopoverAppearance)); + + // Ensure popover was displayed below the button. + expect(popoverRect.top, greaterThan(buttonRect.bottom)); + }); + + testWidgetsOnAllPlatforms('positions the popover above button if there is room', (tester) async { + final boundaryKey = GlobalKey(); + final buttonKey = GlobalKey(); + final popoverController = PopoverController(); + + // Pump a tree with a popover that fits above the button. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + key: boundaryKey, + height: 600, + child: Stack( + children: [ + Positioned( + top: 550, + child: PopoverScaffold( + controller: popoverController, + boundaryKey: boundaryKey, + buttonBuilder: (context) => SizedBox( + key: buttonKey, + height: 50, + ), + popoverBuilder: (context) => const RoundedRectanglePopoverAppearance( + child: SizedBox(height: 500), + ), + ), + ), + ], + ), + ), + ), + ), + ); + + // Ensure the popover isn't displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsNothing); + + // Show the popover. + popoverController.open(); + await tester.pumpAndSettle(); + + // Ensure the popover is displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsOneWidget); + + final buttonRect = tester.getRect(find.byKey(buttonKey)); + final popoverRect = tester.getRect(find.byType(RoundedRectanglePopoverAppearance)); + + // Ensure popover was displayed above the button. + expect(popoverRect.bottom, lessThan(buttonRect.top)); + }); + + testWidgetsOnAllPlatforms( + 'pins the popover to the bottom if there isn\'t room neither below or above the button', (tester) async { + final boundaryKey = GlobalKey(); + final buttonKey = GlobalKey(); + final popoverController = PopoverController(); + + // Pump a tree with a popover that doesn't fit neither below or above the button. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + key: boundaryKey, + height: 600, + child: Stack( + children: [ + Positioned( + top: 400, + child: PopoverScaffold( + controller: popoverController, + boundaryKey: boundaryKey, + buttonBuilder: (context) => SizedBox( + key: buttonKey, + height: 50, + ), + popoverBuilder: (context) => const RoundedRectanglePopoverAppearance( + child: SizedBox(height: 500), + ), + ), + ), + ], + ), + ), + ), + ), + ); + + // Ensure the popover isn't displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsNothing); + + // Show the popover. + popoverController.open(); + await tester.pumpAndSettle(); + + // Ensure the popover is displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsOneWidget); + + final popoverRect = tester.getRect(find.byType(RoundedRectanglePopoverAppearance)); + + // Ensure popover was pinned of the bottom to the boundary widget. + expect(popoverRect.bottom, 600); + }); + }); + + group('without a boundary key', () { + testWidgetsOnAllPlatforms('positions the popover below button if there is room', (tester) async { + final buttonKey = GlobalKey(); + final popoverController = PopoverController(); + + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = const Size(1000, 600); + + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + // Pump a tree with a popover that fits below the button. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PopoverScaffold( + controller: popoverController, + buttonBuilder: (context) => SizedBox( + key: buttonKey, + height: 50, + ), + popoverBuilder: (context) => const RoundedRectanglePopoverAppearance( + child: SizedBox(height: 500), + ), + ), + ), + ), + ); + + // Ensure the popover isn't displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsNothing); + + // Show the popover. + popoverController.open(); + await tester.pumpAndSettle(); + + // Ensure the popover is displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsOneWidget); + + final buttonRect = tester.getRect(find.byKey(buttonKey)); + final popoverRect = tester.getRect(find.byType(RoundedRectanglePopoverAppearance)); + + // Ensure popover was displayed below the button. + expect(popoverRect.top, greaterThan(buttonRect.bottom)); + }); + + testWidgetsOnAllPlatforms('positions the popover above button if there is room', (tester) async { + final buttonKey = GlobalKey(); + final popoverController = PopoverController(); + + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = const Size(1000, 800); + + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + // Pump a tree with a popover that doesn't fit below the button, but fits above it. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Container( + alignment: Alignment.bottomCenter, + child: PopoverScaffold( + controller: popoverController, + buttonBuilder: (context) => SizedBox( + key: buttonKey, + height: 50, + ), + popoverBuilder: (context) => const RoundedRectanglePopoverAppearance( + child: SizedBox(height: 500), + ), + ), + ), + ), + ), + ); + + // Ensure the popover isn't displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsNothing); + + // Show the popover. + popoverController.open(); + await tester.pumpAndSettle(); + + // Ensure the popover is displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsOneWidget); + + final buttonRect = tester.getRect(find.byKey(buttonKey)); + final popoverRect = tester.getRect(find.byType(RoundedRectanglePopoverAppearance)); + + // Ensure popover was displayed above the button. + expect(popoverRect.bottom, lessThan(buttonRect.top)); + }); + + testWidgetsOnAllPlatforms( + 'pins the popover to the bottom if there isn\'t room neither below or above the button', (tester) async { + final buttonKey = GlobalKey(); + final popoverController = PopoverController(); + + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = const Size(1000, 600); + + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + // Pump a tree with a popover that doesn't fit neither below or above the button. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopoverScaffold( + controller: popoverController, + buttonBuilder: (context) => SizedBox( + key: buttonKey, + height: 50, + ), + popoverBuilder: (context) => const RoundedRectanglePopoverAppearance( + child: SizedBox(height: 500), + ), + ), + ), + ), + ), + ); + + // Ensure the popover isn't displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsNothing); + + // Show the popover. + popoverController.open(); + await tester.pumpAndSettle(); + + // Ensure the popover is displayed. + expect(find.byType(RoundedRectanglePopoverAppearance), findsOneWidget); + + final popoverRect = tester.getRect(find.byType(RoundedRectanglePopoverAppearance)); + + // Ensure popover was displayed pinned to the bottom of the screen. + expect(popoverRect.bottom, 600); + }); + }); + }); + testWidgetsOnAllPlatforms('shares focus with other widgets', (tester) async { final parentFocusNode = FocusNode(); final popoverFocusNode = FocusNode(); From 5bd8ff9470e2694728caf7d49f521637f61c7cc3 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Tue, 26 Dec 2023 19:41:04 -0300 Subject: [PATCH 35/36] Fix popover size when using boundary key --- .../src/infrastructure/popover_scaffold.dart | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/super_editor/lib/src/infrastructure/popover_scaffold.dart b/super_editor/lib/src/infrastructure/popover_scaffold.dart index c82d40f0ba..c92a0c8635 100644 --- a/super_editor/lib/src/infrastructure/popover_scaffold.dart +++ b/super_editor/lib/src/infrastructure/popover_scaffold.dart @@ -164,6 +164,27 @@ class _PopoverScaffoldState extends State { widget.onTapOutside(widget.controller); } + BoxConstraints _computePopoverConstraints() { + if (widget.popoverGeometry.constraints != null) { + return widget.popoverGeometry.constraints!; + } + + if (widget.boundaryKey != null) { + // Let the popover be at most the size of the boundary widget. + final boundaryBox = widget.boundaryKey!.currentContext!.findRenderObject() as RenderBox; + return BoxConstraints( + maxHeight: boundaryBox.size.height, + maxWidth: boundaryBox.size.width, + ); + } + + // Let the popover be at most the size of the screen. + return BoxConstraints( + maxHeight: _screenSize.height, + maxWidth: _screenSize.width, + ); + } + @override Widget build(BuildContext context) { return OverlayPortal( @@ -189,7 +210,7 @@ class _PopoverScaffoldState extends State { widget.popoverGeometry.align(globalLeaderRect, followerSize, _screenSize, widget.boundaryKey), ), child: ConstrainedBox( - constraints: widget.popoverGeometry.constraints ?? const BoxConstraints(), + constraints: _computePopoverConstraints(), child: Focus( parentNode: widget.parentFocusNode, child: widget.popoverBuilder(context), From 724dd1ae8cc74ce5beef1d4b9ccdc20ceaf083d9 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Tue, 26 Dec 2023 19:52:33 -0300 Subject: [PATCH 36/36] Update tests --- .../test/infrastructure/popover_test.dart | 73 ++++++++++++++----- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/super_editor/test/infrastructure/popover_test.dart b/super_editor/test/infrastructure/popover_test.dart index 4031cf6996..fd3a776fc4 100644 --- a/super_editor/test/infrastructure/popover_test.dart +++ b/super_editor/test/infrastructure/popover_test.dart @@ -137,13 +137,22 @@ void main() { final buttonKey = GlobalKey(); final popoverController = PopoverController(); + // Use a screen size bigger than the boundary widget + // to make sure we use the widget size instead of the screen size + // to size and position the popover. + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = const Size(1000, 2000); + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + // Pump a tree with a popover that fits below the button. await tester.pumpWidget( MaterialApp( home: Scaffold( body: SizedBox( key: boundaryKey, - height: 600, + height: 300, child: Stack( children: [ Positioned( @@ -156,7 +165,7 @@ void main() { height: 50, ), popoverBuilder: (context) => const RoundedRectanglePopoverAppearance( - child: SizedBox(height: 500), + child: SizedBox(height: 200), ), ), ), @@ -184,22 +193,32 @@ void main() { expect(popoverRect.top, greaterThan(buttonRect.bottom)); }); - testWidgetsOnAllPlatforms('positions the popover above button if there is room', (tester) async { + testWidgetsOnAllPlatforms('positions the popover above button if there is room above but not below', + (tester) async { final boundaryKey = GlobalKey(); final buttonKey = GlobalKey(); final popoverController = PopoverController(); + // Use a screen size bigger than the boundary widget + // to make sure we use the widget size instead of the screen size + // to size and position the popover. + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = const Size(1000, 2000); + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + // Pump a tree with a popover that fits above the button. await tester.pumpWidget( MaterialApp( home: Scaffold( body: SizedBox( key: boundaryKey, - height: 600, + height: 300, child: Stack( children: [ Positioned( - top: 550, + top: 250, child: PopoverScaffold( controller: popoverController, boundaryKey: boundaryKey, @@ -208,7 +227,7 @@ void main() { height: 50, ), popoverBuilder: (context) => const RoundedRectanglePopoverAppearance( - child: SizedBox(height: 500), + child: SizedBox(height: 200), ), ), ), @@ -236,19 +255,28 @@ void main() { expect(popoverRect.bottom, lessThan(buttonRect.top)); }); - testWidgetsOnAllPlatforms( - 'pins the popover to the bottom if there isn\'t room neither below or above the button', (tester) async { + testWidgetsOnAllPlatforms('pins the popover to the bottom if there is not room below or above the button', + (tester) async { final boundaryKey = GlobalKey(); final buttonKey = GlobalKey(); final popoverController = PopoverController(); - // Pump a tree with a popover that doesn't fit neither below or above the button. + // Use a screen size bigger than the boundary widget + // to make sure we use the widget size instead of the screen size + // to size and position the popover. + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = const Size(1000, 2000); + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + // Pump a tree with a popover that doesn't fit below or above the button. await tester.pumpWidget( MaterialApp( home: Scaffold( body: SizedBox( key: boundaryKey, - height: 600, + height: 500, child: Stack( children: [ Positioned( @@ -261,7 +289,7 @@ void main() { height: 50, ), popoverBuilder: (context) => const RoundedRectanglePopoverAppearance( - child: SizedBox(height: 500), + child: SizedBox(height: 700), ), ), ), @@ -284,8 +312,10 @@ void main() { final popoverRect = tester.getRect(find.byType(RoundedRectanglePopoverAppearance)); - // Ensure popover was pinned of the bottom to the boundary widget. - expect(popoverRect.bottom, 600); + // Ensure popover was pinned of the bottom to the boundary widget + // and did not exceeded the boundary size. + expect(popoverRect.bottom, 500); + expect(popoverRect.height, 500); }); }); @@ -336,7 +366,8 @@ void main() { expect(popoverRect.top, greaterThan(buttonRect.bottom)); }); - testWidgetsOnAllPlatforms('positions the popover above button if there is room', (tester) async { + testWidgetsOnAllPlatforms('positions the popover above button if there is room above but not below', + (tester) async { final buttonKey = GlobalKey(); final popoverController = PopoverController(); @@ -385,8 +416,8 @@ void main() { expect(popoverRect.bottom, lessThan(buttonRect.top)); }); - testWidgetsOnAllPlatforms( - 'pins the popover to the bottom if there isn\'t room neither below or above the button', (tester) async { + testWidgetsOnAllPlatforms('pins the popover to the bottom if there is not room below or above the button', + (tester) async { final buttonKey = GlobalKey(); final popoverController = PopoverController(); @@ -397,7 +428,7 @@ void main() { addTearDown(() => tester.platformDispatcher.clearAllTestValues()); - // Pump a tree with a popover that doesn't fit neither below or above the button. + // Pump a tree with a popover that doesn't fit below or above the button. await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -435,7 +466,13 @@ void main() { }); }); - testWidgetsOnAllPlatforms('shares focus with other widgets', (tester) async { + testWidgetsOnAllPlatforms('shares focus with widgets of a different subtree', (tester) async { + // When PopoverScaffold is in a different subtree from the currently focused widget, + // for example, an Overlay or OverlayPortal, it doesn't naturally shares focus with it. + // + // This test makes sure PopoverScaffold has the ability to setup focus sharing + // with widgets of a different subtree, so the popover shares focus with a parent FocusNode. + final parentFocusNode = FocusNode(); final popoverFocusNode = FocusNode();