diff --git a/packages/dropdown_button2/CHANGELOG.md b/packages/dropdown_button2/CHANGELOG.md index 51d8d3e..2b944ac 100644 --- a/packages/dropdown_button2/CHANGELOG.md +++ b/packages/dropdown_button2/CHANGELOG.md @@ -3,6 +3,7 @@ - Upgrade minimum required Flutter SDK version to 3.32.0. - Add `errorBuilder` support for DropdownButtonFormField2 [Flutter core]. - Add `forceErrorText` support for DropdownButtonFormField2 [Flutter core]. +- Add ARIA menu roles to menu-related widgets for accessibility [Flutter core]. ## 3.0.0 diff --git a/packages/dropdown_button2/lib/src/dropdown_button2.dart b/packages/dropdown_button2/lib/src/dropdown_button2.dart index 59cf71d..40323fc 100644 --- a/packages/dropdown_button2/lib/src/dropdown_button2.dart +++ b/packages/dropdown_button2/lib/src/dropdown_button2.dart @@ -6,6 +6,7 @@ */ import 'dart:math' as math; +import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -419,7 +420,7 @@ class _DropdownButton2State extends State> with WidgetsBin bool _isFocused = false; // Using ValueNotifier for tracking when menu is open/close to update the button icon. - final ValueNotifier _isMenuOpen = ValueNotifier(false); + final ValueNotifier _isMenuExpanded = ValueNotifier(false); final _buttonRectKey = GlobalKey(); @@ -463,7 +464,7 @@ class _DropdownButton2State extends State> with WidgetsBin _removeDropdownRoute(); _focusNode.removeListener(_handleFocusChanged); _internalNode?.dispose(); - _isMenuOpen.dispose(); + _isMenuExpanded.dispose(); _buttonRect.dispose(); super.dispose(); } @@ -545,7 +546,7 @@ class _DropdownButton2State extends State> with WidgetsBin } void _programmaticallyOpenDropdown() { - if (_enabled && !_isMenuOpen.value) { + if (_enabled && !_isMenuExpanded.value) { _handleTap(); } } @@ -707,7 +708,6 @@ class _DropdownButton2State extends State> with WidgetsBin dropdownSeparator: separator, ); - _isMenuOpen.value = true; _focusNode.requestFocus(); // This is a temporary fix for the "dropdown menu steal the focus from the // underlying button" issue, until share focus is fixed in flutter (#106923). @@ -717,11 +717,12 @@ class _DropdownButton2State extends State> with WidgetsBin navigator.push(_dropdownRoute!).then((_DropdownRouteResult? newValue) { _removeDropdownRoute(); if (mounted) { - _isMenuOpen.value = false; + _isMenuExpanded.value = false; } widget.onMenuStateChange?.call(false); }); + _isMenuExpanded.value = true; widget.onMenuStateChange?.call(true); } @@ -898,10 +899,10 @@ class _DropdownButton2State extends State> with WidgetsBin size: _iconStyle.iconSize, ), child: ValueListenableBuilder( - valueListenable: _isMenuOpen, - builder: (BuildContext context, bool isOpen, _) { + valueListenable: _isMenuExpanded, + builder: (BuildContext context, bool isExpanded, _) { return _iconStyle.openMenuIcon != null - ? isOpen + ? isExpanded ? _iconStyle.openMenuIcon! : _iconStyle.icon : _iconStyle.icon; @@ -1018,8 +1019,15 @@ class _DropdownButton2State extends State> with WidgetsBin final bool childHasButtonSemantic = hintIndex != null || (_selectedIndex != null && widget.selectedItemBuilder == null); - return Semantics( - button: !childHasButtonSemantic, + return ValueListenableBuilder( + valueListenable: _isMenuExpanded, + builder: (BuildContext context, bool isExpanded, Widget? child) { + return Semantics( + button: !childHasButtonSemantic, + expanded: isExpanded, + child: child, + ); + }, child: Actions( actions: _actionMap, child: result, diff --git a/packages/dropdown_button2/lib/src/dropdown_menu.dart b/packages/dropdown_button2/lib/src/dropdown_menu.dart index 2185b7d..f9bf800 100644 --- a/packages/dropdown_button2/lib/src/dropdown_menu.dart +++ b/packages/dropdown_button2/lib/src/dropdown_menu.dart @@ -237,6 +237,7 @@ class _DropdownMenuState extends State<_DropdownMenu> { textDirection: widget.textDirection, ), child: Semantics( + role: SemanticsRole.menu, scopesRoute: true, namesRoute: true, explicitChildNodes: true, diff --git a/packages/dropdown_button2/lib/src/dropdown_menu_item.dart b/packages/dropdown_button2/lib/src/dropdown_menu_item.dart index 03220e4..34927e4 100644 --- a/packages/dropdown_button2/lib/src/dropdown_menu_item.dart +++ b/packages/dropdown_button2/lib/src/dropdown_menu_item.dart @@ -272,6 +272,6 @@ class _DropdownItemButtonState extends State<_DropdownItemButton> { child: child, ); } - return child; + return Semantics(role: SemanticsRole.menuItem, child: child); } } diff --git a/packages/dropdown_button2/test/dropdown_button2_test.dart b/packages/dropdown_button2/test/dropdown_button2_test.dart index 81e7a05..52e7617 100644 --- a/packages/dropdown_button2/test/dropdown_button2_test.dart +++ b/packages/dropdown_button2/test/dropdown_button2_test.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -7,7 +9,6 @@ void main() { 'Button and Menu Focus', () { final List menuItems = List.generate(10, (int index) => index); - final valueListenable = ValueNotifier(menuItems.first); final findDropdownButton = find.byType(DropdownButton2); final findDropdownButtonFormField = find.byType(DropdownButtonFormField2); @@ -16,32 +17,32 @@ void main() { final findDropdownButtonFocus = find .descendant(of: find.byType(DropdownButton2), matching: find.byType(Focus)) .first; - final findDropdownButtonText = find.descendant( - of: findDropdownButton, - matching: find.text('${valueListenable.value}'), - ); - final findSelectedMenuItemText = find.descendant( - of: findDropdownMenu, - matching: find.text('${valueListenable.value}'), - ); testWidgets('onTap should request focus for both button and selected menu item', ( WidgetTester tester, ) async { + final valueListenable = ValueNotifier(menuItems.first); + final findDropdownButtonText = find.descendant( + of: findDropdownButton, + matching: find.text('${valueListenable.value}'), + ); + final findSelectedMenuItemText = find.descendant( + of: findDropdownMenu, + matching: find.text('${valueListenable.value}'), + ); + await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Center( - child: DropdownButton2( - valueListenable: valueListenable, - items: menuItems.map>((int item) { - return DropdownItem( - value: item, - child: Text(item.toString()), - ); - }).toList(), - onChanged: (_) {}, - ), + body: DropdownButton2( + valueListenable: valueListenable, + items: menuItems.map>((int item) { + return DropdownItem( + value: item, + child: Text(item.toString()), + ); + }).toList(), + onChanged: (_) {}, ), ), ), @@ -63,20 +64,28 @@ void main() { }); testWidgets('button should stay highlighted when menu closes', (WidgetTester tester) async { + final valueListenable = ValueNotifier(menuItems.first); + final findDropdownButtonText = find.descendant( + of: findDropdownButton, + matching: find.text('${valueListenable.value}'), + ); + final findSelectedMenuItemText = find.descendant( + of: findDropdownMenu, + matching: find.text('${valueListenable.value}'), + ); + await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Center( - child: DropdownButton2( - valueListenable: valueListenable, - items: menuItems.map>((int item) { - return DropdownItem( - value: item, - child: Text(item.toString()), - ); - }).toList(), - onChanged: (_) {}, - ), + body: DropdownButton2( + valueListenable: valueListenable, + items: menuItems.map>((int item) { + return DropdownItem( + value: item, + child: Text(item.toString()), + ); + }).toList(), + onChanged: (_) {}, ), ), ), @@ -105,25 +114,24 @@ void main() { // https://github.com/AhmedLSayed9/dropdown_button2/issues/199 final GlobalKey formKey = GlobalKey(); + final valueListenable = ValueNotifier(menuItems.first); const errorMessage = 'error_message'; await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Center( - child: Form( - key: formKey, - child: DropdownButtonFormField2( - valueListenable: valueListenable, - items: menuItems.map>((int item) { - return DropdownItem( - value: item, - child: Text(item.toString()), - ); - }).toList(), - onChanged: (_) {}, - validator: (value) => errorMessage, - ), + body: Form( + key: formKey, + child: DropdownButtonFormField2( + valueListenable: valueListenable, + items: menuItems.map>((int item) { + return DropdownItem( + value: item, + child: Text(item.toString()), + ); + }).toList(), + onChanged: (_) {}, + validator: (value) => errorMessage, ), ), ), @@ -152,7 +160,6 @@ void main() { 'DropdownButtonFormField2 Error Properties', () { final List menuItems = List.generate(10, (int index) => index); - final valueListenable = ValueNotifier(menuItems.first); final findDropdownButtonFormField = find.byType(DropdownButtonFormField2); @@ -177,20 +184,19 @@ void main() { WidgetTester tester, ) async { final GlobalKey formKey = GlobalKey(); + final valueListenable = ValueNotifier(menuItems.first); const errorMessage = 'Please select a value'; await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Center( - child: Form( - key: formKey, - child: DropdownButtonFormField2( - valueListenable: valueListenable, - items: buildItems(), - onChanged: (_) {}, - validator: (int? v) => errorMessage, - ), + body: Form( + key: formKey, + child: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + validator: (int? v) => errorMessage, ), ), ), @@ -207,19 +213,18 @@ void main() { WidgetTester tester, ) async { final GlobalKey formKey = GlobalKey(); + final valueListenable = ValueNotifier(menuItems.first); await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Center( - child: Form( - key: formKey, - child: DropdownButtonFormField2( - valueListenable: valueListenable, - items: buildItems(), - onChanged: (_) {}, - validator: (int? v) => null, - ), + body: Form( + key: formKey, + child: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + validator: (int? v) => null, ), ), ), @@ -237,22 +242,21 @@ void main() { testWidgets('autovalidateMode.always should validate on first build', ( WidgetTester tester, ) async { + final valueListenable = ValueNotifier(menuItems.first); int validateCalled = 0; await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Center( - child: DropdownButtonFormField2( - valueListenable: valueListenable, - items: buildItems(), - onChanged: (_) {}, - autovalidateMode: AutovalidateMode.always, - validator: (int? value) { - validateCalled++; - return 'Error'; - }, - ), + body: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + autovalidateMode: AutovalidateMode.always, + validator: (int? value) { + validateCalled++; + return 'Error'; + }, ), ), ), @@ -265,20 +269,19 @@ void main() { testWidgets('decoration errorStyle should be applied to error text', ( WidgetTester tester, ) async { + final valueListenable = ValueNotifier(menuItems.first); const errorStyle = TextStyle(color: Colors.orange, fontSize: 20); await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Center( - child: DropdownButtonFormField2( - valueListenable: valueListenable, - items: buildItems(), - onChanged: (_) {}, - autovalidateMode: AutovalidateMode.always, - validator: (int? v) => 'Styled error', - decoration: const InputDecoration(errorStyle: errorStyle), - ), + body: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + autovalidateMode: AutovalidateMode.always, + validator: (int? v) => 'Styled error', + decoration: const InputDecoration(errorStyle: errorStyle), ), ), ), @@ -292,18 +295,18 @@ void main() { }); testWidgets('decoration errorMaxLines should be respected', (WidgetTester tester) async { + final valueListenable = ValueNotifier(menuItems.first); + await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Center( - child: DropdownButtonFormField2( - valueListenable: valueListenable, - items: buildItems(), - onChanged: (_) {}, - autovalidateMode: AutovalidateMode.always, - validator: (int? v) => 'A very long error message\nthat spans multiple lines', - decoration: const InputDecoration(errorMaxLines: 2), - ), + body: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + autovalidateMode: AutovalidateMode.always, + validator: (int? v) => 'A very long error message\nthat spans multiple lines', + decoration: const InputDecoration(errorMaxLines: 2), ), ), ), @@ -318,20 +321,20 @@ void main() { testWidgets('errorBuilder should replace default error text when provided', ( WidgetTester tester, ) async { + final valueListenable = ValueNotifier(menuItems.first); + await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Center( - child: DropdownButtonFormField2( - valueListenable: valueListenable, - items: buildItems(), - onChanged: (_) {}, - autovalidateMode: AutovalidateMode.always, - validator: (int? v) => 'Required', - errorBuilder: (BuildContext context, String errorText) { - return Text('Custom: $errorText'); - }, - ), + body: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + autovalidateMode: AutovalidateMode.always, + validator: (int? v) => 'Required', + errorBuilder: (BuildContext context, String errorText) { + return Text('Custom: $errorText'); + }, ), ), ), @@ -347,27 +350,26 @@ void main() { WidgetTester tester, ) async { final GlobalKey formKey = GlobalKey(); + final valueListenable = ValueNotifier(menuItems.first); await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Center( - child: Form( - key: formKey, - child: DropdownButtonFormField2( - valueListenable: valueListenable, - items: buildItems(), - onChanged: (_) {}, - validator: (int? v) => 'Required', - errorBuilder: (BuildContext context, String errorText) { - return Row( - children: [ - const Icon(Icons.error, color: Colors.red), - Text(errorText), - ], - ); - }, - ), + body: Form( + key: formKey, + child: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + validator: (int? v) => 'Required', + errorBuilder: (BuildContext context, String errorText) { + return Row( + children: [ + const Icon(Icons.error, color: Colors.red), + Text(errorText), + ], + ); + }, ), ), ), @@ -387,23 +389,22 @@ void main() { testWidgets('errorBuilder should not be called when there is no error', ( WidgetTester tester, ) async { + final valueListenable = ValueNotifier(menuItems.first); bool errorBuilderCalled = false; await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Center( - child: DropdownButtonFormField2( - valueListenable: valueListenable, - items: buildItems(), - onChanged: (_) {}, - autovalidateMode: AutovalidateMode.always, - validator: (int? v) => null, - errorBuilder: (BuildContext context, String errorText) { - errorBuilderCalled = true; - return Text(errorText); - }, - ), + body: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + autovalidateMode: AutovalidateMode.always, + validator: (int? v) => null, + errorBuilder: (BuildContext context, String errorText) { + errorBuilderCalled = true; + return Text(errorText); + }, ), ), ), @@ -417,16 +418,16 @@ void main() { testWidgets('forceErrorText should force field to display error', ( WidgetTester tester, ) async { + final valueListenable = ValueNotifier(menuItems.first); + await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Center( - child: DropdownButtonFormField2( - valueListenable: valueListenable, - items: buildItems(), - onChanged: (_) {}, - forceErrorText: 'Forced error', - ), + body: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + forceErrorText: 'Forced error', ), ), ), @@ -441,18 +442,17 @@ void main() { testWidgets('forceErrorText should make isValid return false', (WidgetTester tester) async { final GlobalKey> fieldKey = GlobalKey>(); + final valueListenable = ValueNotifier(menuItems.first); await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Center( - child: DropdownButtonFormField2( - key: fieldKey, - valueListenable: valueListenable, - items: buildItems(), - onChanged: (_) {}, - forceErrorText: 'Forced error', - ), + body: DropdownButtonFormField2( + key: fieldKey, + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + forceErrorText: 'Forced error', ), ), ), @@ -464,31 +464,151 @@ void main() { testWidgets( 'forceErrorText should override InputDecoration.errorText when both are provided', + (WidgetTester tester) async { + final valueListenable = ValueNotifier(menuItems.first); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownButtonFormField2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + decoration: const InputDecoration(errorText: 'Decoration error'), + forceErrorText: 'Forced error', + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Forced error'), findsOneWidget); + expect(find.text('Decoration error'), findsNothing); + }, + ); + }, + ); + + group( + 'Semantics', + () { + final List menuItems = List.generate(4, (int index) => index); + + List> buildItems() { + return menuItems.map>((int item) { + return DropdownItem( + value: item, + child: Text(item.toString()), + ); + }).toList(); + } + + testWidgets( + 'button should include expanded state semantics and update when menu opens and closes', ( WidgetTester tester, ) async { + final valueListenable = ValueNotifier(menuItems.first); + await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Center( - child: DropdownButtonFormField2( - valueListenable: valueListenable, - items: buildItems(), - onChanged: (_) {}, - decoration: const InputDecoration(errorText: 'Decoration error'), - forceErrorText: 'Forced error', - ), + body: DropdownButton2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, ), ), ), ); + // Before opening: should have expanded state but not be expanded. + expect( + tester.getSemantics(find.byType(DropdownButton2)), + matchesSemantics( + isButton: true, + hasExpandedState: true, + isFocusable: true, + hasTapAction: true, + hasFocusAction: true, + label: '${valueListenable.value}', + ), + ); + + // Open the menu. + await tester.tap(find.text('${valueListenable.value}')); + await tester.pumpAndSettle(); + + // While the menu is open, BlockSemantics in ModalBarrier blocks the + // button's semantics node (making tester.getSemantics return a stale node). + // Verify the Semantics widget's expanded property directly instead. + final expandedSemantics = tester.widget( + find.descendant( + of: find.byType(DropdownButton2), + matching: find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.expanded != null, + ), + ), + ); + expect(expandedSemantics.properties.expanded, isTrue); + + // Close the menu by selecting an item. + await tester.tap(find.text('1')); await tester.pumpAndSettle(); - expect(find.text('Forced error'), findsOneWidget); - expect(find.text('Decoration error'), findsNothing); + expect( + tester.getSemantics(find.byType(DropdownButton2)), + matchesSemantics( + isButton: true, + hasExpandedState: true, + isFocusable: true, + isFocused: true, + hasTapAction: true, + hasFocusAction: true, + label: '${valueListenable.value}', + ), + ); }, ); + + testWidgets('menu should include menu and menuItem role semantics', ( + WidgetTester tester, + ) async { + final valueListenable = ValueNotifier(menuItems.first); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DropdownButton2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.text('${valueListenable.value}')); + await tester.pumpAndSettle(); + + // Verify menu role. + expect( + find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.role == SemanticsRole.menu, + ), + findsOneWidget, + ); + + // Verify menuItem roles for each item. + expect( + find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.role == SemanticsRole.menuItem, + ), + findsNWidgets(menuItems.length), + ); + }); }, ); }