diff --git a/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/input_localizations.dart b/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/input_localizations.dart index 7755033b5f2..4dc1adcfc7a 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/input_localizations.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/input_localizations.dart @@ -306,6 +306,18 @@ abstract class AuthenticatorInputLocalizations { /// In en, this message translates to: /// **'Please enter the code from your registered Authenticator app'** String get totpCodePrompt; + + /// Tooltip and accessibility label for the button that reveals the hidden password in a password field. + /// + /// In en, this message translates to: + /// **'Show password'** + String get showPassword; + + /// Tooltip and accessibility label for the button that hides the visible password in a password field. + /// + /// In en, this message translates to: + /// **'Hide password'** + String get hidePassword; } class _AuthenticatorInputLocalizationsDelegate diff --git a/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/input_localizations_en.dart b/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/input_localizations_en.dart index beef6169605..8fcdd3a15e7 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/input_localizations_en.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/l10n/generated/input_localizations_en.dart @@ -153,4 +153,10 @@ class AuthenticatorInputLocalizationsEn @override String get totpCodePrompt => 'Please enter the code from your registered Authenticator app'; + + @override + String get showPassword => 'Show password'; + + @override + String get hidePassword => 'Hide password'; } diff --git a/packages/authenticator/amplify_authenticator/lib/src/l10n/input_resolver.dart b/packages/authenticator/amplify_authenticator/lib/src/l10n/input_resolver.dart index 81c4b51cff8..a23bc712e09 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/l10n/input_resolver.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/l10n/input_resolver.dart @@ -47,6 +47,8 @@ enum InputResolverKeyType { passwordRequirements, format, mismatch, + showPasswordTooltip, + hidePasswordTooltip, } class InputResolverKey { @@ -388,6 +390,16 @@ class InputResolverKey { field: InputField.usernameType, ); + static const showPasswordTooltip = InputResolverKey._( + InputResolverKeyType.showPasswordTooltip, + field: InputField.password, + ); + + static const hidePasswordTooltip = InputResolverKey._( + InputResolverKeyType.hidePasswordTooltip, + field: InputField.password, + ); + String resolve(BuildContext context, InputResolver inputResolver) => inputResolver.resolve(context, this); } @@ -552,6 +564,16 @@ class InputResolver extends Resolver { return AuthenticatorLocalizations.inputsOf(context).optional(title); } + /// Returns the tooltip text for showing a hidden password. + String showPasswordTooltip(BuildContext context) { + return AuthenticatorLocalizations.inputsOf(context).showPassword; + } + + /// Returns the tooltip text for hiding a visible password. + String hidePasswordTooltip(BuildContext context) { + return AuthenticatorLocalizations.inputsOf(context).hidePassword; + } + @override String resolve(BuildContext context, InputResolverKey key) { switch (key.type) { @@ -571,6 +593,10 @@ class InputResolver extends Resolver { return AuthenticatorLocalizations.inputsOf(context).passwordsDoNotMatch; case InputResolverKeyType.format: return format(context, key.field); + case InputResolverKeyType.showPasswordTooltip: + return showPasswordTooltip(context); + case InputResolverKeyType.hidePasswordTooltip: + return hidePasswordTooltip(context); } } } diff --git a/packages/authenticator/amplify_authenticator/lib/src/l10n/src/inputs/inputs_en.arb b/packages/authenticator/amplify_authenticator/lib/src/l10n/src/inputs/inputs_en.arb index e32dd76731d..70fd9cbb7ae 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/l10n/src/inputs/inputs_en.arb +++ b/packages/authenticator/amplify_authenticator/lib/src/l10n/src/inputs/inputs_en.arb @@ -221,4 +221,12 @@ "@selectEmail": { "description": "Label for the radio button to select email as the user's chosen MFA method." }, + "showPassword": "Show password", + "@showPassword": { + "description": "Tooltip and accessibility label for the button that reveals the hidden password in a password field." + }, + "hidePassword": "Hide password", + "@hidePassword": { + "description": "Tooltip and accessibility label for the button that hides the visible password in a password field." + } } diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/authenticator_banner.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/authenticator_banner.dart index 7e1a4a57b6b..5050f50614e 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/authenticator_banner.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/authenticator_banner.dart @@ -25,17 +25,20 @@ MaterialBanner createMaterialBanner( key: keyAuthenticatorBanner, backgroundColor: colorsChoices.background, leading: Icon(type.icon, color: colorsChoices.foreground), - content: Center( - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Text( - message.trim(), - style: TextStyle(color: colorsChoices.foreground), + content: Semantics( + liveRegion: true, + child: Center( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + message.trim(), + style: TextStyle(color: colorsChoices.foreground), + ), ), - ), - ], + ], + ), ), ), actions: [ @@ -70,18 +73,21 @@ SnackBar createSnackBar( return SnackBar( key: keyAuthenticatorBanner, backgroundColor: colorsChoices.background, - content: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(type.icon, color: fallbackIconColor), - const SizedBox(width: 16), - Expanded( - child: Text( - message.trim(), - style: TextStyle(color: colorsChoices.foreground), + content: Semantics( + liveRegion: true, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(type.icon, color: fallbackIconColor), + const SizedBox(width: 16), + Expanded( + child: Text( + message.trim(), + style: TextStyle(color: colorsChoices.foreground), + ), ), - ), - ], + ], + ), ), ); } diff --git a/packages/authenticator/amplify_authenticator/lib/src/widgets/form.dart b/packages/authenticator/amplify_authenticator/lib/src/widgets/form.dart index 25bbb022a38..49147d609ab 100644 --- a/packages/authenticator/amplify_authenticator/lib/src/widgets/form.dart +++ b/packages/authenticator/amplify_authenticator/lib/src/widgets/form.dart @@ -145,6 +145,7 @@ class AuthenticatorFormState return ValueListenableBuilder( valueListenable: obscureTextToggleValue, builder: (BuildContext context, bool toggleObscureText, Widget? _) { + final inputResolver = stringResolver.inputs; return IconButton( onPressed: () { obscureTextToggleValue.value = !toggleObscureText; @@ -152,6 +153,9 @@ class AuthenticatorFormState icon: Icon( toggleObscureText ? Icons.visibility : Icons.visibility_off, ), + tooltip: toggleObscureText + ? inputResolver.showPasswordTooltip(context) + : inputResolver.hidePasswordTooltip(context), ); }, ); diff --git a/packages/authenticator/amplify_authenticator/test/authenticator_accessibility_test.dart b/packages/authenticator/amplify_authenticator/test/authenticator_accessibility_test.dart new file mode 100644 index 00000000000..ac7595d0c41 --- /dev/null +++ b/packages/authenticator/amplify_authenticator/test/authenticator_accessibility_test.dart @@ -0,0 +1,142 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_authenticator/amplify_authenticator.dart'; +import 'package:amplify_authenticator/src/enums/status_type.dart'; +import 'package:amplify_authenticator/src/keys.dart'; +import 'package:amplify_authenticator/src/widgets/authenticator_banner.dart'; +import 'package:amplify_authenticator_test/amplify_authenticator_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(TestWidgetsFlutterBinding.ensureInitialized); + + group('Password visibility toggle accessibility (WCAG 4.1.2)', () { + testWidgets( + 'exposes a "Show password" accessible name while the password is hidden', + (tester) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget(const MockAuthenticatorApp()); + await tester.pumpAndSettle(); + + SignInPage(tester: tester).expectStep(AuthenticatorStep.signIn); + + // Password starts obscured, so the toggle offers to show it + expect(find.byTooltip('Show password'), findsOneWidget); + expect(find.byTooltip('Hide password'), findsNothing); + + // The accessibility node carries the "Show password" label + expect( + tester.getSemantics(find.byTooltip('Show password')), + isSemantics(tooltip: 'Show password'), + ); + + semantics.dispose(); + }, + ); + + testWidgets( + 'flips the accessible name to "Hide password" once the password is shown', + (tester) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget(const MockAuthenticatorApp()); + await tester.pumpAndSettle(); + + SignInPage(tester: tester).expectStep(AuthenticatorStep.signIn); + + // Reveal the password + await tester.tap(find.byTooltip('Show password')); + await tester.pumpAndSettle(); + + // Password is now visible, so the toggle offers to hide it + expect(find.byTooltip('Hide password'), findsOneWidget); + expect(find.byTooltip('Show password'), findsNothing); + + // The accessibility node now carries the "Hide password" label + expect( + tester.getSemantics(find.byTooltip('Hide password')), + isSemantics(tooltip: 'Hide password'), + ); + + semantics.dispose(); + }, + ); + }); + + group('Status banner screen-reader announcements', () { + testWidgets( + 'MaterialBanner content is wrapped in a live-region Semantics node', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => createMaterialBanner( + context, + type: StatusType.error, + message: 'Something went wrong', + actionCallback: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final banner = find.byKey(keyAuthenticatorBanner); + expect(banner, findsOneWidget); + + final liveRegion = find.descendant( + of: banner, + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && (widget.properties.liveRegion ?? false), + ), + ); + expect(liveRegion, findsOneWidget); + }, + ); + + testWidgets('SnackBar content is wrapped in a live-region Semantics node', ( + tester, + ) async { + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + capturedContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + + // A SnackBar only builds its content once shown via the messenger + ScaffoldMessenger.of(capturedContext).showSnackBar( + createSnackBar( + capturedContext, + type: StatusType.error, + message: 'Something went wrong', + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 750)); + + // Flutter's SnackBar adds its own live-region, so allow more than one + final liveRegionAroundMessage = find.ancestor( + of: find.text('Something went wrong'), + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && (widget.properties.liveRegion ?? false), + ), + ); + expect(liveRegionAroundMessage, findsWidgets); + }); + }); +}