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/BitwardenKit/UI/Platform/Application/Views/BitwardenTextValueField.swift b/BitwardenKit/UI/Platform/Application/Views/BitwardenTextValueField.swift index 1e9f39f4c0..f84118ebea 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,7 @@ public struct BitwardenTextValueField: View where AccessoryCon : SharedAsset.Colors.textDisabled.swiftUIColor, ) .accessibilityIdentifier(valueAccessibilityIdentifier ?? value) + .speechSpellsOutCharacters(spellOutAccessibilityValue) .if(textSelectionEnabled) { textView in textView .textSelection(.enabled) @@ -87,6 +93,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 +104,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 +113,7 @@ public struct BitwardenTextValueField: View where AccessoryCon self.value = value self.useUIKitTextView = useUIKitTextView self.valueAccessibilityIdentifier = valueAccessibilityIdentifier + self.spellOutAccessibilityValue = spellOutAccessibilityValue self.accessoryContent = accessoryContent() } } @@ -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, @@ -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, @@ -137,6 +150,7 @@ public extension BitwardenTextValueField where AccessoryContent == EmptyView { valueAccessibilityIdentifier: valueAccessibilityIdentifier, textSelectionEnabled: textSelectionEnabled, useUIKitTextView: useUIKitTextView, + spellOutAccessibilityValue: spellOutAccessibilityValue, ) { EmptyView() } @@ -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. @@ -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, ) { @@ -176,6 +193,7 @@ public extension BitwardenTextValueField where AccessoryContent == AccessoryButt valueAccessibilityIdentifier: valueAccessibilityIdentifier, textSelectionEnabled: textSelectionEnabled, useUIKitTextView: useUIKitTextView, + spellOutAccessibilityValue: spellOutAccessibilityValue, accessoryContent: { AccessoryButton( asset: SharedAsset.Icons.copy24, 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/PasswordText.swift b/BitwardenShared/UI/Platform/Application/Views/PasswordText.swift index a60570da24..34b4650004 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,7 @@ struct PasswordText: View { : Text(String(repeating: "•", count: Constants.hiddenPasswordLength)), ) .styleGuide(.bodyMonospaced) + .speechSpellsOutCharacters(spellOutAccessibilityValue && isPasswordVisible) } // MARK: Private Properties 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/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/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) + } } 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, 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() } diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView+ViewInspectorTests.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView+ViewInspectorTests.swift index 71aeb80ad4..2a6ba831c8 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView+ViewInspectorTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewBankAccountItem/ViewBankAccountItemView+ViewInspectorTests.swift @@ -1,10 +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 { @@ -158,6 +159,151 @@ 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), + ) + } + + /// 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_spellsOutCharacters() throws { + var state = populatedState() + state.isAccountNumberVisible = true + initSubject(state: state) + let passwordText = try subject.inspect().find(PasswordText.self) { view in + try view.actualView().password == "1234567890123456" + }.actualView() + XCTAssertTrue(passwordText.spellOutAccessibilityValue) + } + + /// 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_spellsOutCharacters() throws { + var state = populatedState() + state.isPinVisible = true + initSubject(state: state) + let passwordText = try subject.inspect().find(PasswordText.self) { view in + try view.actualView().password == "1234" + }.actualView() + XCTAssertTrue(passwordText.spellOutAccessibilityValue) + } + + /// 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_spellsOutCharacters() throws { + var state = populatedState() + state.isIbanVisible = true + initSubject(state: state) + let passwordText = try subject.inspect().find(PasswordText.self) { view in + try view.actualView().password == "GB33BUKB20201555555555" + }.actualView() + XCTAssertTrue(passwordText.spellOutAccessibilityValue) + } + + /// 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_spellsOutCharacters() throws { + let field = try subject.inspect().find( + BitwardenTextValueField.self, + ) { view in + try view.actualView().value == "1234567890" + }.actualView() + XCTAssertTrue(field.spellOutAccessibilityValue) + } + + /// 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_spellsOutCharacters() throws { + let field = try subject.inspect().find( + BitwardenTextValueField.self, + ) { view in + try view.actualView().value == "100" + }.actualView() + XCTAssertTrue(field.spellOutAccessibilityValue) + } + + /// 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_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. @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..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,13 +97,16 @@ 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") } accessoryContent: { PasswordVisibilityButton( accessibilityIdentifier: "ShowBankAccountNumberButton", + accessibilityLabel: isVisible + ? Localizations.fieldValueIsVisibleTapToHide(Localizations.accountNumber) + : Localizations.fieldValueIsNotVisibleTapToShow(Localizations.accountNumber), isPasswordVisible: isVisible, ) { store.send(.bankAccountItemAction(.toggleAccountNumberVisibilityChanged(!isVisible))) @@ -125,13 +131,16 @@ 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") } accessoryContent: { PasswordVisibilityButton( accessibilityIdentifier: "ShowBankAccountIbanButton", + accessibilityLabel: isVisible + ? Localizations.fieldValueIsVisibleTapToHide(Localizations.iban) + : Localizations.fieldValueIsNotVisibleTapToShow(Localizations.iban), isPasswordVisible: isVisible, ) { store.send(.bankAccountItemAction(.toggleIbanVisibilityChanged(!isVisible))) @@ -156,13 +165,16 @@ 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") } accessoryContent: { PasswordVisibilityButton( accessibilityIdentifier: "ShowBankAccountPinButton", + accessibilityLabel: isVisible + ? Localizations.fieldValueIsVisibleTapToHide(Localizations.pin) + : Localizations.fieldValueIsNotVisibleTapToShow(Localizations.pin), isPasswordVisible: isVisible, ) { store.send(.bankAccountItemAction(.togglePinVisibilityChanged(!isVisible))) @@ -189,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( @@ -197,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)) }, ) 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..88c01d2aae 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") + .accessibilityAsyncAction(named: Localizations.moreOptions) { + await store.perform(.morePressed) + } } case let .group(group, count):