Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix voice over for invalid input #10272

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
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 @@ -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
Expand All @@ -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<CardBrand>

abstract val selectedCardBrandFlow: StateFlow<CardBrand>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,7 +29,7 @@ class CvcController constructor(
cardBrandFlow: StateFlow<CardBrand>,
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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.stripe.android.paymentsheet

import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
Expand All @@ -15,6 +18,9 @@ internal class FormPage(
private val composeTestRule: ComposeTestRule,
) {
val cardNumber: SemanticsNodeInteraction = nodeWithLabel("Card number")
val expirationDate: SemanticsNodeInteraction = composeTestRule.onNode(
hasContentDescription(value = "Expiration date", substring = true)
)
val title: SemanticsNodeInteraction = composeTestRule.onNodeWithTag(TEST_TAG_HEADER_TITLE)
val headerIcon: SemanticsNodeInteraction = composeTestRule.onNodeWithTag(TEST_TAG_ICON_FROM_RES)

Expand Down Expand Up @@ -71,3 +77,17 @@ internal class FormPage(
replaceText(cardNumber, number)
}
}

fun SemanticsNodeInteraction.assertHasErrorMessage(expectedMessage: String) =
assert(
SemanticsMatcher("has error '$expectedMessage'") { node ->
node.config[SemanticsProperties.Error] == expectedMessage
}
)

fun SemanticsNodeInteraction.assertHasNoErrorMessage() =
assert(
SemanticsMatcher("has no error ") { node ->
node.config.contains(SemanticsProperties.Error).not()
}
)
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import com.stripe.android.model.PaymentMethod
import com.stripe.android.paymentsheet.FormPage
import com.stripe.android.paymentsheet.PaymentSheet
import com.stripe.android.paymentsheet.ViewActionRecorder
import com.stripe.android.paymentsheet.assertHasErrorMessage
import com.stripe.android.paymentsheet.assertHasNoErrorMessage
import com.stripe.android.paymentsheet.paymentdatacollection.FormArguments
import com.stripe.android.paymentsheet.ui.FORM_ELEMENT_TEST_TAG
import com.stripe.android.ui.core.Amount
Expand Down Expand Up @@ -90,6 +92,24 @@ internal class VerticalModeFormUITest {
formPage.title.assert(hasText("Klarna"))
}

@Test
fun testCardNumberErrorMessage() = runScenario(createCardState(customerHasSavedPaymentMethods = true)) {
formPage.cardNumber.assertHasNoErrorMessage()
formPage.fillCardNumber("4242424242424244")
formPage.cardNumber.assertHasErrorMessage("Your card's number is invalid.")
formPage.fillCardNumber("4242424242424242")
formPage.cardNumber.assertHasNoErrorMessage()
}

@Test
fun testExpirationDateErrorMessage() = runScenario(createCardState(customerHasSavedPaymentMethods = true)) {
formPage.expirationDate.assertHasNoErrorMessage()
formPage.expirationDate.performTextReplacement("1111")
formPage.expirationDate.assertHasErrorMessage("Your card's expiration year is invalid.")
formPage.expirationDate.performTextReplacement("1151")
formPage.expirationDate.assertHasNoErrorMessage()
}

private fun runScenario(
initialState: VerticalModeFormInteractor.State,
block: Scenario.() -> Unit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down Expand Up @@ -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<TextFieldIcon?> = textFieldConfig.trailingIcon
override val capitalization: KeyboardCapitalization = textFieldConfig.capitalization
override val keyboardType: KeyboardType = textFieldConfig.keyboard
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -229,6 +239,7 @@ fun TextField(
placeholder = placeHolder,
trailingIcon = trailingIcon,
shouldShowError = shouldShowError,
errorMessage = sectionErrorString,
visualTransformation = visualTransformation,
layoutDirection = textFieldController.layoutDirection,
keyboardOptions = KeyboardOptions(
Expand Down Expand Up @@ -257,6 +268,7 @@ internal fun TextFieldUi(
trailingIcon: TextFieldIcon?,
showOptionalLabel: Boolean,
shouldShowError: Boolean,
errorMessage: String?,
shouldAnnounceLabel: Boolean = true,
modifier: Modifier = Modifier,
visualTransformation: VisualTransformation = VisualTransformation.None,
Expand Down Expand Up @@ -302,6 +314,7 @@ internal fun TextFieldUi(
}
},
isError = shouldShowError,
errorMessage = errorMessage,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -100,6 +101,8 @@ import androidx.compose.ui.R as ComposeUiR
* container
* @param isError indicates if the text field's current value is in error state. If set to
* true, the label, bottom indicator and trailing icon by default will be displayed in error color
* @param errorMessage defines what would be announced in TalkBack when the text field is in error.
* If not provided, a default error message will be used.
* @param visualTransformation transforms the visual representation of the input [value].
* For example, you can use
* [PasswordVisualTransformation][androidx.compose.ui.text.input.PasswordVisualTransformation] to
Expand Down Expand Up @@ -139,6 +142,7 @@ internal fun CompatTextField(
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
isError: Boolean = false,
errorMessage: String?,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions(),
Expand All @@ -160,7 +164,7 @@ internal fun CompatTextField(
value = value,
modifier = modifier
.indicatorLine(enabled, isError, interactionSource, colors)
.defaultErrorSemantics(isError, stringResource(ComposeUiR.string.default_error_message))
.errorSemanticsWithDefault(isError, errorMessage)
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = TextFieldDefaults.MinHeight
Expand Down Expand Up @@ -447,10 +451,19 @@ 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
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class TextFieldUiScreenshotTest {
loading = false,
placeholder = null,
shouldShowError = false,
errorMessage = null,
showOptionalLabel = false,
trailingIcon = null
)
Expand All @@ -50,6 +51,7 @@ class TextFieldUiScreenshotTest {
loading = false,
placeholder = null,
shouldShowError = false,
errorMessage = null,
showOptionalLabel = false,
trailingIcon = null
)
Expand All @@ -66,6 +68,7 @@ class TextFieldUiScreenshotTest {
loading = false,
placeholder = null,
shouldShowError = true,
errorMessage = null,
showOptionalLabel = false,
trailingIcon = null
)
Expand All @@ -82,6 +85,7 @@ class TextFieldUiScreenshotTest {
loading = false,
placeholder = null,
shouldShowError = false,
errorMessage = null,
showOptionalLabel = true,
trailingIcon = null
)
Expand All @@ -98,6 +102,7 @@ class TextFieldUiScreenshotTest {
loading = false,
placeholder = null,
shouldShowError = false,
errorMessage = null,
showOptionalLabel = false,
trailingIcon = TextFieldIcon.Trailing(
idRes = R.drawable.stripe_ic_search,
Expand All @@ -117,6 +122,7 @@ class TextFieldUiScreenshotTest {
loading = false,
placeholder = null,
shouldShowError = false,
errorMessage = null,
showOptionalLabel = false,
trailingIcon = TextFieldIcon.Dropdown(
title = "Select an option".resolvableString,
Expand Down Expand Up @@ -148,6 +154,7 @@ class TextFieldUiScreenshotTest {
loading = false,
placeholder = null,
shouldShowError = false,
errorMessage = null,
showOptionalLabel = false,
trailingIcon = TextFieldIcon.Dropdown(
title = "Select an option".resolvableString,
Expand Down Expand Up @@ -179,6 +186,7 @@ class TextFieldUiScreenshotTest {
loading = false,
placeholder = null,
shouldShowError = false,
errorMessage = null,
showOptionalLabel = false,
trailingIcon = null
)
Expand All @@ -195,6 +203,7 @@ class TextFieldUiScreenshotTest {
loading = false,
placeholder = null,
shouldShowError = false,
errorMessage = null,
showOptionalLabel = false,
trailingIcon = null
)
Expand All @@ -211,6 +220,7 @@ class TextFieldUiScreenshotTest {
loading = false,
placeholder = "Search for someone...",
shouldShowError = false,
errorMessage = null,
showOptionalLabel = false,
trailingIcon = null
)
Expand All @@ -227,6 +237,7 @@ class TextFieldUiScreenshotTest {
loading = false,
placeholder = "Search for someone...",
shouldShowError = false,
errorMessage = null,
showOptionalLabel = false,
trailingIcon = null
)
Expand Down
Loading