diff --git a/DashWallet/Sources/UI/SwiftUI Components/DashAmount.swift b/DashWallet/Sources/UI/SwiftUI Components/DashAmount.swift index 18510e2021..972f2b1275 100644 --- a/DashWallet/Sources/UI/SwiftUI Components/DashAmount.swift +++ b/DashWallet/Sources/UI/SwiftUI Components/DashAmount.swift @@ -49,6 +49,11 @@ struct DashAmount: View { DashSymbol() .padding(.leading, 2) } + // The currency symbol is an image, so the stack is collapsed into a + // single element with a spoken label; child-by-child, VoiceOver + // reads the digits and then the image asset name, never "Dash". + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(spokenAmount(cleanedAbsAmount))) } } @@ -70,6 +75,32 @@ struct DashAmount: View { } } + /// The VoiceOver utterance for the rendered row: the same formatted number + /// that is displayed, followed by the currency name. The "+"/"-" glyphs + /// become spoken words only when `showDirection` renders them, so the + /// utterance always matches what is visible. + private func spokenAmount(_ cleanedAbsAmount: String) -> String { + // "Dash" is the currency's proper name and is deliberately left + // unlocalized, matching the wordmark label in HomeBalanceView. + let unsignedAmount = "\(cleanedAbsAmount.trimmingCharacters(in: .whitespaces)) Dash" + + guard showDirection else { + return unsignedAmount + } + + if amount > 0 { + return String( + format: NSLocalizedString("Plus %@", comment: "VoiceOver-only label for an incoming amount; %@ is the amount with its currency, e.g. 'Plus 0.05 Dash'"), + unsignedAmount) + } else if amount < 0 { + return String( + format: NSLocalizedString("Minus %@", comment: "VoiceOver-only label for an outgoing amount; %@ is the amount with its currency, e.g. 'Minus 0.05 Dash'"), + unsignedAmount) + } else { + return unsignedAmount + } + } + private func cleanAmount(_ amount: String) -> String { var result = amount diff --git a/DashWallet/Sources/UI/SwiftUI Components/MenuItem.swift b/DashWallet/Sources/UI/SwiftUI Components/MenuItem.swift index 8547ecacf9..de7c7bc4d7 100644 --- a/DashWallet/Sources/UI/SwiftUI Components/MenuItem.swift +++ b/DashWallet/Sources/UI/SwiftUI Components/MenuItem.swift @@ -233,6 +233,11 @@ struct MenuItem: View { .tint(Color.dash.blue) .scaleEffect(0.75) .frame(maxWidth: 60) + // The row Button already carries the switch semantics + // (see `toggleRowAccessibility`), so this stays out of + // the accessibility tree rather than appearing as a + // second, nested control. + .accessibilityHidden(true) } if showChevron { @@ -273,6 +278,7 @@ struct MenuItem: View { } .padding(10) .frame(maxWidth: .infinity) + .toggleRowAccessibility(isToggleRow: showToggle, title: title, isOn: isToggled) .onChange(of: isToggled) { newValue in action?() } @@ -288,6 +294,33 @@ struct MenuItem: View { } } +extension View { + /// Presents a `MenuItem` that carries a switch as one accessibility element + /// with switch semantics. + /// + /// The row is a `Button` whose label contains the `Toggle`, so without this + /// the two nest: VoiceOver is handed two overlapping controls and has to + /// guess which one an activation belongs to. Collapsing the row to a single + /// element with `.isToggle` and a spoken on/off value leaves exactly one + /// target, while the `Button` keeps driving the state as before. + /// + /// Rows without a switch are untouched — `Button` already merges its label + /// content into one element with the correct trait. + @ViewBuilder + func toggleRowAccessibility(isToggleRow: Bool, title: String, isOn: Bool) -> some View { + if isToggleRow { + accessibilityElement(children: .ignore) + .accessibilityLabel(title) + .accessibilityValue(isOn + ? NSLocalizedString("On", comment: "Accessibility value for a settings switch that is enabled") + : NSLocalizedString("Off", comment: "Accessibility value for a settings switch that is disabled")) + .accessibilityAddTraits(.isToggle) + } else { + self + } + } +} + #Preview { VStack { MenuItem( diff --git a/DashWallet/Sources/UI/SwiftUI Components/NavigationBar.swift b/DashWallet/Sources/UI/SwiftUI Components/NavigationBar.swift index d331b60add..a8695c5c02 100644 --- a/DashWallet/Sources/UI/SwiftUI Components/NavigationBar.swift +++ b/DashWallet/Sources/UI/SwiftUI Components/NavigationBar.swift @@ -113,6 +113,20 @@ enum NavigationBarElement: String { .contentShape(Rectangle()) } .buttonStyle(NavigationBarButtonStyle()) + .accessibilityLabel(Text(accessibilityLabelText)) + } + + var accessibilityLabelText: String { + switch self { + case .back: + return NSLocalizedString("Back", comment: "Accessibility label for the navigation bar back button") + case .close: + return NSLocalizedString("Close", comment: "Accessibility label for the navigation bar close button") + case .plus: + return NSLocalizedString("Add", comment: "Accessibility label for the navigation bar add button") + case .info: + return NSLocalizedString("Info", comment: "Accessibility label for the navigation bar info button") + } } } @@ -169,6 +183,7 @@ struct NavBarBack: View { .foregroundColor(.dash.primaryText) } } + .accessibilityLabel(Text(NavigationBarElement.back.accessibilityLabelText)) .padding(.leading, 20) Spacer() @@ -225,6 +240,7 @@ struct NavBarBackPlus: View { .foregroundColor(.dash.primaryText) } } + .accessibilityLabel(Text(NavigationBarElement.back.accessibilityLabelText)) .padding(.leading, 20) Spacer() @@ -246,6 +262,7 @@ struct NavBarBackPlus: View { .foregroundColor(.dash.primaryText) } } + .accessibilityLabel(Text(NavigationBarElement.plus.accessibilityLabelText)) .padding(.trailing, 20) } .frame(height: 64) @@ -300,6 +317,7 @@ struct NavBarClose: View { .foregroundColor(.dash.primaryText) } } + .accessibilityLabel(Text(NavigationBarElement.close.accessibilityLabelText)) .padding(.trailing, 20) } .frame(height: 64) diff --git a/DashWallet/Sources/UI/Views/Navigation/BaseNavigationController.swift b/DashWallet/Sources/UI/Views/Navigation/BaseNavigationController.swift index e8cc7aa81b..f82b991dc0 100644 --- a/DashWallet/Sources/UI/Views/Navigation/BaseNavigationController.swift +++ b/DashWallet/Sources/UI/Views/Navigation/BaseNavigationController.swift @@ -178,8 +178,10 @@ extension BaseNavigationController: UINavigationControllerDelegate { backButton.frame = .init(x: 0, y: 0, width: 30, height: 30) backButton.setImage(UIImage(systemName: "arrow.backward"), for: .normal) backButton.tintColor = backButtonTintColor + backButton.accessibilityLabel = NSLocalizedString("Back", comment: "Accessibility label for the navigation bar back button") backButton.addTarget(self, action: #selector(backButtonAction), for: .touchUpInside) let item = UIBarButtonItem(customView: backButton) + item.accessibilityLabel = backButton.accessibilityLabel viewController.navigationItem.leftBarButtonItem = item viewController.navigationItem.leftItemsSupplementBackButton = false diff --git a/DashWallet/Sources/UI/Views/SharedViews/Keyboard/NumberKeyboardButton.swift b/DashWallet/Sources/UI/Views/SharedViews/Keyboard/NumberKeyboardButton.swift index 290442272c..2ba84045f0 100644 --- a/DashWallet/Sources/UI/Views/SharedViews/Keyboard/NumberKeyboardButton.swift +++ b/DashWallet/Sources/UI/Views/SharedViews/Keyboard/NumberKeyboardButton.swift @@ -120,6 +120,9 @@ class NumberKeyboardButton: UIView { private func reloadTitle() { #if SNAPSHOT if value == .separator { + // The button view is the exposed accessibility element, so the XCUITest + // id lives on it as well as on the label + accessibilityIdentifier = "amount_button_separator" titleLabel.accessibilityIdentifier = "amount_button_separator" } #endif // SNAPSHOT @@ -153,6 +156,37 @@ class NumberKeyboardButton: UIView { titleLabel.attributedText = attributedText } + + updateAccessibility() + } + + private func updateAccessibility() { + switch value { + case .empty: + // A spacer key: hidden from VoiceOver entirely + isAccessibilityElement = false + accessibilityElementsHidden = true + accessibilityLabel = nil + accessibilityTraits = [] + case .digit, .custom: + isAccessibilityElement = true + accessibilityElementsHidden = false + accessibilityLabel = value.stringValue + accessibilityTraits = .button + case .separator: + isAccessibilityElement = true + accessibilityElementsHidden = false + accessibilityLabel = NSLocalizedString("Decimal separator", + comment: "VoiceOver label for the decimal separator key on the number keyboard") + accessibilityTraits = .button + case .delete: + // The visible title is an SF Symbol attachment, so the spoken label is set explicitly + isAccessibilityElement = true + accessibilityElementsHidden = false + accessibilityLabel = NSLocalizedString("Delete", + comment: "VoiceOver label for the delete key on the number keyboard") + accessibilityTraits = .button + } } private func updateBackgroundView() { diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index f9dfb84b1b..be4f28cac0 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -474,6 +474,9 @@ /* DashPay Contacts */ "Available: %@ DASH" = "Available: %@ DASH"; +/* Accessibility label for the navigation bar back button */ +"Back" = "Back"; + /* Dash DEX */ "Back home" = "Back home"; @@ -746,6 +749,9 @@ /* DashPay: title of the enable success sheet */ "DashPay enabled" = "DashPay enabled"; +/* Accessibility label for the decimal separator key on the number keypad */ +"Decimal separator" = "Decimal separator"; + /* Identity top-up sheet — custom amount below the floor */ "Discard" = "Discard"; "Enter at least %@ DASH" = "Enter at least %@ DASH"; @@ -793,6 +799,9 @@ /* Usernames */ "In the meantime you are reachable at “%1$@”, which is yours to keep. If the vote awards you “%2$@”, you will be reachable at both usernames." = "In the meantime you are reachable at “%1$@”, which is yours to keep. If the vote awards you “%2$@”, you will be reachable at both usernames."; +/* Accessibility label for the navigation bar info button */ +"Info" = "Info"; + /* Username marketplace: search row for a label a past vote locked */ "Inputs" = "Inputs"; "Locked by a network vote — nobody can register it" = "Locked by a network vote — nobody can register it"; @@ -812,6 +821,9 @@ /* Username marketplace: set price fee note */ "Listing is a network transaction with a small fee, paid from your identity balance." = "Listing is a network transaction with a small fee, paid from your identity balance."; +/* VoiceOver-only label for an outgoing amount; %@ is the amount with its currency, e.g. 'Minus 0.05 Dash' */ +"Minus %@" = "Minus %@"; + /* DashPay: banner fallback when the identity has no name yet */ "Move %@ from shielded balance" = "Move %@ from shielded balance"; "Moving funds from the shielded balance…" = "Moving funds from the shielded balance…"; @@ -831,9 +843,18 @@ /* Usernames */ "Non-standard output" = "Non-standard output"; +/* Accessibility value for a settings switch that is enabled */ +"On" = "On"; + +/* Accessibility value for a settings switch that is disabled */ +"Off" = "Off"; + "Operator payout address" = "Operator payout address"; "Operator signature (BLS)" = "Operator signature (BLS)"; "Platform P2P port" = "Platform P2P port"; +/* VoiceOver-only label for an incoming amount; %@ is the amount with its currency, e.g. 'Plus 0.05 Dash' */ +"Plus %@" = "Plus %@"; + "Provider update payload" = "Provider update payload"; "Receive some DASH to this wallet, or fund its shielded balance, then return here." = "Receive some DASH to this wallet, or fund its shielded balance, then return here."; "Register username" = "Register username"; diff --git a/scripts/a11y_baseline.json b/scripts/a11y_baseline.json index 9afabcbcc9..0ee8cf53af 100644 --- a/scripts/a11y_baseline.json +++ b/scripts/a11y_baseline.json @@ -7,18 +7,17 @@ ], "counts": { "A11Y008": 8, - "A11Y002": 10, + "A11Y002": 9, "A11Y003": 1, - "A11Y004": 37, - "A11Y005": 1, - "A11Y006": 2, + "A11Y004": 33, + "A11Y006": 1, "A11Y009": 2, "A11Y010": 3, "A11Y011": 11, "A11Y012": 31, "A11Y007": 559 }, - "total": 665, + "total": 658, "findings": [ { "rule": "A11Y008", @@ -139,13 +138,6 @@ "line_when_recorded": 80, "snippet": "let barButtonItem = UIBarButtonItem(customView: activityIndicator)" }, - { - "rule": "A11Y002", - "path": "DashWallet/Sources/UI/Views/Navigation/BaseNavigationController.swift", - "fingerprint": "6ab921512e79bf0f", - "line_when_recorded": 182, - "snippet": "let item = UIBarButtonItem(customView: backButton)" - }, { "rule": "A11Y003", "path": "DashWallet/Sources/UI/Explore Dash/Merchants & ATMs/List/Views/ExploreMapView.swift", @@ -377,34 +369,6 @@ "line_when_recorded": 61, "snippet": "Button(action: { torchOn.toggle() }) {" }, - { - "rule": "A11Y004", - "path": "DashWallet/Sources/UI/SwiftUI Components/NavigationBar.swift", - "fingerprint": "099a421bad6a3019", - "line_when_recorded": 152, - "snippet": "Button(action: onBack) {" - }, - { - "rule": "A11Y004", - "path": "DashWallet/Sources/UI/SwiftUI Components/NavigationBar.swift", - "fingerprint": "099a421bad6a3019", - "line_when_recorded": 211, - "snippet": "Button(action: onBack) {" - }, - { - "rule": "A11Y004", - "path": "DashWallet/Sources/UI/SwiftUI Components/NavigationBar.swift", - "fingerprint": "7690c023535454ae", - "line_when_recorded": 233, - "snippet": "Button(action: onAdd) {" - }, - { - "rule": "A11Y004", - "path": "DashWallet/Sources/UI/SwiftUI Components/NavigationBar.swift", - "fingerprint": "07fa3ed86b3e1f10", - "line_when_recorded": 287, - "snippet": "Button(action: onClose) {" - }, { "rule": "A11Y004", "path": "DashWallet/Sources/UI/SwiftUI Components/SendIntro.swift", @@ -412,13 +376,6 @@ "line_when_recorded": 69, "snippet": "Button(action: {" }, - { - "rule": "A11Y005", - "path": "DashWallet/Sources/UI/SwiftUI Components/MenuItem.swift", - "fingerprint": "34bcf206edfe454d", - "line_when_recorded": 232, - "snippet": "Toggle(isOn: $isToggled) { }" - }, { "rule": "A11Y006", "path": "DashWallet/Sources/UI/CrowdNode/Portal/CrowdNodePortalViewController.swift", @@ -426,13 +383,6 @@ "line_when_recorded": 109, "snippet": "let backButton = UIButton(type: .custom)" }, - { - "rule": "A11Y006", - "path": "DashWallet/Sources/UI/Views/Navigation/BaseNavigationController.swift", - "fingerprint": "6ee73a8d95d7ac7f", - "line_when_recorded": 177, - "snippet": "let backButton = UIButton(type: .custom)" - }, { "rule": "A11Y009", "path": "DashWallet/Sources/UI/Menu/Tools/StorageExplorer/StorageModelListViews.swift",