From 720a680314129d080e2213be35a4f6c26cec89cb Mon Sep 17 00:00:00 2001 From: Morgan Zellers Date: Wed, 19 Aug 2026 11:47:49 -0500 Subject: [PATCH 1/7] [PM-41938] fix: Announce field-specific show/hide state for Bank Account view toggles VoiceOver announced the Account Number, PIN, and IBAN reveal toggles on the Bank Account view screen with a generic "button" label because PasswordVisibilityButton accepted an accessibilityLabel but never applied it. Wires up field-specific announcements (e.g. "Account number is not visible, tap to show.") for each toggle. --- .../en.lproj/Localizable.strings | 2 + .../Views/PasswordVisibilityButton.swift | 1 + ...nkAccountItemView+ViewInspectorTests.swift | 52 +++++++++++++++++++ .../ViewBankAccountItemView.swift | 9 ++++ 4 files changed, 64 insertions(+) diff --git a/BitwardenResources/Localizations/en.lproj/Localizable.strings b/BitwardenResources/Localizations/en.lproj/Localizable.strings index ee1902efb3..d5555108de 100644 --- a/BitwardenResources/Localizations/en.lproj/Localizable.strings +++ b/BitwardenResources/Localizations/en.lproj/Localizable.strings @@ -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"; diff --git a/BitwardenShared/UI/Platform/Application/Views/PasswordVisibilityButton.swift b/BitwardenShared/UI/Platform/Application/Views/PasswordVisibilityButton.swift index d249932dcf..3163c2a03e 100644 --- a/BitwardenShared/UI/Platform/Application/Views/PasswordVisibilityButton.swift +++ b/BitwardenShared/UI/Platform/Application/Views/PasswordVisibilityButton.swift @@ -32,6 +32,7 @@ struct PasswordVisibilityButton: View { .resizable() .frame(width: size, height: size) } + .accessibilityLabel(accessibilityLabel) .accessibilityIdentifier(accessibilityIdentifier) } diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView+ViewInspectorTests.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView+ViewInspectorTests.swift index 71aeb80ad4..5233b5348c 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView+ViewInspectorTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView+ViewInspectorTests.swift @@ -1,6 +1,7 @@ // swiftlint:disable:this file_name import BitwardenKit import BitwardenKitMocks +import BitwardenResources import SwiftUI import ViewInspector import XCTest @@ -158,6 +159,57 @@ class ViewBankAccountItemViewTests: BitwardenTestCase { ) } + /// The account number visibility toggle announces the field name and current state to VoiceOver. + @MainActor + func test_accountNumberVisibilityToggle_accessibilityLabel() throws { + var state = populatedState() + state.isAccountNumberVisible = false + initSubject(state: state) + _ = try subject.inspect().find( + buttonWithAccessibilityLabel: Localizations.fieldValueIsNotVisibleTapToShow(Localizations.accountNumber), + ) + + state.isAccountNumberVisible = true + initSubject(state: state) + _ = try subject.inspect().find( + buttonWithAccessibilityLabel: Localizations.fieldValueIsVisibleTapToHide(Localizations.accountNumber), + ) + } + + /// The PIN visibility toggle announces the field name and current state to VoiceOver. + @MainActor + func test_pinVisibilityToggle_accessibilityLabel() throws { + var state = populatedState() + state.isPinVisible = false + initSubject(state: state) + _ = try subject.inspect().find( + buttonWithAccessibilityLabel: Localizations.fieldValueIsNotVisibleTapToShow(Localizations.pin), + ) + + state.isPinVisible = true + initSubject(state: state) + _ = try subject.inspect().find( + buttonWithAccessibilityLabel: Localizations.fieldValueIsVisibleTapToHide(Localizations.pin), + ) + } + + /// The IBAN visibility toggle announces the field name and current state to VoiceOver. + @MainActor + func test_ibanVisibilityToggle_accessibilityLabel() throws { + var state = populatedState() + state.isIbanVisible = false + initSubject(state: state) + _ = try subject.inspect().find( + buttonWithAccessibilityLabel: Localizations.fieldValueIsNotVisibleTapToShow(Localizations.iban), + ) + + state.isIbanVisible = true + initSubject(state: state) + _ = try subject.inspect().find( + buttonWithAccessibilityLabel: Localizations.fieldValueIsVisibleTapToHide(Localizations.iban), + ) + } + /// An empty state renders no fields, so the copy and reveal buttons are absent. @MainActor func test_emptyState_hidesFields() throws { diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView.swift index e0fea714ad..37a1476ff3 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView.swift @@ -101,6 +101,9 @@ struct ViewBankAccountItemView: View { } accessoryContent: { PasswordVisibilityButton( accessibilityIdentifier: "ShowBankAccountNumberButton", + accessibilityLabel: isVisible + ? Localizations.fieldValueIsVisibleTapToHide(Localizations.accountNumber) + : Localizations.fieldValueIsNotVisibleTapToShow(Localizations.accountNumber), isPasswordVisible: isVisible, ) { store.send(.bankAccountItemAction(.toggleAccountNumberVisibilityChanged(!isVisible))) @@ -132,6 +135,9 @@ struct ViewBankAccountItemView: View { } accessoryContent: { PasswordVisibilityButton( accessibilityIdentifier: "ShowBankAccountIbanButton", + accessibilityLabel: isVisible + ? Localizations.fieldValueIsVisibleTapToHide(Localizations.iban) + : Localizations.fieldValueIsNotVisibleTapToShow(Localizations.iban), isPasswordVisible: isVisible, ) { store.send(.bankAccountItemAction(.toggleIbanVisibilityChanged(!isVisible))) @@ -163,6 +169,9 @@ struct ViewBankAccountItemView: View { } accessoryContent: { PasswordVisibilityButton( accessibilityIdentifier: "ShowBankAccountPinButton", + accessibilityLabel: isVisible + ? Localizations.fieldValueIsVisibleTapToHide(Localizations.pin) + : Localizations.fieldValueIsNotVisibleTapToShow(Localizations.pin), isPasswordVisible: isVisible, ) { store.send(.bankAccountItemAction(.togglePinVisibilityChanged(!isVisible))) From 5d4abeb7423f24850f0a96741d0d5d67f5257076 Mon Sep 17 00:00:00 2001 From: Morgan Zellers Date: Wed, 19 Aug 2026 11:50:59 -0500 Subject: [PATCH 2/7] [PM-41939] fix: Announce field-specific show/hide state for Bank Account add/edit toggles VoiceOver announced the Account Number, PIN, and IBAN reveal toggles on the Bank Account add/edit screen with the same generic "Password is/is not visible" wording regardless of which field was being toggled. Adds an optional field-name parameter to BitwardenTextField so these three fields announce their own name (e.g. "PIN is visible, tap to hide.") while every other password-style field in the app keeps its existing generic wording. --- .../Views/BitwardenTextField.swift | 44 +++++++++++++++++-- .../AddEditBankAccountItemView.swift | 3 ++ ...emView+BankAccountViewInspectorTests.swift | 13 +++--- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/BitwardenKit/UI/Platform/Application/Views/BitwardenTextField.swift b/BitwardenKit/UI/Platform/Application/Views/BitwardenTextField.swift index ea90c1383c..d6cef1d069 100644 --- a/BitwardenKit/UI/Platform/Application/Views/BitwardenTextField.swift +++ b/BitwardenKit/UI/Platform/Application/Views/BitwardenTextField.swift @@ -90,6 +90,11 @@ public struct BitwardenTextField: 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 { @@ -142,9 +147,9 @@ public struct BitwardenTextField: 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() } @@ -243,6 +248,8 @@ public struct BitwardenTextField: 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. @@ -255,6 +262,7 @@ public struct BitwardenTextField: Vi footer: String? = nil, accessibilityIdentifier: String? = nil, passwordVisibilityAccessibilityId: String? = nil, + passwordVisibilityFieldName: String? = nil, canViewPassword: Bool = true, isPasswordAutoFocused: Bool = false, isPasswordVisible: Binding? = nil, @@ -269,6 +277,7 @@ public struct BitwardenTextField: Vi footerContent = nil self.canViewPassword = canViewPassword self.passwordVisibilityAccessibilityId = passwordVisibilityAccessibilityId + self.passwordVisibilityFieldName = passwordVisibilityFieldName _text = text _localText = State(initialValue: text.wrappedValue) self.title = title @@ -282,6 +291,8 @@ public struct BitwardenTextField: 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. @@ -294,6 +305,7 @@ public struct BitwardenTextField: Vi text: Binding, accessibilityIdentifier: String? = nil, passwordVisibilityAccessibilityId: String? = nil, + passwordVisibilityFieldName: String? = nil, canViewPassword: Bool = true, isPasswordAutoFocused: Bool = false, isPasswordVisible: Binding? = nil, @@ -309,11 +321,29 @@ public struct BitwardenTextField: 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 { @@ -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. @@ -337,6 +369,7 @@ public extension BitwardenTextField where TrailingContent == EmptyView { text: Binding, accessibilityIdentifier: String? = nil, passwordVisibilityAccessibilityId: String? = nil, + passwordVisibilityFieldName: String? = nil, canViewPassword: Bool = true, isPasswordAutoFocused: Bool = false, isPasswordVisible: Binding? = nil, @@ -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 @@ -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. @@ -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? = nil, @@ -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 diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditBankAccountItem/AddEditBankAccountItemView.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditBankAccountItem/AddEditBankAccountItemView.swift index 339e6b9469..acb1a21c5a 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditBankAccountItem/AddEditBankAccountItemView.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditBankAccountItem/AddEditBankAccountItemView.swift @@ -77,6 +77,7 @@ struct AddEditBankAccountItemView: View { ), accessibilityIdentifier: "AccountNumberEntry", passwordVisibilityAccessibilityId: "ShowAccountNumberButton", + passwordVisibilityFieldName: Localizations.accountNumber, isPasswordVisible: store.binding( get: \.isAccountNumberVisible, send: AddEditBankAccountItemAction.toggleAccountNumberVisibilityChanged, @@ -115,6 +116,7 @@ struct AddEditBankAccountItemView: View { ), accessibilityIdentifier: "PinEntry", passwordVisibilityAccessibilityId: "ShowPinButton", + passwordVisibilityFieldName: Localizations.pin, isPasswordVisible: store.binding( get: \.isPinVisible, send: AddEditBankAccountItemAction.togglePinVisibilityChanged, @@ -142,6 +144,7 @@ struct AddEditBankAccountItemView: View { ), accessibilityIdentifier: "IbanEntry", passwordVisibilityAccessibilityId: "ShowIbanButton", + passwordVisibilityFieldName: Localizations.iban, isPasswordVisible: store.binding( get: \.isIbanVisible, send: AddEditBankAccountItemAction.toggleIbanVisibilityChanged, diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView+BankAccountViewInspectorTests.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView+BankAccountViewInspectorTests.swift index a6a4cd44e6..d67e63fd5a 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView+BankAccountViewInspectorTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView+BankAccountViewInspectorTests.swift @@ -136,9 +136,10 @@ class AddEditItemViewBankAccountTests: BitwardenTestCase { func test_bankAccount_accountNumberVisibilityButton_tap_whenNotVisible() throws { processor.state.type = .bankAccount processor.state.bankAccountItemState.isAccountNumberVisible = false + let accessibilityLabel = Localizations.fieldValueIsNotVisibleTapToShow(Localizations.accountNumber) let button = try subject.inspect() .find(bitwardenTextField: Localizations.accountNumber) - .find(buttonWithAccessibilityLabel: Localizations.passwordIsNotVisibleTapToShow) + .find(buttonWithAccessibilityLabel: accessibilityLabel) try button.tap() XCTAssertEqual( processor.dispatchedActions.last, @@ -154,7 +155,7 @@ class AddEditItemViewBankAccountTests: BitwardenTestCase { processor.state.bankAccountItemState.isAccountNumberVisible = true let button = try subject.inspect() .find(bitwardenTextField: Localizations.accountNumber) - .find(buttonWithAccessibilityLabel: Localizations.passwordIsVisibleTapToHide) + .find(buttonWithAccessibilityLabel: Localizations.fieldValueIsVisibleTapToHide(Localizations.accountNumber)) try button.tap() XCTAssertEqual( processor.dispatchedActions.last, @@ -170,7 +171,7 @@ class AddEditItemViewBankAccountTests: BitwardenTestCase { processor.state.bankAccountItemState.isPinVisible = false let button = try subject.inspect() .find(bitwardenTextField: Localizations.pin) - .find(buttonWithAccessibilityLabel: Localizations.passwordIsNotVisibleTapToShow) + .find(buttonWithAccessibilityLabel: Localizations.fieldValueIsNotVisibleTapToShow(Localizations.pin)) try button.tap() XCTAssertEqual( processor.dispatchedActions.last, @@ -186,7 +187,7 @@ class AddEditItemViewBankAccountTests: BitwardenTestCase { processor.state.bankAccountItemState.isPinVisible = true let button = try subject.inspect() .find(bitwardenTextField: Localizations.pin) - .find(buttonWithAccessibilityLabel: Localizations.passwordIsVisibleTapToHide) + .find(buttonWithAccessibilityLabel: Localizations.fieldValueIsVisibleTapToHide(Localizations.pin)) try button.tap() XCTAssertEqual( processor.dispatchedActions.last, @@ -202,7 +203,7 @@ class AddEditItemViewBankAccountTests: BitwardenTestCase { processor.state.bankAccountItemState.isIbanVisible = false let button = try subject.inspect() .find(bitwardenTextField: Localizations.iban) - .find(buttonWithAccessibilityLabel: Localizations.passwordIsNotVisibleTapToShow) + .find(buttonWithAccessibilityLabel: Localizations.fieldValueIsNotVisibleTapToShow(Localizations.iban)) try button.tap() XCTAssertEqual( processor.dispatchedActions.last, @@ -218,7 +219,7 @@ class AddEditItemViewBankAccountTests: BitwardenTestCase { processor.state.bankAccountItemState.isIbanVisible = true let button = try subject.inspect() .find(bitwardenTextField: Localizations.iban) - .find(buttonWithAccessibilityLabel: Localizations.passwordIsVisibleTapToHide) + .find(buttonWithAccessibilityLabel: Localizations.fieldValueIsVisibleTapToHide(Localizations.iban)) try button.tap() XCTAssertEqual( processor.dispatchedActions.last, From ab45537c3ae9e44d1b2a6122a59a4b92cb93d69a Mon Sep 17 00:00:00 2001 From: Morgan Zellers Date: Wed, 19 Aug 2026 15:40:28 -0500 Subject: [PATCH 3/7] [PM-41940] fix: Announce Bank Account field characters individually to VoiceOver VoiceOver was reading the Account Number, Routing Number, Branch Number, PIN, SWIFT Code, and IBAN values on the Bank Account view screen as whole numbers or words instead of spelling out each character, since no explicit accessibilityValue was set for these fields. Adds an opt-in spellOutAccessibilityValue flag to PasswordText and BitwardenTextValueField, backed by a new String.spellingOutCharacters() helper, and wires it up only for the six affected Bank Account fields so other consumers of these shared components are unaffected. --- .../Core/Platform/Extensions/String.swift | 10 +++ .../Platform/Extensions/StringTests.swift | 8 +++ .../Views/BitwardenTextValueField.swift | 20 ++++++ .../Application/Views/PasswordText.swift | 8 +++ ...nkAccountItemView+ViewInspectorTests.swift | 63 +++++++++++++++++++ .../ViewBankAccountItemView.swift | 13 +++- 6 files changed, 119 insertions(+), 3 deletions(-) diff --git a/BitwardenKit/Core/Platform/Extensions/String.swift b/BitwardenKit/Core/Platform/Extensions/String.swift index f86d13593a..a43124dae5 100644 --- a/BitwardenKit/Core/Platform/Extensions/String.swift +++ b/BitwardenKit/Core/Platform/Extensions/String.swift @@ -115,6 +115,16 @@ public extension String { return result } + /// Returns a copy of the string with each character separated by a space so that VoiceOver + /// announces the characters individually instead of interpreting the string as a whole word + /// or number. + /// + /// - Returns: A copy of the string with each character separated by a space. + /// + func spellingOutCharacters() -> String { + map { String($0) }.joined(separator: " ") + } + /// Validates whether the string is a valid email address. /// /// - Parameter useStrictValidation: If `true`, validates against a regex pattern requiring diff --git a/BitwardenKit/Core/Platform/Extensions/StringTests.swift b/BitwardenKit/Core/Platform/Extensions/StringTests.swift index 029fc75fde..c6d1a76e4b 100644 --- a/BitwardenKit/Core/Platform/Extensions/StringTests.swift +++ b/BitwardenKit/Core/Platform/Extensions/StringTests.swift @@ -32,6 +32,14 @@ class StringTests: BitwardenTestCase { XCTAssertEqual("4111-1111-1111-1111".formattedCreditCardNumber(), "4111-1111-1111-1111") } + /// `spellingOutCharacters()` separates each character with a space. + func test_spellingOutCharacters() { + XCTAssertEqual("1234".spellingOutCharacters(), "1 2 3 4") + XCTAssertEqual("BOFAUS3N".spellingOutCharacters(), "B O F A U S 3 N") + XCTAssertEqual("A".spellingOutCharacters(), "A") + XCTAssertEqual("".spellingOutCharacters(), "") + } + /// `hashColor` returns a color generated from a hash of the string's characters. func test_hashColor() { XCTAssertEqual("test".hashColor.description, "#924436FF") diff --git a/BitwardenKit/UI/Platform/Application/Views/BitwardenTextValueField.swift b/BitwardenKit/UI/Platform/Application/Views/BitwardenTextValueField.swift index 1e9f39f4c0..fef7852973 100644 --- a/BitwardenKit/UI/Platform/Application/Views/BitwardenTextValueField.swift +++ b/BitwardenKit/UI/Platform/Application/Views/BitwardenTextValueField.swift @@ -26,6 +26,11 @@ public struct BitwardenTextValueField: 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? @@ -64,6 +69,9 @@ public struct BitwardenTextValueField: View where AccessoryCon : SharedAsset.Colors.textDisabled.swiftUIColor, ) .accessibilityIdentifier(valueAccessibilityIdentifier ?? value) + .if(spellOutAccessibilityValue) { textView in + textView.accessibilityValue(value.spellingOutCharacters()) + } .if(textSelectionEnabled) { textView in textView .textSelection(.enabled) @@ -87,6 +95,8 @@ public struct BitwardenTextValueField: 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( @@ -96,6 +106,7 @@ public struct BitwardenTextValueField: View where AccessoryCon valueAccessibilityIdentifier: String? = "ItemValue", textSelectionEnabled: Bool = true, useUIKitTextView: Bool = false, + spellOutAccessibilityValue: Bool = false, @ViewBuilder accessoryContent: () -> AccessoryContent, ) { self.textSelectionEnabled = textSelectionEnabled @@ -104,6 +115,7 @@ public struct BitwardenTextValueField: View where AccessoryCon self.value = value self.useUIKitTextView = useUIKitTextView self.valueAccessibilityIdentifier = valueAccessibilityIdentifier + self.spellOutAccessibilityValue = spellOutAccessibilityValue self.accessoryContent = accessoryContent() } } @@ -121,6 +133,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, @@ -129,6 +143,7 @@ public extension BitwardenTextValueField where AccessoryContent == EmptyView { valueAccessibilityIdentifier: String? = "ItemValue", textSelectionEnabled: Bool = true, useUIKitTextView: Bool = false, + spellOutAccessibilityValue: Bool = false, ) { self.init( title: title, @@ -137,6 +152,7 @@ public extension BitwardenTextValueField where AccessoryContent == EmptyView { valueAccessibilityIdentifier: valueAccessibilityIdentifier, textSelectionEnabled: textSelectionEnabled, useUIKitTextView: useUIKitTextView, + spellOutAccessibilityValue: spellOutAccessibilityValue, ) { EmptyView() } @@ -155,6 +171,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. @@ -165,6 +183,7 @@ public extension BitwardenTextValueField where AccessoryContent == AccessoryButt valueAccessibilityIdentifier: String? = "ItemValue", textSelectionEnabled: Bool = true, useUIKitTextView: Bool = false, + spellOutAccessibilityValue: Bool = false, copyButtonAccessibilityIdentifier: String, copyButtonAction: @escaping () -> Void, ) { @@ -176,6 +195,7 @@ public extension BitwardenTextValueField where AccessoryContent == AccessoryButt valueAccessibilityIdentifier: valueAccessibilityIdentifier, textSelectionEnabled: textSelectionEnabled, useUIKitTextView: useUIKitTextView, + spellOutAccessibilityValue: spellOutAccessibilityValue, accessoryContent: { AccessoryButton( asset: SharedAsset.Icons.copy24, diff --git a/BitwardenShared/UI/Platform/Application/Views/PasswordText.swift b/BitwardenShared/UI/Platform/Application/Views/PasswordText.swift index a60570da24..0d9dab3774 100644 --- a/BitwardenShared/UI/Platform/Application/Views/PasswordText.swift +++ b/BitwardenShared/UI/Platform/Application/Views/PasswordText.swift @@ -14,6 +14,11 @@ 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 @@ -21,6 +26,9 @@ struct PasswordText: View { : Text(String(repeating: "•", count: Constants.hiddenPasswordLength)), ) .styleGuide(.bodyMonospaced) + .if(spellOutAccessibilityValue && isPasswordVisible) { view in + view.accessibilityValue(password.spellingOutCharacters()) + } } // MARK: Private Properties diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView+ViewInspectorTests.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView+ViewInspectorTests.swift index 5233b5348c..9deb6dfd71 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView+ViewInspectorTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView+ViewInspectorTests.swift @@ -210,6 +210,69 @@ class ViewBankAccountItemViewTests: BitwardenTestCase { ) } + /// The account number value announces its characters individually to VoiceOver when visible. + @MainActor + func test_accountNumber_accessibilityValue() throws { + var state = populatedState() + state.isAccountNumberVisible = true + initSubject(state: state) + let value = try subject.inspect().find( + viewWithAccessibilityIdentifier: "BankAccountNumberEntry", + ).text().accessibilityValue().string() + XCTAssertEqual(value, "1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6") + } + + /// The PIN value announces its characters individually to VoiceOver when visible. + @MainActor + func test_pin_accessibilityValue() throws { + var state = populatedState() + state.isPinVisible = true + initSubject(state: state) + let value = try subject.inspect().find( + viewWithAccessibilityIdentifier: "BankAccountPinEntry", + ).text().accessibilityValue().string() + XCTAssertEqual(value, "1 2 3 4") + } + + /// The IBAN value announces its characters individually to VoiceOver when visible. + @MainActor + func test_iban_accessibilityValue() throws { + var state = populatedState() + state.isIbanVisible = true + initSubject(state: state) + let value = try subject.inspect().find( + viewWithAccessibilityIdentifier: "BankAccountIbanEntry", + ).text().accessibilityValue().string() + XCTAssertEqual(value, "G B 3 3 B U K B 2 0 2 0 1 5 5 5 5 5 5 5 5 5") + } + + /// The routing number value announces its characters individually to VoiceOver. + @MainActor + func test_routingNumber_accessibilityValue() throws { + let value = try subject.inspect().find( + viewWithAccessibilityIdentifier: "BankAccountRoutingNumberEntry", + ).accessibilityValue().string() + XCTAssertEqual(value, "1 2 3 4 5 6 7 8 9 0") + } + + /// The branch number value announces its characters individually to VoiceOver. + @MainActor + func test_branchNumber_accessibilityValue() throws { + let value = try subject.inspect().find( + viewWithAccessibilityIdentifier: "BankAccountBranchNumberEntry", + ).accessibilityValue().string() + XCTAssertEqual(value, "1 0 0") + } + + /// The SWIFT code value announces its characters individually to VoiceOver. + @MainActor + func test_swiftCode_accessibilityValue() throws { + let value = try subject.inspect().find( + viewWithAccessibilityIdentifier: "BankAccountSwiftCodeEntry", + ).accessibilityValue().string() + XCTAssertEqual(value, "B O F A U S 3 N") + } + /// An empty state renders no fields, so the copy and reveal buttons are absent. @MainActor func test_emptyState_hidesFields() throws { diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView.swift index 37a1476ff3..14cf7b3646 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView.swift @@ -40,6 +40,7 @@ struct ViewBankAccountItemView: View { valueAccessibilityIdentifier: "BankAccountRoutingNumberEntry", copyButtonAccessibilityIdentifier: "BankAccountCopyRoutingNumberButton", copyField: .routingNumber, + spellOutAccessibilityValue: true, ) copyableTextField( @@ -48,6 +49,7 @@ struct ViewBankAccountItemView: View { valueAccessibilityIdentifier: "BankAccountBranchNumberEntry", copyButtonAccessibilityIdentifier: "BankAccountCopyBranchNumberButton", copyField: .branchNumber, + spellOutAccessibilityValue: true, ) pinItem @@ -58,6 +60,7 @@ struct ViewBankAccountItemView: View { valueAccessibilityIdentifier: "BankAccountSwiftCodeEntry", copyButtonAccessibilityIdentifier: "BankAccountCopySwiftCodeButton", copyField: .swiftCode, + spellOutAccessibilityValue: true, ) ibanItem @@ -94,7 +97,7 @@ struct ViewBankAccountItemView: View { let isVisible = store.state.isAccountNumberVisible if !accountNumber.isEmpty { BitwardenField(title: Localizations.accountNumber) { - PasswordText(password: accountNumber, isPasswordVisible: isVisible) + PasswordText(password: accountNumber, isPasswordVisible: isVisible, spellOutAccessibilityValue: true) .styleGuide(.body) .foregroundColor(SharedAsset.Colors.textPrimary.swiftUIColor) .accessibilityIdentifier("BankAccountNumberEntry") @@ -128,7 +131,7 @@ struct ViewBankAccountItemView: View { let isVisible = store.state.isIbanVisible if !iban.isEmpty { BitwardenField(title: Localizations.iban) { - PasswordText(password: iban, isPasswordVisible: isVisible) + PasswordText(password: iban, isPasswordVisible: isVisible, spellOutAccessibilityValue: true) .styleGuide(.body) .foregroundColor(SharedAsset.Colors.textPrimary.swiftUIColor) .accessibilityIdentifier("BankAccountIbanEntry") @@ -162,7 +165,7 @@ struct ViewBankAccountItemView: View { let isVisible = store.state.isPinVisible if !pin.isEmpty { BitwardenField(title: Localizations.pin) { - PasswordText(password: pin, isPasswordVisible: isVisible) + PasswordText(password: pin, isPasswordVisible: isVisible, spellOutAccessibilityValue: true) .styleGuide(.body) .foregroundColor(SharedAsset.Colors.textPrimary.swiftUIColor) .accessibilityIdentifier("BankAccountPinEntry") @@ -198,6 +201,8 @@ struct ViewBankAccountItemView: View { /// - valueAccessibilityIdentifier: The accessibility identifier for the value. /// - copyButtonAccessibilityIdentifier: The accessibility identifier for the copy button. /// - copyField: The field identifying the value being copied. + /// - spellOutAccessibilityValue: Whether VoiceOver should announce the value's characters + /// individually rather than using its default heuristics for the rendered text. /// @ViewBuilder private func copyableTextField( @@ -206,12 +211,14 @@ struct ViewBankAccountItemView: View { valueAccessibilityIdentifier: String, copyButtonAccessibilityIdentifier: String, copyField: CopyableField, + spellOutAccessibilityValue: Bool = false, ) -> some View { if !value.isEmpty { BitwardenTextValueField( title: title, value: value, valueAccessibilityIdentifier: valueAccessibilityIdentifier, + spellOutAccessibilityValue: spellOutAccessibilityValue, copyButtonAccessibilityIdentifier: copyButtonAccessibilityIdentifier, copyButtonAction: { store.send(.copyPressed(value: value, field: copyField)) }, ) From 7cb60a6d51dae256d1e84b4858518f98f17ee72f Mon Sep 17 00:00:00 2001 From: Morgan Zellers Date: Wed, 19 Aug 2026 12:38:22 -0500 Subject: [PATCH 4/7] [PM-41941] fix: Announce and expose the overflow menu on vault list rows to VoiceOver The row's more options button was visually tappable but unreachable by VoiceOver because the row combines its content into a single accessibility element, which swallows the nested button's own announcement and activation. Adds a named accessibility action so the overflow menu remains announced and accessible, matching the pattern already used for PasswordHistoryListView's combined row. --- .../VaultListItemRowView+ViewInspectorTests.swift | 15 +++++++++++++++ .../VaultListItemRow/VaultListItemRowView.swift | 3 +++ 2 files changed, 18 insertions(+) diff --git a/BitwardenShared/UI/Vault/Views/VaultListItemRow/VaultListItemRowView+ViewInspectorTests.swift b/BitwardenShared/UI/Vault/Views/VaultListItemRow/VaultListItemRowView+ViewInspectorTests.swift index 572d2d61a6..6a8321b4b3 100644 --- a/BitwardenShared/UI/Vault/Views/VaultListItemRow/VaultListItemRowView+ViewInspectorTests.swift +++ b/BitwardenShared/UI/Vault/Views/VaultListItemRow/VaultListItemRowView+ViewInspectorTests.swift @@ -44,6 +44,21 @@ class VaultListItemRowViewTests: BitwardenTestCase { XCTAssertEqual(processor.effects.last, .morePressed) } + /// Test that the more options button, and with it its VoiceOver accessibility action, isn't + /// shown when displayed from an extension. + @MainActor + func test_moreButton_notShownFromExtension() throws { + processor.state = VaultListItemRowState( + isFromExtension: true, + item: .fixture(), + hasDivider: false, + showWebIcons: true, + ) + XCTAssertThrowsError( + try subject.inspect().find(asyncButtonWithAccessibilityLabel: Localizations.moreOptions), + ) + } + /// Test that tapping the totp copy button dispatches the `.copyTOTPCode` action. @MainActor func test_totpCopyButton_tap() throws { diff --git a/BitwardenShared/UI/Vault/Views/VaultListItemRow/VaultListItemRowView.swift b/BitwardenShared/UI/Vault/Views/VaultListItemRow/VaultListItemRowView.swift index d84b2f05e9..e542433495 100644 --- a/BitwardenShared/UI/Vault/Views/VaultListItemRow/VaultListItemRowView.swift +++ b/BitwardenShared/UI/Vault/Views/VaultListItemRow/VaultListItemRowView.swift @@ -90,6 +90,9 @@ struct VaultListItemRowView: View { } .accessibilityLabel(Localizations.moreOptions) .accessibilityIdentifier("CipherOptionsButton") + .accessibilityAction(named: Localizations.moreOptions) { + Task { await store.perform(.morePressed) } + } } case let .group(group, count): From d2261b0a07dfefe01a1e1c1ca044ac871cbf5df7 Mon Sep 17 00:00:00 2001 From: Morgan Zellers Date: Wed, 19 Aug 2026 16:03:56 -0500 Subject: [PATCH 5/7] [PM-41942] fix: Announce and expose the Master Password Re-prompt info icon to VoiceOver The info button was nested inside BitwardenToggle's title content, so VoiceOver collapsed the whole row into one element and swallowed the button's own label and tap target. Move it into BitwardenToggle's accessory slot, which keeps it independently reachable, and add an "External link" hint, mirroring the Fill Assist toggle fix (PM-41097). --- .../AddEditItemView+ViewInspectorTests.swift | 14 ++++++++++- .../AddEditItem/AddEditItemView.swift | 25 +++++++++++-------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView+ViewInspectorTests.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView+ViewInspectorTests.swift index 1a0a715511..4a1338c909 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView+ViewInspectorTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView+ViewInspectorTests.swift @@ -193,12 +193,24 @@ class AddEditItemViewTests: BitwardenTestCase { // swiftlint:disable:this type_b if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { throw XCTSkip("Unable to run test in iOS 16, keep an eye on ViewInspector to see if it gets updated.") } + processor.state.isAdditionalOptionsExpanded = true processor.state.isMasterPasswordRePromptOn = false - let toggle = try subject.inspect().find(ViewType.Toggle.self, containing: Localizations.passwordPrompt) + let toggle = try subject.inspect().find(toggleWithAccessibilityLabel: Localizations.passwordPrompt) try toggle.tap() XCTAssertEqual(processor.dispatchedActions.last, .masterPasswordRePromptChanged(true)) } + /// The master password re-prompt info button is independently reachable by VoiceOver and is + /// announced as an external link. + @MainActor + func test_masterPasswordRePromptToggle_infoButton_accessibility() throws { + processor.state.isAdditionalOptionsExpanded = true + let button = try subject.inspect().find( + buttonWithAccessibilityLabel: Localizations.masterPasswordRePromptHelp, + ) + try XCTAssertEqual(button.accessibilityHint().string(), Localizations.externalLink) + } + /// Updating the name text field dispatches the `.nameChanged()` action. @MainActor func test_nameTextField_updateValue() throws { diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView.swift index b539da1b45..9d380590ce 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView.swift @@ -245,25 +245,28 @@ private extension AddEditItemView { } if store.state.showMasterPasswordReprompt { - BitwardenToggle(isOn: store.binding( - get: \.isMasterPasswordRePromptOn, - send: AddEditItemAction.masterPasswordRePromptChanged, - )) { - HStack(alignment: .center, spacing: 8) { + BitwardenToggle( + isOn: store.binding( + get: \.isMasterPasswordRePromptOn, + send: AddEditItemAction.masterPasswordRePromptChanged, + ), + accessibilityIdentifier: "MasterPasswordRepromptToggle", + accessibilityLabel: Localizations.passwordPrompt, + title: { Text(Localizations.passwordPrompt) - + }, + accessory: { Button { openURL(ExternalLinksConstants.protectIndividualItems) } label: { SharedAsset.Icons.questionCircle16.swiftUIImage } - .accessibilityLabel(Localizations.masterPasswordRePromptHelp) .buttonStyle(.fieldLabelIcon) - } - } + .accessibilityLabel(Localizations.masterPasswordRePromptHelp) + .accessibilityHint(Localizations.externalLink) + }, + ) .toggleStyle(.bitwarden) - .accessibilityIdentifier("MasterPasswordRepromptToggle") - .accessibilityLabel(Localizations.passwordPrompt) .contentBlock() } From 0ef9ec35268cef5c0fe332af90ba1973efa56ec2 Mon Sep 17 00:00:00 2001 From: Morgan Zellers Date: Thu, 20 Aug 2026 11:46:59 -0500 Subject: [PATCH 6/7] Fix double VoiceOver announcement for spelled-out field values .accessibilityValue(value.spellingOutCharacters()) was being added on top of a Text whose own content is already exposed as its accessibility label, so VoiceOver would likely announce the value twice: once normally, once spelled out. Use speechSpellsOutCharacters instead, which tells VoiceOver to spell out the label itself rather than adding a redundant value. Drops the now-unused String.spellingOutCharacters() helper. Also replaces a hand-rolled Task { await ... } in an accessibilityAction with the existing accessibilityAsyncAction helper for consistency. ViewInspector can't inspect speechSpellsOutCharacters directly, so the affected tests now verify the flag is passed through to PasswordText/ BitwardenTextValueField instead of the rendered accessibility value; the actual VoiceOver announcement should be confirmed on-device. --- .../Core/Platform/Extensions/String.swift | 10 -- .../Platform/Extensions/StringTests.swift | 8 -- .../Views/BitwardenTextValueField.swift | 4 +- .../Application/Views/PasswordText.swift | 4 +- ...nkAccountItemView+ViewInspectorTests.swift | 105 ++++++++++++------ .../VaultListItemRowView.swift | 4 +- 6 files changed, 72 insertions(+), 63 deletions(-) diff --git a/BitwardenKit/Core/Platform/Extensions/String.swift b/BitwardenKit/Core/Platform/Extensions/String.swift index a43124dae5..f86d13593a 100644 --- a/BitwardenKit/Core/Platform/Extensions/String.swift +++ b/BitwardenKit/Core/Platform/Extensions/String.swift @@ -115,16 +115,6 @@ public extension String { return result } - /// Returns a copy of the string with each character separated by a space so that VoiceOver - /// announces the characters individually instead of interpreting the string as a whole word - /// or number. - /// - /// - Returns: A copy of the string with each character separated by a space. - /// - func spellingOutCharacters() -> String { - map { String($0) }.joined(separator: " ") - } - /// Validates whether the string is a valid email address. /// /// - Parameter useStrictValidation: If `true`, validates against a regex pattern requiring diff --git a/BitwardenKit/Core/Platform/Extensions/StringTests.swift b/BitwardenKit/Core/Platform/Extensions/StringTests.swift index c6d1a76e4b..029fc75fde 100644 --- a/BitwardenKit/Core/Platform/Extensions/StringTests.swift +++ b/BitwardenKit/Core/Platform/Extensions/StringTests.swift @@ -32,14 +32,6 @@ class StringTests: BitwardenTestCase { XCTAssertEqual("4111-1111-1111-1111".formattedCreditCardNumber(), "4111-1111-1111-1111") } - /// `spellingOutCharacters()` separates each character with a space. - func test_spellingOutCharacters() { - XCTAssertEqual("1234".spellingOutCharacters(), "1 2 3 4") - XCTAssertEqual("BOFAUS3N".spellingOutCharacters(), "B O F A U S 3 N") - XCTAssertEqual("A".spellingOutCharacters(), "A") - XCTAssertEqual("".spellingOutCharacters(), "") - } - /// `hashColor` returns a color generated from a hash of the string's characters. func test_hashColor() { XCTAssertEqual("test".hashColor.description, "#924436FF") diff --git a/BitwardenKit/UI/Platform/Application/Views/BitwardenTextValueField.swift b/BitwardenKit/UI/Platform/Application/Views/BitwardenTextValueField.swift index fef7852973..f84118ebea 100644 --- a/BitwardenKit/UI/Platform/Application/Views/BitwardenTextValueField.swift +++ b/BitwardenKit/UI/Platform/Application/Views/BitwardenTextValueField.swift @@ -69,9 +69,7 @@ public struct BitwardenTextValueField: View where AccessoryCon : SharedAsset.Colors.textDisabled.swiftUIColor, ) .accessibilityIdentifier(valueAccessibilityIdentifier ?? value) - .if(spellOutAccessibilityValue) { textView in - textView.accessibilityValue(value.spellingOutCharacters()) - } + .speechSpellsOutCharacters(spellOutAccessibilityValue) .if(textSelectionEnabled) { textView in textView .textSelection(.enabled) diff --git a/BitwardenShared/UI/Platform/Application/Views/PasswordText.swift b/BitwardenShared/UI/Platform/Application/Views/PasswordText.swift index 0d9dab3774..34b4650004 100644 --- a/BitwardenShared/UI/Platform/Application/Views/PasswordText.swift +++ b/BitwardenShared/UI/Platform/Application/Views/PasswordText.swift @@ -26,9 +26,7 @@ struct PasswordText: View { : Text(String(repeating: "•", count: Constants.hiddenPasswordLength)), ) .styleGuide(.bodyMonospaced) - .if(spellOutAccessibilityValue && isPasswordVisible) { view in - view.accessibilityValue(password.spellingOutCharacters()) - } + .speechSpellsOutCharacters(spellOutAccessibilityValue && isPasswordVisible) } // MARK: Private Properties diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView+ViewInspectorTests.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView+ViewInspectorTests.swift index 9deb6dfd71..2a6ba831c8 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView+ViewInspectorTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView+ViewInspectorTests.swift @@ -1,11 +1,11 @@ // swiftlint:disable:this file_name -import BitwardenKit import BitwardenKitMocks import BitwardenResources import SwiftUI import ViewInspector import XCTest +@testable import BitwardenKit @testable import BitwardenShared class ViewBankAccountItemViewTests: BitwardenTestCase { @@ -210,67 +210,98 @@ class ViewBankAccountItemViewTests: BitwardenTestCase { ) } - /// The account number value announces its characters individually to VoiceOver when visible. + /// The account number field asks VoiceOver to spell out its characters individually when + /// visible. + /// + /// - Note: ViewInspector doesn't support inspecting `speechSpellsOutCharacters`, so this + /// verifies the flag is passed through to `PasswordText` rather than the final VoiceOver + /// announcement, which should be confirmed with on-device VoiceOver testing. @MainActor - func test_accountNumber_accessibilityValue() throws { + func test_accountNumber_spellsOutCharacters() throws { var state = populatedState() state.isAccountNumberVisible = true initSubject(state: state) - let value = try subject.inspect().find( - viewWithAccessibilityIdentifier: "BankAccountNumberEntry", - ).text().accessibilityValue().string() - XCTAssertEqual(value, "1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6") + let passwordText = try subject.inspect().find(PasswordText.self) { view in + try view.actualView().password == "1234567890123456" + }.actualView() + XCTAssertTrue(passwordText.spellOutAccessibilityValue) } - /// The PIN value announces its characters individually to VoiceOver when visible. + /// The PIN field asks VoiceOver to spell out its characters individually when visible. + /// + /// - Note: ViewInspector doesn't support inspecting `speechSpellsOutCharacters`, so this + /// verifies the flag is passed through to `PasswordText` rather than the final VoiceOver + /// announcement, which should be confirmed with on-device VoiceOver testing. @MainActor - func test_pin_accessibilityValue() throws { + func test_pin_spellsOutCharacters() throws { var state = populatedState() state.isPinVisible = true initSubject(state: state) - let value = try subject.inspect().find( - viewWithAccessibilityIdentifier: "BankAccountPinEntry", - ).text().accessibilityValue().string() - XCTAssertEqual(value, "1 2 3 4") + let passwordText = try subject.inspect().find(PasswordText.self) { view in + try view.actualView().password == "1234" + }.actualView() + XCTAssertTrue(passwordText.spellOutAccessibilityValue) } - /// The IBAN value announces its characters individually to VoiceOver when visible. + /// The IBAN field asks VoiceOver to spell out its characters individually when visible. + /// + /// - Note: ViewInspector doesn't support inspecting `speechSpellsOutCharacters`, so this + /// verifies the flag is passed through to `PasswordText` rather than the final VoiceOver + /// announcement, which should be confirmed with on-device VoiceOver testing. @MainActor - func test_iban_accessibilityValue() throws { + func test_iban_spellsOutCharacters() throws { var state = populatedState() state.isIbanVisible = true initSubject(state: state) - let value = try subject.inspect().find( - viewWithAccessibilityIdentifier: "BankAccountIbanEntry", - ).text().accessibilityValue().string() - XCTAssertEqual(value, "G B 3 3 B U K B 2 0 2 0 1 5 5 5 5 5 5 5 5 5") + let passwordText = try subject.inspect().find(PasswordText.self) { view in + try view.actualView().password == "GB33BUKB20201555555555" + }.actualView() + XCTAssertTrue(passwordText.spellOutAccessibilityValue) } - /// The routing number value announces its characters individually to VoiceOver. + /// The routing number field asks VoiceOver to spell out its characters individually. + /// + /// - Note: ViewInspector doesn't support inspecting `speechSpellsOutCharacters`, so this + /// verifies the flag is passed through to `BitwardenTextValueField` rather than the final + /// VoiceOver announcement, which should be confirmed with on-device VoiceOver testing. @MainActor - func test_routingNumber_accessibilityValue() throws { - let value = try subject.inspect().find( - viewWithAccessibilityIdentifier: "BankAccountRoutingNumberEntry", - ).accessibilityValue().string() - XCTAssertEqual(value, "1 2 3 4 5 6 7 8 9 0") + func test_routingNumber_spellsOutCharacters() throws { + let field = try subject.inspect().find( + BitwardenTextValueField.self, + ) { view in + try view.actualView().value == "1234567890" + }.actualView() + XCTAssertTrue(field.spellOutAccessibilityValue) } - /// The branch number value announces its characters individually to VoiceOver. + /// The branch number field asks VoiceOver to spell out its characters individually. + /// + /// - Note: ViewInspector doesn't support inspecting `speechSpellsOutCharacters`, so this + /// verifies the flag is passed through to `BitwardenTextValueField` rather than the final + /// VoiceOver announcement, which should be confirmed with on-device VoiceOver testing. @MainActor - func test_branchNumber_accessibilityValue() throws { - let value = try subject.inspect().find( - viewWithAccessibilityIdentifier: "BankAccountBranchNumberEntry", - ).accessibilityValue().string() - XCTAssertEqual(value, "1 0 0") + func test_branchNumber_spellsOutCharacters() throws { + let field = try subject.inspect().find( + BitwardenTextValueField.self, + ) { view in + try view.actualView().value == "100" + }.actualView() + XCTAssertTrue(field.spellOutAccessibilityValue) } - /// The SWIFT code value announces its characters individually to VoiceOver. + /// The SWIFT code field asks VoiceOver to spell out its characters individually. + /// + /// - Note: ViewInspector doesn't support inspecting `speechSpellsOutCharacters`, so this + /// verifies the flag is passed through to `BitwardenTextValueField` rather than the final + /// VoiceOver announcement, which should be confirmed with on-device VoiceOver testing. @MainActor - func test_swiftCode_accessibilityValue() throws { - let value = try subject.inspect().find( - viewWithAccessibilityIdentifier: "BankAccountSwiftCodeEntry", - ).accessibilityValue().string() - XCTAssertEqual(value, "B O F A U S 3 N") + func test_swiftCode_spellsOutCharacters() throws { + let field = try subject.inspect().find( + BitwardenTextValueField.self, + ) { view in + try view.actualView().value == "BOFAUS3N" + }.actualView() + XCTAssertTrue(field.spellOutAccessibilityValue) } /// An empty state renders no fields, so the copy and reveal buttons are absent. diff --git a/BitwardenShared/UI/Vault/Views/VaultListItemRow/VaultListItemRowView.swift b/BitwardenShared/UI/Vault/Views/VaultListItemRow/VaultListItemRowView.swift index e542433495..88c01d2aae 100644 --- a/BitwardenShared/UI/Vault/Views/VaultListItemRow/VaultListItemRowView.swift +++ b/BitwardenShared/UI/Vault/Views/VaultListItemRow/VaultListItemRowView.swift @@ -90,8 +90,8 @@ struct VaultListItemRowView: View { } .accessibilityLabel(Localizations.moreOptions) .accessibilityIdentifier("CipherOptionsButton") - .accessibilityAction(named: Localizations.moreOptions) { - Task { await store.perform(.morePressed) } + .accessibilityAsyncAction(named: Localizations.moreOptions) { + await store.perform(.morePressed) } } From 04c13c5ce65919f6cd890030ca83241b5f4365cc Mon Sep 17 00:00:00 2001 From: Morgan Zellers Date: Fri, 21 Aug 2026 11:03:48 -0500 Subject: [PATCH 7/7] [PM-41938] fix: Show blank Account Type until user selects a value The Add/Edit Bank Account screen's Account Type menu defaulted to displaying "Checking" even when the user had never opened the picker, because DefaultableType's default-case label reused the "Checking" option name instead of a blank placeholder. --- .../AddEditBankAccountItem/BankAccountItemStateTests.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditBankAccountItem/BankAccountItemStateTests.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditBankAccountItem/BankAccountItemStateTests.swift index f4e780d7f2..136b9c2d33 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditBankAccountItem/BankAccountItemStateTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditBankAccountItem/BankAccountItemStateTests.swift @@ -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) + } }