From 955794c19108ca5e1c6665beb1a756b4cc35b76d Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Wed, 1 Apr 2026 18:54:17 +0200 Subject: [PATCH 1/9] Add AccessibilityFocusTrigger for programmatic VoiceOver focus Introduces a late-binding trigger that moves VoiceOver focus to a Blueprint element's backing view. Includes an Element modifier, backing view integration, unit tests, and a sample app demo. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../AccessibilityFocusTrigger.swift | 66 ++++++ .../AccessibilityFocusableElement.swift | 55 +++++ .../Element/Element+AccessibilityFocus.swift | 18 ++ .../AccessibilityFocusTriggerTests.swift | 84 ++++++++ ...essibilityFocusTriggerViewController.swift | 191 ++++++++++++++++++ SampleApp/Sources/RootViewController.swift | 3 + 6 files changed, 417 insertions(+) create mode 100644 BlueprintUI/Sources/Accessibility/AccessibilityFocusTrigger.swift create mode 100644 BlueprintUI/Sources/Accessibility/AccessibilityFocusableElement.swift create mode 100644 BlueprintUI/Sources/Element/Element+AccessibilityFocus.swift create mode 100644 BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift create mode 100644 SampleApp/Sources/AccessibilityFocusTriggerViewController.swift diff --git a/BlueprintUI/Sources/Accessibility/AccessibilityFocusTrigger.swift b/BlueprintUI/Sources/Accessibility/AccessibilityFocusTrigger.swift new file mode 100644 index 000000000..9bdbafa7a --- /dev/null +++ b/BlueprintUI/Sources/Accessibility/AccessibilityFocusTrigger.swift @@ -0,0 +1,66 @@ +import UIKit + +/// A trigger that moves VoiceOver focus to a backing view. +/// +/// Like `FocusTrigger`, this uses late-binding: create the trigger +/// before any view exists, bind it to a backing view during layout, +/// and invoke it later to move VoiceOver focus. +/// +/// ## Example +/// +/// let errorFocusTrigger = AccessibilityFocusTrigger() +/// +/// // In the element tree: +/// Label(text: "Invalid amount") +/// .accessibilityFocus(trigger: errorFocusTrigger) +/// +/// // After validation fails: +/// errorFocusTrigger.requestFocus() +/// +public final class AccessibilityFocusTrigger { + + /// The type of accessibility notification to post when requesting focus. + public enum Notification { + /// Use for focus changes within the current screen. + case layoutChanged + /// Use for major screen transitions. + case screenChanged + + var uiAccessibilityNotification: UIAccessibility.Notification { + switch self { + case .layoutChanged: + return .layoutChanged + case .screenChanged: + return .screenChanged + } + } + } + + /// The notification type to post when focus is requested. + public let notification: Notification + + /// Creates a new trigger, not yet bound to any view. + /// - Parameter notification: The type of accessibility notification to post. Defaults to `.layoutChanged`. + public init(notification: Notification = .layoutChanged) { + self.notification = notification + } + + /// Bound by the backing view during apply(). + /// The closure posts `UIAccessibility.post(notification:argument:)` + /// targeting the bound view. + var action: (() -> Void)? + + /// Moves VoiceOver focus to the bound backing view. + /// No-op if VoiceOver is not running or trigger is unbound. + public func requestFocus() { + action?() + } + + /// Posts a VoiceOver announcement without focusing a specific view. + /// No-op if VoiceOver is not running. + /// - Parameter message: The string to announce. + public func announce(_ message: String) { + guard UIAccessibility.isVoiceOverRunning else { return } + UIAccessibility.post(notification: .announcement, argument: message) + } +} diff --git a/BlueprintUI/Sources/Accessibility/AccessibilityFocusableElement.swift b/BlueprintUI/Sources/Accessibility/AccessibilityFocusableElement.swift new file mode 100644 index 000000000..956181bd1 --- /dev/null +++ b/BlueprintUI/Sources/Accessibility/AccessibilityFocusableElement.swift @@ -0,0 +1,55 @@ +import UIKit + +/// A wrapping element that binds an `AccessibilityFocusTrigger` to a backing view, +/// enabling VoiceOver focus to be programmatically moved to the wrapped element. +struct AccessibilityFocusableElement: Element { + + var wrapped: Element + var trigger: AccessibilityFocusTrigger + + // MARK: Element + + var content: ElementContent { + ElementContent(child: wrapped) + } + + func backingViewDescription(with context: ViewDescriptionContext) -> ViewDescription? { + BackingView.describe { config in + config.apply { view in + view.apply(trigger: self.trigger) + } + } + } +} + +// MARK: - Backing View + +extension AccessibilityFocusableElement { + + private final class BackingView: UIView { + + private var currentTrigger: AccessibilityFocusTrigger? + + func apply(trigger: AccessibilityFocusTrigger) { + // Tear down old trigger binding. + currentTrigger?.action = nil + + currentTrigger = trigger + + // Bind the new trigger to this view. + let notification = trigger.notification + trigger.action = { [weak self] in + guard let self, UIAccessibility.isVoiceOverRunning else { return } + UIAccessibility.post( + notification: notification.uiAccessibilityNotification, + argument: self + ) + } + } + + override var isAccessibilityElement: Bool { + get { false } + set {} + } + } +} diff --git a/BlueprintUI/Sources/Element/Element+AccessibilityFocus.swift b/BlueprintUI/Sources/Element/Element+AccessibilityFocus.swift new file mode 100644 index 000000000..f54aa8a65 --- /dev/null +++ b/BlueprintUI/Sources/Element/Element+AccessibilityFocus.swift @@ -0,0 +1,18 @@ +extension Element { + + /// Binds an `AccessibilityFocusTrigger` to this element. + /// + /// When `trigger.requestFocus()` is called, VoiceOver focus + /// moves to this element's backing view. + /// + /// - Parameter trigger: A trigger that can later be used to move VoiceOver focus to this element. + /// - Returns: A wrapping element that provides a backing view for VoiceOver focus. + public func accessibilityFocus( + trigger: AccessibilityFocusTrigger + ) -> Element { + AccessibilityFocusableElement( + wrapped: self, + trigger: trigger + ) + } +} diff --git a/BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift b/BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift new file mode 100644 index 000000000..82af29600 --- /dev/null +++ b/BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift @@ -0,0 +1,84 @@ +import BlueprintUI +import XCTest + +final class AccessibilityFocusTriggerTests: XCTestCase { + + // MARK: - Trigger + + func test_unbound_requestFocus_is_noop() { + let trigger = AccessibilityFocusTrigger() + // Should not crash or have any effect. + trigger.requestFocus() + } + + func test_bound_requestFocus_invokes_action() { + let trigger = AccessibilityFocusTrigger() + + var didInvoke = false + trigger.action = { + didInvoke = true + } + + trigger.requestFocus() + XCTAssertTrue(didInvoke) + } + + func test_action_is_cleared_on_rebind() { + let trigger = AccessibilityFocusTrigger() + + var invokeCount = 0 + trigger.action = { + invokeCount += 1 + } + + trigger.requestFocus() + XCTAssertEqual(invokeCount, 1) + + // Simulate rebinding (as would happen when a new backing view takes over). + trigger.action = nil + + trigger.requestFocus() + XCTAssertEqual(invokeCount, 1, "Action should not fire after being cleared") + } + + func test_default_notification_is_layoutChanged() { + let trigger = AccessibilityFocusTrigger() + switch trigger.notification { + case .layoutChanged: + break // Expected + case .screenChanged: + XCTFail("Expected default notification to be .layoutChanged") + } + } + + func test_screenChanged_notification() { + let trigger = AccessibilityFocusTrigger(notification: .screenChanged) + switch trigger.notification { + case .screenChanged: + break // Expected + case .layoutChanged: + XCTFail("Expected notification to be .screenChanged") + } + } + + // MARK: - Element modifier + + func test_accessibilityFocus_modifier_wraps_element() { + let trigger = AccessibilityFocusTrigger() + let base = TestElement() + let wrapped = base.accessibilityFocus(trigger: trigger) + XCTAssertTrue(wrapped is AccessibilityFocusableElement) + } +} + +// MARK: - Helpers + +private struct TestElement: Element { + var content: ElementContent { + ElementContent(intrinsicSize: CGSize(width: 100, height: 44)) + } + + func backingViewDescription(with context: ViewDescriptionContext) -> ViewDescription? { + nil + } +} diff --git a/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift b/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift new file mode 100644 index 000000000..b22caffb5 --- /dev/null +++ b/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift @@ -0,0 +1,191 @@ +import BlueprintUI +import BlueprintUICommonControls +import UIKit + +final class AccessibilityFocusTriggerViewController: UIViewController { + + private let blueprintView = BlueprintView() + + private enum DemoState { + case idle, loading, result + } + + private var errorState: DemoState = .idle { + didSet { update() } + } + + private var transferState: DemoState = .idle { + didSet { update() } + } + + private let errorTrigger = AccessibilityFocusTrigger() + private let resultTrigger = AccessibilityFocusTrigger() + private let layoutChangeTrigger = AccessibilityFocusTrigger(notification: .layoutChanged) + private let screenChangeTrigger = AccessibilityFocusTrigger(notification: .screenChanged) + + override func loadView() { + view = blueprintView + view.backgroundColor = .systemBackground + } + + override func viewDidLoad() { + super.viewDidLoad() + title = "AccessibilityFocusTrigger" + update() + } + + private func update() { + blueprintView.element = element + } + + var element: Element { + Column(alignment: .fill, minimumSpacing: 24) { + + // MARK: - Section: Focus on error + + sectionHeader("Focus on Error Message") + + if errorState == .result { + Label(text: "Invalid amount entered.") { label in + label.font = .systemFont(ofSize: 16, weight: .medium) + label.color = .systemRed + } + .accessibilityFocus(trigger: errorTrigger) + } + + switch errorState { + case .idle: + Button( + onTap: { + self.errorState = .loading + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.errorState = .result + DispatchQueue.main.async { + self.errorTrigger.requestFocus() + } + } + }, + wrapping: buttonLabel("Validate Amount") + ) + case .loading: + buttonLabel("Validating…", color: .systemGray) + .opacity(0.6) + case .result: + Button( + onTap: { + self.errorState = .idle + }, + wrapping: buttonLabel("Reset", color: .systemGray) + ) + } + + separator() + + // MARK: - Section: Focus after async operation + + sectionHeader("Focus After Async Operation") + + if transferState == .result { + Label(text: "Transfer complete! $42.00 sent.") { label in + label.font = .systemFont(ofSize: 16, weight: .medium) + label.color = .systemGreen + } + .accessibilityFocus(trigger: resultTrigger) + } + + switch transferState { + case .idle: + Button( + onTap: { + self.transferState = .loading + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.transferState = .result + DispatchQueue.main.async { + self.resultTrigger.requestFocus() + } + } + }, + wrapping: buttonLabel("Send Transfer") + ) + case .loading: + buttonLabel("Sending…", color: .systemGray) + .opacity(0.6) + case .result: + Button( + onTap: { + self.transferState = .idle + }, + wrapping: buttonLabel("Reset", color: .systemGray) + ) + } + + separator() + + // MARK: - Section: Layout changed + + sectionHeader("Layout Changed (.layoutChanged)") + + Label(text: "Focuses this label with .layoutChanged") { label in + label.font = .systemFont(ofSize: 16) + label.color = .secondaryLabel + } + .accessibilityFocus(trigger: layoutChangeTrigger) + + Button( + onTap: { + self.layoutChangeTrigger.requestFocus() + }, + wrapping: buttonLabel("Trigger Layout Changed") + ) + + separator() + + // MARK: - Section: Screen changed + + sectionHeader("Screen Changed (.screenChanged)") + + Label(text: "Focuses this label with .screenChanged") { label in + label.font = .systemFont(ofSize: 16) + label.color = .secondaryLabel + } + .accessibilityFocus(trigger: screenChangeTrigger) + + Button( + onTap: { + self.screenChangeTrigger.requestFocus() + }, + wrapping: buttonLabel("Trigger Screen Changed") + ) + } + .inset(uniform: 20) + .scrollable(.fittingHeight) { scrollView in + scrollView.alwaysBounceVertical = true + } + } + + // MARK: - Helpers + + private func sectionHeader(_ text: String) -> Element { + Label(text: text) { label in + label.font = .systemFont(ofSize: 20, weight: .bold) + label.color = .label + } + } + + private func buttonLabel(_ text: String, color: UIColor = .systemBlue) -> Element { + Label(text: text) { label in + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.color = color + } + .inset(by: UIEdgeInsets(top: 12, left: 20, bottom: 12, right: 20)) + .box( + background: color.withAlphaComponent(0.12), + corners: .rounded(radius: 10) + ) + } + + private func separator() -> Element { + Box(backgroundColor: .separator) + .constrainedTo(height: .absolute(1.0 / UIScreen.main.scale)) + } +} diff --git a/SampleApp/Sources/RootViewController.swift b/SampleApp/Sources/RootViewController.swift index 47057290e..3873d8427 100644 --- a/SampleApp/Sources/RootViewController.swift +++ b/SampleApp/Sources/RootViewController.swift @@ -12,6 +12,9 @@ final class RootViewController: UIViewController { DemoItem(title: "Accessibility", onTap: { [weak self] in self?.push(AccessibilityViewController()) }), + DemoItem(title: "Accessibility Focus Trigger", onTap: { [weak self] in + self?.push(AccessibilityFocusTriggerViewController()) + }), DemoItem(title: "Text Links", onTap: { [weak self] in self?.push(TextLinkViewController()) }), From d938c43ab1e0e3cf0762b75d3d1d150028c12eec Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Thu, 2 Apr 2026 10:55:48 +0200 Subject: [PATCH 2/9] Fix buttons losing VoiceOver focus on state change Keep a single Button in the element tree across all states instead of swapping between Button and Label elements. Use isEnabled to disable during loading so VoiceOver focus is preserved on the same backing view. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...essibilityFocusTriggerViewController.swift | 60 +++++++++---------- 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift b/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift index b22caffb5..c4a13c7d1 100644 --- a/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift +++ b/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift @@ -53,10 +53,11 @@ final class AccessibilityFocusTriggerViewController: UIViewController { .accessibilityFocus(trigger: errorTrigger) } - switch errorState { - case .idle: - Button( - onTap: { + Button( + isEnabled: errorState != .loading, + onTap: { + switch self.errorState { + case .idle: self.errorState = .loading DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.errorState = .result @@ -64,20 +65,17 @@ final class AccessibilityFocusTriggerViewController: UIViewController { self.errorTrigger.requestFocus() } } - }, - wrapping: buttonLabel("Validate Amount") - ) - case .loading: - buttonLabel("Validating…", color: .systemGray) - .opacity(0.6) - case .result: - Button( - onTap: { + case .result: self.errorState = .idle - }, - wrapping: buttonLabel("Reset", color: .systemGray) + case .loading: + break + } + }, + wrapping: buttonLabel( + errorState == .idle ? "Validate Amount" : errorState == .loading ? "Validating…" : "Reset", + color: errorState == .loading ? .systemGray : errorState == .result ? .systemGray : .systemBlue ) - } + ) separator() @@ -93,10 +91,11 @@ final class AccessibilityFocusTriggerViewController: UIViewController { .accessibilityFocus(trigger: resultTrigger) } - switch transferState { - case .idle: - Button( - onTap: { + Button( + isEnabled: transferState != .loading, + onTap: { + switch self.transferState { + case .idle: self.transferState = .loading DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.transferState = .result @@ -104,20 +103,17 @@ final class AccessibilityFocusTriggerViewController: UIViewController { self.resultTrigger.requestFocus() } } - }, - wrapping: buttonLabel("Send Transfer") - ) - case .loading: - buttonLabel("Sending…", color: .systemGray) - .opacity(0.6) - case .result: - Button( - onTap: { + case .result: self.transferState = .idle - }, - wrapping: buttonLabel("Reset", color: .systemGray) + case .loading: + break + } + }, + wrapping: buttonLabel( + transferState == .idle ? "Send Transfer" : transferState == .loading ? "Sending…" : "Reset", + color: transferState == .loading ? .systemGray : transferState == .result ? .systemGray : .systemBlue ) - } + ) separator() From cbb99992094cb8216b87f088e8092567b01dff16 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Thu, 2 Apr 2026 10:59:02 +0200 Subject: [PATCH 3/9] Remove error demo, reorder sections in sample app Drop the Focus on Error Message demo. Reorder to: Layout Changed, Screen Changed, then Focus After Async Operation at the bottom. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...essibilityFocusTriggerViewController.swift | 97 ++++++------------- 1 file changed, 27 insertions(+), 70 deletions(-) diff --git a/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift b/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift index c4a13c7d1..baa86ba16 100644 --- a/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift +++ b/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift @@ -10,15 +10,10 @@ final class AccessibilityFocusTriggerViewController: UIViewController { case idle, loading, result } - private var errorState: DemoState = .idle { - didSet { update() } - } - private var transferState: DemoState = .idle { didSet { update() } } - private let errorTrigger = AccessibilityFocusTrigger() private let resultTrigger = AccessibilityFocusTrigger() private let layoutChangeTrigger = AccessibilityFocusTrigger(notification: .layoutChanged) private let screenChangeTrigger = AccessibilityFocusTrigger(notification: .screenChanged) @@ -41,40 +36,40 @@ final class AccessibilityFocusTriggerViewController: UIViewController { var element: Element { Column(alignment: .fill, minimumSpacing: 24) { - // MARK: - Section: Focus on error + // MARK: - Section: Layout changed - sectionHeader("Focus on Error Message") + sectionHeader("Layout Changed (.layoutChanged)") - if errorState == .result { - Label(text: "Invalid amount entered.") { label in - label.font = .systemFont(ofSize: 16, weight: .medium) - label.color = .systemRed - } - .accessibilityFocus(trigger: errorTrigger) + Label(text: "Focuses this label with .layoutChanged") { label in + label.font = .systemFont(ofSize: 16) + label.color = .secondaryLabel } + .accessibilityFocus(trigger: layoutChangeTrigger) Button( - isEnabled: errorState != .loading, onTap: { - switch self.errorState { - case .idle: - self.errorState = .loading - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self.errorState = .result - DispatchQueue.main.async { - self.errorTrigger.requestFocus() - } - } - case .result: - self.errorState = .idle - case .loading: - break - } + self.layoutChangeTrigger.requestFocus() }, - wrapping: buttonLabel( - errorState == .idle ? "Validate Amount" : errorState == .loading ? "Validating…" : "Reset", - color: errorState == .loading ? .systemGray : errorState == .result ? .systemGray : .systemBlue - ) + wrapping: buttonLabel("Trigger Layout Changed") + ) + + separator() + + // MARK: - Section: Screen changed + + sectionHeader("Screen Changed (.screenChanged)") + + Label(text: "Focuses this label with .screenChanged") { label in + label.font = .systemFont(ofSize: 16) + label.color = .secondaryLabel + } + .accessibilityFocus(trigger: screenChangeTrigger) + + Button( + onTap: { + self.screenChangeTrigger.requestFocus() + }, + wrapping: buttonLabel("Trigger Screen Changed") ) separator() @@ -114,44 +109,6 @@ final class AccessibilityFocusTriggerViewController: UIViewController { color: transferState == .loading ? .systemGray : transferState == .result ? .systemGray : .systemBlue ) ) - - separator() - - // MARK: - Section: Layout changed - - sectionHeader("Layout Changed (.layoutChanged)") - - Label(text: "Focuses this label with .layoutChanged") { label in - label.font = .systemFont(ofSize: 16) - label.color = .secondaryLabel - } - .accessibilityFocus(trigger: layoutChangeTrigger) - - Button( - onTap: { - self.layoutChangeTrigger.requestFocus() - }, - wrapping: buttonLabel("Trigger Layout Changed") - ) - - separator() - - // MARK: - Section: Screen changed - - sectionHeader("Screen Changed (.screenChanged)") - - Label(text: "Focuses this label with .screenChanged") { label in - label.font = .systemFont(ofSize: 16) - label.color = .secondaryLabel - } - .accessibilityFocus(trigger: screenChangeTrigger) - - Button( - onTap: { - self.screenChangeTrigger.requestFocus() - }, - wrapping: buttonLabel("Trigger Screen Changed") - ) } .inset(uniform: 20) .scrollable(.fittingHeight) { scrollView in From f97113e59d4922cae748b07caa750b88de7f60e5 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Thu, 2 Apr 2026 11:12:23 +0200 Subject: [PATCH 4/9] Move AccessibilityFocusTrigger to BlueprintUIAccessibilityCore Moves trigger, focusable element, and Element extension from BlueprintUI into BlueprintUIAccessibilityCore where accessibility primitives live. Removes error demo from sample app, reorders sections. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Sources}/AccessibilityFocusTrigger.swift | 0 .../Sources}/AccessibilityFocusableElement.swift | 1 + .../Sources}/Element+AccessibilityFocus.swift | 2 ++ .../Tests/AccessibilityFocusTriggerTests.swift | 1 + Package.swift | 5 +++++ .../Sources/AccessibilityFocusTriggerViewController.swift | 1 + .../Tuist/ProjectDescriptionHelpers/Project+Blueprint.swift | 1 + 7 files changed, 11 insertions(+) rename {BlueprintUI/Sources/Accessibility => BlueprintUIAccessibilityCore/Sources}/AccessibilityFocusTrigger.swift (100%) rename {BlueprintUI/Sources/Accessibility => BlueprintUIAccessibilityCore/Sources}/AccessibilityFocusableElement.swift (98%) rename {BlueprintUI/Sources/Element => BlueprintUIAccessibilityCore/Sources}/Element+AccessibilityFocus.swift (96%) rename {BlueprintUI => BlueprintUIAccessibilityCore}/Tests/AccessibilityFocusTriggerTests.swift (98%) diff --git a/BlueprintUI/Sources/Accessibility/AccessibilityFocusTrigger.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusTrigger.swift similarity index 100% rename from BlueprintUI/Sources/Accessibility/AccessibilityFocusTrigger.swift rename to BlueprintUIAccessibilityCore/Sources/AccessibilityFocusTrigger.swift diff --git a/BlueprintUI/Sources/Accessibility/AccessibilityFocusableElement.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusableElement.swift similarity index 98% rename from BlueprintUI/Sources/Accessibility/AccessibilityFocusableElement.swift rename to BlueprintUIAccessibilityCore/Sources/AccessibilityFocusableElement.swift index 956181bd1..a827d3365 100644 --- a/BlueprintUI/Sources/Accessibility/AccessibilityFocusableElement.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusableElement.swift @@ -1,3 +1,4 @@ +import BlueprintUI import UIKit /// A wrapping element that binds an `AccessibilityFocusTrigger` to a backing view, diff --git a/BlueprintUI/Sources/Element/Element+AccessibilityFocus.swift b/BlueprintUIAccessibilityCore/Sources/Element+AccessibilityFocus.swift similarity index 96% rename from BlueprintUI/Sources/Element/Element+AccessibilityFocus.swift rename to BlueprintUIAccessibilityCore/Sources/Element+AccessibilityFocus.swift index f54aa8a65..c1c2bbd64 100644 --- a/BlueprintUI/Sources/Element/Element+AccessibilityFocus.swift +++ b/BlueprintUIAccessibilityCore/Sources/Element+AccessibilityFocus.swift @@ -1,3 +1,5 @@ +import BlueprintUI + extension Element { /// Binds an `AccessibilityFocusTrigger` to this element. diff --git a/BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift b/BlueprintUIAccessibilityCore/Tests/AccessibilityFocusTriggerTests.swift similarity index 98% rename from BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift rename to BlueprintUIAccessibilityCore/Tests/AccessibilityFocusTriggerTests.swift index 82af29600..6d682ceea 100644 --- a/BlueprintUI/Tests/AccessibilityFocusTriggerTests.swift +++ b/BlueprintUIAccessibilityCore/Tests/AccessibilityFocusTriggerTests.swift @@ -1,3 +1,4 @@ +@testable import BlueprintUIAccessibilityCore import BlueprintUI import XCTest diff --git a/Package.swift b/Package.swift index b122d6e25..3a54000db 100644 --- a/Package.swift +++ b/Package.swift @@ -69,6 +69,11 @@ let package = Package( .process("Resources"), ], ), + .testTarget( + name: "BlueprintUIAccessibilityCoreTests", + dependencies: ["BlueprintUIAccessibilityCore", "BlueprintUI"], + path: "BlueprintUIAccessibilityCore/Tests" + ), ], swiftLanguageModes: [.v5] ) diff --git a/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift b/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift index baa86ba16..bdd7d4b39 100644 --- a/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift +++ b/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift @@ -1,4 +1,5 @@ import BlueprintUI +import BlueprintUIAccessibilityCore import BlueprintUICommonControls import UIKit diff --git a/SampleApp/Tuist/ProjectDescriptionHelpers/Project+Blueprint.swift b/SampleApp/Tuist/ProjectDescriptionHelpers/Project+Blueprint.swift index 789111f73..d8c668936 100644 --- a/SampleApp/Tuist/ProjectDescriptionHelpers/Project+Blueprint.swift +++ b/SampleApp/Tuist/ProjectDescriptionHelpers/Project+Blueprint.swift @@ -8,6 +8,7 @@ public let blueprintDeploymentTargets: DeploymentTargets = .iOS("15.0") public let blueprintDependencies: [TargetDependency] = [ .external(name: "BlueprintUI"), .external(name: "BlueprintUICommonControls"), + .external(name: "BlueprintUIAccessibilityCore"), ] extension String { From c12fdf464d034913296b9debbc175d5777797e0e Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Thu, 2 Apr 2026 12:21:03 +0200 Subject: [PATCH 5/9] Fix SwiftFormat lint: sort imports alphabetically Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Tests/AccessibilityFocusTriggerTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BlueprintUIAccessibilityCore/Tests/AccessibilityFocusTriggerTests.swift b/BlueprintUIAccessibilityCore/Tests/AccessibilityFocusTriggerTests.swift index 6d682ceea..bb900fc6a 100644 --- a/BlueprintUIAccessibilityCore/Tests/AccessibilityFocusTriggerTests.swift +++ b/BlueprintUIAccessibilityCore/Tests/AccessibilityFocusTriggerTests.swift @@ -1,5 +1,5 @@ -@testable import BlueprintUIAccessibilityCore import BlueprintUI +@testable import BlueprintUIAccessibilityCore import XCTest final class AccessibilityFocusTriggerTests: XCTestCase { From 72d11b9c1c08335c77904281937f6bf647e801b2 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Thu, 2 Apr 2026 12:23:05 +0200 Subject: [PATCH 6/9] Fix SwiftFormat: move @testable import to bottom per importgrouping config Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Tests/AccessibilityFocusTriggerTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BlueprintUIAccessibilityCore/Tests/AccessibilityFocusTriggerTests.swift b/BlueprintUIAccessibilityCore/Tests/AccessibilityFocusTriggerTests.swift index bb900fc6a..d1c925849 100644 --- a/BlueprintUIAccessibilityCore/Tests/AccessibilityFocusTriggerTests.swift +++ b/BlueprintUIAccessibilityCore/Tests/AccessibilityFocusTriggerTests.swift @@ -1,6 +1,6 @@ import BlueprintUI -@testable import BlueprintUIAccessibilityCore import XCTest +@testable import BlueprintUIAccessibilityCore final class AccessibilityFocusTriggerTests: XCTestCase { From 816fd0d7fee0c20084a716ee91f0a2e2944d00b3 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Thu, 2 Apr 2026 12:43:22 +0200 Subject: [PATCH 7/9] Address PR review: deinit cleanup, static announce, changelog, deprecation fix - Add deinit to BackingView to clear trigger binding on deallocation - Make announce(_:) static since it uses no instance state - Add CHANGELOG entries for new AccessibilityFocusTrigger API - Replace deprecated UIScreen.main with UITraitCollection.current.displayScale Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Sources/AccessibilityFocusTrigger.swift | 2 +- .../Sources/AccessibilityFocusableElement.swift | 4 ++++ CHANGELOG.md | 3 +++ .../Sources/AccessibilityFocusTriggerViewController.swift | 2 +- 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusTrigger.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusTrigger.swift index 9bdbafa7a..c71e0610c 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusTrigger.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusTrigger.swift @@ -59,7 +59,7 @@ public final class AccessibilityFocusTrigger { /// Posts a VoiceOver announcement without focusing a specific view. /// No-op if VoiceOver is not running. /// - Parameter message: The string to announce. - public func announce(_ message: String) { + public static func announce(_ message: String) { guard UIAccessibility.isVoiceOverRunning else { return } UIAccessibility.post(notification: .announcement, argument: message) } diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusableElement.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusableElement.swift index a827d3365..7ab8556a3 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusableElement.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusableElement.swift @@ -31,6 +31,10 @@ extension AccessibilityFocusableElement { private var currentTrigger: AccessibilityFocusTrigger? + deinit { + currentTrigger?.action = nil + } + func apply(trigger: AccessibilityFocusTrigger) { // Tear down old trigger binding. currentTrigger?.action = nil diff --git a/CHANGELOG.md b/CHANGELOG.md index bace273e7..4abf813da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added `AccessibilityFocusTrigger` and `Element.accessibilityFocus(trigger:)` for programmatic VoiceOver focus management. +- Added `AccessibilityFocusTrigger.announce(_:)` for posting VoiceOver announcements. + ### Removed ### Changed diff --git a/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift b/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift index bdd7d4b39..ee43753d3 100644 --- a/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift +++ b/SampleApp/Sources/AccessibilityFocusTriggerViewController.swift @@ -140,6 +140,6 @@ final class AccessibilityFocusTriggerViewController: UIViewController { private func separator() -> Element { Box(backgroundColor: .separator) - .constrainedTo(height: .absolute(1.0 / UIScreen.main.scale)) + .constrainedTo(height: .absolute(1.0 / UITraitCollection.current.displayScale)) } } From 3194581e5a812ec46952cf5fc3b347929d189f45 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Thu, 2 Apr 2026 12:48:25 +0200 Subject: [PATCH 8/9] Remove announce(_:) from AccessibilityFocusTrigger Not focus-related, doesn't belong on this type. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Sources/AccessibilityFocusTrigger.swift | 7 ------- CHANGELOG.md | 1 - 2 files changed, 8 deletions(-) diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusTrigger.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusTrigger.swift index c71e0610c..ddaa11f1b 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusTrigger.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusTrigger.swift @@ -56,11 +56,4 @@ public final class AccessibilityFocusTrigger { action?() } - /// Posts a VoiceOver announcement without focusing a specific view. - /// No-op if VoiceOver is not running. - /// - Parameter message: The string to announce. - public static func announce(_ message: String) { - guard UIAccessibility.isVoiceOverRunning else { return } - UIAccessibility.post(notification: .announcement, argument: message) - } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 4abf813da..2d5eee72e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added `AccessibilityFocusTrigger` and `Element.accessibilityFocus(trigger:)` for programmatic VoiceOver focus management. -- Added `AccessibilityFocusTrigger.announce(_:)` for posting VoiceOver announcements. ### Removed From 54b71a2331e1b2a42cce5f561061332b1dcbe41b Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Thu, 2 Apr 2026 20:34:35 +0200 Subject: [PATCH 9/9] Merge Element+AccessibilityFocus extension into AccessibilityFocusTrigger.swift Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Sources/AccessibilityFocusTrigger.swift | 20 +++++++++++++++++++ .../Sources/Element+AccessibilityFocus.swift | 20 ------------------- 2 files changed, 20 insertions(+), 20 deletions(-) delete mode 100644 BlueprintUIAccessibilityCore/Sources/Element+AccessibilityFocus.swift diff --git a/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusTrigger.swift b/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusTrigger.swift index ddaa11f1b..7e49bfd6e 100644 --- a/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusTrigger.swift +++ b/BlueprintUIAccessibilityCore/Sources/AccessibilityFocusTrigger.swift @@ -1,3 +1,4 @@ +import BlueprintUI import UIKit /// A trigger that moves VoiceOver focus to a backing view. @@ -57,3 +58,22 @@ public final class AccessibilityFocusTrigger { } } + +extension Element { + + /// Binds an `AccessibilityFocusTrigger` to this element. + /// + /// When `trigger.requestFocus()` is called, VoiceOver focus + /// moves to this element's backing view. + /// + /// - Parameter trigger: A trigger that can later be used to move VoiceOver focus to this element. + /// - Returns: A wrapping element that provides a backing view for VoiceOver focus. + public func accessibilityFocus( + trigger: AccessibilityFocusTrigger + ) -> Element { + AccessibilityFocusableElement( + wrapped: self, + trigger: trigger + ) + } +} diff --git a/BlueprintUIAccessibilityCore/Sources/Element+AccessibilityFocus.swift b/BlueprintUIAccessibilityCore/Sources/Element+AccessibilityFocus.swift deleted file mode 100644 index c1c2bbd64..000000000 --- a/BlueprintUIAccessibilityCore/Sources/Element+AccessibilityFocus.swift +++ /dev/null @@ -1,20 +0,0 @@ -import BlueprintUI - -extension Element { - - /// Binds an `AccessibilityFocusTrigger` to this element. - /// - /// When `trigger.requestFocus()` is called, VoiceOver focus - /// moves to this element's backing view. - /// - /// - Parameter trigger: A trigger that can later be used to move VoiceOver focus to this element. - /// - Returns: A wrapping element that provides a backing view for VoiceOver focus. - public func accessibilityFocus( - trigger: AccessibilityFocusTrigger - ) -> Element { - AccessibilityFocusableElement( - wrapped: self, - trigger: trigger - ) - } -}