From fd54637acd846f83a3717fd5d9930fc89eecf97b Mon Sep 17 00:00:00 2001 From: Chengtin Tsai Date: Wed, 26 Feb 2025 11:00:19 -0800 Subject: [PATCH 1/3] Set error message for voice over --- .../ui/core/elements/CardNumberController.kt | 3 +-- .../android/ui/core/elements/CvcController.kt | 3 +-- .../uicore/elements/AddressTextFieldController.kt | 2 +- .../uicore/elements/TextFieldController.kt | 4 ++-- .../stripe/android/uicore/elements/TextFieldUI.kt | 13 +++++++++++++ .../uicore/elements/compat/CompatTextField.kt | 15 +++++++++++---- 6 files changed, 29 insertions(+), 11 deletions(-) diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberController.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberController.kt index 04d4ed3f3cb..d8f4a22599f 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberController.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberController.kt @@ -33,7 +33,6 @@ import com.stripe.android.ui.core.elements.events.LocalCardNumberCompletedEventR import com.stripe.android.uicore.elements.FieldError import com.stripe.android.uicore.elements.IdentifierSpec import com.stripe.android.uicore.elements.SectionFieldElement -import com.stripe.android.uicore.elements.SectionFieldErrorController import com.stripe.android.uicore.elements.TextFieldController import com.stripe.android.uicore.elements.TextFieldIcon import com.stripe.android.uicore.elements.TextFieldState @@ -50,7 +49,7 @@ import kotlinx.coroutines.flow.drop import kotlin.coroutines.CoroutineContext import com.stripe.android.R as PaymentsCoreR -internal sealed class CardNumberController : TextFieldController, SectionFieldErrorController { +internal sealed class CardNumberController : TextFieldController { abstract val cardBrandFlow: StateFlow abstract val selectedCardBrandFlow: StateFlow diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcController.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcController.kt index 56c52ce3a83..0037dde7f45 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcController.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcController.kt @@ -11,7 +11,6 @@ import com.stripe.android.core.strings.resolvableString import com.stripe.android.model.CardBrand import com.stripe.android.ui.core.asIndividualDigits import com.stripe.android.uicore.elements.FieldError -import com.stripe.android.uicore.elements.SectionFieldErrorController import com.stripe.android.uicore.elements.TextFieldController import com.stripe.android.uicore.elements.TextFieldIcon import com.stripe.android.uicore.elements.TextFieldState @@ -30,7 +29,7 @@ class CvcController constructor( cardBrandFlow: StateFlow, override val initialValue: String? = null, override val showOptionalLabel: Boolean = false -) : TextFieldController, SectionFieldErrorController { +) : TextFieldController { override val capitalization: KeyboardCapitalization = cvcTextFieldConfig.capitalization override val keyboardType: KeyboardType = cvcTextFieldConfig.keyboard diff --git a/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/AddressTextFieldController.kt b/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/AddressTextFieldController.kt index a67f9629a3f..d421aee887e 100644 --- a/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/AddressTextFieldController.kt +++ b/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/AddressTextFieldController.kt @@ -25,7 +25,7 @@ class AddressTextFieldController( private val config: TextFieldConfig, private val onNavigation: (() -> Unit)? = null, override val initialValue: String? = null -) : TextFieldController, InputController, SectionFieldErrorController, SectionFieldComposable { +) : TextFieldController, InputController, SectionFieldComposable { init { initialValue?.let { onRawValueChange(it) } diff --git a/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/TextFieldController.kt b/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/TextFieldController.kt index 6183c3b46ae..9254687c4cb 100644 --- a/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/TextFieldController.kt +++ b/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/TextFieldController.kt @@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow @OptIn(ExperimentalComposeUiApi::class) @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) -interface TextFieldController : InputController, SectionFieldComposable { +interface TextFieldController : InputController, SectionFieldComposable, SectionFieldErrorController { fun onValueChange(displayFormatted: String): TextFieldState? fun onFocusChange(newHasFocus: Boolean) fun onDropdownItemClicked(item: TextFieldIcon.Dropdown.Item) {} @@ -131,7 +131,7 @@ class SimpleTextFieldController( private val overrideContentDescriptionProvider: ((fieldValue: String) -> ResolvableString)? = null, private val shouldAnnounceLabel: Boolean = true, private val shouldAnnounceFieldValue: Boolean = true -) : TextFieldController, SectionFieldErrorController { +) : TextFieldController { override val trailingIcon: StateFlow = textFieldConfig.trailingIcon override val capitalization: KeyboardCapitalization = textFieldConfig.capitalization override val keyboardType: KeyboardType = textFieldConfig.keyboard diff --git a/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/TextFieldUI.kt b/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/TextFieldUI.kt index 3863d0d62fa..579ce829d27 100644 --- a/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/TextFieldUI.kt +++ b/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/TextFieldUI.kt @@ -157,6 +157,16 @@ fun TextField( val fieldState by textFieldController.fieldState.collectAsState() val label by textFieldController.label.collectAsState() + val error by textFieldController.error.collectAsState() + val sectionErrorString = error?.let { + it.formatArgs?.let { args -> + stringResource( + it.errorMessage, + *args + ) + } ?: stringResource(it.errorMessage) + } + LaunchedEffect(fieldState) { // When field is in focus and full, move to next field so the user can keep typing if (fieldState == TextFieldStateConstants.Valid.Full && hasFocus.value) { @@ -229,6 +239,7 @@ fun TextField( placeholder = placeHolder, trailingIcon = trailingIcon, shouldShowError = shouldShowError, + errorString = sectionErrorString, visualTransformation = visualTransformation, layoutDirection = textFieldController.layoutDirection, keyboardOptions = KeyboardOptions( @@ -257,6 +268,7 @@ internal fun TextFieldUi( trailingIcon: TextFieldIcon?, showOptionalLabel: Boolean, shouldShowError: Boolean, + errorString: String?, shouldAnnounceLabel: Boolean = true, modifier: Modifier = Modifier, visualTransformation: VisualTransformation = VisualTransformation.None, @@ -302,6 +314,7 @@ internal fun TextFieldUi( } }, isError = shouldShowError, + errorString = errorString, visualTransformation = visualTransformation, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, diff --git a/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/compat/CompatTextField.kt b/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/compat/CompatTextField.kt index 465f7d10e43..ec7a8074222 100644 --- a/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/compat/CompatTextField.kt +++ b/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/compat/CompatTextField.kt @@ -54,6 +54,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape @@ -139,6 +140,7 @@ internal fun CompatTextField( leadingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null, isError: Boolean = false, + errorString: String?, visualTransformation: VisualTransformation = VisualTransformation.None, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions(), @@ -160,7 +162,7 @@ internal fun CompatTextField( value = value, modifier = modifier .indicatorLine(enabled, isError, interactionSource, colors) - .defaultErrorSemantics(isError, stringResource(ComposeUiR.string.default_error_message)) + .errorSemanticsWithDefault(isError, errorString) .defaultMinSize( minWidth = TextFieldDefaults.MinWidth, minHeight = TextFieldDefaults.MinHeight @@ -447,10 +449,15 @@ private object TextFieldTransitionScope { } } -private fun Modifier.defaultErrorSemantics( +private fun Modifier.errorSemanticsWithDefault( isError: Boolean, - defaultErrorMessage: String, -): Modifier = if (isError) semantics { error(defaultErrorMessage) } else this + errorMessage: String?, +): Modifier = composed { + val defaultErrorMessage = stringResource(ComposeUiR.string.default_error_message) + if (isError) semantics { + error(errorMessage ?: defaultErrorMessage) + } else this +} private const val PlaceholderAnimationDuration = 83 private const val PlaceholderAnimationDelayOrDuration = 67 From e6b033699d5d4edd1ae150c1be548e17051d1d58 Mon Sep 17 00:00:00 2001 From: Chengtin Tsai Date: Wed, 26 Feb 2025 11:29:26 -0800 Subject: [PATCH 2/3] Add test --- .../android/paymentsheet/PaymentSheetPage.kt | 10 ++++++ .../android/paymentsheet/PaymentSheetTest.kt | 36 +++++++++++++++++++ .../elements/TextFieldUiScreenshotTest.kt | 11 ++++++ 3 files changed, 57 insertions(+) diff --git a/paymentsheet/src/androidTest/java/com/stripe/android/paymentsheet/PaymentSheetPage.kt b/paymentsheet/src/androidTest/java/com/stripe/android/paymentsheet/PaymentSheetPage.kt index a85822ce479..e41fa1c1e47 100644 --- a/paymentsheet/src/androidTest/java/com/stripe/android/paymentsheet/PaymentSheetPage.kt +++ b/paymentsheet/src/androidTest/java/com/stripe/android/paymentsheet/PaymentSheetPage.kt @@ -4,7 +4,11 @@ package com.stripe.android.paymentsheet import android.view.accessibility.AccessibilityNodeInfo import android.widget.Button +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.hasContentDescription @@ -275,3 +279,9 @@ internal class PaymentSheetPage( replaceText("Phone (optional)", phone) } } + +fun SemanticsNodeInteraction.assertHasErrorMessage(expectedMessage: String) + = assert(SemanticsMatcher("has error '$expectedMessage'") { node -> + node.config[SemanticsProperties.Error] == expectedMessage +}) + diff --git a/paymentsheet/src/androidTest/java/com/stripe/android/paymentsheet/PaymentSheetTest.kt b/paymentsheet/src/androidTest/java/com/stripe/android/paymentsheet/PaymentSheetTest.kt index 74a6603ce37..6fe7dd91635 100644 --- a/paymentsheet/src/androidTest/java/com/stripe/android/paymentsheet/PaymentSheetTest.kt +++ b/paymentsheet/src/androidTest/java/com/stripe/android/paymentsheet/PaymentSheetTest.kt @@ -1,7 +1,9 @@ package com.stripe.android.paymentsheet import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithText import com.google.testing.junit.testparameterinjector.TestParameter import com.google.testing.junit.testparameterinjector.TestParameterInjector import com.stripe.android.core.utils.urlEncode @@ -451,4 +453,38 @@ internal class PaymentSheetTest { testContext.markTestSucceeded() } + + @Test + fun testCardDetailsErrorMessageAccessibility() = runPaymentSheetTest( + networkRule = networkRule, + integrationType = integrationType, + resultCallback = ::assertCompleted, + ) { testContext -> + networkRule.enqueue( + host("api.stripe.com"), + method("GET"), + path("/v1/elements/sessions"), + ) { response -> + response.testBodyFromFile("elements-sessions-requires_payment_method.json") + } + + testContext.presentPaymentSheet { + presentWithPaymentIntent( + paymentIntentClientSecret = "pi_example_secret_example", + configuration = defaultConfiguration, + ) + } + + page.waitForText("Card number") + + page.replaceText("Card number", "4242424242424244") + composeTestRule.onNodeWithText("Card number") + .assertHasErrorMessage("Your card's number is invalid.") + + page.fillExpirationDate("11/11") + composeTestRule.onNode(hasContentDescription(value = "Expiration date", substring = true)) + .assertHasErrorMessage("Your card's expiration year is invalid.") + + testContext.markTestSucceeded() + } } diff --git a/stripe-ui-core/src/test/java/com/stripe/android/uicore/elements/TextFieldUiScreenshotTest.kt b/stripe-ui-core/src/test/java/com/stripe/android/uicore/elements/TextFieldUiScreenshotTest.kt index bb01b2169cd..3c6d98d188a 100644 --- a/stripe-ui-core/src/test/java/com/stripe/android/uicore/elements/TextFieldUiScreenshotTest.kt +++ b/stripe-ui-core/src/test/java/com/stripe/android/uicore/elements/TextFieldUiScreenshotTest.kt @@ -34,6 +34,7 @@ class TextFieldUiScreenshotTest { loading = false, placeholder = null, shouldShowError = false, + errorString = null, showOptionalLabel = false, trailingIcon = null ) @@ -50,6 +51,7 @@ class TextFieldUiScreenshotTest { loading = false, placeholder = null, shouldShowError = false, + errorString = null, showOptionalLabel = false, trailingIcon = null ) @@ -66,6 +68,7 @@ class TextFieldUiScreenshotTest { loading = false, placeholder = null, shouldShowError = true, + errorString = null, showOptionalLabel = false, trailingIcon = null ) @@ -82,6 +85,7 @@ class TextFieldUiScreenshotTest { loading = false, placeholder = null, shouldShowError = false, + errorString = null, showOptionalLabel = true, trailingIcon = null ) @@ -98,6 +102,7 @@ class TextFieldUiScreenshotTest { loading = false, placeholder = null, shouldShowError = false, + errorString = null, showOptionalLabel = false, trailingIcon = TextFieldIcon.Trailing( idRes = R.drawable.stripe_ic_search, @@ -117,6 +122,7 @@ class TextFieldUiScreenshotTest { loading = false, placeholder = null, shouldShowError = false, + errorString = null, showOptionalLabel = false, trailingIcon = TextFieldIcon.Dropdown( title = "Select an option".resolvableString, @@ -148,6 +154,7 @@ class TextFieldUiScreenshotTest { loading = false, placeholder = null, shouldShowError = false, + errorString = null, showOptionalLabel = false, trailingIcon = TextFieldIcon.Dropdown( title = "Select an option".resolvableString, @@ -179,6 +186,7 @@ class TextFieldUiScreenshotTest { loading = false, placeholder = null, shouldShowError = false, + errorString = null, showOptionalLabel = false, trailingIcon = null ) @@ -195,6 +203,7 @@ class TextFieldUiScreenshotTest { loading = false, placeholder = null, shouldShowError = false, + errorString = null, showOptionalLabel = false, trailingIcon = null ) @@ -211,6 +220,7 @@ class TextFieldUiScreenshotTest { loading = false, placeholder = "Search for someone...", shouldShowError = false, + errorString = null, showOptionalLabel = false, trailingIcon = null ) @@ -227,6 +237,7 @@ class TextFieldUiScreenshotTest { loading = false, placeholder = "Search for someone...", shouldShowError = false, + errorString = null, showOptionalLabel = false, trailingIcon = null ) From c5683baac96ff2126c8bc2925776ae546f913771 Mon Sep 17 00:00:00 2001 From: Chengtin Tsai Date: Wed, 26 Feb 2025 11:31:24 -0800 Subject: [PATCH 3/3] Fix lint --- .../stripe/android/paymentsheet/PaymentSheetPage.kt | 11 ++++++----- .../android/uicore/elements/compat/CompatTextField.kt | 10 +++++++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/paymentsheet/src/androidTest/java/com/stripe/android/paymentsheet/PaymentSheetPage.kt b/paymentsheet/src/androidTest/java/com/stripe/android/paymentsheet/PaymentSheetPage.kt index e41fa1c1e47..09d02c6e962 100644 --- a/paymentsheet/src/androidTest/java/com/stripe/android/paymentsheet/PaymentSheetPage.kt +++ b/paymentsheet/src/androidTest/java/com/stripe/android/paymentsheet/PaymentSheetPage.kt @@ -280,8 +280,9 @@ internal class PaymentSheetPage( } } -fun SemanticsNodeInteraction.assertHasErrorMessage(expectedMessage: String) - = assert(SemanticsMatcher("has error '$expectedMessage'") { node -> - node.config[SemanticsProperties.Error] == expectedMessage -}) - +fun SemanticsNodeInteraction.assertHasErrorMessage(expectedMessage: String) = + assert( + SemanticsMatcher("has error '$expectedMessage'") { node -> + node.config[SemanticsProperties.Error] == expectedMessage + } + ) diff --git a/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/compat/CompatTextField.kt b/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/compat/CompatTextField.kt index ec7a8074222..c7467539c97 100644 --- a/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/compat/CompatTextField.kt +++ b/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/compat/CompatTextField.kt @@ -454,9 +454,13 @@ private fun Modifier.errorSemanticsWithDefault( errorMessage: String?, ): Modifier = composed { val defaultErrorMessage = stringResource(ComposeUiR.string.default_error_message) - if (isError) semantics { - error(errorMessage ?: defaultErrorMessage) - } else this + if (isError) { + semantics { + error(errorMessage ?: defaultErrorMessage) + } + } else { + this + } } private const val PlaceholderAnimationDuration = 83