From 35e43506ef4560abb687dead770a73228d0fc2c9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 11 Oct 2026 07:36:01 +0300 Subject: [PATCH 1/3] feat(ui): QueryaMenuButton, QueryaSplitButton and QueryaDialogActions Compositions of QueryaButton that the review found hand-assembled: a button that opens a menu, a primary action with a menu chevron as one control (one outline, equal height, divider instead of a gap), and the dialog footer with the standard order, spacing, lg size and a single main action enforced in debug. QueryaButton gets a public general constructor and a radius override. Closes #1335 (without QueryaButtonGroup). --- lib/shared/widgets/querya_button.dart | 34 ++- lib/shared/widgets/querya_dialog_actions.dart | 154 ++++++++++++ lib/shared/widgets/querya_menu_button.dart | 82 +++++++ lib/shared/widgets/querya_menu_surface.dart | 84 +++++++ lib/shared/widgets/querya_split_button.dart | 132 ++++++++++ lib/shared/widgets/widgets.dart | 4 + .../widgets/querya_dialog_actions_test.dart | 147 ++++++++++++ .../querya_menu_and_split_button_test.dart | 226 ++++++++++++++++++ 8 files changed, 853 insertions(+), 10 deletions(-) create mode 100644 lib/shared/widgets/querya_dialog_actions.dart create mode 100644 lib/shared/widgets/querya_menu_button.dart create mode 100644 lib/shared/widgets/querya_menu_surface.dart create mode 100644 lib/shared/widgets/querya_split_button.dart create mode 100644 test/shared/widgets/querya_dialog_actions_test.dart create mode 100644 test/shared/widgets/querya_menu_and_split_button_test.dart diff --git a/lib/shared/widgets/querya_button.dart b/lib/shared/widgets/querya_button.dart index 1bd11848..1a8e14ba 100644 --- a/lib/shared/widgets/querya_button.dart +++ b/lib/shared/widgets/querya_button.dart @@ -52,12 +52,14 @@ enum QueryaButtonTone { /// keyboard focus. /// - The label never wraps: it ellipsizes. class QueryaButton extends material.StatefulWidget { - const QueryaButton._({ + /// The general constructor the named ones build on: pick the [variant] + /// yourself (for compositions that take it as a parameter). + const QueryaButton({ super.key, required this.variant, - required this.label, - required this.icon, - required this.onPressed, + this.label, + this.icon, + this.onPressed, this.trailing, this.shortcutHint, this.loading = false, @@ -68,6 +70,7 @@ class QueryaButton extends material.StatefulWidget { this.semanticLabel, this.size, this.tone = QueryaButtonTone.normal, + this.borderRadius, }) : assert( label != null || icon != null, 'A QueryaButton needs a label, an icon, or both', @@ -87,7 +90,7 @@ class QueryaButton extends material.StatefulWidget { bool autofocus = false, String? semanticLabel, QueryaControlSize? size, - }) : this._( + }) : this( key: key, variant: QueryaButtonVariant.primary, label: label, @@ -119,7 +122,7 @@ class QueryaButton extends material.StatefulWidget { String? semanticLabel, QueryaControlSize? size, QueryaButtonTone tone = QueryaButtonTone.normal, - }) : this._( + }) : this( key: key, variant: QueryaButtonVariant.secondary, label: label, @@ -153,7 +156,7 @@ class QueryaButton extends material.StatefulWidget { String? semanticLabel, QueryaControlSize? size, QueryaButtonTone tone = QueryaButtonTone.normal, - }) : this._( + }) : this( key: key, variant: QueryaButtonVariant.ghost, label: label, @@ -185,7 +188,7 @@ class QueryaButton extends material.StatefulWidget { bool autofocus = false, String? semanticLabel, QueryaControlSize? size, - }) : this._( + }) : this( key: key, variant: QueryaButtonVariant.destructive, label: label, @@ -212,7 +215,7 @@ class QueryaButton extends material.StatefulWidget { bool autofocus = false, QueryaControlSize? size, QueryaButtonTone tone = QueryaButtonTone.normal, - }) : this._( + }) : this( key: key, variant: QueryaButtonVariant.ghost, label: null, @@ -266,6 +269,10 @@ class QueryaButton extends material.StatefulWidget { /// See [QueryaButtonTone]; ignored by the filled variants. final QueryaButtonTone tone; + /// Overrides the corner radius of the control size; a segment of a split + /// button squares its inner corners with it. + final material.BorderRadius? borderRadius; + @override material.State createState() => _QueryaButtonState(); } @@ -407,6 +414,12 @@ class _QueryaButtonState extends material.State { children: children, ); + // A trailing icon takes the button's colour and size. + content = material.IconTheme( + data: material.IconThemeData(color: foreground, size: iconSize), + child: content, + ); + // Loading without an icon: keep the label's width, spinner on top. if (w.loading && w.icon == null) { content = material.Stack( @@ -419,7 +432,8 @@ class _QueryaButtonState extends material.State { } final height = size.scaledHeight(context); - final radius = material.BorderRadius.circular(size.scaledRadius(context)); + final radius = w.borderRadius ?? + material.BorderRadius.circular(size.scaledRadius(context)); final ring = scheme.ring; material.Widget button = material.AnimatedContainer( diff --git a/lib/shared/widgets/querya_dialog_actions.dart b/lib/shared/widgets/querya_dialog_actions.dart new file mode 100644 index 00000000..d7cbedb1 --- /dev/null +++ b/lib/shared/widgets/querya_dialog_actions.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; +import 'package:querya_desktop/shared/widgets/querya_button.dart'; + +/// The footer row of a dialog (docs/ui-kit.md, "Dialog footers"; #1335): +/// +/// ``` +/// [ leading ] [ Cancel ] [ Secondary… ] [ Primary ] +/// ``` +/// +/// - Everything is `lg`, 8 apart, right-aligned, under a divider. +/// - **One** main action: [primary] or [destructive], never both; in debug the +/// variants of every slot are checked (cancel is ghost, secondary slots are +/// secondary, the main one is primary / destructive). +/// - `Esc` runs [cancel] while focus is in the footer. For `Esc` and `Enter` +/// across the whole dialog wrap its body in a `CallbackShortcuts` with +/// [QueryaDialogActions.bindings]. +class QueryaDialogActions extends material.StatelessWidget { + const QueryaDialogActions({ + super.key, + this.cancel, + this.secondary = const [], + this.primary, + this.destructive, + this.leading, + this.showDivider = true, + }) : assert( + primary == null || destructive == null, + 'A dialog footer has one main action: primary or destructive, not both', + ); + + /// Cancel / Close: a [QueryaButtonVariant.ghost] button, placed first. + final QueryaButton? cancel; + + /// Other actions (Test connection, Save as draft): `secondary` buttons. + final List secondary; + + /// The main action: a [QueryaButtonVariant.primary] button. + final QueryaButton? primary; + + /// The main action when it is irreversible: a + /// [QueryaButtonVariant.destructive] button. + final QueryaButton? destructive; + + /// Something left-aligned in the footer (a status, a checkbox). + final material.Widget? leading; + final bool showDivider; + + QueryaButton? get _main => primary ?? destructive; + + /// `Esc` -> cancel and `Enter` -> the main action, for the dialog's + /// `CallbackShortcuts`. A disabled button is not run. + static Map bindings({ + QueryaButton? cancel, + QueryaButton? primary, + QueryaButton? destructive, + }) { + final main = primary ?? destructive; + return { + if (cancel?.onPressed != null) + const material.SingleActivator(LogicalKeyboardKey.escape): + cancel!.onPressed!, + if (main?.onPressed != null && main?.loading != true) + const material.SingleActivator(LogicalKeyboardKey.enter): + main!.onPressed!, + }; + } + + @override + material.Widget build(material.BuildContext context) { + assert(() { + final c = cancel; + if (c != null && c.variant != QueryaButtonVariant.ghost) { + throw material.FlutterError( + 'QueryaDialogActions.cancel must be a ghost button, got ' + '${c.variant.name}.', + ); + } + for (final s in secondary) { + if (s.variant != QueryaButtonVariant.secondary) { + throw material.FlutterError( + 'QueryaDialogActions.secondary buttons must be secondary, got ' + '${s.variant.name}.', + ); + } + } + final p = primary; + if (p != null && p.variant != QueryaButtonVariant.primary) { + throw material.FlutterError( + 'QueryaDialogActions.primary must be a primary button, got ' + '${p.variant.name}.', + ); + } + final d = destructive; + if (d != null && d.variant != QueryaButtonVariant.destructive) { + throw material.FlutterError( + 'QueryaDialogActions.destructive must be a destructive button, got ' + '${d.variant.name}.', + ); + } + return true; + }()); + + final wb = context.workbench; + final gap = context.scaled(8); + final buttons = [ + if (cancel != null) cancel!, + ...secondary, + if (_main != null) _main!, + ]; + + final row = material.Row( + children: [ + if (leading != null) material.Flexible(child: leading!), + const material.Spacer(), + for (var i = 0; i < buttons.length; i++) ...[ + if (i > 0) material.SizedBox(width: gap), + buttons[i], + ], + ], + ); + + final footer = material.CallbackShortcuts( + bindings: { + if (cancel?.onPressed != null) + const material.SingleActivator(LogicalKeyboardKey.escape): + cancel!.onPressed!, + }, + child: QueryaControlScope( + size: QueryaControlSize.lg, + child: material.Padding( + padding: const material.EdgeInsets.fromLTRB(16, 12, 16, 12), + child: row, + ), + ), + ); + + if (!showDivider) return footer; + return material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.SizedBox( + height: 1, + child: material.ColoredBox(color: wb.borderSubtle), + ), + footer, + ], + ); + } +} diff --git a/lib/shared/widgets/querya_menu_button.dart b/lib/shared/widgets/querya_menu_button.dart new file mode 100644 index 00000000..9660f716 --- /dev/null +++ b/lib/shared/widgets/querya_menu_button.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; +import 'package:querya_desktop/shared/widgets/querya_action_menu.dart' + show QueryaActionMenuItem; +import 'package:querya_desktop/shared/widgets/querya_button.dart'; +import 'package:querya_desktop/shared/widgets/querya_menu_surface.dart'; + +/// A button that opens a menu of commands (#1335): a [QueryaButton] with a +/// trailing chevron, shown as active while the menu is open. The menu surface +/// is the one `QueryaDropdown` uses. +/// +/// It replaces the trigger of `QueryaActionMenu` / `ExportMenuButton`, so a +/// menu next to a button in a toolbar has the same height and states. +class QueryaMenuButton extends material.StatelessWidget { + const QueryaMenuButton({ + super.key, + required this.items, + required this.onSelected, + this.label, + this.icon, + this.variant = QueryaButtonVariant.secondary, + this.tone = QueryaButtonTone.normal, + this.size, + this.tooltip, + this.compact = false, + this.enabled = true, + this.menuController, + }) : assert( + label != null || icon != null, + 'A QueryaMenuButton needs a label, an icon, or both', + ); + + final List> items; + final material.ValueChanged onSelected; + + /// The text; null for an icon-only trigger. + final String? label; + final material.IconData? icon; + + /// [QueryaButtonVariant.secondary] by default; [QueryaButtonVariant.ghost] + /// for toolbars. + final QueryaButtonVariant variant; + final QueryaButtonTone tone; + final QueryaControlSize? size; + final String? tooltip; + + /// Icon only on narrow toolbars; the label becomes the tooltip. + final bool compact; + final bool enabled; + + /// Optional external controller, to open or close the menu from code. + final material.MenuController? menuController; + + @override + material.Widget build(material.BuildContext context) { + final controlSize = size ?? QueryaControlSize.of(context); + return material.MenuAnchor( + controller: menuController, + alignmentOffset: queryaMenuAlignmentOffset(context), + consumeOutsideTap: true, + style: queryaMenuStyle(context), + menuChildren: queryaMenuItems(context, items, onSelected), + builder: (context, controller, _) => QueryaButton( + variant: variant, + tone: tone, + label: label, + icon: icon, + size: size, + tooltip: tooltip, + compact: compact, + isActive: controller.isOpen, + trailing: material.Icon( + material.Icons.keyboard_arrow_down_rounded, + size: controlSize.scaledIconSize(context), + ), + onPressed: enabled + ? () => controller.isOpen ? controller.close() : controller.open() + : null, + ), + ); + } +} diff --git a/lib/shared/widgets/querya_menu_surface.dart b/lib/shared/widgets/querya_menu_surface.dart new file mode 100644 index 00000000..a7342e86 --- /dev/null +++ b/lib/shared/widgets/querya_menu_surface.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/shared/widgets/querya_action_menu.dart' + show QueryaActionMenuItem; +import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; + +/// The popup surface every Querya menu shares with `QueryaDropdown`: popover +/// fill, border, radius, soft shadow, compact rows, no ripple (#1335). +material.MenuStyle queryaMenuStyle(material.BuildContext context) { + final cs = context.colors; + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + return material.MenuStyle( + backgroundColor: material.WidgetStatePropertyAll(cs.popover), + surfaceTintColor: material.WidgetStatePropertyAll(cs.popover), + elevation: const material.WidgetStatePropertyAll( + QueryaDropdownTokens.menuElevation, + ), + shadowColor: const material.WidgetStatePropertyAll( + QueryaDropdownTokens.menuShadowColor, + ), + padding: const material.WidgetStatePropertyAll( + QueryaDropdownTokens.menuPadding, + ), + shape: material.WidgetStatePropertyAll( + material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(radius), + side: material.BorderSide(color: cs.border), + ), + ), + ); +} + +/// Where a menu opens relative to its trigger. +material.Offset queryaMenuAlignmentOffset(material.BuildContext context) => + material.Offset( + 0, + context.scaled(QueryaDropdownTokens.menuAlignmentOffset.dy), + ); + +/// The command rows of a menu. +List queryaMenuItems( + material.BuildContext context, + List> items, + material.ValueChanged onSelected, +) { + final cs = context.colors; + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + return [ + for (final item in items) + material.MenuItemButton( + style: material.MenuItemButton.styleFrom( + minimumSize: material.Size( + 0, + QueryaDropdownTokens.scaledMenuItemHeight(context), + ), + padding: material.EdgeInsets.symmetric( + horizontal: context.scaled(8), + ), + foregroundColor: cs.popoverForeground, + overlayColor: cs.accent.withValues(alpha: 0.14), + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(radius), + ), + ), + leadingIcon: item.icon == null + ? null + : material.Icon( + item.icon, + size: context.scaled(16), + color: cs.mutedForeground, + ), + onPressed: () => onSelected(item.value), + child: material.Text( + item.label, + style: QueryaDropdownTokens.menuItemTextStyle( + context, + cs.popoverForeground, + selected: false, + ), + ), + ), + ]; +} diff --git a/lib/shared/widgets/querya_split_button.dart b/lib/shared/widgets/querya_split_button.dart new file mode 100644 index 00000000..d6f08527 --- /dev/null +++ b/lib/shared/widgets/querya_split_button.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; +import 'package:querya_desktop/shared/widgets/querya_action_menu.dart' + show QueryaActionMenuItem; +import 'package:querya_desktop/shared/widgets/querya_button.dart'; +import 'package:querya_desktop/shared/widgets/querya_menu_surface.dart'; + +/// A primary action and a chevron that opens a menu of related actions +/// (Execute + Explain / Run selection), as one control (#1335): one outline and +/// radius, equal height, a divider between the halves instead of a gap, its own +/// tooltip on each half, and a [loading] state that disables both. +/// +/// ```dart +/// QueryaSplitButton( +/// label: 'Execute', +/// icon: Icons.play_arrow_rounded, +/// onPressed: _run, +/// items: const [QueryaActionMenuItem(value: 'explain', label: 'Explain')], +/// onSelected: _runCommand, +/// ) +/// ``` +class QueryaSplitButton extends material.StatelessWidget { + const QueryaSplitButton({ + super.key, + required this.label, + required this.onPressed, + required this.items, + required this.onSelected, + this.icon, + this.variant = QueryaButtonVariant.primary, + this.size, + this.loading = false, + this.tooltip, + this.shortcutHint, + this.menuTooltip = 'More actions', + }); + + final String label; + final material.IconData? icon; + + /// The main action; `null` disables the whole control. + final material.VoidCallback? onPressed; + final List> items; + final material.ValueChanged onSelected; + + /// How the control looks. [QueryaButtonVariant.secondary] and `ghost` draw + /// one outline (secondary only) around both halves. + final QueryaButtonVariant variant; + final QueryaControlSize? size; + + /// Shows a spinner on the main half and disables both halves. + final bool loading; + final String? tooltip; + final String? shortcutHint; + + /// Tooltip and spoken label of the chevron half. + final String menuTooltip; + + @override + material.Widget build(material.BuildContext context) { + final wb = context.workbench; + final controlSize = size ?? QueryaControlSize.of(context); + final radius = + material.BorderRadius.circular(controlSize.scaledRadius(context)); + final filled = variant == QueryaButtonVariant.primary || + variant == QueryaButtonVariant.destructive; + // The outline of a secondary control is drawn once, around both halves. + final halfVariant = + filled ? variant : QueryaButtonVariant.ghost; + final divider = filled + ? wb.onAccent.withValues(alpha: 0.35) + : wb.borderSubtle; + final enabled = onPressed != null && !loading; + + final main = QueryaButton( + variant: halfVariant, + label: label, + icon: icon, + onPressed: onPressed, + loading: loading, + tooltip: tooltip, + shortcutHint: shortcutHint, + size: controlSize, + borderRadius: material.BorderRadius.zero, + ); + + final chevron = material.MenuAnchor( + alignmentOffset: queryaMenuAlignmentOffset(context), + consumeOutsideTap: true, + style: queryaMenuStyle(context), + menuChildren: queryaMenuItems(context, items, onSelected), + builder: (context, controller, _) => QueryaButton( + variant: halfVariant, + icon: material.Icons.keyboard_arrow_down_rounded, + semanticLabel: menuTooltip, + tooltip: menuTooltip, + isActive: controller.isOpen, + size: controlSize, + borderRadius: material.BorderRadius.zero, + onPressed: enabled + ? () => controller.isOpen ? controller.close() : controller.open() + : null, + ), + ); + + return material.ClipRRect( + borderRadius: radius, + child: material.DecoratedBox( + position: material.DecorationPosition.foreground, + decoration: material.BoxDecoration( + borderRadius: radius, + border: variant == QueryaButtonVariant.secondary + ? material.Border.all(color: wb.borderSubtle) + : null, + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + main, + material.SizedBox( + width: 1, + height: controlSize.scaledHeight(context), + child: material.ColoredBox(color: divider), + ), + chevron, + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 3f0b8ed1..0ee98575 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -21,8 +21,12 @@ export 'querya_dropdown.dart' kPreferencesLabelWidth; export 'connection_tree_loading_row.dart'; export 'querya_action_button.dart'; +export 'querya_action_menu.dart' show QueryaActionMenuItem; export 'querya_badge.dart'; export 'querya_button.dart'; +export 'querya_dialog_actions.dart'; +export 'querya_menu_button.dart'; +export 'querya_split_button.dart'; export 'querya_empty_state.dart'; export 'querya_icon_button.dart'; export 'querya_modal_dialog.dart'; diff --git a/test/shared/widgets/querya_dialog_actions_test.dart b/test/shared/widgets/querya_dialog_actions_test.dart new file mode 100644 index 00000000..c205104e --- /dev/null +++ b/test/shared/widgets/querya_dialog_actions_test.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; +import 'package:querya_desktop/shared/widgets/querya_button.dart'; +import 'package:querya_desktop/shared/widgets/querya_dialog_actions.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +material.Widget _shell(material.Widget child) => queryaThemeTestShell( + child: material.Center(child: material.SizedBox(width: 600, child: child)), + ); + +void main() { + testWidgets('orders cancel, secondary and the main action left to right', + (tester) async { + await tester.pumpWidget( + _shell( + QueryaDialogActions( + cancel: QueryaButton.ghost(label: 'Cancel', onPressed: () {}), + secondary: [ + QueryaButton.secondary(label: 'Test', onPressed: () {}), + ], + primary: QueryaButton.primary(label: 'Save', onPressed: () {}), + ), + ), + ); + + final cancel = tester.getTopLeft(find.text('Cancel')).dx; + final test = tester.getTopLeft(find.text('Test')).dx; + final save = tester.getTopLeft(find.text('Save')).dx; + + expect(cancel, lessThan(test)); + expect(test, lessThan(save)); + }); + + testWidgets('buttons are lg and 8 px apart', (tester) async { + await tester.pumpWidget( + _shell( + QueryaDialogActions( + cancel: QueryaButton.ghost(label: 'Cancel', onPressed: () {}), + primary: QueryaButton.primary(label: 'Save', onPressed: () {}), + ), + ), + ); + + final buttons = find.byType(QueryaButton); + final cancel = tester.getRect(buttons.at(0)); + final save = tester.getRect(buttons.at(1)); + + expect(cancel.height, QueryaControlSize.lg.height); + expect(save.height, QueryaControlSize.lg.height); + expect(save.left - cancel.right, 8); + }); + + testWidgets('the main action is right-aligned', (tester) async { + await tester.pumpWidget( + _shell( + QueryaDialogActions( + primary: QueryaButton.primary(label: 'Save', onPressed: () {}), + ), + ), + ); + + final footer = tester.getRect(find.byType(QueryaDialogActions)); + final save = tester.getRect(find.byType(QueryaButton)); + + // 16 px of footer padding on the right. + expect(footer.right - save.right, 16); + }); + + testWidgets('Esc runs cancel while focus is in the footer', (tester) async { + var cancelled = 0; + await tester.pumpWidget( + _shell( + QueryaDialogActions( + cancel: QueryaButton.ghost( + label: 'Cancel', + autofocus: true, + onPressed: () => cancelled++, + ), + primary: QueryaButton.primary(label: 'Save', onPressed: () {}), + ), + ), + ); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + + expect(cancelled, 1); + }); + + test('bindings map Esc to cancel and Enter to the main action', () { + var cancelled = 0; + var saved = 0; + final bindings = QueryaDialogActions.bindings( + cancel: QueryaButton.ghost(label: 'Cancel', onPressed: () => cancelled++), + primary: QueryaButton.primary(label: 'Save', onPressed: () => saved++), + ); + + bindings[const SingleActivator(LogicalKeyboardKey.escape)]!(); + bindings[const SingleActivator(LogicalKeyboardKey.enter)]!(); + + expect(cancelled, 1); + expect(saved, 1); + }); + + test('bindings do not run a disabled or loading main action', () { + final disabled = QueryaDialogActions.bindings( + primary: const QueryaButton.primary(label: 'Save'), + ); + final loading = QueryaDialogActions.bindings( + primary: QueryaButton.primary( + label: 'Save', + loading: true, + onPressed: () {}, + ), + ); + + expect(disabled, isEmpty); + expect(loading, isEmpty); + }); + + test('a footer cannot have both a primary and a destructive action', () { + expect( + () => QueryaDialogActions( + primary: QueryaButton.primary(label: 'Save', onPressed: () {}), + destructive: QueryaButton.destructive(label: 'Delete', onPressed: () {}), + ), + throwsAssertionError, + ); + }); + + testWidgets('a cancel that is not ghost is rejected in debug', + (tester) async { + await tester.pumpWidget( + _shell( + QueryaDialogActions( + cancel: QueryaButton.primary(label: 'Cancel', onPressed: () {}), + ), + ), + ); + + expect(tester.takeException(), isNotNull); + }); +} diff --git a/test/shared/widgets/querya_menu_and_split_button_test.dart b/test/shared/widgets/querya_menu_and_split_button_test.dart new file mode 100644 index 00000000..39329620 --- /dev/null +++ b/test/shared/widgets/querya_menu_and_split_button_test.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; +import 'package:querya_desktop/shared/widgets/querya_action_menu.dart' + show QueryaActionMenuItem; +import 'package:querya_desktop/shared/widgets/querya_button.dart'; +import 'package:querya_desktop/shared/widgets/querya_menu_button.dart'; +import 'package:querya_desktop/shared/widgets/querya_split_button.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +const _items = [ + QueryaActionMenuItem(value: 'svg', label: 'SVG'), + QueryaActionMenuItem(value: 'png', label: 'PNG'), +]; + +material.Widget _shell(material.Widget child) => queryaThemeTestShell( + child: material.Center(child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [child], + )), + ); + +void main() { + group('QueryaMenuButton', () { + testWidgets('opens its menu and reports the picked value', (tester) async { + String? picked; + await tester.pumpWidget( + _shell( + QueryaMenuButton( + label: 'Export', + items: _items, + onSelected: (v) => picked = v, + ), + ), + ); + + expect(find.text('SVG'), findsNothing); + + await tester.tap(find.text('Export')); + await tester.pumpAndSettle(); + expect(find.text('SVG'), findsOneWidget); + expect(find.text('PNG'), findsOneWidget); + + await tester.tap(find.text('PNG')); + await tester.pumpAndSettle(); + + expect(picked, 'png'); + expect(find.text('SVG'), findsNothing); + }); + + testWidgets('is as tall as a QueryaButton of the same size', + (tester) async { + await tester.pumpWidget( + _shell( + QueryaMenuButton( + label: 'Export', + items: _items, + onSelected: (_) {}, + size: QueryaControlSize.md, + ), + ), + ); + + expect( + tester.getSize(find.byType(QueryaButton)).height, + QueryaControlSize.md.height, + ); + }); + + testWidgets('a disabled menu button does not open', (tester) async { + await tester.pumpWidget( + _shell( + QueryaMenuButton( + label: 'Export', + items: _items, + onSelected: (_) {}, + enabled: false, + ), + ), + ); + + await tester.tap(find.text('Export')); + await tester.pumpAndSettle(); + + expect(find.text('SVG'), findsNothing); + }); + + testWidgets('Enter on the focused button opens the menu', (tester) async { + await tester.pumpWidget( + _shell( + QueryaMenuButton( + label: 'Export', + items: _items, + onSelected: (_) {}, + ), + ), + ); + // Tab moves focus to the trigger. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.text('SVG'), findsOneWidget); + }); + }); + + group('QueryaSplitButton', () { + testWidgets('the main half runs the action', (tester) async { + var runs = 0; + await tester.pumpWidget( + _shell( + QueryaSplitButton( + label: 'Execute', + onPressed: () => runs++, + items: _items, + onSelected: (_) {}, + ), + ), + ); + + await tester.tap(find.text('Execute')); + await tester.pump(); + + expect(runs, 1); + }); + + testWidgets('the chevron half opens the menu and picks', (tester) async { + var runs = 0; + String? picked; + await tester.pumpWidget( + _shell( + QueryaSplitButton( + label: 'Execute', + onPressed: () => runs++, + items: _items, + onSelected: (v) => picked = v, + ), + ), + ); + + await tester.tap(find.byTooltip('More actions')); + await tester.pumpAndSettle(); + expect(find.text('SVG'), findsOneWidget); + + await tester.tap(find.text('SVG')); + await tester.pumpAndSettle(); + + expect(picked, 'svg'); + expect(runs, 0); + }); + + testWidgets('both halves have the height of the control size', + (tester) async { + await tester.pumpWidget( + _shell( + QueryaSplitButton( + label: 'Execute', + onPressed: () {}, + items: _items, + onSelected: (_) {}, + size: QueryaControlSize.md, + ), + ), + ); + + final halves = find.byType(QueryaButton); + expect(halves, findsNWidgets(2)); + for (final half in halves.evaluate()) { + expect( + tester.getSize(find.byWidget(half.widget)).height, + QueryaControlSize.md.height, + ); + } + expect( + tester.getSize(find.byType(QueryaSplitButton)).height, + QueryaControlSize.md.height, + ); + }); + + testWidgets('loading disables the main half and the menu', (tester) async { + var runs = 0; + await tester.pumpWidget( + _shell( + QueryaSplitButton( + label: 'Execute', + loading: true, + onPressed: () => runs++, + items: _items, + onSelected: (_) {}, + ), + ), + ); + + await tester.tap(find.byType(QueryaButton).first); + await tester.pump(); + await tester.tap(find.byTooltip('More actions')); + await tester.pumpAndSettle(); + + expect(runs, 0); + expect(find.text('SVG'), findsNothing); + }); + + testWidgets('no gap between the halves except the 1 px divider', + (tester) async { + await tester.pumpWidget( + _shell( + QueryaSplitButton( + label: 'Execute', + onPressed: () {}, + items: _items, + onSelected: (_) {}, + ), + ), + ); + + final halves = find.byType(QueryaButton); + final left = tester.getRect(halves.at(0)); + final right = tester.getRect(halves.at(1)); + + expect(right.left - left.right, 1); + }); + }); +} From 7be09c74741c3a10b965ba6b4b68b1e261247055 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 11 Oct 2026 07:40:58 +0300 Subject: [PATCH 2/3] test(ui): material.SingleActivator and no settle while a spinner runs --- test/shared/widgets/querya_dialog_actions_test.dart | 4 ++-- test/shared/widgets/querya_menu_and_split_button_test.dart | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/test/shared/widgets/querya_dialog_actions_test.dart b/test/shared/widgets/querya_dialog_actions_test.dart index c205104e..fb22fb94 100644 --- a/test/shared/widgets/querya_dialog_actions_test.dart +++ b/test/shared/widgets/querya_dialog_actions_test.dart @@ -99,8 +99,8 @@ void main() { primary: QueryaButton.primary(label: 'Save', onPressed: () => saved++), ); - bindings[const SingleActivator(LogicalKeyboardKey.escape)]!(); - bindings[const SingleActivator(LogicalKeyboardKey.enter)]!(); + bindings[const material.SingleActivator(LogicalKeyboardKey.escape)]!(); + bindings[const material.SingleActivator(LogicalKeyboardKey.enter)]!(); expect(cancelled, 1); expect(saved, 1); diff --git a/test/shared/widgets/querya_menu_and_split_button_test.dart b/test/shared/widgets/querya_menu_and_split_button_test.dart index 39329620..66664d79 100644 --- a/test/shared/widgets/querya_menu_and_split_button_test.dart +++ b/test/shared/widgets/querya_menu_and_split_button_test.dart @@ -197,7 +197,8 @@ void main() { await tester.tap(find.byType(QueryaButton).first); await tester.pump(); await tester.tap(find.byTooltip('More actions')); - await tester.pumpAndSettle(); + // The loading spinner animates forever: pump a frame, do not settle. + await tester.pump(const Duration(milliseconds: 100)); expect(runs, 0); expect(find.text('SVG'), findsNothing); From 0f2c6ee109eb2dccad24adf8eb6996fdebc50372 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 11 Oct 2026 07:43:24 +0300 Subject: [PATCH 3/3] fix(ui): kit buttons read the workbench with a default when there is no scope QueryaButton asserted on a missing QueryaThemeScope, which dialogs built under a bare app (and their tests) do not have. workbenchOrDefault falls back to the defaults for the ambient brightness; the scheme comes from context.colors. --- lib/core/theme/querya_theme_scope.dart | 10 ++++++++++ lib/shared/widgets/querya_button.dart | 4 ++-- lib/shared/widgets/querya_dialog_actions.dart | 2 +- lib/shared/widgets/querya_split_button.dart | 2 +- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/core/theme/querya_theme_scope.dart b/lib/core/theme/querya_theme_scope.dart index c05e451f..58067fd6 100644 --- a/lib/core/theme/querya_theme_scope.dart +++ b/lib/core/theme/querya_theme_scope.dart @@ -60,6 +60,16 @@ extension QueryaThemeContext on BuildContext { QueryaWorkbenchTheme get workbench => queryaTheme.workbench; + /// The workbench tokens of the nearest [QueryaThemeScope], or the defaults + /// for the ambient brightness when there is none (a widget under a bare app, + /// as in tests). Kit controls use this so they never assert on a missing + /// scope. + QueryaWorkbenchTheme get workbenchOrDefault => + QueryaThemeScope.maybeOf(this)?.workbench ?? + (colors.brightness == Brightness.light + ? QueryaWorkbenchTheme.lightDefault + : QueryaWorkbenchTheme.darkDefault); + QueryaEditorTheme get editorTheme => queryaTheme.editor; QueryaSemanticPalette get semanticPalette => diff --git a/lib/shared/widgets/querya_button.dart b/lib/shared/widgets/querya_button.dart index 1a8e14ba..2ce9630b 100644 --- a/lib/shared/widgets/querya_button.dart +++ b/lib/shared/widgets/querya_button.dart @@ -301,8 +301,8 @@ class _QueryaButtonState extends material.State { @override material.Widget build(material.BuildContext context) { final w = widget; - final wb = context.workbench; - final scheme = context.queryaTheme.colorScheme; + final wb = context.workbenchOrDefault; + final scheme = context.colors; final size = w.size ?? QueryaControlSize.of(context); final enabled = _enabled; final hasLabel = w.label != null; diff --git a/lib/shared/widgets/querya_dialog_actions.dart b/lib/shared/widgets/querya_dialog_actions.dart index d7cbedb1..2bd8c7cd 100644 --- a/lib/shared/widgets/querya_dialog_actions.dart +++ b/lib/shared/widgets/querya_dialog_actions.dart @@ -104,7 +104,7 @@ class QueryaDialogActions extends material.StatelessWidget { return true; }()); - final wb = context.workbench; + final wb = context.workbenchOrDefault; final gap = context.scaled(8); final buttons = [ if (cancel != null) cancel!, diff --git a/lib/shared/widgets/querya_split_button.dart b/lib/shared/widgets/querya_split_button.dart index d6f08527..54304136 100644 --- a/lib/shared/widgets/querya_split_button.dart +++ b/lib/shared/widgets/querya_split_button.dart @@ -59,7 +59,7 @@ class QueryaSplitButton extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - final wb = context.workbench; + final wb = context.workbenchOrDefault; final controlSize = size ?? QueryaControlSize.of(context); final radius = material.BorderRadius.circular(controlSize.scaledRadius(context));