From e9b4299c41c74af97f0a0a87ed051f3cdc08fc32 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 25 Oct 2026 21:59:33 +0300 Subject: [PATCH 1/2] fix(a11y): tree rows, connection form fields and the database tiles are labelled nodes; one show-password button, one node per environment option (#1822) --- .../connections/environment_section.dart | 3 + .../forms/connection_auth_section.dart | 35 +++------- .../forms/connection_host_port_section.dart | 2 + .../connections/new_connection_dialog.dart | 11 +++ .../querya_connection_tree_row.dart | 23 +++--- lib/features/redis/redis_connection_form.dart | 37 +++------- lib/shared/widgets/querya_text_field.dart | 12 +++- .../connection_form_semantics_test.dart | 70 +++++++++++++++++++ .../querya_connection_tree_row_test.dart | 33 +++++++++ 9 files changed, 162 insertions(+), 64 deletions(-) create mode 100644 test/features/connections/connection_form_semantics_test.dart diff --git a/lib/features/connections/environment_section.dart b/lib/features/connections/environment_section.dart index 27a1b5cf..7a30186f 100644 --- a/lib/features/connections/environment_section.dart +++ b/lib/features/connections/environment_section.dart @@ -79,6 +79,9 @@ class _Option extends material.StatelessWidget { button: true, selected: selected, label: label, + // The text inside is the same label; one node, not two. + excludeSemantics: true, + onTap: onTap, child: material.InkWell( key: material.Key('environment_option_$optionKey'), onTap: onTap, diff --git a/lib/features/connections/forms/connection_auth_section.dart b/lib/features/connections/forms/connection_auth_section.dart index cc68b139..6c05d285 100644 --- a/lib/features/connections/forms/connection_auth_section.dart +++ b/lib/features/connections/forms/connection_auth_section.dart @@ -1,6 +1,5 @@ import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; import 'package:querya_desktop/features/connections/remove_saved_password_option.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -35,8 +34,6 @@ class ConnectionAuthSection extends material.StatefulWidget { } class _ConnectionAuthSectionState extends material.State { - bool _showPassword = false; - material.Widget _buildUsernameField() { return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, @@ -46,6 +43,7 @@ class _ConnectionAuthSectionState extends material.State const Gap(8), QueryaTextField( controller: widget.usernameController, + semanticLabel: widget.usernameLabel, placeholder: widget.usernamePlaceholder, ), ], @@ -64,30 +62,13 @@ class _ConnectionAuthSectionState extends material.State onChanged: widget.onRemoveSavedPasswordChanged!, ), const Gap(8), - material.Stack( - children: [ - QueryaTextField( - controller: widget.passwordController, - enabled: !widget.removeSavedPassword, - placeholder: widget.isEditing ? 'Leave blank to keep existing' : 'Password', - obscureText: !_showPassword, - ), - material.Positioned( - right: 8, - top: 0, - bottom: 0, - child: material.Center( - child: QueryaIconButton( -icon: material.Icon(_showPassword - ? material.Icons.visibility_off - : material.Icons.visibility), -tooltip: _showPassword ? 'Hide password' : 'Show password', -size: QueryaControlSize.sm, -onPressed: () => setState(() => _showPassword = !_showPassword), -), - ), - ), - ], + QueryaTextField( + controller: widget.passwordController, + enabled: !widget.removeSavedPassword, + placeholder: widget.isEditing ? 'Leave blank to keep existing' : 'Password', + semanticLabel: widget.passwordLabel, + // The field brings its own show / hide button. + obscureText: true, ), ], ); diff --git a/lib/features/connections/forms/connection_host_port_section.dart b/lib/features/connections/forms/connection_host_port_section.dart index a2090ee0..21e21108 100644 --- a/lib/features/connections/forms/connection_host_port_section.dart +++ b/lib/features/connections/forms/connection_host_port_section.dart @@ -42,6 +42,7 @@ class ConnectionHostPortSection extends StatelessWidget { const Gap(8), QueryaTextField( controller: hostController, + semanticLabel: hostLabel, placeholder: hostPlaceholder, ), ], @@ -58,6 +59,7 @@ class ConnectionHostPortSection extends StatelessWidget { const Gap(8), QueryaTextField( controller: portController, + semanticLabel: portLabel, placeholder: portPlaceholder, ), ], diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index 01aa900d..e08900f4 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -367,6 +367,17 @@ class _DbTypeCard extends material.StatelessWidget { material.Widget build(material.BuildContext context) { final t = theme; final highlight = t.muted.withValues(alpha: 0.4); + return material.Semantics( + button: true, + selected: selected, + label: choice.label, + onTap: onTap, + excludeSemantics: true, + child: _surface(t, highlight), + ); + } + + material.Widget _surface(QueryaUiColors t, material.Color highlight) { return QueryaHoverSurface( borderRadius: material.BorderRadius.circular(10), padding: const material.EdgeInsets.symmetric(vertical: 10, horizontal: 8), diff --git a/lib/features/connections/querya_connection_tree_row.dart b/lib/features/connections/querya_connection_tree_row.dart index 8e866b79..616f4807 100644 --- a/lib/features/connections/querya_connection_tree_row.dart +++ b/lib/features/connections/querya_connection_tree_row.dart @@ -188,13 +188,7 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { onOpenSqlWorkspace != null || onTogglePin != null; if (!wantsMenu) { - return expanded == null - ? row - : material.Semantics( - button: true, - expanded: expanded, - child: row, - ); + return expanded == null ? row : _expandSemantics(row); } final menu = QueryaContextMenu( items: [ @@ -311,10 +305,23 @@ class QueryaConnectionTreeRow extends material.StatelessWidget { child: row, ); if (expanded == null) return menu; + return _expandSemantics(menu); + } + + /// One labelled node with the expand / collapse state and a tap action, so + /// a screen reader or a switch can open the branch (#1822). The inner + /// nodes are excluded: the label is the row's own, not a second one. + material.Widget _expandSemantics(material.Widget child) { return material.Semantics( + container: true, button: true, expanded: expanded, - child: menu, + selected: isSelected, + label: label, + onTap: onTap, + // A trailing control keeps its own node. + excludeSemantics: trailing == null, + child: child, ); } diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index 1bc338fb..7c3839be 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -72,7 +72,6 @@ class _RedisConnectionFormContentState bool _removeSavedPassword = false; bool _useSSL = false; - bool _showPassword = false; bool _isTesting = false; String? _testResult; Timer? _dismissTimer; @@ -448,33 +447,15 @@ class _RedisConnectionFormContentState setState(() => _removeSavedPassword = v), ), const Gap(8), - material.Stack( - children: [ - QueryaTextField( - controller: _passwordController, - enabled: !_removeSavedPassword, - placeholder: _isEditing - ? 'Leave blank to keep existing' - : 'Password', - obscureText: !_showPassword, - ), - material.Positioned( - right: 8, - top: 0, - bottom: 0, - child: material.Center( - child: QueryaIconButton( -icon: material.Icon(_showPassword - ? material.Icons.visibility_off - : material.Icons.visibility), -tooltip: _showPassword ? 'Hide password' : 'Show password', -size: QueryaControlSize.sm, -onPressed: () => setState( - () => _showPassword = !_showPassword), -), - ), - ), - ], + QueryaTextField( + controller: _passwordController, + enabled: !_removeSavedPassword, + placeholder: _isEditing + ? 'Leave blank to keep existing' + : 'Password', + semanticLabel: 'Password', + // The field brings its own show / hide button. + obscureText: true, ), material.Row( children: [ diff --git a/lib/shared/widgets/querya_text_field.dart b/lib/shared/widgets/querya_text_field.dart index bf0a3515..1519ac8b 100644 --- a/lib/shared/widgets/querya_text_field.dart +++ b/lib/shared/widgets/querya_text_field.dart @@ -43,6 +43,7 @@ class QueryaTextField extends material.StatefulWidget { this.textAlign = material.TextAlign.start, this.autocorrect = true, this.contentPadding, + this.semanticLabel, }); final material.TextEditingController? controller; @@ -88,6 +89,10 @@ class QueryaTextField extends material.StatefulWidget { /// Replaces the padding between the border and the text. final material.EdgeInsetsGeometry? contentPadding; + /// Spoken name of the field, usually its caption (#1822); without it a + /// screen reader hears only the placeholder. + final String? semanticLabel; + @override material.State createState() => _QueryaTextFieldState(); } @@ -154,7 +159,7 @@ class _QueryaTextFieldState extends material.State { icon: _revealed ? material.Icons.visibility_outlined : material.Icons.visibility_off_outlined, - tooltip: _revealed ? 'Hide' : 'Show', + tooltip: _revealed ? 'Hide password' : 'Show password', size: QueryaControlSize.sm, onPressed: widget.enabled ? () => setState(() => _revealed = !_revealed) @@ -252,6 +257,11 @@ class _QueryaTextFieldState extends material.State { ) : field; + final label = widget.semanticLabel; + if (label != null) { + content = material.Semantics(label: label, child: content); + } + if (hasError) { content = material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, diff --git a/test/features/connections/connection_form_semantics_test.dart b/test/features/connections/connection_form_semantics_test.dart new file mode 100644 index 00000000..d6b66c24 --- /dev/null +++ b/test/features/connections/connection_form_semantics_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/connections/environment_section.dart'; +import 'package:querya_desktop/features/connections/forms/connection_auth_section.dart'; +import 'package:querya_desktop/features/connections/forms/connection_host_port_section.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +int _nodesLabelled(WidgetTester tester, String label) { + var count = 0; + void visit(SemanticsNode node) { + if (node.label.split('\n').first.trim() == label) count++; + node.visitChildren((c) { + visit(c); + return true; + }); + } + + visit(tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!); + return count; +} + +void main() { + testWidgets('host, port, user and password fields carry their caption ' + 'and the password has one show button (#1822)', (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SingleChildScrollView( + child: material.Column( + children: [ + ConnectionHostPortSection( + hostController: material.TextEditingController(), + portController: material.TextEditingController(), + ), + ConnectionAuthSection( + usernameController: material.TextEditingController(), + passwordController: material.TextEditingController(), + ), + ], + ), + ), + ), + ); + await tester.pump(); + + for (final label in ['Host', 'Port', 'Username', 'Password']) { + final labelled = find.semantics.byLabel(label); + expect(labelled, findsWidgets, reason: label); + } + expect(find.byTooltip('Show password'), findsOneWidget); + expect(find.byTooltip('Show'), findsNothing); + handle.dispose(); + }); + + testWidgets('an environment option is one semantics node (#1822)', + (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget( + queryaThemeTestShell( + child: EnvironmentSection(value: null, onChanged: (_) {}), + ), + ); + await tester.pump(); + + expect(_nodesLabelled(tester, 'None'), 1); + handle.dispose(); + }); +} diff --git a/test/features/connections/querya_connection_tree_row_test.dart b/test/features/connections/querya_connection_tree_row_test.dart index 4b89d72e..914aad84 100644 --- a/test/features/connections/querya_connection_tree_row_test.dart +++ b/test/features/connections/querya_connection_tree_row_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:flutter/semantics.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/connections/querya_connection_tree_row.dart'; @@ -7,6 +8,38 @@ import '../../support/querya_theme_test_shell.dart'; void main() { group('QueryaConnectionTreeRow', () { + testWidgets( + 'an expandable row is one labelled node that a semantics tap opens ' + '(#1822)', (tester) async { + final handle = tester.ensureSemantics(); + var taps = 0; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: QueryaConnectionTreeRow( + label: 'querya', + expanded: false, + onTap: () => taps++, + textStyle: const material.TextStyle(fontSize: 13), + ), + ), + ), + ); + await tester.pump(); + + final node = tester.getSemantics(find.text('querya')); + expect(node.label, 'querya'); + expect( + node.getSemanticsData().hasAction(SemanticsAction.tap), + isTrue, + ); + expect(node.getSemanticsData().flagsCollection.isExpanded, isFalse); + tester.binding.pipelineOwner.semanticsOwner! + .performAction(node.id, SemanticsAction.tap); + expect(taps, 1); + handle.dispose(); + }); + testWidgets('renders pinned star icon with workbench.warning (#1643)', (tester) async { await tester.pumpWidget( From d055599914290e17993964b4c82e592fbae0e59d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 25 Oct 2026 22:03:22 +0300 Subject: [PATCH 2/2] test(a11y): the built-in show-password icons; the expanded tristate --- .../connections/connection_form_sections_test.dart | 7 ++++--- .../connections/querya_connection_tree_row_test.dart | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/test/features/connections/connection_form_sections_test.dart b/test/features/connections/connection_form_sections_test.dart index e8c776ab..16b19e6f 100644 --- a/test/features/connections/connection_form_sections_test.dart +++ b/test/features/connections/connection_form_sections_test.dart @@ -83,12 +83,13 @@ void main() { expect(find.text('Username'), findsOneWidget); // Label plus the field's own placeholder. expect(find.text('Password'), findsNWidgets(2)); - expect(find.byIcon(material.Icons.visibility), findsOneWidget); + expect(find.byIcon(material.Icons.visibility_off_outlined), + findsOneWidget); - await tester.tap(find.byIcon(material.Icons.visibility)); + await tester.tap(find.byIcon(material.Icons.visibility_off_outlined)); await tester.pumpAndSettle(); - expect(find.byIcon(material.Icons.visibility_off), findsOneWidget); + expect(find.byIcon(material.Icons.visibility_outlined), findsOneWidget); }); testWidgets('renders in row layout and shows remove saved password when editing', diff --git a/test/features/connections/querya_connection_tree_row_test.dart b/test/features/connections/querya_connection_tree_row_test.dart index 914aad84..31152296 100644 --- a/test/features/connections/querya_connection_tree_row_test.dart +++ b/test/features/connections/querya_connection_tree_row_test.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart' as material; +import 'dart:ui' show Tristate; + import 'package:flutter/semantics.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; @@ -33,7 +35,7 @@ void main() { node.getSemanticsData().hasAction(SemanticsAction.tap), isTrue, ); - expect(node.getSemanticsData().flagsCollection.isExpanded, isFalse); + expect(node.getSemanticsData().flagsCollection.isExpanded, Tristate.isFalse); tester.binding.pipelineOwner.semanticsOwner! .performAction(node.id, SemanticsAction.tap); expect(taps, 1);