From ff82213bdf353521a88efa819214a52e88e2fad2 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Thu, 28 Dec 2023 17:55:19 -0300 Subject: [PATCH] [Infrastructure] Implement custom dropdown widget (Resolves #1527, Resolves #731, Resolves #689) (#1538) --- .../lib/demos/example_editor/_toolbar.dart | 125 +++-- .../super_editor_item_selector.dart | 402 +++++++++++++ super_editor/example/pubspec.yaml | 2 +- .../src/infrastructure/default_popovers.dart | 55 ++ .../src/infrastructure/popover_scaffold.dart | 329 +++++++++++ .../src/infrastructure/selectable_list.dart | 270 +++++++++ super_editor/lib/super_editor.dart | 3 + super_editor/pubspec.yaml | 2 +- .../test/infrastructure/popover_test.dart | 530 ++++++++++++++++++ .../infrastructure/selectable_list_test.dart | 184 ++++++ 10 files changed, 1852 insertions(+), 50 deletions(-) create mode 100644 super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart create mode 100644 super_editor/lib/src/infrastructure/default_popovers.dart create mode 100644 super_editor/lib/src/infrastructure/popover_scaffold.dart create mode 100644 super_editor/lib/src/infrastructure/selectable_list.dart create mode 100644 super_editor/test/infrastructure/popover_test.dart create mode 100644 super_editor/test/infrastructure/selectable_list_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 ddd8d0ec9b..f80cf22969 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'; @@ -100,6 +101,7 @@ class _EditorToolbarState extends State { _urlFocusNode.dispose(); _urlController!.dispose(); _popoverFocusNode.dispose(); + super.dispose(); } @@ -139,7 +141,7 @@ 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'); + throw Exception('Alignment does not apply to node of type: $selectedNode'); } } @@ -163,7 +165,7 @@ class _EditorToolbarState extends State { return TextAlign.left; } } else { - throw Exception('Alignment does not apply to node of type: $selectedNode'); + throw Exception('Invalid node type: $selectedNode'); } } @@ -482,6 +484,26 @@ class _EditorToolbarState extends State { } } + /// Called when the user selects a block type on the toolbar. + void _onBlockTypeSelected(SuperEditorDemoTextItem? selectedItem) { + if (selectedItem != null) { + setState(() { + _convertTextToNewType(_TextType.values // + .where((e) => e.name == selectedItem.id) + .first); + }); + } + } + + /// Called when the user selects an alignment on the toolbar. + void _onAlignmentSelected(SuperEditorDemoIconItem? selectedItem) { + if (selectedItem != null) { + setState(() { + _changeAlignment(TextAlign.values.firstWhere((e) => e.name == selectedItem.id)); + }); + } + } + @override Widget build(BuildContext context) { return BuildInOrder( @@ -534,27 +556,7 @@ class _EditorToolbarState extends State { if (_isConvertibleNode()) ...[ Tooltip( message: AppLocalizations.of(context)!.labelTextBlockType, - child: DropdownButton<_TextType>( - 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, - ), - underline: const SizedBox(), - elevation: 0, - itemHeight: 48, - onChanged: _convertTextToNewType, - ), + child: _buildBlockTypeSelector(), ), _buildVerticalDivider(), ], @@ -593,33 +595,18 @@ 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: DropdownButton( - 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, + if (_isTextAlignable()) // + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildVerticalDivider(), + Tooltip( + message: AppLocalizations.of(context)!.labelTextAlignment, + child: _buildAlignmentSelector(), ), - underline: const SizedBox(), - elevation: 0, - itemHeight: 48, - onChanged: _changeAlignment, - ), + ], ), - ], + _buildVerticalDivider(), Center( child: IconButton( @@ -636,6 +623,48 @@ class _EditorToolbarState extends State { ); } + Widget _buildAlignmentSelector() { + final alignment = _getCurrentTextAlignment(); + return SuperEditorDemoIconItemSelector( + parentFocusNode: widget.editorFocusNode, + boundaryKey: widget.editorViewportKey, + value: SuperEditorDemoIconItem( + id: alignment.name, + icon: _buildTextAlignIcon(alignment), + ), + items: TextAlign.values + .map( + (alignment) => SuperEditorDemoIconItem( + icon: _buildTextAlignIcon(alignment), + id: alignment.name, + ), + ) + .toList(), + onSelected: _onAlignmentSelected, + ); + } + + Widget _buildBlockTypeSelector() { + final currentBlockType = _getCurrentTextType(); + return SuperEditorDemoTextItemSelector( + parentFocusNode: widget.editorFocusNode, + boundaryKey: widget.editorViewportKey, + id: SuperEditorDemoTextItem( + id: currentBlockType.name, + label: _getTextTypeName(currentBlockType), + ), + items: _TextType.values + .map( + (blockType) => SuperEditorDemoTextItem( + id: blockType.name, + label: _getTextTypeName(blockType), + ), + ) + .toList(), + onSelected: _onBlockTypeSelected, + ); + } + Widget _buildUrlField() { return Material( shape: const StadiumBorder(), 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..f47b0807b8 --- /dev/null +++ b/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart @@ -0,0 +1,402 @@ +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.id, + 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? id; + + /// 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(); + } + + void _onItemSelected(SuperEditorDemoTextItem? value) { + _popoverController.close(); + widget.onSelected(value); + } + + @override + Widget build(BuildContext context) { + return PopoverScaffold( + controller: _popoverController, + buttonBuilder: _buildButton, + popoverFocusNode: _popoverFocusNode, + parentFocusNode: widget.parentFocusNode, + boundaryKey: widget.boundaryKey, + popoverBuilder: (context) => RoundedRectanglePopoverAppearance( + child: ItemSelectionList( + focusNode: _popoverFocusNode, + value: widget.id, + items: widget.items, + itemBuilder: _buildPopoverListItem, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + ), + ), + ); + } + + 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( + 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, + ), + ), + ), + ), + ); + } +} + +/// An option that is displayed as text by a [SuperEditorDemoTextItemSelector]. +/// +/// Two [SuperEditorDemoTextItem]s are considered to be equal if they have the same [id]. +class SuperEditorDemoTextItem { + const SuperEditorDemoTextItem({ + required this.id, + required this.label, + }); + + /// The value that identifies this item. + 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 && id == other.id; + + @override + int get hashCode => id.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, + parentFocusNode: widget.parentFocusNode, + popoverBuilder: (context) => RoundedRectanglePopoverAppearance( + child: ItemSelectionList( + value: widget.value, + items: widget.items, + itemBuilder: _buildItem, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + 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 [id]. +class SuperEditorDemoIconItem { + const SuperEditorDemoIconItem({ + required this.id, + required this.icon, + }); + + /// The value that identifies this item. + final String id; + + /// The icon that is displayed. + final IconData icon; + + @override + bool operator ==(Object other) => + identical(this, other) || other is SuperEditorDemoIconItem && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.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, + this.padding, + required this.onTap, + this.child, + }); + + /// 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. + 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/example/pubspec.yaml b/super_editor/example/pubspec.yaml index 326019f660..784e971a01 100644 --- a/super_editor/example/pubspec.yaml +++ b/super_editor/example/pubspec.yaml @@ -52,7 +52,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: 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/popover_scaffold.dart b/super_editor/lib/src/infrastructure/popover_scaffold.dart new file mode 100644 index 0000000000..c92a0c8635 --- /dev/null +++ b/super_editor/lib/src/infrastructure/popover_scaffold.dart @@ -0,0 +1,329 @@ +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.parentFocusNode, + this.boundaryKey, + this.onTapOutside = _PopoverScaffoldState.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; + + /// 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 + /// 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(); + final FocusNode _scaffoldFocusNode = FocusNode(); + + late Size _screenSize; + late FollowerBoundary _screenBoundary; + + /// Closes the popover when tapping outside. + static void closePopoverOnTapOutside(PopoverController controller) { + controller.close(); + } + + @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(); + _scaffoldFocusNode.dispose(); + + super.dispose(); + } + + void _updateFollowerBoundary() { + _screenSize = MediaQuery.sizeOf(context); + if (widget.boundaryKey != null) { + _screenBoundary = WidgetFollowerBoundary( + boundaryKey: widget.boundaryKey, + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + ); + } else { + _screenBoundary = ScreenFollowerBoundary( + screenSize: _screenSize, + 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); + } + + 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( + 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, _screenSize, widget.boundaryKey), + ), + child: ConstrainedBox( + constraints: _computePopoverConstraints(), + child: Focus( + parentNode: widget.parentFocusNode, + 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; +} + +/// 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, Size screenSize, GlobalKey? boundaryKey); + +/// 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 popover beneath the button, +/// position it below the button. +/// +/// 2. If there is enough room to display the popover above the button, +/// position it above the button. +/// +/// 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)), + ) + : Offset.zero & screenSize; + 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; +} 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..68f6edf525 --- /dev/null +++ b/super_editor/lib/src/infrastructure/selectable_list.dart @@ -0,0 +1,270 @@ +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. +/// +/// 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. +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, + }); + + /// 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 SelectableListItemBuilder 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; + + @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, + 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 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/lib/super_editor.dart b/super_editor/lib/super_editor.dart index eda3fbf0a4..9f3464a62f 100644 --- a/super_editor/lib/super_editor.dart +++ b/super_editor/lib/super_editor.dart @@ -87,6 +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/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'; diff --git a/super_editor/pubspec.yaml b/super_editor/pubspec.yaml index 3cce4c16fa..938844d298 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 diff --git a/super_editor/test/infrastructure/popover_test.dart b/super_editor/test/infrastructure/popover_test.dart new file mode 100644 index 0000000000..fd3a776fc4 --- /dev/null +++ b/super_editor/test/infrastructure/popover_test.dart @@ -0,0 +1,530 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; + +import 'package:super_editor/src/infrastructure/default_popovers.dart'; +import 'package:super_editor/src/infrastructure/popover_scaffold.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 buttonKey = GlobalKey(); + final popoverController = PopoverController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PopoverScaffold( + controller: popoverController, + popoverGeometry: PopoverGeometry( + constraints: const BoxConstraints(maxHeight: 300), + align: (globalLeaderRect, followerSize, screenSize, boundaryKey) => const FollowerAlignment( + leaderAnchor: Alignment.topRight, + followerAnchor: Alignment.topLeft, + followerOffset: Offset(10, 10), + ), + ), + buttonBuilder: (context) => SizedBox(key: buttonKey), + 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 the given geometry was honored. + expect(popoverRect.height, 300); + expect(popoverRect.top, buttonRect.top + 10); + 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(); + + // 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: 300, + 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: 200), + ), + ), + ), + ], + ), + ), + ), + ), + ); + + // 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 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: 300, + child: Stack( + children: [ + Positioned( + top: 250, + child: PopoverScaffold( + controller: popoverController, + boundaryKey: boundaryKey, + buttonBuilder: (context) => SizedBox( + key: buttonKey, + height: 50, + ), + popoverBuilder: (context) => const RoundedRectanglePopoverAppearance( + child: SizedBox(height: 200), + ), + ), + ), + ], + ), + ), + ), + ), + ); + + // 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 is not room below or above the button', + (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 doesn't fit below or above the button. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + key: boundaryKey, + height: 500, + 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: 700), + ), + ), + ), + ], + ), + ), + ), + ), + ); + + // 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 + // and did not exceeded the boundary size. + expect(popoverRect.bottom, 500); + expect(popoverRect.height, 500); + }); + }); + + 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 above but not below', + (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 is not room 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 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 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(); + + final popoverController = PopoverController(); + final overLayControler = OverlayPortalController(); + + await tester.pumpWidget( + MaterialApp( + 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(), + ), + ), + ), + ), + ), + ), + ); + + // Focus the parent node. + parentFocusNode.requestFocus(); + await tester.pump(); + expect(parentFocusNode.hasPrimaryFocus, true); + + // Show the overlay. + overLayControler.show(); + await tester.pumpAndSettle(); + + // Show the popover. + popoverController.open(); + await tester.pumpAndSettle(); + + // 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 parent node has primary focus again. + expect(parentFocusNode.hasPrimaryFocus, true); + }); + }); +} 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(); +}