Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ public struct BitwardenTextField<FooterContent: View, TrailingContent: View>: Vi
/// The accessibility identifier for the button to toggle password visibility.
let passwordVisibilityAccessibilityId: String?

/// The name of the field to include in the password visibility toggle's accessibility label
/// (e.g. "Account number"), so VoiceOver announces which field the toggle affects instead of
/// the generic "Password" wording. Defaults to `nil`, which falls back to the generic wording.
let passwordVisibilityFieldName: String?

// MARK: View

public var body: some View {
Expand Down Expand Up @@ -142,9 +147,9 @@ public struct BitwardenTextField<FooterContent: View, TrailingContent: View>: Vi
asset: isPasswordVisible.wrappedValue
? SharedAsset.Icons.eyeSlash24
: SharedAsset.Icons.eye24,
accessibilityLabel: isPasswordVisible.wrappedValue
? Localizations.passwordIsVisibleTapToHide
: Localizations.passwordIsNotVisibleTapToShow,
accessibilityLabel: passwordVisibilityAccessibilityLabel(
isVisible: isPasswordVisible.wrappedValue,
),
) {
isPasswordVisible.wrappedValue.toggle()
}
Expand Down Expand Up @@ -243,6 +248,8 @@ public struct BitwardenTextField<FooterContent: View, TrailingContent: View>: Vi
/// - footer: The footer text displayed below the text field.
/// - accessibilityIdentifier: The accessibility identifier for the text field.
/// - passwordVisibilityAccessibilityId: The accessibility ID for the button to toggle password visibility.
/// - passwordVisibilityFieldName: The name of the field to include in the password visibility
/// toggle's accessibility label, or `nil` to use the generic password wording.
/// - canViewPassword: Whether the password can be viewed.
/// - isPasswordAutoFocused: Whether the password field shows the keyboard initially.
/// - isPasswordVisible: Whether the password is visible.
Expand All @@ -255,6 +262,7 @@ public struct BitwardenTextField<FooterContent: View, TrailingContent: View>: Vi
footer: String? = nil,
accessibilityIdentifier: String? = nil,
passwordVisibilityAccessibilityId: String? = nil,
passwordVisibilityFieldName: String? = nil,
canViewPassword: Bool = true,
isPasswordAutoFocused: Bool = false,
isPasswordVisible: Binding<Bool>? = nil,
Expand All @@ -269,6 +277,7 @@ public struct BitwardenTextField<FooterContent: View, TrailingContent: View>: Vi
footerContent = nil
self.canViewPassword = canViewPassword
self.passwordVisibilityAccessibilityId = passwordVisibilityAccessibilityId
self.passwordVisibilityFieldName = passwordVisibilityFieldName
_text = text
_localText = State(initialValue: text.wrappedValue)
self.title = title
Expand All @@ -282,6 +291,8 @@ public struct BitwardenTextField<FooterContent: View, TrailingContent: View>: Vi
/// - text: The text entered into the text field.
/// - accessibilityIdentifier: The accessibility identifier for the text field.
/// - passwordVisibilityAccessibilityId: The accessibility ID for the button to toggle password visibility.
/// - passwordVisibilityFieldName: The name of the field to include in the password visibility
/// toggle's accessibility label, or `nil` to use the generic password wording.
/// - canViewPassword: Whether the password can be viewed.
/// - isPasswordAutoFocused: Whether the password field shows the keyboard initially.
/// - isPasswordVisible: Whether the password is visible.
Expand All @@ -294,6 +305,7 @@ public struct BitwardenTextField<FooterContent: View, TrailingContent: View>: Vi
text: Binding<String>,
accessibilityIdentifier: String? = nil,
passwordVisibilityAccessibilityId: String? = nil,
passwordVisibilityFieldName: String? = nil,
canViewPassword: Bool = true,
isPasswordAutoFocused: Bool = false,
isPasswordVisible: Binding<Bool>? = nil,
Expand All @@ -309,11 +321,29 @@ public struct BitwardenTextField<FooterContent: View, TrailingContent: View>: Vi
self.footerContent = footerContent()
self.canViewPassword = canViewPassword
self.passwordVisibilityAccessibilityId = passwordVisibilityAccessibilityId
self.passwordVisibilityFieldName = passwordVisibilityFieldName
_text = text
_localText = State(initialValue: text.wrappedValue)
self.title = title
self.trailingContent = trailingContent()
}

/// The accessibility label for the password visibility toggle button, announcing the
/// specific field name if one was provided, or falling back to generic password wording.
///
/// - Parameter isVisible: Whether the password is currently visible.
/// - Returns: The accessibility label to use for the toggle button.
///
private func passwordVisibilityAccessibilityLabel(isVisible: Bool) -> String {
guard let passwordVisibilityFieldName else {
return isVisible
? Localizations.passwordIsVisibleTapToHide
: Localizations.passwordIsNotVisibleTapToShow
}
return isVisible
? Localizations.fieldValueIsVisibleTapToHide(passwordVisibilityFieldName)
: Localizations.fieldValueIsNotVisibleTapToShow(passwordVisibilityFieldName)
}
}

public extension BitwardenTextField where TrailingContent == EmptyView {
Expand All @@ -325,6 +355,8 @@ public extension BitwardenTextField where TrailingContent == EmptyView {
/// - text: The text entered into the text field.
/// - accessibilityIdentifier: The accessibility identifier for the text field.
/// - passwordVisibilityAccessibilityId: The accessibility ID for the button to toggle password visibility.
/// - passwordVisibilityFieldName: The name of the field to include in the password visibility
/// toggle's accessibility label, or `nil` to use the generic password wording.
/// - canViewPassword: Whether the password can be viewed.
/// - isPasswordAutoFocused: Whether the password field shows the keyboard initially.
/// - isPasswordVisible: Whether the password is visible.
Expand All @@ -337,6 +369,7 @@ public extension BitwardenTextField where TrailingContent == EmptyView {
text: Binding<String>,
accessibilityIdentifier: String? = nil,
passwordVisibilityAccessibilityId: String? = nil,
passwordVisibilityFieldName: String? = nil,
canViewPassword: Bool = true,
isPasswordAutoFocused: Bool = false,
isPasswordVisible: Binding<Bool>? = nil,
Expand All @@ -351,6 +384,7 @@ public extension BitwardenTextField where TrailingContent == EmptyView {
self.isPasswordVisible = isPasswordVisible
self.isTextFieldDisabled = isTextFieldDisabled
self.passwordVisibilityAccessibilityId = passwordVisibilityAccessibilityId
self.passwordVisibilityFieldName = passwordVisibilityFieldName
_text = text
_localText = State(initialValue: text.wrappedValue)
self.title = title
Expand All @@ -367,6 +401,8 @@ public extension BitwardenTextField where FooterContent == EmptyView, TrailingCo
/// - text: The text entered into the text field.
/// - accessibilityIdentifier: The accessibility identifier for the text field.
/// - passwordVisibilityAccessibilityId: The accessibility ID for the button to toggle password visibility.
/// - passwordVisibilityFieldName: The name of the field to include in the password visibility
/// toggle's accessibility label, or `nil` to use the generic password wording.
/// - canViewPassword: Whether the password can be viewed.
/// - isPasswordAutoFocused: Whether the password field shows the keyboard initially.
/// - isPasswordVisible: Whether the password is visible.
Expand All @@ -378,6 +414,7 @@ public extension BitwardenTextField where FooterContent == EmptyView, TrailingCo
footer: String? = nil,
accessibilityIdentifier: String? = nil,
passwordVisibilityAccessibilityId: String? = nil,
passwordVisibilityFieldName: String? = nil,
canViewPassword: Bool = true,
isPasswordAutoFocused: Bool = false,
isPasswordVisible: Binding<Bool>? = nil,
Expand All @@ -391,6 +428,7 @@ public extension BitwardenTextField where FooterContent == EmptyView, TrailingCo
self.isPasswordVisible = isPasswordVisible
self.isTextFieldDisabled = isTextFieldDisabled
self.passwordVisibilityAccessibilityId = passwordVisibilityAccessibilityId
self.passwordVisibilityFieldName = passwordVisibilityFieldName
_text = text
_localText = State(initialValue: text.wrappedValue)
self.title = title
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ public struct BitwardenTextValueField<AccessoryContent>: View where AccessoryCon
/// The (optional) accessibility identifier to apply to the displayed value of the field
var valueAccessibilityIdentifier: String?

/// Whether VoiceOver should announce the value's characters individually (e.g.
/// "1 2 3 4" instead of "one thousand two hundred thirty-four") rather than using its
/// default heuristics for the rendered text.
var spellOutAccessibilityValue: Bool

/// Any accessory content that should be displayed on the trailing edge of the field. This
/// content automatically has the `AccessoryButtonStyle` applied to it.
var accessoryContent: AccessoryContent?
Expand Down Expand Up @@ -64,6 +69,7 @@ public struct BitwardenTextValueField<AccessoryContent>: View where AccessoryCon
: SharedAsset.Colors.textDisabled.swiftUIColor,
)
.accessibilityIdentifier(valueAccessibilityIdentifier ?? value)
.speechSpellsOutCharacters(spellOutAccessibilityValue)
.if(textSelectionEnabled) { textView in
textView
.textSelection(.enabled)
Expand All @@ -87,6 +93,8 @@ public struct BitwardenTextValueField<AccessoryContent>: View where AccessoryCon
/// - textSelectionEnabled: Whether text selection is enabled.
/// This doesn't allow range selection, only copy/share actions.
/// - useUIKitTextView: Whether we should use a UITextView or a SwiftUI version.
/// - spellOutAccessibilityValue: Whether VoiceOver should announce the value's characters
/// individually rather than using its default heuristics for the rendered text.
/// - accessoryContent: Any accessory content that should be displayed on the trailing edge of
/// the field. This content automatically has the `AccessoryButtonStyle` applied to it.
public init(
Expand All @@ -96,6 +104,7 @@ public struct BitwardenTextValueField<AccessoryContent>: View where AccessoryCon
valueAccessibilityIdentifier: String? = "ItemValue",
textSelectionEnabled: Bool = true,
useUIKitTextView: Bool = false,
spellOutAccessibilityValue: Bool = false,
@ViewBuilder accessoryContent: () -> AccessoryContent,
) {
self.textSelectionEnabled = textSelectionEnabled
Expand All @@ -104,6 +113,7 @@ public struct BitwardenTextValueField<AccessoryContent>: View where AccessoryCon
self.value = value
self.useUIKitTextView = useUIKitTextView
self.valueAccessibilityIdentifier = valueAccessibilityIdentifier
self.spellOutAccessibilityValue = spellOutAccessibilityValue
self.accessoryContent = accessoryContent()
}
}
Expand All @@ -121,6 +131,8 @@ public extension BitwardenTextValueField where AccessoryContent == EmptyView {
/// - textSelectionEnabled: Whether text selection is enabled.
/// This doesn't allow range selection, only copy/share actions.
/// - useUIKitTextView: Whether we should use a UITextView or a SwiftUI version.
/// - spellOutAccessibilityValue: Whether VoiceOver should announce the value's characters
/// individually rather than using its default heuristics for the rendered text.
///
init(
title: String? = nil,
Expand All @@ -129,6 +141,7 @@ public extension BitwardenTextValueField where AccessoryContent == EmptyView {
valueAccessibilityIdentifier: String? = "ItemValue",
textSelectionEnabled: Bool = true,
useUIKitTextView: Bool = false,
spellOutAccessibilityValue: Bool = false,
) {
self.init(
title: title,
Expand All @@ -137,6 +150,7 @@ public extension BitwardenTextValueField where AccessoryContent == EmptyView {
valueAccessibilityIdentifier: valueAccessibilityIdentifier,
textSelectionEnabled: textSelectionEnabled,
useUIKitTextView: useUIKitTextView,
spellOutAccessibilityValue: spellOutAccessibilityValue,
) {
EmptyView()
}
Expand All @@ -155,6 +169,8 @@ public extension BitwardenTextValueField where AccessoryContent == AccessoryButt
/// to the displayed value of the field.
/// - textSelectionEnabled: Whether text selection is enabled.
/// - useUIKitTextView: Whether we should use a UITextView or a SwiftUI version.
/// - spellOutAccessibilityValue: Whether VoiceOver should announce the value's characters
/// individually rather than using its default heuristics for the rendered text.
/// - copyButtonAction: The action to perform when the button is pressed.
/// - copyButtonAccessibilityIdentifier: The (optional) accessibility identifier to apply
/// to the button.
Expand All @@ -165,6 +181,7 @@ public extension BitwardenTextValueField where AccessoryContent == AccessoryButt
valueAccessibilityIdentifier: String? = "ItemValue",
textSelectionEnabled: Bool = true,
useUIKitTextView: Bool = false,
spellOutAccessibilityValue: Bool = false,
copyButtonAccessibilityIdentifier: String,
copyButtonAction: @escaping () -> Void,
) {
Expand All @@ -176,6 +193,7 @@ public extension BitwardenTextValueField where AccessoryContent == AccessoryButt
valueAccessibilityIdentifier: valueAccessibilityIdentifier,
textSelectionEnabled: textSelectionEnabled,
useUIKitTextView: useUIKitTextView,
spellOutAccessibilityValue: spellOutAccessibilityValue,
accessoryContent: {
AccessoryButton(
asset: SharedAsset.Icons.copy24,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,8 @@
"SpecialCharacters" = "Special characters (!@#$%^&*)";
"PasswordIsVisibleTapToHide" = "Password is visible, tap to hide.";
"PasswordIsNotVisibleTapToShow" = "Password is not visible, tap to show.";
"FieldValueIsVisibleTapToHide" = "%1$@ is visible, tap to hide.";
"FieldValueIsNotVisibleTapToShow" = "%1$@ is not visible, tap to show.";
"FilterByVault" = "Filter items by vault";
"AllVaults" = "All vaults";
"Vaults" = "Vaults";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,19 @@ struct PasswordText: View {
/// A flag indicating if the password is visible or not.
let isPasswordVisible: Bool

/// A flag indicating whether VoiceOver should announce the password's characters
/// individually (e.g. "1 2 3 4" instead of "one thousand two hundred thirty-four") rather
/// than using its default heuristics for the rendered text.
var spellOutAccessibilityValue = false

var body: some View {
(
isPasswordVisible
? Text(colorCodedText(for: password))
: Text(String(repeating: "•", count: Constants.hiddenPasswordLength)),
)
.styleGuide(.bodyMonospaced)
.speechSpellsOutCharacters(spellOutAccessibilityValue && isPasswordVisible)
}

// MARK: Private Properties
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ struct PasswordVisibilityButton: View {
.resizable()
.frame(width: size, height: size)
}
.accessibilityLabel(accessibilityLabel)
.accessibilityIdentifier(accessibilityIdentifier)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ struct AddEditBankAccountItemView: View {
),
accessibilityIdentifier: "AccountNumberEntry",
passwordVisibilityAccessibilityId: "ShowAccountNumberButton",
passwordVisibilityFieldName: Localizations.accountNumber,
isPasswordVisible: store.binding(
get: \.isAccountNumberVisible,
send: AddEditBankAccountItemAction.toggleAccountNumberVisibilityChanged,
Expand Down Expand Up @@ -115,6 +116,7 @@ struct AddEditBankAccountItemView: View {
),
accessibilityIdentifier: "PinEntry",
passwordVisibilityAccessibilityId: "ShowPinButton",
passwordVisibilityFieldName: Localizations.pin,
isPasswordVisible: store.binding(
get: \.isPinVisible,
send: AddEditBankAccountItemAction.togglePinVisibilityChanged,
Expand Down Expand Up @@ -142,6 +144,7 @@ struct AddEditBankAccountItemView: View {
),
accessibilityIdentifier: "IbanEntry",
passwordVisibilityAccessibilityId: "ShowIbanButton",
passwordVisibilityFieldName: Localizations.iban,
isPasswordVisible: store.binding(
get: \.isIbanVisible,
send: AddEditBankAccountItemAction.toggleIbanVisibilityChanged,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,10 @@ struct BankAccountItemStateTests {
subject.accountType = .custom(.savings)
#expect(!subject.isBankAccountDetailsSectionEmpty)
}

/// The account type field is blank until the user has explicitly selected a value.
@Test
func accountType_defaultLocalizedName_isBlank() {
#expect(BankAccountItemState().accountType.localizedName.isEmpty)
}
}
Loading
Loading