Skip to content

Commit

Permalink
Fix PrimaryButton accessibility area and content description (#9889)
Browse files Browse the repository at this point in the history
* Fix PrimaryButton accessibility area and content description

* Slight change to pass primary button label test in vertical mode

* Polish tests & comments

* Further clarification on disabling semantic visibility of LabelUI

* Initialize AccessibilityNodeInfo to reflect button's enabled state

* Use direct view assertions instead of espresso matches
  • Loading branch information
cttsai-stripe authored Jan 13, 2025
1 parent 7d51a49 commit 5ca3b70
Show file tree
Hide file tree
Showing 3 changed files with 79 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

package com.stripe.android.paymentsheet

import android.view.accessibility.AccessibilityNodeInfo
import android.widget.Button
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
Expand All @@ -19,6 +21,9 @@ import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTextReplacement
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.matcher.ViewMatchers.withId
import com.google.common.truth.Truth.assertThat
import com.stripe.android.paymentsheet.ui.FORM_ELEMENT_TEST_TAG
import com.stripe.android.paymentsheet.ui.PAYMENT_SHEET_PRIMARY_BUTTON_TEST_TAG
import com.stripe.android.paymentsheet.ui.TEST_TAG_LIST
Expand Down Expand Up @@ -152,6 +157,21 @@ internal class PaymentSheetPage(
.performClick()
}

fun assertPrimaryButton(expectedContentDescription: String, canPay: Boolean) {
onView(withId(R.id.primary_button)).check { view, _ ->
val nodeInfo = AccessibilityNodeInfo()
view.onInitializeAccessibilityNodeInfo(nodeInfo)
assertThat(nodeInfo.contentDescription).isEqualTo(expectedContentDescription)
assertThat(nodeInfo.className).isEqualTo(Button::class.java.name)
if (canPay) {
assertThat(nodeInfo.isClickable).isTrue()
assertThat(nodeInfo.isEnabled).isTrue()
} else {
assertThat(nodeInfo.isEnabled).isFalse()
}
}
}

fun fillCvcRecollection(cvc: String) {
waitForText("Confirm your CVC")
composeTestRule
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -364,4 +364,42 @@ internal class PaymentSheetTest {
page.fillCvcRecollection("123")
page.clickPrimaryButton()
}

@Test
fun testPrimaryButtonAccessibility() = 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.fillOutCardDetails()

page.assertPrimaryButton(
expectedContentDescription = "Pay \$50.99",
canPay = true
)

page.clearCard()

page.assertPrimaryButton(
expectedContentDescription = "Pay \$50.99",
canPay = false
)

testContext.markTestSucceeded()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,21 @@ import android.content.res.ColorStateList
import android.graphics.drawable.GradientDrawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.accessibility.AccessibilityNodeInfo
import android.widget.Button
import android.widget.FrameLayout
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.semantics.invisibleToUser
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.withStyledAttributes
Expand Down Expand Up @@ -207,6 +212,13 @@ internal class PrimaryButton @JvmOverloads constructor(
updateAlpha()
}

override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo?) {
super.onInitializeAccessibilityNodeInfo(info)
// Indicate this custom view is a button, so TalkBack can announce it as such.
info?.className = Button::class.java.name
info?.isEnabled = isEnabled
}

fun updateUiState(uiState: UIState?) {
isVisible = uiState != null

Expand All @@ -220,6 +232,8 @@ internal class PrimaryButton @JvmOverloads constructor(
lockVisible = uiState.lockVisible
viewBinding.lockIcon.isVisible = lockVisible
setOnClickListener { uiState.onClick() }

contentDescription = uiState.label.resolve(context)
}
}

Expand Down Expand Up @@ -282,6 +296,7 @@ internal class PrimaryButton @JvmOverloads constructor(
)
}

@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun LabelUI(label: String, color: Int?) {
StripeTheme {
Expand All @@ -292,6 +307,12 @@ private fun LabelUI(label: String, color: Int?) {
style = StripeTheme.primaryButtonStyle.getComposeTextStyle(),
modifier = Modifier
.padding(start = 4.dp, end = 4.dp, top = 4.dp, bottom = 5.dp)
.semantics {
// This shouldn't be visible for accessibility purposes
// due to the content description and the click listener
// being defined outside of compose, in PrimaryButton.
invisibleToUser()
}
)
}
}

0 comments on commit 5ca3b70

Please sign in to comment.