From 36aa6175e00ae0caa6e9dd0dfa1df0720123105f Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Mon, 29 Jun 2026 16:52:54 +0200 Subject: [PATCH 1/3] 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 c22ad83467c00509064665961da562d561b649c5 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Tue, 30 Jun 2026 14:45:19 +0200 Subject: [PATCH 2/3] 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 02aefbf0f782d17545d2a18c168ff27d52222d50 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Tue, 7 Jul 2026 16:32:30 +0200 Subject: [PATCH 3/3] 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 {