diff --git a/lib/src/components/top_app_bar/top_app_bar.dart b/lib/src/components/top_app_bar/top_app_bar.dart index a896280c..4a04f9aa 100644 --- a/lib/src/components/top_app_bar/top_app_bar.dart +++ b/lib/src/components/top_app_bar/top_app_bar.dart @@ -33,6 +33,10 @@ class ZetaTopAppBar extends ZetaStatefulWidget implements PreferredSizeWidget { onSearch = null, searchHintText = null, searchController = null, + clearSemanticLabel = null, + microphoneSemanticLabel = null, + searchBackSemanticLabel = null, + searchSemanticLabel = null, onSearchMicrophoneIconPressed = null; /// Creates a ZetaTopAppBar with centered title. @@ -54,6 +58,10 @@ class ZetaTopAppBar extends ZetaStatefulWidget implements PreferredSizeWidget { searchHintText = null, searchController = null, onSearchMicrophoneIconPressed = null, + clearSemanticLabel = null, + searchBackSemanticLabel = null, + microphoneSemanticLabel = null, + searchSemanticLabel = null, shrinks = false; /// Creates a ZetaTopAppBar with an expanding search field. @@ -74,6 +82,10 @@ class ZetaTopAppBar extends ZetaStatefulWidget implements PreferredSizeWidget { this.searchHintText, this.onSearchMicrophoneIconPressed, this.actions = const [], + this.clearSemanticLabel, + this.microphoneSemanticLabel, + this.searchSemanticLabel, + this.searchBackSemanticLabel, }) : shrinks = false, assert(type != ZetaTopAppBarType.extended, 'Search app bars cannot be extended'); @@ -98,6 +110,10 @@ class ZetaTopAppBar extends ZetaStatefulWidget implements PreferredSizeWidget { onSearch = null, searchHintText = null, onSearchMicrophoneIconPressed = null, + clearSemanticLabel = null, + microphoneSemanticLabel = null, + searchSemanticLabel = null, + searchBackSemanticLabel = null, searchController = null; /// Called when text in the search field is submitted. @@ -133,6 +149,17 @@ class ZetaTopAppBar extends ZetaStatefulWidget implements PreferredSizeWidget { /// If `ZetaTopAppBarType.extend` shrinks. Does not affect other types of app bar. final bool shrinks; + /// The semantic label for the clear icon. + final String? clearSemanticLabel; + + /// The semantic label for the microphone icon. + final String? microphoneSemanticLabel; + + final String? searchSemanticLabel; + + /// The semantic label for the back icon when search is open. + final String? searchBackSemanticLabel; + @override State createState() => _ZetaTopAppBarState(); @@ -165,12 +192,6 @@ class _ZetaTopAppBarState extends State { super.initState(); } - @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } - Widget _getTitleText(ZetaColors colors) { Widget? title = widget.title; if (widget.title is Row) { @@ -203,30 +224,36 @@ class _ZetaTopAppBarState extends State { List? _getActions(ZetaColors colors) { if (_searchActive) { return [ - IconButtonTheme( - data: IconButtonThemeData( - style: IconButton.styleFrom(iconSize: Zeta.of(context).spacing.xl), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + label: widget.clearSemanticLabel, + button: true, + child: IconButton( color: colors.cool.shade50, onPressed: () => _searchController.clearText(), - icon: const ZetaIcon(ZetaIcons.cancel), - ), - if (widget.onSearchMicrophoneIconPressed != null) ...[ - SizedBox( - height: Zeta.of(context).spacing.xl_2, - child: VerticalDivider(width: ZetaBorders.medium, color: colors.cool.shade70), + icon: ZetaIcon( + ZetaIcons.cancel, + size: Zeta.of(context).spacing.xl, ), - IconButton( + ), + ), + if (widget.onSearchMicrophoneIconPressed != null) ...[ + SizedBox( + height: Zeta.of(context).spacing.xl_2, + child: VerticalDivider(width: ZetaBorders.medium, color: colors.cool.shade70), + ), + Semantics( + label: widget.microphoneSemanticLabel, + button: true, + child: IconButton( onPressed: widget.onSearchMicrophoneIconPressed, icon: const ZetaIcon(ZetaIcons.microphone), ), - ], + ), ], - ), + ], ), ]; } @@ -234,11 +261,15 @@ class _ZetaTopAppBarState extends State { if (_searchEnabled) { return [ ...widget.actions, - IconButton( - onPressed: () => setState(() { - _searchController.startSearch(); - }), - icon: const ZetaIcon(ZetaIcons.search), + Semantics( + label: widget.searchSemanticLabel, + button: true, + child: IconButton( + onPressed: () => setState(() { + _searchController.startSearch(); + }), + icon: const ZetaIcon(ZetaIcons.search), + ), ), ]; } @@ -278,9 +309,13 @@ class _ZetaTopAppBarState extends State { Widget? leading = widget.leading; if (_searchActive) { - leading = IconButton( - onPressed: _searchController.closeSearch, - icon: const ZetaIcon(ZetaIcons.arrow_back), + leading = Semantics( + label: widget.searchBackSemanticLabel, + button: true, + child: IconButton( + onPressed: _searchController.closeSearch, + icon: const ZetaIcon(ZetaIcons.arrow_back), + ), ); } @@ -288,25 +323,21 @@ class _ZetaTopAppBarState extends State { rounded: context.rounded, child: ColoredBox( color: colors.surfacePrimary, - child: IconButtonTheme( - data: IconButtonThemeData(style: IconButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap)), - child: Padding( - padding: EdgeInsets.symmetric(horizontal: Zeta.of(context).spacing.minimum), - child: AppBar( - elevation: 0, - scrolledUnderElevation: 0, - iconTheme: IconThemeData(color: colors.cool.shade90), - leadingWidth: Zeta.of(context).spacing.xl_6, - leading: leading, - automaticallyImplyLeading: widget.automaticallyImplyLeading, - surfaceTintColor: Colors.transparent, - centerTitle: widget.type == ZetaTopAppBarType.centered, - titleTextStyle: widget.titleTextStyle == null - ? ZetaTextStyles.bodyLarge.copyWith(color: colors.textDefault) - : widget.titleTextStyle!.copyWith(color: colors.textDefault), - title: title, - actions: actions, - ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: Zeta.of(context).spacing.minimum), + child: AppBar( + elevation: 0, + scrolledUnderElevation: 0, + iconTheme: IconThemeData(color: colors.iconDefault), + leading: leading, + automaticallyImplyLeading: widget.automaticallyImplyLeading, + surfaceTintColor: Colors.transparent, + centerTitle: widget.type == ZetaTopAppBarType.centered, + titleTextStyle: widget.titleTextStyle == null + ? ZetaTextStyles.bodyLarge.copyWith(color: colors.textDefault) + : widget.titleTextStyle!.copyWith(color: colors.textDefault), + title: title, + actions: actions, ), ), ), diff --git a/test/src/components/top_app_bar/golden/top_app_bar_default.png b/test/src/components/top_app_bar/golden/top_app_bar_default.png new file mode 100644 index 00000000..e17872e8 Binary files /dev/null and b/test/src/components/top_app_bar/golden/top_app_bar_default.png differ diff --git a/test/src/components/top_app_bar/top_app_bar_test.dart b/test/src/components/top_app_bar/top_app_bar_test.dart new file mode 100644 index 00000000..82c537c4 --- /dev/null +++ b/test/src/components/top_app_bar/top_app_bar_test.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../../test_utils/test_app.dart'; +import '../../../test_utils/tolerant_comparator.dart'; +import '../../../test_utils/utils.dart'; + +void main() { + const String componentName = 'ZetaTopAppbar'; + const String parentFolder = 'top_app_bar'; + + const goldenFile = GoldenFiles(component: parentFolder); + setUpAll(() { + goldenFileComparator = TolerantComparator(goldenFile.uri); + }); + + group('$componentName Accessibility Tests', () { + testWidgets('$componentName meets accessibility requirements', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await tester.pumpWidget( + TestApp( + home: ZetaTopAppBar( + title: const Text('Title'), + actions: [ + IconButton( + icon: const Icon(Icons.search), + tooltip: 'Search', + onPressed: () {}, + ) + ], + leading: IconButton( + icon: const Icon(Icons.menu), + tooltip: 'Menu', + onPressed: () {}, + ), + ), + ), + ); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + handle.dispose(); + }); + + testWidgets('$componentName passes semantic labels to the search actions', (WidgetTester tester) async { + const microphoneSemanticLabel = 'Search with voice'; + const clearSemanticLabel = 'Clear search'; + const searchBackSemanticLabel = 'Back'; + const searchSemanticLabel = 'Search'; + final ZetaSearchController searchController = ZetaSearchController(); + await tester.pumpWidget( + TestApp( + home: ZetaTopAppBar.search( + title: const Text('Title'), + microphoneSemanticLabel: microphoneSemanticLabel, + clearSemanticLabel: clearSemanticLabel, + searchController: searchController, + searchSemanticLabel: searchSemanticLabel, + searchBackSemanticLabel: searchBackSemanticLabel, + onSearchMicrophoneIconPressed: () {}, + ), + ), + ); + + expect( + find.bySemanticsLabel(searchSemanticLabel), + findsOneWidget, + ); + + searchController.startSearch(); + await tester.pumpAndSettle(); + + expect( + find.bySemanticsLabel(microphoneSemanticLabel), + findsOneWidget, + ); + expect( + find.bySemanticsLabel(clearSemanticLabel), + findsOneWidget, + ); + expect( + find.bySemanticsLabel(searchBackSemanticLabel), + findsOneWidget, + ); + }); + }); + + group('$componentName Content Tests', () { + final debugFillProperties = { + 'titleTextStyle': 'null', + 'onSearch': 'null', + 'automaticallyImplyLeading': 'true', + 'onSearchMicrophoneIconPressed': 'null', + 'searchController': 'null', + 'searchHintText': 'null', + 'type': 'defaultAppBar', + 'shrinks': 'false', + }; + debugFillPropertiesTest( + const ZetaTopAppBar(), + debugFillProperties, + ); + }); + + group('$componentName Dimensions Tests', () {}); + group('$componentName Styling Tests', () {}); + group('$componentName Interaction Tests', () { + late ZetaSearchController searchController; + const searchLabel = 'Search'; + const clearLabel = 'Clear'; + const backLabel = 'Back'; + + late Widget subject; + + setUp(() { + searchController = ZetaSearchController(); + subject = TestApp( + home: ZetaTopAppBar.search( + title: const Text('Title'), + searchController: searchController, + searchSemanticLabel: searchLabel, + clearSemanticLabel: clearLabel, + searchBackSemanticLabel: backLabel, + ), + ); + }); + + testWidgets( + '$componentName Search opens and closes the search bar when the search/back icon is tapped', + (WidgetTester tester) async { + await tester.pumpWidget(subject); + + expect(searchController.isEnabled, isFalse); + await tester.tap(find.bySemanticsLabel(searchLabel)); + await tester.pumpAndSettle(); + + expect(searchController.isEnabled, isTrue); + + await tester.tap(find.bySemanticsLabel(backLabel)); + await tester.pumpAndSettle(); + + expect(searchController.isEnabled, isFalse); + }, + ); + + testWidgets( + '$componentName Search allows text to be typed in the search field', + (WidgetTester tester) async { + await tester.pumpWidget(subject); + + searchController.startSearch(); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Search text'); + expect(searchController.text, 'Search text'); + }, + ); + + testWidgets( + '$componentName Search gets cleared when the clear button is tapped', + (WidgetTester tester) async { + await tester.pumpWidget(subject); + + searchController.startSearch(); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Search text'); + await tester.tap(find.bySemanticsLabel(clearLabel)); + expect(searchController.text, ''); + }, + ); + + testWidgets( + '$componentName Search submits the correct text when the search input is submitted', + (WidgetTester tester) async { + String inputtedText = ''; + await tester.pumpWidget( + TestApp( + home: ZetaTopAppBar.search( + title: const Text('Title'), + searchController: searchController, + onSearch: (String text) { + inputtedText = text; + }, + ), + ), + ); + + searchController.startSearch(); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Search text'); + await tester.testTextInput.receiveAction(TextInputAction.done); + expect(inputtedText, 'Search text'); + }, + ); + }); + + group('$componentName Golden Tests', () { + goldenTest( + goldenFile, + const ZetaTopAppBar( + title: Text('Title'), + ), + ZetaTopAppBar, + 'top_app_bar_default', + ); + }); + + group('$componentName Performance Tests', () {}); +}