diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift index c83053dc5..cf6d9b357 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityComposition.swift @@ -136,6 +136,28 @@ extension AccessibilityComposition { interactiveChildren = allInteractiveChildren.isEmpty ? nil : allInteractiveChildren } + // returns a new representation with the combined accessibility, favoring the accessibility of the receiver. + internal func merge(with other: AccessibilityComposition.CompositeRepresentation?) -> AccessibilityComposition.CompositeRepresentation { + guard let other else { return self } + var new = AccessibilityComposition.CompositeRepresentation([], invalidator: invalidator) + new.label = [label, other.label].joinedAccessibilityString() + new.value = [value, other.value].joinedAccessibilityString() + new.hint = [hint, other.hint].joinedAccessibilityString() + new.identifier = [identifier, other.identifier].joinedAccessibilityString() + + new.traits = traits.union(other.traits) + + new.actions = actions + new.actions.customActions += other.allActions + + new.rotors = rotors + other.rotors + new.interactiveChildren = interactiveChildren + other.interactiveChildren + + new.activationPoint = activationPoint ?? other.activationPoint + + return new + } + internal func override(with override: AccessibilityComposition.CompositeRepresentation?) -> AccessibilityComposition.CompositeRepresentation { guard let override else { return self } var new = AccessibilityComposition.CompositeRepresentation([], invalidator: invalidator) @@ -315,11 +337,14 @@ extension AccessibilityComposition { extension AccessibilityComposition { - public final class CombinableView: UIView, AXCustomContentProvider, AccessibilityCombinable { + public class CombinableView: UIView, AXCustomContentProvider, AccessibilityCombinable { // An accessibility representation with values that should override the combined representation public var overrideValues: AccessibilityComposition.CompositeRepresentation? = nil + // An accessibility representation with values that should be merged with the combined representation + public var mergeValues: AccessibilityComposition.CompositeRepresentation? = nil + // If enabled, a combined view with only a single interactive child element will include the child in the accessibility representation rather than as a custom action. E.G. a button and label become a single button element. public var mergeInteractiveSingleChild: Bool = true @@ -387,10 +412,12 @@ extension AccessibilityComposition { root: self, userInterfaceIdiom: interfaceidiom ) - let combined = combineChildren(filter: customFilter, sorting: sorting) + let accessibility = combineChildren(filter: customFilter, sorting: sorting) + .override(with: overrideValues) + .merge(with: mergeValues) applyAccessibility( - combined.override(with: overrideValues), + accessibility, mergeInteractiveSingleChild: mergeInteractiveSingleChild ) diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift index c99aeff52..79ee0ec67 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityDeferral.swift @@ -24,11 +24,9 @@ public struct AccessibilityDeferral { /// Content from an outside source that will be exposed via AccessibilityCustomContent var deferredAccessibilityContent: [AccessibilityDeferral.Content]? { get set } - /// Called by the parent container. Default implementation provided. - /// - parameter content: the accessibility content to apply to the receiver. - func applyDeferredAccessibility( - content: [AccessibilityDeferral.Content]? - ) + /// Called by the parent container after deferred value update pass completes. + /// - parameter frameProvider: an optional accessibility frame to apply at the receiver's discretion. + func updateDeferredAccessibility(frameProvider: AccessibilityDeferral.FrameProvider?) } /// An accessibility container wrapping an element that natively provides the deferred accessibility content. This element's accessibility is conditionally exposed based on the presence of a receiver. @@ -44,12 +42,10 @@ public struct AccessibilityDeferral { public struct Content: Equatable { public enum Kind: Equatable { - /// Uses accessibility values from the contained element and exposes them as custom via the accessiblity rotor. + /// Uses accessibility values from the contained element and exposes them as custom via the accessibility rotor. case inherited(Accessibility.CustomContent.Importance = .default) /// Announces an error message with high importance using accessibility values from the contained element. case error - /// Exposes the custom content provided. - case custom(Accessibility.CustomContent) } public var kind: Kind @@ -57,7 +53,7 @@ public struct AccessibilityDeferral { /// Used to identify a specific `Source` element to inherit accessibility from. public var sourceIdentifier: AnyHashable - /// : A stable identifier used to identify a given update pass through he view hierarchy. Content with matching updateIdentifiers should be combined. + /// A stable identifier used to identify a given update pass through the view hierarchy. Content with matching updateIdentifiers should be combined. internal var updateIdentifier: UUID? internal var inheritedAccessibility: AccessibilityComposition.CompositeRepresentation? @@ -77,8 +73,59 @@ public struct AccessibilityDeferral { content?.value = value content?.label = LocalizedStrings.Accessibility.errorTitle return content?.axCustomContent - case .custom(let customContent): - return customContent.axCustomContent + } + } + } +} + +extension AccessibilityDeferral { + + // Prefer accessibilityPath API to simplify overrides and provide a common codepath. + public struct FrameProvider { + public static let accessibilityCornerRadius = 8.0 // Matches Voiceover's CGRect API + + fileprivate static let accessibilityPathInset = -2.0 + + private let provider: () -> UIBezierPath + + private init(_ provider: @escaping () -> UIBezierPath) { + self.provider = provider + } + + public func callAsFunction() -> UIBezierPath { + provider() + } + + /// Creates a container frame from a CGRect with rounded corners + /// - Parameters: + /// - rect: The frame in global coordinate space + /// - cornerRadius: The radius for rounded corners + public static func frame(_ rect: CGRect, cornerRadius: CGFloat = accessibilityCornerRadius) -> Self { + .init { + UIBezierPath(roundedRect: rect, cornerRadius: max(0, cornerRadius - accessibilityPathInset)) + } + } + + /// Creates a container frame from a UIView + /// - Parameters: + /// - view: The view providing the frame geometry + public static func view(_ view: UIView) -> Self { + .init { [weak view] in + guard let view else { return UIBezierPath() } + + // Prefer the path if it's already set. + guard view.accessibilityPath == nil else { return view.accessibilityPath! } + + let bounds = view.bounds + let outsetFrame = bounds.insetBy(dx: accessibilityPathInset * 2, dy: accessibilityPathInset * 2) + let convertedFrame = UIAccessibility.convertToScreenCoordinates(outsetFrame, in: view) + + // Apply corner radius from layer if present, otherwise use default text field radius + let cornerRadius = view.layer.cornerRadius > 0 ? view.layer.cornerRadius : accessibilityCornerRadius + return UIBezierPath( + roundedRect: convertedFrame, + cornerRadius: max(0, cornerRadius - accessibilityPathInset) + ) } } } @@ -98,6 +145,11 @@ extension Element { public func deferredAccessibilitySource(identifier: AnyHashable) -> AccessibilityDeferral.SourceContainer { AccessibilityDeferral.SourceContainer(wrapping: { self }, identifier: identifier) } + + /// Creates a `ReceiverContainer` element to expose the deferred accessibility. + public func deferredAccessibilityReceiver() -> AccessibilityDeferral.ReceiverContainer { + AccessibilityDeferral.ReceiverContainer(wrapping: { self }) + } } extension AccessibilityDeferral { @@ -130,7 +182,6 @@ extension AccessibilityDeferral { private final class DeferralContainerView: UIView { - var useContainerFrame: Bool = true var contents: [Content]? { didSet { if oldValue != contents { @@ -177,9 +228,7 @@ extension AccessibilityDeferral { guard receivers.count <= 1 else { // We cannot reasonably determine which receiver to apply the content to. - receivers.forEach { $0.applyDeferredAccessibility( - content: nil - ) } + receivers.forEach { $0.apply(content: nil, frameProvider: nil) } return } @@ -199,14 +248,111 @@ extension AccessibilityDeferral { } // Apply content to receiver. - receiver.applyDeferredAccessibility( - content: deferredContent - ) + receiver.apply(content: deferredContent, frameProvider: .view(self)) + } } } } +extension AccessibilityDeferral { + + public struct ReceiverContainer: Element { + public var wrappedElement: Element + + init(wrapping: @escaping () -> Element) { + wrappedElement = wrapping() + } + + public var content: ElementContent { + ElementContent(measuring: wrappedElement) + } + + public func backingViewDescription(with context: BlueprintUI.ViewDescriptionContext) -> BlueprintUI.ViewDescription? { + ReceiverContainerView.describe { config in + config.apply { view in + view.isAccessibilityElement = true + view.needsAccessibilityUpdate = true + view.layoutDirection = context.environment.layoutDirection + view.element = wrappedElement + } + } + } + + private final class ReceiverContainerView: AccessibilityComposition.CombinableView, AccessibilityDeferral.Receiver { + var element: Element? { + didSet { + blueprintView.element = element + blueprintView.setNeedsLayout() + } + } + + private var blueprintView = BlueprintView() + + override init(frame: CGRect) { + super.init(frame: frame) + isAccessibilityElement = true + mergeInteractiveSingleChild = false + + blueprintView.backgroundColor = .clear + addSubview(blueprintView) + } + + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + blueprintView.frame = bounds + needsAccessibilityUpdate = true + } + + // MARK: - Accessibility Deferral and Custom Content + internal var frameProvider: FrameProvider? + + var customContent: [Accessibility.CustomContent]? + + var deferredAccessibilityContent: [AccessibilityDeferral.Content]? + + public override var accessibilityCustomRotors: [UIAccessibilityCustomRotor]? { + get { super.accessibilityCustomRotors + rotorSequencer?.rotors } + set { super.accessibilityCustomRotors = newValue } + } + + public override var accessibilityPath: UIBezierPath? { + get { frameProvider?() ?? UIBezierPath(rect: super.accessibilityFrame) } + set { fatalError("Not settable, please use frameProvider instead.") } + } + + public override var accessibilityCustomContent: [AXCustomContent]! { + get { + let existing = super.accessibilityCustomContent + let applied = customContent?.map { AXCustomContent($0) } + return (existing + applied)?.removingDuplicates ?? [] + } + set { super.accessibilityCustomContent = newValue } + } + + public func updateDeferredAccessibility(frameProvider: FrameProvider?) { + needsAccessibilityUpdate = true + + self.frameProvider = frameProvider + + if let deferred = deferredAccessibilityContent?.compactMap({ $0.inheritedAccessibility }), + let first = deferred.first + { + mergeValues = deferred.dropFirst() + .reduce(first) { result, value in + result.merge(with: value) + } + } + } + } + } +} + + extension AccessibilityDeferral { @@ -261,10 +407,6 @@ extension AccessibilityDeferral { addSubview(blueprintView) } - override func addSubview(_ view: UIView) { - super.addSubview(view) - } - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -325,11 +467,15 @@ extension AccessibilityComposition.CompositeRepresentation { } } -/// Default Implementation extension AccessibilityDeferral.Receiver { - public func applyDeferredAccessibility( - content: [AccessibilityDeferral.Content]? + // Default implementation ignores frame + public func updateDeferredAccessibility(frameProvider: AccessibilityDeferral.FrameProvider?) {} + + + internal func apply( + content: [AccessibilityDeferral.Content]?, + frameProvider: AccessibilityDeferral.FrameProvider? ) { guard let content, !content.isEmpty else { replaceContent([]); return } guard let updateID = content.first?.updateIdentifier, content.allSatisfy({ $0.updateIdentifier == updateID }) else { @@ -342,30 +488,32 @@ extension AccessibilityDeferral.Receiver { } else { replaceContent(content) } + updateDeferredAccessibility(frameProvider: frameProvider) + } - func replaceContent(_ content: [AccessibilityDeferral.Content]?) { - deferredAccessibilityContent = content + internal func replaceContent(_ content: [AccessibilityDeferral.Content]?) { + deferredAccessibilityContent = content - accessibilityCustomActions = content?.compactMap { $0.inheritedAccessibility?.allActions }.flatMap { $0 }.removingDuplicateActions() + accessibilityCustomActions = content?.compactMap { $0.inheritedAccessibility?.allActions }.flatMap { $0 }.removingDuplicateActions() - if let rotors = content?.compactMap({ $0.inheritedAccessibility?.rotors }).flatMap({ $0 }), !rotors.isEmpty { - rotorSequencer = .init(rotors: rotors) - } else { - rotorSequencer = nil - } + if let rotors = content?.compactMap({ $0.inheritedAccessibility?.rotors }).flatMap({ $0 }), !rotors.isEmpty { + rotorSequencer = .init(rotors: rotors) + } else { + rotorSequencer = nil } + } - func mergeContent(_ content: [AccessibilityDeferral.Content]?) { - deferredAccessibilityContent = (deferredAccessibilityContent + content)?.removingDuplicates + internal func mergeContent(_ content: [AccessibilityDeferral.Content]?) { + deferredAccessibilityContent = (deferredAccessibilityContent + content)?.removingDuplicates - let contentActions = content?.compactMap { $0.inheritedAccessibility?.allActions }.flatMap { $0 } - accessibilityCustomActions = (accessibilityCustomActions + contentActions)?.removingDuplicateActions() + let contentActions = content?.compactMap { $0.inheritedAccessibility?.allActions }.flatMap { $0 } + accessibilityCustomActions = (accessibilityCustomActions + contentActions)?.removingDuplicateActions() - if let rotors = content?.compactMap({ $0.inheritedAccessibility?.rotors }).flatMap({ $0 }), !rotors.isEmpty { - let mergedRotors = (rotorSequencer?.rotors ?? []) + rotors - rotorSequencer = .init(rotors: mergedRotors) - accessibilityCustomRotors = rotorSequencer?.rotors - } + if let rotors = content?.compactMap({ $0.inheritedAccessibility?.rotors }).flatMap({ $0 }), !rotors.isEmpty { + let mergedRotors = (rotorSequencer?.rotors ?? []) + rotors + rotorSequencer = .init(rotors: mergedRotors) + accessibilityCustomRotors = rotorSequencer?.rotors } } + } diff --git a/BlueprintUIAccessibilityCore/Sources/Extensions/Array+Extensions.swift b/BlueprintUIAccessibilityCore/Sources/Extensions/Array+Extensions.swift index 20228b0a5..823d65edb 100644 --- a/BlueprintUIAccessibilityCore/Sources/Extensions/Array+Extensions.swift +++ b/BlueprintUIAccessibilityCore/Sources/Extensions/Array+Extensions.swift @@ -3,7 +3,7 @@ import Foundation extension [String?] { /// Joins non-empty optional strings into a single string formatted for use in accessibility contexts. - internal func joinedAccessibilityString() -> String? { + package func joinedAccessibilityString() -> String? { let joined = compactMap { $0 } .filter { !$0.isEmpty } .joined(separator: ", ") @@ -14,7 +14,7 @@ extension [String?] { extension Array where Element: Equatable { /// Returns an array where only the first instance of any duplicated element is included. - internal var removingDuplicates: Self { + package var removingDuplicates: Self { reduce([]) { $0.contains($1) ? $0 : $0 + [$1] } } } diff --git a/BlueprintUIAccessibilityCore/Sources/Extensions/Optional+Extensions.swift b/BlueprintUIAccessibilityCore/Sources/Extensions/Optional+Extensions.swift index d23b7531d..2432d0d1b 100644 --- a/BlueprintUIAccessibilityCore/Sources/Extensions/Optional+Extensions.swift +++ b/BlueprintUIAccessibilityCore/Sources/Extensions/Optional+Extensions.swift @@ -7,4 +7,8 @@ extension Optional where Wrapped: RangeReplaceableCollection { return val.isEmpty ? nil : val } + static func += (lhs: inout Wrapped?, rhs: Wrapped?) { + lhs = lhs + rhs + } + } diff --git a/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift b/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift new file mode 100644 index 000000000..c7d1c455c --- /dev/null +++ b/BlueprintUIAccessibilityCore/Tests/AccessibilityDeferralTests.swift @@ -0,0 +1,195 @@ +import BlueprintUI +import XCTest +@testable import BlueprintUIAccessibilityCore + + +class AccessibilityDeferralTests: XCTestCase { + + // MARK: - CompositeRepresentation.merge chaining + + func test_merge_multiple_representations() { + let rep1 = makeRepresentation(label: "Label 1", value: "Value 1", hint: "Hint 1") + let rep2 = makeRepresentation(label: "Label 2", value: "Value 2", hint: "Hint 2") + let rep3 = makeRepresentation(label: "Label 3", value: "Value 3", hint: "Hint 3") + + let representations = [rep1, rep2, rep3] + + // This mirrors the exact reduce pattern used in updateDeferredAccessibility + let merged = representations.dropFirst() + .reduce(representations.first!) { result, value in + result.merge(with: value) + } + + XCTAssertEqual(merged.label, "Label 1, Label 2, Label 3") + XCTAssertEqual(merged.value, "Value 1, Value 2, Value 3") + XCTAssertEqual(merged.hint, "Hint 1, Hint 2, Hint 3") + } + + func test_merge_two_representations() { + let rep1 = makeRepresentation(label: "First", value: "A") + let rep2 = makeRepresentation(label: "Second", value: "B") + + let merged = rep1.merge(with: rep2) + + XCTAssertEqual(merged.label, "First, Second") + XCTAssertEqual(merged.value, "A, B") + } + + func test_merge_preserves_traits() { + let container = NSObject() + let element1 = UIAccessibilityElement(accessibilityContainer: container) + element1.accessibilityLabel = "Header" + element1.accessibilityTraits = .header + + let element2 = UIAccessibilityElement(accessibilityContainer: container) + element2.accessibilityLabel = "Static" + element2.accessibilityTraits = .staticText + + let rep1 = AccessibilityComposition.CompositeRepresentation([element1]) {} + let rep2 = AccessibilityComposition.CompositeRepresentation([element2]) {} + + let merged = rep1.merge(with: rep2) + + XCTAssertTrue(merged.traits.contains(.header)) + XCTAssertTrue(merged.traits.contains(.staticText)) + } + + func test_merge_with_nil_returns_self() { + let rep = makeRepresentation(label: "Only", value: "One") + let merged = rep.merge(with: nil) + + XCTAssertEqual(merged.label, "Only") + XCTAssertEqual(merged.value, "One") + } + + // MARK: - Receiver apply / replace / merge + + func test_apply_replaces_content_on_new_updateID() { + let receiver = TestReceiver() + + var content1 = AccessibilityDeferral.Content(kind: .inherited(), identifier: "source1") + content1.updateIdentifier = UUID() + content1.inheritedAccessibility = makeRepresentation(label: "First") + + receiver.apply(content: [content1], frameProvider: nil) + + XCTAssertEqual(receiver.deferredAccessibilityContent?.count, 1) + XCTAssertEqual(receiver.deferredAccessibilityContent?.first?.inheritedAccessibility?.label, "First") + } + + func test_apply_merges_content_on_same_updateID() { + let receiver = TestReceiver() + let sharedID = UUID() + + var content1 = AccessibilityDeferral.Content(kind: .inherited(), identifier: "source1") + content1.updateIdentifier = sharedID + content1.inheritedAccessibility = makeRepresentation(label: "First") + + var content2 = AccessibilityDeferral.Content(kind: .inherited(), identifier: "source2") + content2.updateIdentifier = sharedID + content2.inheritedAccessibility = makeRepresentation(label: "Second") + + // First call replaces + receiver.apply(content: [content1], frameProvider: nil) + XCTAssertEqual(receiver.deferredAccessibilityContent?.count, 1) + + // Second call with same updateID merges + receiver.apply(content: [content2], frameProvider: nil) + XCTAssertEqual(receiver.deferredAccessibilityContent?.count, 2) + } + + func test_apply_clears_content_when_nil() { + let receiver = TestReceiver() + + var content = AccessibilityDeferral.Content(kind: .inherited(), identifier: "source1") + content.updateIdentifier = UUID() + content.inheritedAccessibility = makeRepresentation(label: "Something") + + receiver.apply(content: [content], frameProvider: nil) + XCTAssertEqual(receiver.deferredAccessibilityContent?.count, 1) + + receiver.apply(content: nil, frameProvider: nil) + XCTAssertEqual(receiver.deferredAccessibilityContent?.count, 0) + } + + func test_apply_propagates_actions_from_sources() { + let receiver = TestReceiver() + + let container = NSObject() + let element = UIAccessibilityElement(accessibilityContainer: container) + element.accessibilityLabel = "Action Source" + element.accessibilityCustomActions = [ + UIAccessibilityCustomAction(name: "Test Action") { _ in true }, + ] + + let rep = AccessibilityComposition.CompositeRepresentation([element]) {} + + var content = AccessibilityDeferral.Content(kind: .inherited(), identifier: "source1") + content.updateIdentifier = UUID() + content.inheritedAccessibility = rep + + receiver.apply(content: [content], frameProvider: nil) + XCTAssertEqual(receiver.accessibilityCustomActions?.first?.name, "Test Action") + } + + // MARK: - Content customContent generation + + func test_content_inherited_customContent() { + var content = AccessibilityDeferral.Content(kind: .inherited(.high), identifier: "id") + content.inheritedAccessibility = makeRepresentation(label: "Error Label", value: "Error Value") + + let customContent = content.customContent + XCTAssertNotNil(customContent) + XCTAssertEqual(customContent?.label, "Error Label") + XCTAssertEqual(customContent?.value, "Error Value") + XCTAssertEqual(customContent?.importance, .high) + } + + func test_content_error_customContent() { + var content = AccessibilityDeferral.Content(kind: .error, identifier: "id") + content.inheritedAccessibility = makeRepresentation(label: "Field", value: "is required") + + let customContent = content.customContent + XCTAssertNotNil(customContent) + XCTAssertEqual(customContent?.importance, .high) + } + + func test_content_nil_accessibility_returns_nil() { + let content = AccessibilityDeferral.Content(kind: .inherited(), identifier: "id") + XCTAssertNil(content.customContent) + } + + // MARK: - Helpers + + private func makeRepresentation( + label: String? = nil, + value: String? = nil, + hint: String? = nil + ) -> AccessibilityComposition.CompositeRepresentation { + let container = NSObject() + let element = UIAccessibilityElement(accessibilityContainer: container) + element.accessibilityLabel = label + element.accessibilityValue = value + element.accessibilityHint = hint + return AccessibilityComposition.CompositeRepresentation([element]) {} + } +} + +// MARK: - Test Doubles + +private final class TestReceiver: UIView, AccessibilityDeferral.Receiver, AXCustomContentProvider { + + var accessibilityCustomContent: [AXCustomContent]! = [] + + var customContent: [Accessibility.CustomContent]? + + var rotorSequencer: AccessibilityComposition.RotorSequencer? + + var deferredAccessibilityContent: [AccessibilityDeferral.Content]? + + func updateDeferredAccessibility(frameProvider: AccessibilityDeferral.FrameProvider?) { + // No-op for unit testing the apply/replace/merge flow + } +} + +extension TestReceiver: AccessibilityDeferral.DeferralView {}