From 1aa732e745f5cb56e23d93d848cf5044562a55d8 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:30:07 +0300 Subject: [PATCH 1/2] fix(components): label icon-only controls for VoiceOver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Swap and Coinbase screens in the wallet are largely accessible only because they inherit this package's labels — their own files contain no accessibility calls at all. The gaps here are inherited just as widely. Toast's dismiss button, ConverterArrowBadge and the currency-select chevron announced their asset or SF Symbol names ("diagonal-up-down", "icon copy outline"), which sounds like a working label in a quick VoiceOver pass but tells a blind user nothing. Each now carries a proper label, and the chevron also carries an accessibilityValue naming the currency it currently holds — a picker that never says what it is set to is unusable non-visually. DualSwapAmountView's swap area was a bare container .onTapGesture: no button trait, no label, and no way to activate it. It is now a single element with both amounts in its label and an accessibilityAction, so double-tap swaps. DashButton gains an optional accessibilityLabel. Constructing it with an icon and no text produced a button whose only announcement was the asset name, with no way for a caller to override — an API that made accessibility impossible rather than merely absent. The parameter defaults to nil and sits before `action:`, so every existing call site compiles unchanged. The static rules the wallet uses do not catch the chevron: its label is DashIcon.Common.chevronDownCurrencySelect.image rather than a literal Image, so the rule sees no bare image. Found by reading, not by tooling. Verified with swift build and swift build --build-tests. --- Sources/DashUIKit/Button/DashButton.swift | 15 +++++++++++ .../ConverterCard/ConverterArrowBadge.swift | 4 +++ .../EnterAmount/DualSwapAmountView.swift | 26 +++++++++++++++++++ .../EnterAmount/SwapAmountView.swift | 21 +++++++++++++++ Sources/DashUIKit/Components/Toast.swift | 1 + 5 files changed, 67 insertions(+) diff --git a/Sources/DashUIKit/Button/DashButton.swift b/Sources/DashUIKit/Button/DashButton.swift index c84b256..b90d981 100644 --- a/Sources/DashUIKit/Button/DashButton.swift +++ b/Sources/DashUIKit/Button/DashButton.swift @@ -133,6 +133,10 @@ public struct DashButton: View { public var size: DashButtonSize = .large public var style: DashButtonStyle = .filledBlue + /// VoiceOver label for a button whose content doesn't announce itself — + /// an icon-only button has no text, so without this it reads as the icon's + /// asset name. Ignored when nil; `text` then remains the announcement. + public var accessibilityLabel: String? = nil public var action: () -> Void = {} public init( @@ -144,6 +148,7 @@ public struct DashButton: View { fillsWidth: Bool = false, size: DashButtonSize, style: DashButtonStyle, + accessibilityLabel: String? = nil, action: @escaping () -> Void = {} ) { self.text = text @@ -154,10 +159,20 @@ public struct DashButton: View { self.fillsWidth = fillsWidth self.size = size self.style = style + self.accessibilityLabel = accessibilityLabel self.action = action } public var body: some View { + if let accessibilityLabel { + coreButton + .accessibilityLabel(Text(accessibilityLabel)) + } else { + coreButton + } + } + + private var coreButton: some View { Button(action: action) { styledContent } diff --git a/Sources/DashUIKit/Components/ConverterCard/ConverterArrowBadge.swift b/Sources/DashUIKit/Components/ConverterCard/ConverterArrowBadge.swift index 31168fa..a17cb86 100644 --- a/Sources/DashUIKit/Components/ConverterCard/ConverterArrowBadge.swift +++ b/Sources/DashUIKit/Components/ConverterCard/ConverterArrowBadge.swift @@ -39,8 +39,12 @@ struct ConverterArrowBadge: View { badge(iconName: "diagonal-up-down", iconRotation: rotation) } .buttonStyle(.plain) + .accessibilityLabel(Text(NSLocalizedString("Swap direction", bundle: .module, comment: "DashUIKit"))) } else { + // Decorative — the card's row order already conveys the direction, + // and without this the icon announces its asset name. badge(iconName: "arrow-down", iconRotation: 0) + .accessibilityHidden(true) } } .frame(height: 35) diff --git a/Sources/DashUIKit/Components/EnterAmount/DualSwapAmountView.swift b/Sources/DashUIKit/Components/EnterAmount/DualSwapAmountView.swift index 2830e6c..3facd31 100644 --- a/Sources/DashUIKit/Components/EnterAmount/DualSwapAmountView.swift +++ b/Sources/DashUIKit/Components/EnterAmount/DualSwapAmountView.swift @@ -64,6 +64,32 @@ internal struct DualSwapAmountView: View { .contentShape(Rectangle()) .onTapGesture { onSwap() } .dashPasteContextMenu(onPaste: onPaste) + // The swap lives on a bare tap gesture, so VoiceOver gets an explicit + // element: one button announcing both amounts, activated to swap. The + // chevron button inside survives `.combine` as a custom action. + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(accessibilityDescription)) + .accessibilityAddTraits(.isButton) + .accessibilityHint(Text(NSLocalizedString( + "Switches which currency you enter the amount in", + bundle: .module, + comment: "DashUIKit" + ))) + .accessibilityAction { onSwap() } + } + + /// Both amounts with their currency names, in visual order (primary first). + /// The B row's error message stands in for the secondary amount when present, + /// mirroring what the view draws. + private var accessibilityDescription: String { + let primary = "\(primaryAmount.isEmpty ? "0" : primaryAmount) \(primaryCurrency.displayName)" + let secondary: String + if let secondaryErrorMessage { + secondary = secondaryErrorMessage + } else { + secondary = "\(secondaryAmount.isEmpty ? "0" : secondaryAmount) \(secondaryCurrency.displayName)" + } + return "\(primary), \(secondary)" } } diff --git a/Sources/DashUIKit/Components/EnterAmount/SwapAmountView.swift b/Sources/DashUIKit/Components/EnterAmount/SwapAmountView.swift index ec60f0a..5e89b88 100644 --- a/Sources/DashUIKit/Components/EnterAmount/SwapAmountView.swift +++ b/Sources/DashUIKit/Components/EnterAmount/SwapAmountView.swift @@ -177,6 +177,8 @@ public struct SwapAmountView: View { .frame(width: 10, height: 5) } .buttonStyle(.plain) + .accessibilityLabel(Text(NSLocalizedString("Select currency", bundle: .module, comment: "DashUIKit"))) + .accessibilityValue(Text(currencyAccessibilityValue)) } } .scaleToFitWidth() @@ -244,6 +246,13 @@ public struct SwapAmountView: View { if let first = s.first, first == "." || first == "," { return "0" + s } return s } + + /// What the currency picker currently holds, as VoiceOver reads it: + /// the row's symbol, or "Dash" when the row shows the Dash logo instead. + private var currencyAccessibilityValue: String { + if let sym = symbol, !sym.isEmpty { return sym } + return showDashLogo ? "Dash" : "" + } } // MARK: - AnimatedSwapLayout @@ -393,6 +402,8 @@ private struct AnimatedSwapLayout: View { .frame(width: chevronSize.width, height: chevronSize.height) } .buttonStyle(.plain) + .accessibilityLabel(Text(NSLocalizedString("Select currency", bundle: .module, comment: "DashUIKit"))) + .accessibilityValue(Text(secondaryCurrencyAccessibilityValue)) } } .foregroundColor(fontPrimary ? Color.dash.tertiaryText : Color.dash.primaryText) @@ -477,6 +488,13 @@ private struct AnimatedSwapLayout: View { if let first = s.first, first == "." || first == "," { return "0" + s } return s } + + /// What the B row's currency picker currently holds, as VoiceOver reads it: + /// the row's symbol, or "Dash" when the row shows the Dash logo instead. + private var secondaryCurrencyAccessibilityValue: String { + if let sym = secondarySymbol, !sym.isEmpty { return sym } + return showSecondaryDashLogo ? "Dash" : "" + } } // MARK: - Paste Context Menu @@ -660,6 +678,9 @@ private struct SwapAmountAnimatedPreview: View { ) .contentShape(Rectangle()) .onTapGesture { isPrimarySelected.toggle() } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilityAction { isPrimarySelected.toggle() } Button("Tap to swap") { isPrimarySelected.toggle() } .dashFont(.footnote) diff --git a/Sources/DashUIKit/Components/Toast.swift b/Sources/DashUIKit/Components/Toast.swift index 84dfbf7..b135e4b 100644 --- a/Sources/DashUIKit/Components/Toast.swift +++ b/Sources/DashUIKit/Components/Toast.swift @@ -104,6 +104,7 @@ public struct Toast: View { .background(Circle().fill(Color.dash.whiteAlpha10)) } .buttonStyle(.plain) + .accessibilityLabel(Text(NSLocalizedString("Close", bundle: .module, comment: "DashUIKit"))) } } .padding(.leading, 12) From b76f6b3bac0190d4df03dc155e175215c800a638 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:37:58 +0300 Subject: [PATCH 2/2] docs(components): correct what accessibilityDescription's order describes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From @llbartekll's review of #18. The doc comment said the two amounts are listed "in visual order (primary first)", but AnimatedSwapLayout offsets the A and B rows past each other, so when isPrimaryLarge is false the secondary amount is the one drawn on top and the two orders diverge. The order in the label is logical, not visual, and the announcement is still unambiguous because each amount carries its own currency name — so the comment was the thing that was wrong, not the code. Reworded to say that, and to name the case where the two differ. swift build clean. --- .../Components/EnterAmount/DualSwapAmountView.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/DashUIKit/Components/EnterAmount/DualSwapAmountView.swift b/Sources/DashUIKit/Components/EnterAmount/DualSwapAmountView.swift index 3facd31..8e34914 100644 --- a/Sources/DashUIKit/Components/EnterAmount/DualSwapAmountView.swift +++ b/Sources/DashUIKit/Components/EnterAmount/DualSwapAmountView.swift @@ -78,7 +78,14 @@ internal struct DualSwapAmountView: View { .accessibilityAction { onSwap() } } - /// Both amounts with their currency names, in visual order (primary first). + /// Both amounts with their currency names, primary first. + /// + /// That is the logical order, not necessarily the visual one: the animated + /// layout offsets the A and B rows past each other, so with + /// `isPrimaryLarge == false` the secondary amount is the one drawn on top. + /// Each amount carries its own currency name, so the announcement stays + /// unambiguous either way. + /// /// The B row's error message stands in for the secondary amount when present, /// mirroring what the view draws. private var accessibilityDescription: String {