diff --git a/BlueprintUICommonControls/Sources/AttributedLabel.swift b/BlueprintUICommonControls/Sources/AttributedLabel.swift index 395e4f274..e6886244b 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,10 @@ extension AttributedLabel { private var links: [Link] = [] private var linkElements: [LinkElement] = [] + /// 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 { didSet { if oldValue != textRectOffset { @@ -298,7 +311,7 @@ extension AttributedLabel { if previousAttributedText != attributedText { links = attributedLinks(in: model.attributedText) + detectedDataLinks(in: model.attributedText) - accessibilityLabel = accessibilityLabel( + derivedAccessibilityLabel = accessibilityLabel( with: links, in: model.attributedText.string, linkAccessibilityLabel: environment.linkAccessibilityLabel @@ -308,6 +321,10 @@ extension AttributedLabel { .compactMap { .init(sourceLabel: attributedText, link: $0) } } + // 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 { layer.shadowRadius = shadow.radius layer.shadowOpacity = Float(shadow.opacity) 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 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)