diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift index faa52870c..a418ec464 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift @@ -225,14 +225,12 @@ extension AccessibilityDeferral { // Reset source views. sources.forEach { $0.accessibilityElementsHidden = false } - + /* Content is bound to a Receiver by containment, and a container is modeled around a single receiver. Multiple Receivers is still valid, but they're separate accessibility elements with no single target, so we clear all deferral content and bail rather than duplicate it across all of them. */ guard receivers.count <= 1 else { - assertionFailure("AccessibilityDeferral.ParentContainer must contain at most one Receiver; found \(receivers.count).") receivers.forEach { $0.apply(content: nil, frameProvider: nil) } return } - if let receiver = receivers.first { // Inherit accessibility content and disable source element. diff --git a/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift b/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift index c7d1c455c..ae8b7bacb 100644 --- a/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift +++ b/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift @@ -132,6 +132,29 @@ class AccessibilityDeferralTests: XCTestCase { XCTAssertEqual(receiver.accessibilityCustomActions?.first?.name, "Test Action") } + func test_parentContainerWithDuplicateReceiversClearsDeferralContent() { + let view = BlueprintView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + let content = AccessibilityDeferral.Content(kind: .inherited(), identifier: "source") + + view.element = Column { + AccessibilityTestElement(label: "Source") + .deferredAccessibilitySource(identifier: "source") + AccessibilityTestElement(label: "First receiver") + .deferredAccessibilityReceiver() + AccessibilityTestElement(label: "Second receiver") + .deferredAccessibilityReceiver() + } + .deferAccessibilityToChildren(content: [content]) + + view.layoutIfNeeded() + + let receivers = view.recursiveSubviews(matching: { $0 is AccessibilityDeferral.Receiver }) + .compactMap { $0 as? AccessibilityDeferral.Receiver } + + XCTAssertEqual(receivers.count, 2) + XCTAssertTrue(receivers.allSatisfy { ($0.deferredAccessibilityContent ?? []).isEmpty }) + } + // MARK: - Content customContent generation func test_content_inherited_customContent() { @@ -193,3 +216,20 @@ private final class TestReceiver: UIView, AccessibilityDeferral.Receiver, AXCust } extension TestReceiver: AccessibilityDeferral.DeferralView {} + +private struct AccessibilityTestElement: Element { + var label: String + + var content: ElementContent { + ElementContent(intrinsicSize: CGSize(width: 10, height: 10)) + } + + func backingViewDescription(with context: ViewDescriptionContext) -> ViewDescription? { + UIView.describe { config in + config.apply { view in + view.isAccessibilityElement = true + view.accessibilityLabel = label + } + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index b1a74fbcb..d19ca4cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Duplicate accessibility deferral receivers in a parent container are now recovered by clearing deferred content without triggering a debug assertion. + ### Added ### Removed diff --git a/Package.swift b/Package.swift index 9641fc3e9..55d94aef9 100644 --- a/Package.swift +++ b/Package.swift @@ -72,6 +72,14 @@ let package = Package( .process("Resources"), ], ), + .testTarget( + name: "BlueprintUIAccessibilityCoreTests", + dependencies: [ + "BlueprintUI", + "BlueprintUIAccessibilityCore", + ], + path: "BlueprintUIAccessibilityCore/Tests" + ), ], swiftLanguageModes: [.v5] )