Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/app/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ class QueryaApp extends StatelessWidget {
themeMode: themeController.themeMode,
materialTheme: themeController.materialThemeFor(colorScheme),
debugShowCheckedModeBanner: false,
menuHandler: const PopoverOverlayHandler(
defaultShowDuration: Duration(milliseconds: 60),
defaultDismissDuration: Duration(milliseconds: 50),
showCurve: Curves.easeOutCubic,
dismissCurve: Curves.easeIn,
),
enableThemeAnimation: themeAnimEnabled,
themeAnimationDuration: themeDuration,
themeAnimationCurve: themeCurve,
Expand Down
6 changes: 4 additions & 2 deletions lib/features/main_screen/querya_window_title_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ class QueryaWindowTitleBar extends StatelessWidget {
final closeButtonColors = QueryaWindowTitleBar.closeButtonColors(context);

final rowContent = Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: QueryaWindowTitleBar.titleBarLeadingInset(
Expand Down Expand Up @@ -390,10 +391,11 @@ class QueryaWindowTitleBar extends StatelessWidget {

final inner = Row(
children: [
rowContent,
Expanded(
child: useNativeWindowChrome
? MoveWindow(child: rowContent)
: rowContent,
? MoveWindow()
: const SizedBox(),
),
Row(
mainAxisSize: material.MainAxisSize.min,
Expand Down
87 changes: 87 additions & 0 deletions test/features/main_screen/menubar_dropdown_speed_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart' as material;
import 'package:flutter_test/flutter_test.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';

void main() {
testWidgets('Menubar dropdown opens fast and toggles on repeated tap',
(tester) async {
await tester.pumpWidget(
ShadcnApp(
menuHandler: const PopoverOverlayHandler(
defaultShowDuration: Duration(milliseconds: 60),
defaultDismissDuration: Duration(milliseconds: 50),
showCurve: Curves.easeOutCubic,
dismissCurve: Curves.easeIn,
),
home: material.Scaffold(
body: material.Column(
children: [
Menubar(
border: false,
children: [
MenuButton(
subMenu: [
MenuButton(
onPressed: (_) {},
child: const Text('New File'),
),
MenuButton(
onPressed: (_) {},
child: const Text('Save File'),
),
],
child: const Text('File'),
),
MenuButton(
subMenu: [
MenuButton(
onPressed: (_) {},
child: const Text('Undo Action'),
),
],
child: const Text('Edit'),
),
],
),
],
),
),
),
);

// Initial state: menu items are not visible
expect(find.text('New File'), findsNothing);
expect(find.text('Undo Action'), findsNothing);

// Tap 'File'
await tester.tap(find.text('File'));
// Advance by 60ms (the fast open duration)
await tester.pump(const Duration(milliseconds: 60));

expect(find.text('New File'), findsOneWidget);
expect(find.text('Save File'), findsOneWidget);

// Tap 'File' again to toggle close
await tester.tap(find.text('File'));
await tester.pumpAndSettle();

expect(find.text('New File'), findsNothing);

// Open 'File' again
await tester.tap(find.text('File'));
await tester.pump(const Duration(milliseconds: 60));
expect(find.text('New File'), findsOneWidget);

// Tap 'Edit' while 'File' is open -> closes 'File' immediately and opens 'Edit'
await tester.tap(find.text('Edit'));
await tester.pump(const Duration(milliseconds: 60));

expect(find.text('New File'), findsNothing);
expect(find.text('Undo Action'), findsOneWidget);

// Tap outside -> closes 'Edit' menu
await tester.tapAt(const Offset(300, 300));
await tester.pumpAndSettle();
expect(find.text('Undo Action'), findsNothing);
});
}
36 changes: 29 additions & 7 deletions third_party/shadcn_flutter/lib/src/components/menu/menu.dart
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ class MenuCheckbox extends StatelessWidget implements MenuItem {

class _MenuButtonState extends State<MenuButton> {
final ValueNotifier<List<MenuItem>> _children = ValueNotifier([]);
bool _justToggledOnTapDown = false;

@override
void initState() {
Expand Down Expand Up @@ -600,14 +601,16 @@ class _MenuButtonState extends State<MenuButton> {
final isDialogOverlay = DialogOverlayHandler.isDialogOverlay(context);
final isIndependentOverlay = isSheetOverlay || isDialogOverlay;
void openSubMenu(BuildContext context, bool autofocus) {
menuGroupData!.closeOthers();
menuGroupData!.closeOthers(true);
final overlayManager = OverlayManager.of(context);
final effectiveRegionGroupId =
menuGroupData.regionGroupId ?? menuGroupData.root;
menuData!.popoverController.show(
context: context,
regionGroupId: menuGroupData.regionGroupId,
regionGroupId: effectiveRegionGroupId,
consumeOutsideTaps: false,
dismissBackdropFocus: false,
modal: true,
modal: false,
handler: MenuOverlayHandler(overlayManager),
overlayBarrier: OverlayBarrier(
borderRadius: BorderRadius.circular(theme.radiusMd),
Expand All @@ -633,7 +636,7 @@ class _MenuButtonState extends State<MenuButton> {
direction: menuGroupData.direction,
parent: menuGroupData,
onDismissed: menuGroupData.onDismissed,
regionGroupId: menuGroupData.regionGroupId,
regionGroupId: effectiveRegionGroupId,
subMenuOffset: compTheme?.subMenuOffset ??
Offset(densityGap, -densityGap * 0.625),
itemPadding: itemPadding,
Expand Down Expand Up @@ -685,7 +688,7 @@ class _MenuButtonState extends State<MenuButton> {
return Data<MenuData>.boundary(
child: Data<MenubarState>.boundary(
child: TapRegion(
groupId: menuGroupData!.root,
groupId: menuGroupData!.regionGroupId ?? menuGroupData.root,
child: AnimatedBuilder(
animation: menuData!.popoverController,
builder: (context, child) {
Expand Down Expand Up @@ -767,12 +770,31 @@ class _MenuButtonState extends State<MenuButton> {
subFocusState.unfocus();
}
},
onTapDown: (details) {
if (menuBarData != null &&
widget.subMenu != null &&
widget.subMenu!.isNotEmpty) {
if (!menuData.popoverController.hasOpenPopover) {
_justToggledOnTapDown = true;
openSubMenu(context, false);
} else {
_justToggledOnTapDown = true;
menuData.popoverController.close();
}
}
},
onPressed: () {
if (_justToggledOnTapDown) {
_justToggledOnTapDown = false;
return;
}
widget.onPressed?.call(context);
if (widget.subMenu != null &&
widget.subMenu!.isNotEmpty) {
if (!menuData.popoverController.hasOpenPopover) {
openSubMenu(context, false);
} else {
menuData.popoverController.close();
}
} else {
if (widget.autoClose) {
Expand Down Expand Up @@ -863,9 +885,9 @@ class MenuGroupData {
/// Closes all open popovers in child menu items.
///
/// Iterates through children and closes any open submenu popovers.
void closeOthers() {
void closeOthers([bool immediate = false]) {
for (final child in children) {
child.popoverController.close();
child.popoverController.close(immediate);
}
}

Expand Down
34 changes: 27 additions & 7 deletions third_party/shadcn_flutter/lib/src/components/overlay/popover.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,25 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
/// Manages the display, positioning, and lifecycle of popover overlays
/// with support for alignment, constraints, and modal behavior.
class PopoverOverlayHandler extends OverlayHandler {
/// Default animation duration when showing the popover.
final Duration? defaultShowDuration;

/// Default animation duration when dismissing the popover.
final Duration? defaultDismissDuration;

/// Animation curve when showing.
final Curve? showCurve;

/// Animation curve when dismissing.
final Curve? dismissCurve;

/// Creates a [PopoverOverlayHandler].
const PopoverOverlayHandler();
const PopoverOverlayHandler({
this.defaultShowDuration,
this.defaultDismissDuration,
this.showCurve,
this.dismissCurve,
});
@override
OverlayCompleter<T> show<T>({
required BuildContext context,
Expand Down Expand Up @@ -112,12 +129,15 @@ class PopoverOverlayHandler extends OverlayHandler {
value: isClosed.value ? 0.0 : 1.0,
initialValue: 0.0,
curve: isClosed.value
? const Interval(0, 2 / 3)
: Curves.linear,
? (dismissCurve ?? const Interval(0, 2 / 3))
: (showCurve ?? Curves.easeOutCubic),
duration: isClosed.value
? (showDuration ?? kDefaultDuration)
: (dismissDuration ??
const Duration(milliseconds: 100)),
? (dismissDuration ??
defaultDismissDuration ??
const Duration(milliseconds: 60))
: (showDuration ??
defaultShowDuration ??
const Duration(milliseconds: 60)),
onEnd: (value) {
if (value == 0.0 && isClosed.value) {
popoverEntry.remove();
Expand Down Expand Up @@ -685,7 +705,7 @@ class PopoverOverlayWidgetState extends State<PopoverOverlayWidget>
offset: _offset,
margin: _margin?.optionallyResolve(context) ??
EdgeInsets.all(densityGap),
scale: tweenValue(0.9, 1.0, widget.animation.value),
scale: tweenValue(0.98, 1.0, widget.animation.value),
scaleAlignment: (widget.transitionAlignment ?? _alignment)
.optionallyResolve(context),
allowInvertVertical: _allowInvertVertical,
Expand Down
Loading