From 6bc470d02998e6d3f29cde96fccef28772e1e74b Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Mon, 29 Jun 2026 16:52:54 +0200 Subject: [PATCH 1/4] Add accessibilityLabel override to AttributedLabel Adds an optional `accessibilityLabel: String?` to the AttributedLabel model: `nil` (default) derives the label from the displayed text as before, a non-nil value overrides it, and `""` suppresses the spoken label entirely (useful when content is surfaced via accessibilityValue and the label is merged into a combined accessibility element). The expensive text-derived label is now cached behind the existing text-change guard in LabelView, while the override is applied on every update so a change takes effect even when the text is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/AttributedLabel.swift | 24 +++++- .../Tests/Sources/AttributedLabelTests.swift | 84 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/BlueprintUICommonControls/Sources/AttributedLabel.swift b/BlueprintUICommonControls/Sources/AttributedLabel.swift index 395e4f274..5537fa680 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel.swift @@ -77,6 +77,15 @@ public struct AttributedLabel: Element, Hashable { /// A set of accessibility traits that should be applied to the label, these will be merged with any existing traits. public var accessibilityTraits: Set? + /// Overrides the automatically-derived accessibility label. + /// + /// When `nil` (the default), the label is derived from the displayed text — current behavior. + /// Provide a string to override it, or `""` to suppress the spoken label entirely. Suppression + /// is useful when the text is surfaced through `accessibilityValue` / `accessibilityHint` + /// instead, so the content is not announced twice (e.g. when this label is merged into a + /// combined accessibility element). + public var accessibilityLabel: String? + /// A localized string that represents the current value of the accessibility element. /// /// The value is a localized string that contains the current value of an element. @@ -203,6 +212,12 @@ extension AttributedLabel { private var links: [Link] = [] private var linkElements: [LinkElement] = [] + /// The accessibility label derived from the displayed text. Recomputed only when the text + /// changes (see the `previousAttributedText != attributedText` guard in `update`), and cached + /// here so an `accessibilityLabel` override change can be applied on every update without + /// re-running the expensive text derivation. + private var derivedAccessibilityLabel: String? + private var textRectOffset: UIOffset = .zero { didSet { if oldValue != textRectOffset { @@ -298,7 +313,9 @@ extension AttributedLabel { if previousAttributedText != attributedText { links = attributedLinks(in: model.attributedText) + detectedDataLinks(in: model.attributedText) - accessibilityLabel = accessibilityLabel( + // Cache the (expensive, link-enumerating) text-derived label so we can re-apply + // the override below on every update without recomputing it. + derivedAccessibilityLabel = accessibilityLabel( with: links, in: model.attributedText.string, linkAccessibilityLabel: environment.linkAccessibilityLabel @@ -308,6 +325,11 @@ extension AttributedLabel { .compactMap { .init(sourceLabel: attributedText, link: $0) } } + // Apply the override on every update so a change to `model.accessibilityLabel` takes + // effect even when the text is unchanged. `nil` falls back to the cached derived label + // (current behavior); `""` explicitly suppresses the spoken label. + accessibilityLabel = model.accessibilityLabel ?? derivedAccessibilityLabel + if let shadow = model.shadow { layer.shadowRadius = shadow.radius layer.shadowOpacity = Float(shadow.opacity) diff --git a/BlueprintUICommonControls/Tests/Sources/AttributedLabelTests.swift b/BlueprintUICommonControls/Tests/Sources/AttributedLabelTests.swift index 15a195394..2ac29adf1 100644 --- a/BlueprintUICommonControls/Tests/Sources/AttributedLabelTests.swift +++ b/BlueprintUICommonControls/Tests/Sources/AttributedLabelTests.swift @@ -1,4 +1,5 @@ import BlueprintUI +import BlueprintUIAccessibilityCore import XCTest @testable import BlueprintUICommonControls @@ -90,6 +91,89 @@ class AttributedLabelTests: XCTestCase { } } + func test_accessibilityLabel_derivedFromTextByDefault() { + // `nil` (the default) preserves the existing behavior: the backing view's accessibility + // label is derived from the displayed text. + let label = AttributedLabel(attributedText: NSAttributedString(string: "Hello, World!")) + + let labelView = AttributedLabel.LabelView() + labelView.update(model: label, text: label.displayableAttributedText, environment: .empty, isMeasuring: false) + + XCTAssertNil(label.accessibilityLabel) + XCTAssertEqual(labelView.accessibilityLabel, "Hello, World!") + } + + func test_accessibilityLabel_override() { + // A non-nil value overrides the text-derived label. + let label = AttributedLabel(attributedText: NSAttributedString(string: "Hello, World!")) { + $0.accessibilityLabel = "Custom" + } + + let labelView = AttributedLabel.LabelView() + labelView.update(model: label, text: label.displayableAttributedText, environment: .empty, isMeasuring: false) + + XCTAssertEqual(labelView.accessibilityLabel, "Custom") + } + + func test_accessibilityLabel_suppressed() { + // An empty string suppresses the spoken label entirely: the backing view reports "", + // *not* the displayed text. (UILabel returns an explicitly-set empty string verbatim, + // rather than falling back to its text the way it does for `nil`.) + let label = AttributedLabel(attributedText: NSAttributedString(string: "Hello, World!")) { + $0.accessibilityLabel = "" + } + + let labelView = AttributedLabel.LabelView() + labelView.update(model: label, text: label.displayableAttributedText, environment: .empty, isMeasuring: false) + + XCTAssertEqual(labelView.accessibilityLabel, "") + } + + func test_accessibilityLabel_appliesWithoutTextChange() { + // Changing only the override (with the text unchanged) must still update the backing view's + // label. This proves the override is applied outside the text-change guard. + let string = NSAttributedString(string: "Hello, World!") + let labelView = AttributedLabel.LabelView() + + var label = AttributedLabel(attributedText: string) + labelView.update(model: label, text: label.displayableAttributedText, environment: .empty, isMeasuring: false) + XCTAssertEqual(labelView.accessibilityLabel, "Hello, World!") + + label.accessibilityLabel = "Custom" + labelView.update(model: label, text: label.displayableAttributedText, environment: .empty, isMeasuring: false) + XCTAssertEqual(labelView.accessibilityLabel, "Custom") + + label.accessibilityLabel = "" + labelView.update(model: label, text: label.displayableAttributedText, environment: .empty, isMeasuring: false) + XCTAssertEqual(labelView.accessibilityLabel, "") + + // Back to `nil` falls through to the cached derived label, not UILabel's text fallback. + label.accessibilityLabel = nil + labelView.update(model: label, text: label.displayableAttributedText, environment: .empty, isMeasuring: false) + XCTAssertEqual(labelView.accessibilityLabel, "Hello, World!") + } + + func test_accessibilityLabel_suppressionInComposition() { + // A suppressed ("") label paired with a value contributes only to the combined *value* when + // merged into a composite accessibility element — the empty label is filtered out, so the + // content isn't announced twice. + let label = AttributedLabel(attributedText: NSAttributedString(string: "Displayed text")) { + $0.accessibilityLabel = "" + $0.accessibilityValue = "X" + } + + let labelView = AttributedLabel.LabelView() + labelView.update(model: label, text: label.displayableAttributedText, environment: .empty, isMeasuring: false) + + XCTAssertEqual(labelView.accessibilityLabel, "") + XCTAssertEqual(labelView.accessibilityValue, "X") + + let representation = AccessibilityComposition.CompositeRepresentation([labelView]) {} + + XCTAssertNil(representation.label) + XCTAssertEqual(representation.value, "X") + } + func test_displaysText() { let string = NSAttributedString() .appending(string: "H", font: .boldSystemFont(ofSize: 24.0), color: .red) From b3d8677a4d8b491bebb4f99de6f68466261ccd17 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Tue, 30 Jun 2026 14:45:19 +0200 Subject: [PATCH 2/4] Forward accessibilityLabel override from Label to AttributedLabel Co-Authored-By: Claude Opus 4.8 (1M context) --- BlueprintUICommonControls/Sources/Label.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/BlueprintUICommonControls/Sources/Label.swift b/BlueprintUICommonControls/Sources/Label.swift index 803d9d3c7..8c7fc5bb5 100644 --- a/BlueprintUICommonControls/Sources/Label.swift +++ b/BlueprintUICommonControls/Sources/Label.swift @@ -57,6 +57,15 @@ public struct Label: ProxyElement { /// Determines if the label should be included when navigating the UI via accessibility. public var isAccessibilityElement = true + /// Overrides the automatically-derived accessibility label. + /// + /// When `nil` (the default), the label is derived from the displayed text — current behavior. + /// Provide a string to override it, or `""` to suppress the spoken label entirely. Suppression + /// is useful when the text is surfaced through `accessibilityValue` / `accessibilityHint` + /// instead, so the content is not announced twice (e.g. when this label is merged into a + /// combined accessibility element). + public var accessibilityLabel: String? + /// A localized string that represents the current value of the accessibility element. /// /// The value is a localized string that contains the current value of an element. @@ -108,6 +117,7 @@ public struct Label: ProxyElement { label.numberOfLines = numberOfLines label.shadow = shadow label.isAccessibilityElement = isAccessibilityElement + label.accessibilityLabel = accessibilityLabel label.accessibilityValue = accessibilityValue label.accessibilityHint = accessibilityHint label.accessibilityTraits = accessibilityTraits From 4ccde7c3a1f4dc95c9e82c8a6a55c7405e315e79 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Tue, 7 Jul 2026 16:32:30 +0200 Subject: [PATCH 3/4] Trim redundant comments in AttributedLabel Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/AttributedLabel.swift | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/BlueprintUICommonControls/Sources/AttributedLabel.swift b/BlueprintUICommonControls/Sources/AttributedLabel.swift index 5537fa680..e6886244b 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel.swift @@ -212,10 +212,8 @@ extension AttributedLabel { private var links: [Link] = [] private var linkElements: [LinkElement] = [] - /// The accessibility label derived from the displayed text. Recomputed only when the text - /// changes (see the `previousAttributedText != attributedText` guard in `update`), and cached - /// here so an `accessibilityLabel` override change can be applied on every update without - /// re-running the expensive text derivation. + /// Text-derived label, cached so an `accessibilityLabel` override can be re-applied on every + /// update without re-running the expensive derivation. Recomputed only when the text changes. private var derivedAccessibilityLabel: String? private var textRectOffset: UIOffset = .zero { @@ -313,8 +311,6 @@ extension AttributedLabel { if previousAttributedText != attributedText { links = attributedLinks(in: model.attributedText) + detectedDataLinks(in: model.attributedText) - // Cache the (expensive, link-enumerating) text-derived label so we can re-apply - // the override below on every update without recomputing it. derivedAccessibilityLabel = accessibilityLabel( with: links, in: model.attributedText.string, @@ -325,9 +321,8 @@ extension AttributedLabel { .compactMap { .init(sourceLabel: attributedText, link: $0) } } - // Apply the override on every update so a change to `model.accessibilityLabel` takes - // effect even when the text is unchanged. `nil` falls back to the cached derived label - // (current behavior); `""` explicitly suppresses the spoken label. + // Applied every update (outside the text-change guard) so an override change takes + // effect even when the text is unchanged. `nil` falls back to the derived label. accessibilityLabel = model.accessibilityLabel ?? derivedAccessibilityLabel if let shadow = model.shadow { From d0a313d2c42464cfcd1738b1ea3961f0908c2aa1 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Thu, 9 Jul 2026 12:57:18 +0200 Subject: [PATCH 4/4] Address review: named constant for accessibilityLabel suppression Add AttributedLabel/Label.suppressedAccessibilityLabel so the "" suppression value is greppable and legible at the call site (per Rob's review), and rename the suppression test to flag that it guards an undocumented UIKit behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/AttributedLabel.swift | 11 +++++++---- BlueprintUICommonControls/Sources/Label.swift | 11 +++++++---- .../Tests/Sources/AttributedLabelTests.swift | 12 +++++++----- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/BlueprintUICommonControls/Sources/AttributedLabel.swift b/BlueprintUICommonControls/Sources/AttributedLabel.swift index e6886244b..bc6de2c20 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel.swift @@ -80,12 +80,15 @@ public struct AttributedLabel: Element, Hashable { /// Overrides the automatically-derived accessibility label. /// /// When `nil` (the default), the label is derived from the displayed text — current behavior. - /// Provide a string to override it, or `""` to suppress the spoken label entirely. Suppression - /// is useful when the text is surfaced through `accessibilityValue` / `accessibilityHint` - /// instead, so the content is not announced twice (e.g. when this label is merged into a - /// combined accessibility element). + /// Provide a string to override it, or `suppressedAccessibilityLabel` (`""`) to suppress the + /// spoken label entirely. Suppression is useful when the text is surfaced through + /// `accessibilityValue` / `accessibilityHint` instead, so the content is not announced twice + /// (e.g. when this label is merged into a combined accessibility element). public var accessibilityLabel: String? + /// Assign to `accessibilityLabel` to suppress the spoken label entirely (see that property). + public static let suppressedAccessibilityLabel = "" + /// A localized string that represents the current value of the accessibility element. /// /// The value is a localized string that contains the current value of an element. diff --git a/BlueprintUICommonControls/Sources/Label.swift b/BlueprintUICommonControls/Sources/Label.swift index 8c7fc5bb5..8b1c4e19f 100644 --- a/BlueprintUICommonControls/Sources/Label.swift +++ b/BlueprintUICommonControls/Sources/Label.swift @@ -60,12 +60,15 @@ public struct Label: ProxyElement { /// Overrides the automatically-derived accessibility label. /// /// When `nil` (the default), the label is derived from the displayed text — current behavior. - /// Provide a string to override it, or `""` to suppress the spoken label entirely. Suppression - /// is useful when the text is surfaced through `accessibilityValue` / `accessibilityHint` - /// instead, so the content is not announced twice (e.g. when this label is merged into a - /// combined accessibility element). + /// Provide a string to override it, or `suppressedAccessibilityLabel` (`""`) to suppress the + /// spoken label entirely. Suppression is useful when the text is surfaced through + /// `accessibilityValue` / `accessibilityHint` instead, so the content is not announced twice + /// (e.g. when this label is merged into a combined accessibility element). public var accessibilityLabel: String? + /// Assign to `accessibilityLabel` to suppress the spoken label entirely (see that property). + public static let suppressedAccessibilityLabel = "" + /// A localized string that represents the current value of the accessibility element. /// /// The value is a localized string that contains the current value of an element. diff --git a/BlueprintUICommonControls/Tests/Sources/AttributedLabelTests.swift b/BlueprintUICommonControls/Tests/Sources/AttributedLabelTests.swift index 2ac29adf1..fbf8a05ec 100644 --- a/BlueprintUICommonControls/Tests/Sources/AttributedLabelTests.swift +++ b/BlueprintUICommonControls/Tests/Sources/AttributedLabelTests.swift @@ -115,12 +115,14 @@ class AttributedLabelTests: XCTestCase { XCTAssertEqual(labelView.accessibilityLabel, "Custom") } - func test_accessibilityLabel_suppressed() { + // Load-bearing: guards the undocumented UIKit behavior that `""`-suppression relies on. + func test_accessibilityLabel_suppressed_guardsUIKitEmptyStringBehavior() { // An empty string suppresses the spoken label entirely: the backing view reports "", // *not* the displayed text. (UILabel returns an explicitly-set empty string verbatim, - // rather than falling back to its text the way it does for `nil`.) + // rather than falling back to its text the way it does for `nil`.) If a future OS changed + // this fallback, `""`-suppression would silently break — hence this test. let label = AttributedLabel(attributedText: NSAttributedString(string: "Hello, World!")) { - $0.accessibilityLabel = "" + $0.accessibilityLabel = AttributedLabel.suppressedAccessibilityLabel } let labelView = AttributedLabel.LabelView() @@ -143,7 +145,7 @@ class AttributedLabelTests: XCTestCase { labelView.update(model: label, text: label.displayableAttributedText, environment: .empty, isMeasuring: false) XCTAssertEqual(labelView.accessibilityLabel, "Custom") - label.accessibilityLabel = "" + label.accessibilityLabel = AttributedLabel.suppressedAccessibilityLabel labelView.update(model: label, text: label.displayableAttributedText, environment: .empty, isMeasuring: false) XCTAssertEqual(labelView.accessibilityLabel, "") @@ -158,7 +160,7 @@ class AttributedLabelTests: XCTestCase { // merged into a composite accessibility element — the empty label is filtered out, so the // content isn't announced twice. let label = AttributedLabel(attributedText: NSAttributedString(string: "Displayed text")) { - $0.accessibilityLabel = "" + $0.accessibilityLabel = AttributedLabel.suppressedAccessibilityLabel $0.accessibilityValue = "X" }