PM-29871: bug: Add external link callouts (#6634)

This commit is contained in:
David Perez authored and GitHub committed 2026-03-10 20:53:16 +00:00
1 parent 9e3360e421
commit 44f5f614b6
16 files changed
+96 -11

No files matched your search

@@ -169,6 +169,7 @@ private fun SetupBrowserAutofillContent(
label = stringResource(id = BitwardenString.why_is_this_step_required),
style = BitwardenTheme.typography.labelMedium,
onClick = onWhyIsThisStepRequiredClick,
isExternalLink = true,
modifier = Modifier
.wrapContentWidth()
.align(alignment = Alignment.CenterHorizontally)
@@ -18,6 +18,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -157,7 +159,9 @@ fun BitwardenListItem(
selectionItems = {
selectionDataList.forEach { itemData ->
BitwardenBasicDialogRow(
modifier = Modifier.testTag("AlertSelectionOption"),
modifier = Modifier
.semantics { contentDescription = itemData.contentDescription }
.testTag(tag = "AlertSelectionOption"),
text = itemData.text,
onClick = {
shouldShowDialog = false
@@ -171,11 +175,12 @@ fun BitwardenListItem(
}
/**
* Wrapper for the an individual selection item's data.
* Wrapper for an individual selection item's data.
*/
data class SelectionItemData(
val text: String,
val onClick: () -> Unit,
val contentDescription: String = text,
val testTag: String? = null,
)
@@ -140,6 +140,7 @@ fun SearchContent(
.map { option ->
SelectionItemData(
text = option.title(),
contentDescription = option.contentDescription(),
onClick = {
when (option) {
is ListingItemOverflowAction.SendAction.DeleteClick -> {
@@ -201,11 +201,20 @@ private fun AutoFillScreenContent(
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(height = 8.dp))
val autofillServicesLabel = stringResource(id = BitwardenString.autofill_services)
BitwardenSwitch(
label = stringResource(id = BitwardenString.autofill_services),
label = autofillServicesLabel,
supportingText = stringResource(
id = BitwardenString.autofill_services_explanation_long,
),
contentDescription = if (state.isAutoFillServicesEnabled) {
autofillServicesLabel
} else {
stringResource(
id = BitwardenString.external_link_format,
formatArgs = arrayOf(autofillServicesLabel),
)
},
isChecked = state.isAutoFillServicesEnabled,
onCheckedChange = autoFillHandlers.onAutofillServicesClick,
cardStyle = CardStyle.Full,
@@ -397,7 +406,7 @@ private fun AutofillCallToActionCard(
trailingContent = {
Icon(
painter = rememberVectorPainter(BitwardenDrawable.ic_chevron_right),
contentDescription = null,
contentDescription = stringResource(id = BitwardenString.external_link),
tint = BitwardenTheme.colorScheme.icon.primary,
)
},
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.base.util.cardStyle
@@ -13,6 +14,7 @@ import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.base.util.toListItemCardStyle
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.toggle.BitwardenSwitch
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserPackage
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.browser.model.BrowserAutofillSettingsOption
@@ -44,6 +46,10 @@ fun BrowserAutofillSettingsCard(
onCheckedChange = {
onOptionClicked(option.browserPackage)
},
contentDescription = stringResource(
id = BitwardenString.external_link_format,
formatArgs = arrayOf(option.optionText()),
),
cardStyle = when {
supportingText == null -> options.toListItemCardStyle(index = index)
index == 0 -> CardStyle.Top(dividerPadding = 16.dp)
@@ -163,6 +163,7 @@ private fun FlightRecorderContent(
onClick = onHelpCenterClick,
color = BitwardenTheme.colorScheme.text.secondary,
style = BitwardenTheme.typography.bodySmall,
isExternalLink = true,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
@@ -73,6 +73,7 @@ fun ImportLoginsInstructionStep(
accessibilityString = stringResource(id = BitwardenString.import_help),
onClick = onHelpClick,
style = BitwardenTheme.typography.bodySmall,
isExternalLink = true,
modifier = Modifier.standardHorizontalMargin(),
)
Spacer(Modifier.height(24.dp))
@@ -515,7 +515,10 @@ private fun UriField(
if (uriData.isLaunchable) {
BitwardenStandardIconButton(
vectorIconRes = BitwardenDrawable.ic_external_link,
contentDescription = stringResource(id = BitwardenString.launch),
contentDescription = stringResource(
id = BitwardenString.external_link_format,
formatArgs = arrayOf(stringResource(id = BitwardenString.launch)),
),
onClick = { onLaunchUriClick(uriData.uri) },
modifier = Modifier.testTag(tag = "LoginLaunchUriButton"),
)
@@ -261,6 +261,7 @@ fun VaultItemListingContent(
.map { option ->
SelectionItemData(
text = option.title(),
contentDescription = option.contentDescription(),
onClick = {
when (option) {
is ListingItemOverflowAction.SendAction.DeleteClick -> {
@@ -18,6 +18,11 @@ sealed class ListingItemOverflowAction : Parcelable {
*/
abstract val title: Text
/**
* The content description of the option.
*/
abstract val contentDescription: Text
/**
* Represents the send actions.
*/
@@ -31,6 +36,7 @@ sealed class ListingItemOverflowAction : Parcelable {
val sendType: SendType,
) : SendAction() {
override val title: Text get() = BitwardenString.view.asText()
override val contentDescription: Text get() = title
}
/**
@@ -42,6 +48,7 @@ sealed class ListingItemOverflowAction : Parcelable {
val sendType: SendType,
) : SendAction() {
override val title: Text get() = BitwardenString.edit.asText()
override val contentDescription: Text get() = title
}
/**
@@ -50,6 +57,7 @@ sealed class ListingItemOverflowAction : Parcelable {
@Parcelize
data class CopyUrlClick(val sendUrl: String) : SendAction() {
override val title: Text get() = BitwardenString.copy_link.asText()
override val contentDescription: Text get() = title
}
/**
@@ -58,6 +66,7 @@ sealed class ListingItemOverflowAction : Parcelable {
@Parcelize
data class ShareUrlClick(val sendUrl: String) : SendAction() {
override val title: Text get() = BitwardenString.share_link.asText()
override val contentDescription: Text get() = title
}
/**
@@ -66,6 +75,7 @@ sealed class ListingItemOverflowAction : Parcelable {
@Parcelize
data class RemovePasswordClick(val sendId: String) : SendAction() {
override val title: Text get() = BitwardenString.remove_password.asText()
override val contentDescription: Text get() = title
}
/**
@@ -74,6 +84,7 @@ sealed class ListingItemOverflowAction : Parcelable {
@Parcelize
data class DeleteClick(val sendId: String) : SendAction() {
override val title: Text get() = BitwardenString.delete.asText()
override val contentDescription: Text get() = title
}
}
@@ -97,6 +108,7 @@ sealed class ListingItemOverflowAction : Parcelable {
override val requiresPasswordReprompt: Boolean,
) : VaultAction() {
override val title: Text get() = BitwardenString.view.asText()
override val contentDescription: Text get() = title
}
/**
@@ -109,6 +121,7 @@ sealed class ListingItemOverflowAction : Parcelable {
override val requiresPasswordReprompt: Boolean,
) : VaultAction() {
override val title: Text get() = BitwardenString.edit.asText()
override val contentDescription: Text get() = title
}
/**
@@ -118,6 +131,7 @@ sealed class ListingItemOverflowAction : Parcelable {
data class CopyUsernameClick(val username: String) : VaultAction() {
override val title: Text get() = BitwardenString.copy_username.asText()
override val requiresPasswordReprompt: Boolean get() = false
override val contentDescription: Text get() = title
}
/**
@@ -129,6 +143,7 @@ sealed class ListingItemOverflowAction : Parcelable {
override val requiresPasswordReprompt: Boolean,
) : VaultAction() {
override val title: Text get() = BitwardenString.copy_password.asText()
override val contentDescription: Text get() = title
}
/**
@@ -140,6 +155,7 @@ sealed class ListingItemOverflowAction : Parcelable {
override val requiresPasswordReprompt: Boolean,
) : VaultAction() {
override val title: Text get() = BitwardenString.copy_totp.asText()
override val contentDescription: Text get() = title
}
/**
@@ -151,6 +167,7 @@ sealed class ListingItemOverflowAction : Parcelable {
override val requiresPasswordReprompt: Boolean,
) : VaultAction() {
override val title: Text get() = BitwardenString.copy_number.asText()
override val contentDescription: Text get() = title
}
/**
@@ -162,6 +179,7 @@ sealed class ListingItemOverflowAction : Parcelable {
override val requiresPasswordReprompt: Boolean,
) : VaultAction() {
override val title: Text get() = BitwardenString.copy_security_code.asText()
override val contentDescription: Text get() = title
}
/**
@@ -173,6 +191,7 @@ sealed class ListingItemOverflowAction : Parcelable {
override val requiresPasswordReprompt: Boolean,
) : VaultAction() {
override val title: Text get() = BitwardenString.copy_notes.asText()
override val contentDescription: Text get() = title
}
/**
@@ -182,6 +201,8 @@ sealed class ListingItemOverflowAction : Parcelable {
data class LaunchClick(val url: String) : VaultAction() {
override val title: Text get() = BitwardenString.launch.asText()
override val requiresPasswordReprompt: Boolean get() = false
override val contentDescription: Text
get() = BitwardenString.external_link_format.asText(title)
}
/**
@@ -191,6 +212,7 @@ sealed class ListingItemOverflowAction : Parcelable {
data class ArchiveClick(val cipherId: String) : VaultAction() {
override val title: Text get() = BitwardenString.archive_verb.asText()
override val requiresPasswordReprompt: Boolean get() = true
override val contentDescription: Text get() = title
}
/**
@@ -200,6 +222,7 @@ sealed class ListingItemOverflowAction : Parcelable {
data class UnarchiveClick(val cipherId: String) : VaultAction() {
override val title: Text get() = BitwardenString.unarchive.asText()
override val requiresPasswordReprompt: Boolean get() = true
override val contentDescription: Text get() = title
}
}
}
@@ -53,6 +53,7 @@ fun VaultEntryListItem(
.map { option ->
SelectionItemData(
text = option.title(),
contentDescription = option.contentDescription(),
onClick = { onOverflowOptionClick(option) },
)
}
@@ -77,7 +77,7 @@ class FlightRecorderScreenTest : BitwardenComposeTest() {
composeTestRule
.onNodeWithText(text = "Bitwarden help center", substring = true)
.performScrollTo()
.performCustomAccessibilityAction(label = "Bitwarden help center")
.performCustomAccessibilityAction(label = "Bitwarden help center, External link")
verify(exactly = 1) {
viewModel.trySendAction(FlightRecorderAction.HelpCenterClick)
}
@@ -2367,7 +2367,7 @@ class VaultItemScreenTest : BitwardenComposeTest() {
composeTestRule
.onNodeWithTextAfterScroll(uriData.uri)
.onChildren()
.filterToOne(hasContentDescription("Launch"))
.filterToOne(hasContentDescription("Launch, External link"))
.assertIsDisplayed()
mutableStateFlow.update { currentState ->
@@ -2379,7 +2379,7 @@ class VaultItemScreenTest : BitwardenComposeTest() {
composeTestRule
.onNodeWithTextAfterScroll(uriData.uri)
.onSiblings()
.filterToOne(hasContentDescription("Launch"))
.filterToOne(hasContentDescription("Launch, External link"))
.assertDoesNotExist()
}
@@ -2437,7 +2437,7 @@ class VaultItemScreenTest : BitwardenComposeTest() {
composeTestRule
.onNodeWithTextAfterScroll(uriData.uri)
.onChildren()
.filterToOne(hasContentDescription("Launch"))
.filterToOne(hasContentDescription("Launch, External link"))
.performClick()
verify {
@@ -17,6 +17,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.base.util.nullableTestTag
@@ -202,6 +204,7 @@ fun ColumnScope.BitwardenMultiSelectDialogContent(
is MultiSelectOption.Header -> {
Column(
modifier = Modifier
.semantics(mergeDescendants = true) { heading() }
.nullableTestTag(tag = it.testTag)
.padding(horizontal = 16.dp)
.fillMaxWidth(),
@@ -20,12 +20,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
/**
@@ -34,6 +39,7 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme
* @param label The label for the button.
* @param onClick The callback when the button is clicked.
* @param modifier The [Modifier] to be applied to the button.
* @param isExternalLink Indicates that this button launches an external link.
*/
@Composable
fun BitwardenClickableText(
@@ -41,12 +47,21 @@ fun BitwardenClickableText(
onClick: () -> Unit,
style: TextStyle,
modifier: Modifier = Modifier,
isExternalLink: Boolean = false,
leadingIcon: Painter? = null,
innerPadding: PaddingValues = PaddingValues(vertical = 4.dp, horizontal = 16.dp),
isEnabled: Boolean = true,
cornerSize: Dp = 28.dp,
color: Color = BitwardenTheme.colorScheme.text.interaction,
) {
val formattedAccessibilityString = if (isExternalLink) {
stringResource(
id = BitwardenString.external_link_format,
formatArgs = arrayOf(label),
)
} else {
label
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
@@ -58,7 +73,10 @@ fun BitwardenClickableText(
enabled = isEnabled,
onClick = onClick,
)
.padding(paddingValues = innerPadding),
.padding(paddingValues = innerPadding)
.semantics(mergeDescendants = true) {
contentDescription = formattedAccessibilityString
},
) {
leadingIcon?.let {
Icon(
@@ -78,6 +96,7 @@ fun BitwardenClickableText(
textAlign = TextAlign.Start,
color = color,
style = style,
modifier = Modifier.semantics { hideFromAccessibility() },
)
}
}
@@ -5,6 +5,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
@@ -12,6 +13,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import com.bitwarden.ui.platform.base.util.annotatedStringResource
import com.bitwarden.ui.platform.base.util.spanStyleOf
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
/**
@@ -29,7 +31,16 @@ fun BitwardenHyperTextLink(
modifier: Modifier = Modifier,
style: TextStyle = BitwardenTheme.typography.labelMedium,
color: Color = BitwardenTheme.colorScheme.text.secondary,
isExternalLink: Boolean = false,
) {
val formattedAccessibilityString = if (isExternalLink) {
stringResource(
id = BitwardenString.external_link_format,
formatArgs = arrayOf(accessibilityString),
)
} else {
accessibilityString
}
Text(
text = annotatedStringResource(
id = annotatedResId,
@@ -48,7 +59,7 @@ fun BitwardenHyperTextLink(
.semantics {
customActions = listOf(
CustomAccessibilityAction(
label = accessibilityString,
label = formattedAccessibilityString,
action = {
onClick()
true