Skip to content
Merged
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
22 changes: 21 additions & 1 deletion BlueprintUICommonControls/Sources/AttributedLabel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ 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<AccessibilityElement.Trait>?

/// 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 `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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 (on Rob's behalf) The String? shape is the right call here — it's consistent with the sibling accessibilityValue/accessibilityHint on this model, with AccessibilityElement.label, and with UIKit's own accessibilityLabel (nil = derive). And empty-string-means-absent is already the house convention: AccessibilityCombine documents that an element isn't accessible "unless it has a non-nil and non-empty label/value/hint," which is exactly why the composition test works. So the ""-suppresses behavior isn't a novel magic value — it fits existing precedent. I'd keep it over an enum, which would make this the only accessibility prop that isn't a plain optional string and would lose the x.accessibilityLabel = someOptionalString drop-in ergonomics.

One small discoverability nit: ""-as-suppression isn't legible at the call site, and a future reader could "fix" a bare "" thinking it's a mistake. Consider a named constant so intent is greppable:

extension AttributedLabel {
    /// Assign to `accessibilityLabel` to suppress the spoken label entirely.
    public static let suppressedAccessibilityLabel = ""
}

Non-blocking — the doc comment already covers it well.


/// 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.
Expand Down Expand Up @@ -203,6 +215,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 {
Expand Down Expand Up @@ -298,7 +314,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
Expand All @@ -308,6 +324,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)
Expand Down
13 changes: 13 additions & 0 deletions BlueprintUICommonControls/Sources/Label.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ 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 `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.
Expand Down Expand Up @@ -108,6 +120,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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import BlueprintUI
import BlueprintUIAccessibilityCore
import XCTest
@testable import BlueprintUICommonControls

Expand Down Expand Up @@ -90,6 +91,91 @@ 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")
}

// 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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 (on Rob's behalf) Good that this is called out inline — the whole suppression story rests on this undocumented UIKit behavior (an explicitly-set "" is returned verbatim rather than falling back to .text). That makes this test load-bearing rather than incidental: if a future OS changed the fallback, ""-suppression would silently break. Worth keeping and maybe a one-liner in the test name/doc flagging it as guarding a UIKit assumption.

// 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 = AttributedLabel.suppressedAccessibilityLabel
}

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 = AttributedLabel.suppressedAccessibilityLabel
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 = AttributedLabel.suppressedAccessibilityLabel
$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)
Expand Down
Loading