Skip to content
Draft
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
24 changes: 23 additions & 1 deletion BlueprintUICommonControls/Sources/AttributedLabel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 `""` 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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions BlueprintUICommonControls/Sources/Label.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
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,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)
Expand Down
Loading