From a3307b534101e53ebc8b0237c8249e5346e78898 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 22:22:25 +0300 Subject: [PATCH] fix(a11y): focus ring and state semantics for QueryaIconButton and QueryaToolbarButton Track keyboard focus with InkWell.onFocusChange and draw a 2px ring in the theme's ring colour, and wrap both buttons in Semantics (button, enabled, selected) so screen readers announce active toggles. Closes #1370. --- lib/shared/widgets/querya_icon_button.dart | 105 ++++++++++++++---- .../widgets/querya_icon_button_test.dart | 85 ++++++++++++++ 2 files changed, 167 insertions(+), 23 deletions(-) diff --git a/lib/shared/widgets/querya_icon_button.dart b/lib/shared/widgets/querya_icon_button.dart index bd4352f0..35478e58 100644 --- a/lib/shared/widgets/querya_icon_button.dart +++ b/lib/shared/widgets/querya_icon_button.dart @@ -18,7 +18,7 @@ enum QueryaIconButtonDensity { } /// Standard icon button with density presets, hover/active states, and built-in tooltip. -class QueryaIconButton extends material.StatelessWidget { +class QueryaIconButton extends material.StatefulWidget { const QueryaIconButton({ super.key, required this.icon, @@ -59,11 +59,27 @@ class QueryaIconButton extends material.StatelessWidget { /// Custom border radius. Defaults to 6px. 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 density = widget.density; + final isActive = widget.isActive; + final isDestructive = widget.isDestructive; + final color = widget.color; + final activeColor = widget.activeColor; final cs = Theme.of(context).colorScheme; final workbench = QueryaThemeScope.maybeOf(context)?.workbench; - final radius = borderRadius ?? material.BorderRadius.circular(6); + final radius = widget.borderRadius ?? material.BorderRadius.circular(6); final isEnabled = onPressed != null; @@ -98,19 +114,25 @@ class QueryaIconButton extends material.StatelessWidget { color: effectiveBgColor ?? material.Colors.transparent, shape: material.RoundedRectangleBorder( borderRadius: radius, - side: isActive - ? material.BorderSide( - color: (isDestructive ? destructiveColor : accentColor) - .withValues(alpha: 0.3), - width: 1, - ) - : material.BorderSide.none, + 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( @@ -131,9 +153,16 @@ class QueryaIconButton extends material.StatelessWidget { ), ); - if (tooltip != null && tooltip!.isNotEmpty) { + button = material.Semantics( + button: true, + enabled: onPressed != null, + selected: isActive, + child: button, + ); + + if (tooltip != null && tooltip.isNotEmpty) { button = material.Tooltip( - message: tooltip!, + message: tooltip, waitDuration: kQueryaTooltipWait, child: button, ); @@ -144,7 +173,7 @@ class QueryaIconButton extends material.StatelessWidget { } /// Standard toolbar button with optional leading icon, label, and shortcut hint. -class QueryaToolbarButton extends material.StatelessWidget { +class QueryaToolbarButton extends material.StatefulWidget { const QueryaToolbarButton({ super.key, required this.label, @@ -166,8 +195,25 @@ class QueryaToolbarButton extends material.StatelessWidget { final bool isActive; final bool isDestructive; + @override + material.State createState() => + _QueryaToolbarButtonState(); +} + +class _QueryaToolbarButtonState 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 label = widget.label; + final icon = widget.icon; + final tooltip = widget.tooltip; + final shortcutHint = widget.shortcutHint; + final onPressed = widget.onPressed; + final density = widget.density; + final isActive = widget.isActive; + final isDestructive = widget.isDestructive; final cs = Theme.of(context).colorScheme; final workbench = QueryaThemeScope.maybeOf(context)?.workbench; final isEnabled = onPressed != null; @@ -202,7 +248,7 @@ class QueryaToolbarButton extends material.StatelessWidget { size: density.iconSize, color: effectiveFgColor, ), - child: icon!, + child: icon, ), const Gap(6), ], @@ -223,7 +269,7 @@ class QueryaToolbarButton extends material.StatelessWidget { borderRadius: material.BorderRadius.circular(3), ), child: material.Text( - shortcutHint!, + shortcutHint, style: material.TextStyle( fontSize: 9, fontWeight: material.FontWeight.w500, @@ -239,19 +285,25 @@ class QueryaToolbarButton extends material.StatelessWidget { color: effectiveBgColor, shape: material.RoundedRectangleBorder( borderRadius: radius, - side: isActive - ? material.BorderSide( - color: (isDestructive ? destructiveColor : accentColor) - .withValues(alpha: 0.3), - width: 1, - ) - : material.BorderSide.none, + 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, child: material.Container( height: density.boxSize, padding: material.EdgeInsets.symmetric( @@ -263,9 +315,16 @@ class QueryaToolbarButton extends material.StatelessWidget { ), ); - if (tooltip != null && tooltip!.isNotEmpty) { + button = material.Semantics( + button: true, + enabled: onPressed != null, + selected: isActive, + child: button, + ); + + if (tooltip != null && tooltip.isNotEmpty) { button = material.Tooltip( - message: tooltip!, + message: tooltip, waitDuration: kQueryaTooltipWait, child: button, ); diff --git a/test/shared/widgets/querya_icon_button_test.dart b/test/shared/widgets/querya_icon_button_test.dart index 0755d28b..9fafbaf9 100644 --- a/test/shared/widgets/querya_icon_button_test.dart +++ b/test/shared/widgets/querya_icon_button_test.dart @@ -67,6 +67,91 @@ void main() { expect(box.width, 32); expect(box.height, 32); }); + + testWidgets('draws a 2px focus ring while it has keyboard focus (#1370)', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaIconButton( + icon: const material.Icon(material.Icons.refresh), + 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); + }); + + testWidgets('exposes button, enabled and selected state (#1370)', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaIconButton( + icon: const material.Icon(material.Icons.wrap_text), + tooltip: 'Word wrap', + isActive: true, + onPressed: () {}, + ), + ), + ); + + expect( + find.byWidgetPredicate( + (w) => + w is material.Semantics && + w.properties.button == true && + w.properties.enabled == true && + w.properties.selected == true, + ), + findsWidgets, + ); + }); + + testWidgets('a button without a callback is exposed as disabled (#1370)', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const QueryaIconButton( + icon: material.Icon(material.Icons.refresh), + ), + ), + ); + + expect( + find.byWidgetPredicate( + (w) => + w is material.Semantics && + w.properties.button == true && + w.properties.enabled == false, + ), + findsWidgets, + ); + }); }); group('QueryaToolbarButton', () {