diff --git a/Sources/SwiftNavigation/ButtonState.swift b/Sources/SwiftNavigation/ButtonState.swift index d8a45c7ea..148b71c89 100644 --- a/Sources/SwiftNavigation/ButtonState.swift +++ b/Sources/SwiftNavigation/ButtonState.swift @@ -10,6 +10,9 @@ public import Foundation public struct ButtonState: Identifiable { public let id: UUID public let action: ButtonStateAction + /// A Boolean value that indicates whether supporting UI components should make this button the + /// preferred action. + public let isPreferred: Bool public let label: TextState public let role: ButtonStateRole? @@ -17,10 +20,12 @@ public struct ButtonState: Identifiable { id: UUID, action: ButtonStateAction, label: TextState, - role: ButtonStateRole? + role: ButtonStateRole?, + isPreferred: Bool = false ) { self.id = id self.action = action + self.isPreferred = isPreferred self.label = label self.role = role } @@ -118,7 +123,21 @@ public struct ButtonState: Identifiable { id: self.id, action: self.action.map(transform), label: self.label, - role: self.role + role: self.role, + isPreferred: self.isPreferred + ) + } + + /// Returns a copy of the button state that is or is not preferred. + /// + /// Supporting UI components can use a preferred button as their default action. + public func preferred(_ isPreferred: Bool = true) -> Self { + ButtonState( + id: self.id, + action: self.action, + label: self.label, + role: self.role, + isPreferred: isPreferred ) } } @@ -190,6 +209,7 @@ extension ButtonStateRole: Equatable {} extension ButtonState: Equatable where Action: Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { lhs.action == rhs.action + && lhs.isPreferred == rhs.isPreferred && lhs.label == rhs.label && lhs.role == rhs.role } @@ -212,6 +232,7 @@ extension ButtonStateRole: Hashable {} extension ButtonState: Hashable where Action: Hashable { public func hash(into hasher: inout Hasher) { hasher.combine(self.action) + hasher.combine(self.isPreferred) hasher.combine(self.label) hasher.combine(self.role) } diff --git a/Sources/SwiftNavigation/Documentation.docc/Extensions/ButtonState.md b/Sources/SwiftNavigation/Documentation.docc/Extensions/ButtonState.md index f87a416ae..461c70460 100644 --- a/Sources/SwiftNavigation/Documentation.docc/Extensions/ButtonState.md +++ b/Sources/SwiftNavigation/Documentation.docc/Extensions/ButtonState.md @@ -6,6 +6,7 @@ - ``init(role:action:label:)-65t48`` - ``init(role:action:label:)-8tmop`` +- ``preferred(_:)`` - ``ButtonStateRole`` - ``ButtonStateAction`` @@ -17,6 +18,7 @@ - ``id`` - ``role-swift.property`` +- ``isPreferred`` - ``action`` - ``label`` diff --git a/Sources/SwiftNavigation/Traits/CustomDump.swift b/Sources/SwiftNavigation/Traits/CustomDump.swift index 716937cb1..931a9833c 100644 --- a/Sources/SwiftNavigation/Traits/CustomDump.swift +++ b/Sources/SwiftNavigation/Traits/CustomDump.swift @@ -30,6 +30,9 @@ if let role = self.role { children.append(("role", role)) } + if self.isPreferred { + children.append(("isPreferred", self.isPreferred)) + } children.append(("action", self.action)) children.append(("label", self.label)) return Mirror( diff --git a/Sources/UIKitNavigation/Navigation/UIAlertController.swift b/Sources/UIKitNavigation/Navigation/UIAlertController.swift index ec7c0afb8..909a6c3df 100644 --- a/Sources/UIKitNavigation/Navigation/UIAlertController.swift +++ b/Sources/UIKitNavigation/Navigation/UIAlertController.swift @@ -29,9 +29,7 @@ message: state.message.map { String(state: $0) }, preferredStyle: .alert ) - for button in state.buttons { - addAction(UIAlertAction(button, action: handler)) - } + self.addActions(state.buttons, stateName: "AlertState", handler: handler) if state.buttons.isEmpty { addAction(UIAlertAction(title: "OK", style: .cancel)) } @@ -73,13 +71,32 @@ message: state.message.map { String(state: $0) }, preferredStyle: .actionSheet ) - for button in state.buttons { - addAction(UIAlertAction(button, action: handler)) - } + self.addActions(state.buttons, stateName: "ConfirmationDialogState", handler: handler) if state.buttons.isEmpty { addAction(UIAlertAction(title: "OK", style: .cancel)) } } + + private func addActions( + _ buttons: [ButtonState], + stateName: String, + handler: @escaping (_ action: Action?) -> Void + ) { + let actions = buttons.map { UIAlertAction($0, action: handler) } + for action in actions { + self.addAction(action) + } + let preferred = zip(buttons, actions).filter(\.0.isPreferred).map(\.1) + self.preferredAction = preferred.first + if preferred.count > 1 { + reportIssue( + """ + 'UIAlertController' received '\(stateName)' with multiple preferred buttons. Will use \ + the first preferred button. + """ + ) + } + } } @available(iOS 13, *) diff --git a/Tests/SwiftNavigationTests/ButtonStateTests.swift b/Tests/SwiftNavigationTests/ButtonStateTests.swift index 542a410ce..d7d2eeef8 100644 --- a/Tests/SwiftNavigationTests/ButtonStateTests.swift +++ b/Tests/SwiftNavigationTests/ButtonStateTests.swift @@ -6,6 +6,47 @@ import Testing struct ButtonStateTests { + @Test + func preferred() { + let button = ButtonState(action: true) { + TextState("OK") + } + + #expect(!button.isPreferred) + #expect(button.preferred().isPreferred) + #expect(!button.preferred(false).isPreferred) + #expect(button != button.preferred()) + + var dump = "" + customDump(button.preferred(), to: &dump) + expectNoDifference( + dump, + """ + ButtonState( + isPreferred: true, + action: .send( + true + ), + label: "OK" + ) + """ + ) + } + + @Test + func mapPreservesPreferred() { + let button = ButtonState(action: 42) { + TextState("OK") + } + .preferred() + + let mappedButton = button.map { action in + action.map(String.init) + } + + #expect(mappedButton.isPreferred) + } + @Test func testAsyncAnimationWarning() async { let button = ButtonState(action: .send((), animation: .easeInOut)) { diff --git a/Tests/UIKitNavigationTests/UIAlertControllerTests.swift b/Tests/UIKitNavigationTests/UIAlertControllerTests.swift new file mode 100644 index 000000000..180d095ec --- /dev/null +++ b/Tests/UIKitNavigationTests/UIAlertControllerTests.swift @@ -0,0 +1,78 @@ +#if canImport(UIKit) && !os(watchOS) + import UIKitNavigation + import XCTest + + @available(iOS 13, tvOS 13, *) + final class UIAlertControllerTests: XCTestCase { + @MainActor + func testAlertPreferredAction() { + let controller = UIAlertController( + state: AlertState { + TextState("Title") + } actions: { + ButtonState(action: 1) { + TextState("First") + } + ButtonState(action: 2) { + TextState("Second") + } + .preferred() + } + ) + + XCTAssertTrue(controller.preferredAction === controller.actions[1]) + } + + @MainActor + func testConfirmationDialogPreferredAction() { + let controller = UIAlertController( + state: ConfirmationDialogState { + TextState("Title") + } actions: { + ButtonState(action: 1) { + TextState("First") + } + ButtonState(action: 2) { + TextState("Second") + } + .preferred() + } + ) + + XCTAssertTrue(controller.preferredAction === controller.actions[1]) + } + + @MainActor + func testMultiplePreferredActionsReportIssueAndUseFirstPreferredAction() { + var controller: UIAlertController? + + XCTExpectFailure { + controller = UIAlertController( + state: ConfirmationDialogState { + TextState("Title") + } actions: { + ButtonState(action: 1) { + TextState("First") + } + .preferred() + ButtonState(action: 2) { + TextState("Second") + } + .preferred() + } + ) + } issueMatcher: { + $0.compactDescription + == """ + failed - 'UIAlertController' received 'ConfirmationDialogState' with multiple preferred buttons. Will use the first preferred button. + """ + } + + guard let controller else { + XCTFail("Expected a controller.") + return + } + XCTAssertTrue(controller.preferredAction === controller.actions[0]) + } + } +#endif