From 7c15727c2677b639525a0e80737efe6214f7fba3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 11 Oct 2026 16:31:41 +0300 Subject: [PATCH 1/3] refactor(ui): QueryaIconButton is a thin wrapper over QueryaButton One icon-button system: same states, ring and semantics as every other button, no Material InkWell path. The color / activeColor parameters go; the call sites that used a colour as a toggled look pass isActive. Refs #1440 --- .../mongodb/mongo_create_index_dialog.dart | 1 - .../workspace/data_grid_filter_bar.dart | 2 - .../workspace/data_grid_groupings_view.dart | 2 - .../workspace/data_grid_value_panel.dart | 4 - lib/features/workspace/results_tab.dart | 12 +- lib/shared/widgets/querya_icon_button.dart | 158 ++++-------------- .../widgets/querya_icon_button_test.dart | 54 ++---- .../widgets/querya_ui_kit_behavior_test.dart | 4 +- 8 files changed, 49 insertions(+), 188 deletions(-) diff --git a/lib/features/mongodb/mongo_create_index_dialog.dart b/lib/features/mongodb/mongo_create_index_dialog.dart index 9c77d731..d87d35f4 100644 --- a/lib/features/mongodb/mongo_create_index_dialog.dart +++ b/lib/features/mongodb/mongo_create_index_dialog.dart @@ -270,7 +270,6 @@ class _MongoCreateIndexDialogState ), tooltip: 'Remove Field', size: QueryaControlSize.sm, - color: cs.destructive, onPressed: () => _removeField(i), ), ], diff --git a/lib/features/workspace/data_grid_filter_bar.dart b/lib/features/workspace/data_grid_filter_bar.dart index c7e2d7b0..74cd2cc6 100644 --- a/lib/features/workspace/data_grid_filter_bar.dart +++ b/lib/features/workspace/data_grid_filter_bar.dart @@ -431,7 +431,6 @@ class _DataGridFilterBarState extends material.State { : material.Icons.close, size: 14, ), - color: cs.mutedForeground, tooltip: 'Clear filter', onPressed: () { _debounceTimer?.cancel(); @@ -446,7 +445,6 @@ class _DataGridFilterBarState extends material.State { QueryaIconButton( size: QueryaControlSize.sm, icon: const material.Icon(material.Icons.close, size: 14), - color: cs.mutedForeground, tooltip: 'Close filter (Esc)', onPressed: () { _debounceTimer?.cancel(); diff --git a/lib/features/workspace/data_grid_groupings_view.dart b/lib/features/workspace/data_grid_groupings_view.dart index c5571f08..fa0722d7 100644 --- a/lib/features/workspace/data_grid_groupings_view.dart +++ b/lib/features/workspace/data_grid_groupings_view.dart @@ -250,7 +250,6 @@ class _DataGridGroupingsViewState : material.Icons.arrow_downward_rounded, size: 14, ), - color: cs.mutedForeground, onPressed: () => setState(() { _sortAscending = !_sortAscending; _recomputeGroups(); @@ -263,7 +262,6 @@ class _DataGridGroupingsViewState child: QueryaIconButton( size: QueryaControlSize.sm, icon: const material.Icon(material.Icons.copy_rounded, size: 14), - color: cs.mutedForeground, onPressed: _exportPivot, ), ), diff --git a/lib/features/workspace/data_grid_value_panel.dart b/lib/features/workspace/data_grid_value_panel.dart index 6f79788b..bac07d32 100644 --- a/lib/features/workspace/data_grid_value_panel.dart +++ b/lib/features/workspace/data_grid_value_panel.dart @@ -320,7 +320,6 @@ class _DataGridValuePanelState extends material.State { QueryaIconButton( size: QueryaControlSize.sm, icon: const material.Icon(material.Icons.close, size: 14), - color: cs.mutedForeground, onPressed: widget.onClose, ), ], @@ -386,14 +385,11 @@ class _DataGridValuePanelState extends material.State { size: 14, ), isActive: _wordWrap, - color: cs.mutedForeground, - activeColor: cs.primary, onPressed: () => setState(() => _wordWrap = !_wordWrap), ), QueryaIconButton( size: QueryaControlSize.sm, icon: const material.Icon(material.Icons.copy_rounded, size: 14), - color: cs.mutedForeground, onPressed: () { Clipboard.setData(ClipboardData(text: _controller.text)); }, diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index 4cd6a7fb..57be122c 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -565,9 +565,7 @@ class _ResultsTabState extends material.State { size: 15, ), tooltip: 'Toggle Quick Filter', - color: _showFilterBar || _filterText.isNotEmpty - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.mutedForeground, + isActive: _showFilterBar || _filterText.isNotEmpty, onPressed: () { setState(() => _showFilterBar = !_showFilterBar); }, @@ -591,9 +589,7 @@ class _ResultsTabState extends material.State { tooltip: masked ? 'Sensitive data is masked — click to show' : 'Mask sensitive data', - color: masked - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.mutedForeground, + isActive: masked, onPressed: PiiMaskingController.instance.toggle, ); }, @@ -607,9 +603,7 @@ class _ResultsTabState extends material.State { size: 15, ), tooltip: 'Inspect Cell Panel', - color: _showValuePanel - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.mutedForeground, + isActive: _showValuePanel, onPressed: () { setState(() => _showValuePanel = !_showValuePanel); }, diff --git a/lib/shared/widgets/querya_icon_button.dart b/lib/shared/widgets/querya_icon_button.dart index 9485e6b8..3de22d67 100644 --- a/lib/shared/widgets/querya_icon_button.dart +++ b/lib/shared/widgets/querya_icon_button.dart @@ -2,10 +2,17 @@ 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/core/ui/querya_tooltip.dart'; - -/// Standard icon button sized by [QueryaControlSize], with hover / active -/// states and a built-in tooltip. -class QueryaIconButton extends material.StatefulWidget { +import 'package:querya_desktop/shared/widgets/querya_button.dart'; + +/// Icon-only button of the Querya kit, a thin wrapper over [QueryaButton] +/// (#1440): the same states as every other button (hover, pressed, +/// keyboard-only focus ring, disabled, `selected` semantics), square at its +/// [QueryaControlSize]. +/// +/// [icon] is a plain `Icon`: its glyph is used, the size and colour come from +/// the control size and the button's state. [isActive] tints it with the +/// accent, [isDestructive] with the destructive colour. +class QueryaIconButton extends material.StatelessWidget { const QueryaIconButton({ super.key, required this.icon, @@ -14,15 +21,13 @@ class QueryaIconButton extends material.StatefulWidget { this.size, this.isActive = false, this.isDestructive = false, - this.color, - this.activeColor, this.borderRadius, }); - /// The icon widget or Icon to render. - final material.Widget icon; + /// The `Icon` to render. + final material.Icon icon; - /// Optional tooltip message shown on hover using [kQueryaTooltipWait]. + /// Shown on hover after [kQueryaTooltipWait]; also the spoken label. final String? tooltip; /// Callback when pressed. If null, the button is disabled. @@ -35,132 +40,29 @@ class QueryaIconButton extends material.StatefulWidget { /// Whether the button is currently in an active/toggled state. final bool isActive; - /// Whether clicking triggers a destructive action (tinted red on hover). + /// Whether clicking triggers a destructive action. final bool isDestructive; - /// Custom icon color in normal state. - final material.Color? color; - - /// Custom icon color in active state. - final material.Color? activeColor; - - /// Custom border radius. Defaults to 6px. + /// Custom border radius; defaults to the control size's radius. final material.BorderRadius? borderRadius; - @override - material.State createState() => _QueryaIconButtonState(); -} - -class _QueryaIconButtonState extends material.State { - /// Whether the button has keyboard focus (#1370): drawn as a 2px ring. - bool _focused = false; - @override material.Widget build(material.BuildContext context) { - final icon = widget.icon; - final tooltip = widget.tooltip; - final onPressed = widget.onPressed; - final controlSize = widget.size ?? - QueryaControlSize.of(context); - final boxSize = controlSize.scaledHeight(context); - final iconSize = controlSize.scaledIconSize(context); - final isActive = widget.isActive; - final isDestructive = widget.isDestructive; - final color = widget.color; - final activeColor = widget.activeColor; - final cs = context.colors; - final workbench = QueryaThemeScope.maybeOf(context)?.workbench; - final radius = widget.borderRadius ?? - material.BorderRadius.circular(controlSize.scaledRadius(context)); - - final isEnabled = onPressed != null; - - final destructiveColor = workbench?.destructive ?? cs.destructive; - final accentColor = workbench?.accent ?? cs.primary; - final mutedColor = workbench?.mutedForeground ?? cs.mutedForeground; - - // Normal icon color - final normalColor = color ?? - (isDestructive - ? destructiveColor - : (isEnabled ? cs.foreground : cs.mutedForeground.withValues(alpha: 0.5))); - - // Effective icon color - final effectiveIconColor = isActive - ? (activeColor ?? (isDestructive ? destructiveColor : accentColor)) - : normalColor; - - // Effective background color for active state - material.Color? effectiveBgColor; - if (isActive) { - effectiveBgColor = isDestructive - ? destructiveColor.withValues(alpha: 0.16) - : accentColor.withValues(alpha: 0.14); - } - - final hoverColor = isDestructive - ? destructiveColor.withValues(alpha: 0.12) - : cs.muted.withValues(alpha: 0.35); - - material.Widget button = material.Material( - color: effectiveBgColor ?? material.Colors.transparent, - shape: material.RoundedRectangleBorder( - borderRadius: radius, - side: _focused - ? material.BorderSide(color: cs.ring, width: 2) - : isActive - ? material.BorderSide( - color: (isDestructive ? destructiveColor : accentColor) - .withValues(alpha: 0.3), - width: 1, - ) - : material.BorderSide.none, - ), - clipBehavior: material.Clip.antiAlias, - child: material.InkWell( - onTap: onPressed, - onFocusChange: (focused) { - if (_focused != focused) setState(() => _focused = focused); - }, - borderRadius: radius, - hoverColor: hoverColor, - focusColor: material.Colors.transparent, - splashColor: (isDestructive ? destructiveColor : accentColor) - .withValues(alpha: 0.2), - child: material.SizedBox( - width: boxSize, - height: boxSize, - child: material.Center( - child: material.IconTheme( - data: material.IconThemeData( - size: iconSize, - color: isEnabled - ? effectiveIconColor - : mutedColor.withValues(alpha: 0.4), - ), - child: icon, - ), - ), - ), - ), - ); - - button = material.Semantics( - button: true, - enabled: onPressed != null, - selected: isActive, - child: button, + final glyph = icon.icon; + assert(glyph != null, 'QueryaIconButton needs an Icon with a glyph'); + return QueryaButton( + variant: QueryaButtonVariant.ghost, + icon: glyph, + tooltip: tooltip, + semanticLabel: tooltip, + onPressed: onPressed, + isActive: isActive, + size: size, + tone: isDestructive + ? QueryaButtonTone.destructive + : QueryaButtonTone.normal, + borderRadius: borderRadius, ); - - if (tooltip != null && tooltip.isNotEmpty) { - button = material.Tooltip( - message: tooltip, - waitDuration: kQueryaTooltipWait, - child: button, - ); - } - - return button; } } diff --git a/test/shared/widgets/querya_icon_button_test.dart b/test/shared/widgets/querya_icon_button_test.dart index 61caca00..6b544272 100644 --- a/test/shared/widgets/querya_icon_button_test.dart +++ b/test/shared/widgets/querya_icon_button_test.dart @@ -37,12 +37,7 @@ void main() { ), ); - final box = tester.widget( - find.ancestor( - of: find.byIcon(material.Icons.refresh), - matching: find.byType(material.SizedBox), - ), - ); + final box = tester.getSize(find.byType(QueryaIconButton)); expect(box.width, 28); expect(box.height, 28); @@ -58,54 +53,33 @@ void main() { ), ); - final box = tester.widget( - find.ancestor( - of: find.byIcon(material.Icons.refresh), - matching: find.byType(material.SizedBox), - ), - ); + final box = tester.getSize(find.byType(QueryaIconButton)); expect(box.width, 32); expect(box.height, 32); }); - testWidgets('draws a 2px focus ring while it has keyboard focus (#1370)', + testWidgets('is a QueryaButton: ghost, icon only, tone from isDestructive', (tester) async { await tester.pumpWidget( queryaThemeTestShell( child: QueryaIconButton( - icon: const material.Icon(material.Icons.refresh), + icon: const material.Icon(material.Icons.delete_outline), + tooltip: 'Delete', + isDestructive: true, + isActive: true, onPressed: () {}, ), ), ); - final materialFinder = find - .descendant( - of: find.byType(QueryaIconButton), - matching: find.byType(material.Material), - ) - .first; - final inkFinder = find.descendant( - of: find.byType(QueryaIconButton), - matching: find.byType(material.InkWell), - ); - double ringWidth() { - final shape = - tester.widget(materialFinder).shape! - as material.RoundedRectangleBorder; - return shape.side.width; - } - - expect(ringWidth(), 0); - - tester.widget(inkFinder).onFocusChange!(true); - await tester.pump(); - expect(ringWidth(), 2); - - tester.widget(inkFinder).onFocusChange!(false); - await tester.pump(); - expect(ringWidth(), 0); + final button = tester.widget(find.byType(QueryaButton)); + expect(button.variant, QueryaButtonVariant.ghost); + expect(button.label, isNull); + expect(button.icon, material.Icons.delete_outline); + expect(button.tone, QueryaButtonTone.destructive); + expect(button.isActive, isTrue); + expect(button.tooltip, 'Delete'); }); testWidgets('exposes button, enabled and selected state (#1370)', diff --git a/test/shared/widgets/querya_ui_kit_behavior_test.dart b/test/shared/widgets/querya_ui_kit_behavior_test.dart index 2ff2dd15..74a418b2 100644 --- a/test/shared/widgets/querya_ui_kit_behavior_test.dart +++ b/test/shared/widgets/querya_ui_kit_behavior_test.dart @@ -125,8 +125,8 @@ void main() { ), ); - final ink = tester.widget(find.byType(material.InkWell)); - expect(ink.onTap, isNull); + final button = tester.widget(find.byType(QueryaButton)); + expect(button.onPressed, isNull); }); testWidgets('a tooltip message is attached when given', (tester) async { From 2ea532ea52cabe916a13d81b1e55ab4521f83981 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 11 Oct 2026 16:36:34 +0300 Subject: [PATCH 2/3] fix(ui): an active destructive button is tinted with the destructive colour --- lib/shared/widgets/querya_button.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/shared/widgets/querya_button.dart b/lib/shared/widgets/querya_button.dart index 2ce9630b..8bbef19b 100644 --- a/lib/shared/widgets/querya_button.dart +++ b/lib/shared/widgets/querya_button.dart @@ -331,8 +331,12 @@ class _QueryaButtonState extends material.State { ? wb.destructive : scheme.foreground; if (w.isActive) { - foreground = wb.accent; - background = wb.accent.withValues(alpha: _pressed ? 0.22 : 0.14); + // A toggled button is tinted with its tone: the accent, or the + // destructive colour for a destructive one. + final activeColor = + w.tone == QueryaButtonTone.destructive ? wb.destructive : wb.accent; + foreground = activeColor; + background = activeColor.withValues(alpha: _pressed ? 0.22 : 0.14); } else { foreground = toneColor; final tint = _pressed && enabled From 026651faaea69fce22f57fcd6c04b99b307ac11b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 11 Oct 2026 16:40:15 +0300 Subject: [PATCH 3/3] test(ui): measure the icon button with loose constraints --- test/shared/widgets/querya_icon_button_test.dart | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/test/shared/widgets/querya_icon_button_test.dart b/test/shared/widgets/querya_icon_button_test.dart index 6b544272..bf8ac6a1 100644 --- a/test/shared/widgets/querya_icon_button_test.dart +++ b/test/shared/widgets/querya_icon_button_test.dart @@ -30,9 +30,11 @@ void main() { testWidgets('respects sm size (28px)', (tester) async { await tester.pumpWidget( queryaThemeTestShell( - child: const QueryaIconButton( - icon: material.Icon(material.Icons.refresh), - size: QueryaControlSize.sm, + child: const material.Center( + child: QueryaIconButton( + icon: material.Icon(material.Icons.refresh), + size: QueryaControlSize.sm, + ), ), ), ); @@ -46,9 +48,11 @@ void main() { testWidgets('respects md size (32px)', (tester) async { await tester.pumpWidget( queryaThemeTestShell( - child: const QueryaIconButton( - icon: material.Icon(material.Icons.refresh), - size: QueryaControlSize.md, + child: const material.Center( + child: QueryaIconButton( + icon: material.Icon(material.Icons.refresh), + size: QueryaControlSize.md, + ), ), ), );