Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ enum InputResolverKeyType {
passwordRequirements,
format,
mismatch,
showPasswordTooltip,
hidePasswordTooltip,
}

class InputResolverKey {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -552,6 +564,16 @@ class InputResolver extends Resolver<InputResolverKey> {
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) {
Expand All @@ -571,6 +593,10 @@ class InputResolver extends Resolver<InputResolverKey> {
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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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),
),
),
),
],
],
),
),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,17 @@ class AuthenticatorFormState<T extends AuthenticatorForm>
return ValueListenableBuilder<bool>(
valueListenable: obscureTextToggleValue,
builder: (BuildContext context, bool toggleObscureText, Widget? _) {
final inputResolver = stringResolver.inputs;
return IconButton(
onPressed: () {
obscureTextToggleValue.value = !toggleObscureText;
},
icon: Icon(
toggleObscureText ? Icons.visibility : Icons.visibility_off,
),
tooltip: toggleObscureText
? inputResolver.showPasswordTooltip(context)
: inputResolver.hidePasswordTooltip(context),
);
},
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Loading