Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions DashWallet/Sources/UI/SwiftUI Components/DashAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
}

Expand All @@ -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

Expand Down
33 changes: 33 additions & 0 deletions DashWallet/Sources/UI/SwiftUI Components/MenuItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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?()
}
Expand All @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions DashWallet/Sources/UI/SwiftUI Components/NavigationBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}

Expand Down Expand Up @@ -169,6 +183,7 @@ struct NavBarBack: View {
.foregroundColor(.dash.primaryText)
}
}
.accessibilityLabel(Text(NavigationBarElement.back.accessibilityLabelText))
.padding(.leading, 20)

Spacer()
Expand Down Expand Up @@ -225,6 +240,7 @@ struct NavBarBackPlus: View {
.foregroundColor(.dash.primaryText)
}
}
.accessibilityLabel(Text(NavigationBarElement.back.accessibilityLabelText))
.padding(.leading, 20)

Spacer()
Expand All @@ -246,6 +262,7 @@ struct NavBarBackPlus: View {
.foregroundColor(.dash.primaryText)
}
}
.accessibilityLabel(Text(NavigationBarElement.plus.accessibilityLabelText))
.padding(.trailing, 20)
}
.frame(height: 64)
Expand Down Expand Up @@ -300,6 +317,7 @@ struct NavBarClose: View {
.foregroundColor(.dash.primaryText)
}
}
.accessibilityLabel(Text(NavigationBarElement.close.accessibilityLabelText))
.padding(.trailing, 20)
}
.frame(height: 64)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment on lines +172 to +175

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Update the screenshot test to query keypad digits as buttons

Making the key itself an accessibility element with the button trait replaces its child label's static-text representation. However, DashWalletScreenshotsUITests/DashWalletScreenshotsUITests.swift:63 still uses waitAndTap(app.staticTexts["1"]). When the _SNAPSHOT flow reaches amount entry, that query no longer targets the keypad digit and the test cannot proceed to the send-confirmation and receive screenshots. Update the selector to waitAndTap(app.buttons["1"]) alongside this accessibility change.

source: ['claude']

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() {
Expand Down
21 changes: 21 additions & 0 deletions DashWallet/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand All @@ -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…";
Expand All @@ -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";
Expand Down
58 changes: 4 additions & 54 deletions scripts/a11y_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -377,62 +369,20 @@
"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",
"fingerprint": "e5251255b9f564fd",
"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",
"fingerprint": "78003431c215cd98",
"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",
Expand Down
Loading