From b376b2f0ceb6eb044f70d65945f5b3a84d0d9f8c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 22:28:11 +0300 Subject: [PATCH] fix(a11y): expose QueryaDropdown trigger and items to screen readers The trigger is now a Semantics button carrying the current value, the hint and whether the menu is expanded; each menu item reports button, enabled and selected. Closes #1371. --- lib/shared/widgets/querya_dropdown.dart | 118 ++++++++++-------- .../querya_dropdown_semantics_test.dart | 93 ++++++++++++++ 2 files changed, 160 insertions(+), 51 deletions(-) create mode 100644 test/shared/widgets/querya_dropdown_semantics_test.dart diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index 79c89ef6..e8f889dc 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -264,7 +264,7 @@ class _QueryaDropdownState extends material.State> { } // No ink/ripple: hover tint + focus ring only (desktop behaviour). - return material.Focus( + final trigger = material.Focus( canRequestFocus: widget.enabled, onFocusChange: (f) => setState(() => _triggerFocused = f), onKeyEvent: (node, event) { @@ -290,6 +290,17 @@ class _QueryaDropdownState extends material.State> { : triggerBody, ), ); + + // A screen reader hears a button with the current value that says whether + // its menu is open (#1371). + return material.Semantics( + button: true, + enabled: widget.enabled, + expanded: controller.isOpen, + value: label, + hint: widget.hint, + child: trigger, + ); } @override @@ -500,59 +511,64 @@ class _QueryaDropdownMenuItemState final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); final slot = context.scaled(QueryaDropdownTokens.selectedCheckSlotWidth); - return material.MouseRegion( - cursor: widget.enabled - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - onEnter: widget.enabled ? (_) => setState(() => _hovered = true) : null, - onExit: widget.enabled ? (_) => setState(() => _hovered = false) : null, - child: material.MenuItemButton( - // Keep overlay mounted so parent can play exit fade-slide before close. - closeOnActivate: false, - onFocusChange: (f) => setState(() => _focused = f), - style: material.MenuItemButton.styleFrom( - minimumSize: material.Size(double.infinity, itemHeight), - padding: material.EdgeInsets.zero, - foregroundColor: cs.popoverForeground, - disabledForegroundColor: cs.mutedForeground.withValues(alpha: 0.5), - overlayColor: material.Colors.transparent, - shape: material.RoundedRectangleBorder( - borderRadius: material.BorderRadius.circular(radius), - ), - ), - onPressed: widget.enabled ? widget.onPick : null, - child: material.AnimatedContainer( - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.enter), - constraints: material.BoxConstraints(minHeight: itemHeight), - padding: material.EdgeInsets.symmetric( - horizontal: - context.scaled(QueryaDropdownTokens.menuItemPadding.horizontal), - vertical: - context.scaled(QueryaDropdownTokens.menuItemPadding.vertical), - ), - decoration: material.BoxDecoration( - color: bg, - borderRadius: material.BorderRadius.circular(radius), + return material.Semantics( + button: true, + enabled: widget.enabled, + selected: widget.selected, + child: material.MouseRegion( + cursor: widget.enabled + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + onEnter: widget.enabled ? (_) => setState(() => _hovered = true) : null, + onExit: widget.enabled ? (_) => setState(() => _hovered = false) : null, + child: material.MenuItemButton( + // Keep overlay mounted so parent can play exit fade-slide before close. + closeOnActivate: false, + onFocusChange: (f) => setState(() => _focused = f), + style: material.MenuItemButton.styleFrom( + minimumSize: material.Size(double.infinity, itemHeight), + padding: material.EdgeInsets.zero, + foregroundColor: cs.popoverForeground, + disabledForegroundColor: cs.mutedForeground.withValues(alpha: 0.5), + overlayColor: material.Colors.transparent, + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(radius), + ), ), - child: material.Row( - children: [ - material.SizedBox(width: slot, child: _leading(context, cs)), - material.SizedBox(width: context.scaled(6)), - material.Expanded( - child: material.Text( - widget.item.label, - maxLines: 1, - overflow: material.TextOverflow.ellipsis, - style: QueryaDropdownTokens.menuItemTextStyle( - context, - cs.popoverForeground, - selected: widget.selected, - compact: widget.compact, + onPressed: widget.enabled ? widget.onPick : null, + child: material.AnimatedContainer( + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.enter), + constraints: material.BoxConstraints(minHeight: itemHeight), + padding: material.EdgeInsets.symmetric( + horizontal: + context.scaled(QueryaDropdownTokens.menuItemPadding.horizontal), + vertical: + context.scaled(QueryaDropdownTokens.menuItemPadding.vertical), + ), + decoration: material.BoxDecoration( + color: bg, + borderRadius: material.BorderRadius.circular(radius), + ), + child: material.Row( + children: [ + material.SizedBox(width: slot, child: _leading(context, cs)), + material.SizedBox(width: context.scaled(6)), + material.Expanded( + child: material.Text( + widget.item.label, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: QueryaDropdownTokens.menuItemTextStyle( + context, + cs.popoverForeground, + selected: widget.selected, + compact: widget.compact, + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/test/shared/widgets/querya_dropdown_semantics_test.dart b/test/shared/widgets/querya_dropdown_semantics_test.dart new file mode 100644 index 00000000..37a83afa --- /dev/null +++ b/test/shared/widgets/querya_dropdown_semantics_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +Widget _dropdown({bool enabled = true}) { + return queryaThemeTestShell( + child: QueryaDropdown( + value: 'b', + hint: 'Pick one', + enabled: enabled, + items: const [ + QueryaDropdownItem(value: 'a', label: 'Alpha'), + QueryaDropdownItem(value: 'b', label: 'Beta'), + ], + onSelected: (_) {}, + ), + ); +} + +void main() { + group('QueryaDropdown semantics (#1371)', () { + testWidgets('the trigger is a button with the value and hint, collapsed', + (tester) async { + await tester.pumpWidget(_dropdown()); + + expect( + find.byWidgetPredicate( + (w) => + w is material.Semantics && + w.properties.button == true && + w.properties.expanded == false && + w.properties.value == 'Beta' && + w.properties.hint == 'Pick one', + ), + findsOneWidget, + ); + }); + + testWidgets('a disabled dropdown is exposed as disabled', (tester) async { + await tester.pumpWidget(_dropdown(enabled: false)); + + expect( + find.byWidgetPredicate( + (w) => + w is material.Semantics && + w.properties.button == true && + w.properties.enabled == false && + w.properties.value == 'Beta', + ), + findsOneWidget, + ); + }); + + testWidgets('open menu reports expanded and marks the selected item', + (tester) async { + await tester.pumpWidget(_dropdown()); + + await tester.tap(find.text('Beta')); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (w) => + w is material.Semantics && + w.properties.button == true && + w.properties.expanded == true, + ), + findsOneWidget, + ); + expect( + find.byWidgetPredicate( + (w) => + w is material.Semantics && + w.properties.button == true && + w.properties.selected == true && + w.properties.enabled == true, + ), + findsOneWidget, + ); + expect( + find.byWidgetPredicate( + (w) => + w is material.Semantics && + w.properties.button == true && + w.properties.selected == false, + ), + findsOneWidget, + ); + }); + }); +}