diff --git a/Demo/Server/app/views/navigations/show.html.erb b/Demo/Server/app/views/navigations/show.html.erb
index cd02889..44d4f6f 100644
--- a/Demo/Server/app/views/navigations/show.html.erb
+++ b/Demo/Server/app/views/navigations/show.html.erb
@@ -3,7 +3,7 @@
This screen was pushed onto the navigation stack.
This is the default behavior, no custom options are required.
+
<%= render "navigations/item",
path: second_navigation_path,
icon: "bi-arrow-right",
@@ -34,4 +34,20 @@
icon: "bi-bug",
name: "Error handling",
description: "Visit a page that does not exist (404)." %>
+
+ <%= render "navigations/item",
+ path: "#",
+ icon: "bi-exclamation-circle",
+ name: "JavaScript alert dialog",
+ data: {action: "alerts#showAlert", title: "A JavaScript alert."} do %>
+ Present a JavaScript alert()
.
+ <% end %>
+
+ <%= render "navigations/item",
+ path: "#",
+ icon: "bi-question-circle",
+ name: "JavaScript confirm dialog",
+ data: {action: "alerts#showConfirm", title: "Are you sure?"} do %>
+ Present a JavaScript confirm()
, like via data-turbo-confirm
.
+ <% end %>
diff --git a/Demo/Server/app/views/resources/show.html.erb b/Demo/Server/app/views/resources/show.html.erb
index 047438b..0cdfbf4 100644
--- a/Demo/Server/app/views/resources/show.html.erb
+++ b/Demo/Server/app/views/resources/show.html.erb
@@ -5,5 +5,5 @@
<%= link_to "Edit resource", edit_resource_path(@resource), class: "btn btn-outline-secondary" %>
- <%= link_to "Delete resource", resource_path(@resource), data: {turbo_method: :delete}, class: "btn btn-outline-danger" %>
+ <%= link_to "Delete resource", resource_path(@resource), data: {turbo_method: :delete, turbo_confirm: "Delete this resource?"}, class: "btn btn-outline-danger" %>
diff --git a/Sources/TurboNavigationDelegate.swift b/Sources/TurboNavigationDelegate.swift
index dd5c6a5..85cf4bc 100644
--- a/Sources/TurboNavigationDelegate.swift
+++ b/Sources/TurboNavigationDelegate.swift
@@ -5,10 +5,8 @@ import WebKit
/// Implement to be notified when certain navigations are performed
/// or to render a native controller instead of a Turbo web visit.
public protocol TurboNavigationDelegate: AnyObject {
- typealias RetryBlock = () -> Void
-
- /// Respond to authentication challenge presented by web servers behing basic auth.
- func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
+ /// Optional. Override to provide a custom implementation of `WKUIDelegate`, like handling JavaScript alerts.
+ var webViewDelegate: WKUIDelegate? { get }
/// Optional. Accept or reject a visit proposal.
/// If accepted, you may provide a view controller to be displayed, otherwise a new `VisitableViewController` is displayed.
@@ -19,48 +17,31 @@ public protocol TurboNavigationDelegate: AnyObject {
/// - Returns: how to react to the visit proposal
func handle(proposal: VisitProposal) -> ProposalResult
- /// Optional. An error occurred loading the request, present it to the user.
- /// Retry the request by executing the closure.
- /// If not implemented, will present the error's localized description and a Retry button.
- func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock)
-
- /// Optional. Implement to customize handling of external URLs.
- /// If not implemented, will present `SFSafariViewController` as a modal and load the URL.
- func openExternalURL(_ url: URL, from controller: UIViewController)
+ // MARK: - SessionDelegate overrides
+ /// Optional. Implement these functions when via an extension.
- /// Optional. Implement to become the web view's navigation delegate after the initial cold boot visit is completed.
- /// https://github.com/hotwired/turbo-ios/blob/main/Docs/Overview.md#becoming-the-web-views-navigation-delegate
+ func session(_ session: Session, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
func sessionDidLoadWebView(_ session: Session)
- /// Optional. Useful for interacting with the web view after the page loads.
+ func sessionDidStartRequest(_ session: Session)
func sessionDidFinishRequest(_ session: Session)
+ func sessionDidStartFormSubmission(_ session: Session)
}
public extension TurboNavigationDelegate {
- func handle(proposal: VisitProposal) -> ProposalResult { .accept }
+ var webViewDelegate: WKUIDelegate? { nil }
- func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) {
- if let errorPresenter = visitable as? ErrorPresenter {
- errorPresenter.presentError(error) {
- retry()
- }
- }
- }
+ func handle(proposal: VisitProposal) -> ProposalResult { .accept }
- func openExternalURL(_ url: URL, from controller: UIViewController) {
- let safariViewController = SFSafariViewController(url: url)
- safariViewController.modalPresentationStyle = .pageSheet
- if #available(iOS 15.0, *) {
- safariViewController.preferredControlTintColor = .tintColor
- }
- controller.present(safariViewController, animated: true)
- }
+ // MARK: - SessionDelegate
- func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
+ func session(_ session: Session, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
completionHandler(.performDefaultHandling, nil)
}
- func sessionDidFinishRequest(_ session: Session) {}
-
func sessionDidLoadWebView(_ session: Session) {}
+
+ func sessionDidStartRequest(_ session: Session) {}
+ func sessionDidFinishRequest(_ session: Session) {}
+ func sessionDidStartFormSubmission(_ session: Session) {}
}
diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift
index 4e90bcc..aaed176 100644
--- a/Sources/TurboNavigator.swift
+++ b/Sources/TurboNavigator.swift
@@ -5,56 +5,35 @@ import WebKit
/// Handles navigation to new URLs using the following rules:
/// https://github.com/joemasilotti/TurboNavigator#handled-flows
-public class TurboNavigator {
+public class TurboNavigator: NSObject {
/// Default initializer.
/// - Parameters:
- /// - delegate: handle custom controller routing
- /// - pathConfiguration: assigned to internal `Session` instances for custom configuration
- /// - navigationController: optional: override the main navigation stack
- /// - modalNavigationController: optional: override the modal navigation stack
- public init(delegate: TurboNavigationDelegate,
- pathConfiguration: PathConfiguration? = nil,
- navigationController: UINavigationController = UINavigationController(),
- modalNavigationController: UINavigationController = UINavigationController())
+ /// - delegate: Handle custom controller routing.
+ /// - pathConfiguration: Optional. Remotely configure settings and path rules.
+ /// - navigationController: Optional. Override the main navigation stack.
+ /// - modalNavigationController: Optional. Override the modal navigation stack.
+ public init(
+ delegate: TurboNavigationDelegate,
+ pathConfiguration: PathConfiguration? = nil,
+ navigationController: UINavigationController = UINavigationController(),
+ modalNavigationController: UINavigationController = UINavigationController())
{
- self.session = Session(webView: TurboConfig.shared.makeWebView())
- self.modalSession = Session(webView: TurboConfig.shared.makeWebView())
self.delegate = delegate
+ self.pathConfiguration = pathConfiguration
self.navigationController = navigationController
self.modalNavigationController = modalNavigationController
-
- session.delegate = self
- modalSession.delegate = self
- session.pathConfiguration = pathConfiguration
- modalSession.pathConfiguration = pathConfiguration
+ super.init()
}
- /// Provide `Turbo.Session` instances with preconfigured path configurations and delegates.
- /// Note that TurboNavigationDelegate.controller(_:forProposal:) will no longer be called.
- /// - Parameters:
- /// - preconfiguredMainSession: a session whose delegate is not `TurboNavigator`
- /// - preconfiguredModalSession: a session whose delegate is not `TurboNavigator`
- /// - delegate: handle non-routing behavior, like custom error handling
- /// - navigationController: optional: override the main navigation stack
- /// - modalNavigationController: optional: override the modal navigation stack
- public init(preconfiguredMainSession: Turbo.Session,
- preconfiguredModalSession: Turbo.Session,
- delegate: TurboNavigationDelegate,
- navigationController: UINavigationController = UINavigationController(),
- modalNavigationController: UINavigationController = UINavigationController())
- {
- self.session = preconfiguredMainSession
- self.modalSession = preconfiguredModalSession
- self.navigationController = navigationController
- self.modalNavigationController = modalNavigationController
+ /// Set this as the `rootViewController` of your application's `UIWindow`.
+ public var rootViewController: UIViewController { navigationController }
- self.delegate = delegate
+ /// `navigationController` or `modalNavigationController`, whichever is being shown.
+ public var currentNavigationController: UINavigationController {
+ navigationController.presentedViewController != nil ? modalNavigationController : navigationController
}
- public var rootViewController: UIViewController { navigationController }
- public let navigationController: UINavigationController
- public let modalNavigationController: UINavigationController
-
+ /// Follows rules from `pathConfiguration` to route a `URL` to the stack.
public func route(_ url: URL) {
let options = VisitOptions(action: .advance, response: nil)
let properties = session.pathConfiguration?.properties(for: url) ?? PathProperties()
@@ -62,6 +41,7 @@ public class TurboNavigator {
route(proposal)
}
+ /// Follows rules from `pathConfiguration` to route a `VisitProposal` to the stack.
public func route(_ proposal: VisitProposal) {
guard let controller = controller(for: proposal) else { return }
@@ -89,8 +69,11 @@ public class TurboNavigator {
// MARK: Internal
- let session: Session
- let modalSession: Session
+ let navigationController: UINavigationController
+ let modalNavigationController: UINavigationController
+
+ lazy var session = makeSession()
+ lazy var modalSession = makeSession()
// MARK: Private
@@ -100,6 +83,15 @@ public class TurboNavigator {
}
private unowned let delegate: TurboNavigationDelegate
+ private let pathConfiguration: PathConfiguration?
+
+ private func makeSession() -> Session {
+ let session = Session(webView: TurboConfig.shared.makeWebView())
+ session.delegate = self
+ session.pathConfiguration = pathConfiguration
+ session.webView.uiDelegate = delegate.webViewDelegate ?? self
+ return session
+ }
private func controller(for proposal: VisitProposal) -> UIViewController? {
switch delegate.handle(proposal: proposal) {
@@ -248,13 +240,19 @@ extension TurboNavigator: SessionDelegate {
}
public func session(_ session: Session, openExternalURL url: URL) {
- let controller = session === modalSession ? modalNavigationController : navigationController
- delegate.openExternalURL(url, from: controller)
+ let safariViewController = SFSafariViewController(url: url)
+ safariViewController.modalPresentationStyle = .pageSheet
+ if #available(iOS 15.0, *) {
+ safariViewController.preferredControlTintColor = .tintColor
+ }
+ currentNavigationController.visibleViewController?.present(safariViewController, animated: true)
}
public func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) {
- delegate.visitableDidFailRequest(visitable, error: error) {
- session.reload()
+ if let errorPresenter = visitable as? ErrorPresenter {
+ errorPresenter.presentError(error) {
+ session.reload()
+ }
}
}
@@ -262,16 +260,48 @@ extension TurboNavigator: SessionDelegate {
session.reload()
}
+ // MARK: SessionDelegate → TurboNavigationDelegate
+
public func session(_ session: Session, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
- delegate.didReceiveAuthenticationChallenge(challenge, completionHandler: completionHandler)
+ delegate.session(session, didReceiveAuthenticationChallenge: challenge, completionHandler: completionHandler)
+ }
+
+ public func sessionDidLoadWebView(_ session: Session) {
+ delegate.sessionDidLoadWebView(session)
+ }
+
+ public func sessionDidStartRequest(_ session: Session) {
+ delegate.sessionDidStartRequest(session)
}
public func sessionDidFinishRequest(_ session: Session) {
delegate.sessionDidFinishRequest(session)
}
- public func sessionDidLoadWebView(_ session: Session) {
- session.webView.navigationDelegate = session
- delegate.sessionDidLoadWebView(session)
+ public func sessionDidStartFormSubmission(_ session: Session) {
+ delegate.sessionDidStartFormSubmission(session)
+ }
+}
+
+// MARK: - WKUIDelegate
+
+extension TurboNavigator: WKUIDelegate {
+ public func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
+ let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert)
+ alert.addAction(UIAlertAction(title: "Close", style: .default) { _ in
+ completionHandler()
+ })
+ currentNavigationController.visibleViewController?.present(alert, animated: true)
+ }
+
+ public func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
+ let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert)
+ alert.addAction(UIAlertAction(title: "OK", style: .destructive) { _ in
+ completionHandler(true)
+ })
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
+ completionHandler(false)
+ })
+ currentNavigationController.visibleViewController?.present(alert, animated: true)
}
}
diff --git a/Tests/TurboNavigationDelegateTests.swift b/Tests/TurboNavigationDelegateTests.swift
index 65aa5f9..b9e5f90 100644
--- a/Tests/TurboNavigationDelegateTests.swift
+++ b/Tests/TurboNavigationDelegateTests.swift
@@ -12,16 +12,6 @@ final class TurboNavigationDelegateTests: XCTestCase {
XCTAssertEqual(result, .accept)
}
- func test_openExternalURL_presentsSafariViewController() throws {
- let url = URL(string: "https://example.com")!
- let controller = TestableNavigationController()
-
- delegate.openExternalURL(url, from: controller)
-
- XCTAssert(controller.presentedViewController is SFSafariViewController)
- XCTAssertEqual(controller.modalPresentationStyle, .pageSheet)
- }
-
// MARK: Private
private let delegate = DefaultDelegate()
diff --git a/Tests/TurboNavigatorTests.swift b/Tests/TurboNavigatorTests.swift
index 06e0051..49b3de7 100644
--- a/Tests/TurboNavigatorTests.swift
+++ b/Tests/TurboNavigatorTests.swift
@@ -245,6 +245,22 @@ final class TurboNavigatorTests: XCTestCase {
XCTAssertNotEqual(navigator.session.activeVisitable?.visitableURL, proposal.url)
}
+ func test_currentNavigationController_startsAsRoot() {
+ XCTAssertIdentical(navigator.currentNavigationController, navigator.navigationController)
+ }
+
+ func test_currentNavigationController_modalPresented_isModal() {
+ navigator.route(VisitProposal(path: "/one"))
+ navigator.route(VisitProposal(path: "/two", context: .modal))
+ XCTAssertIdentical(navigator.currentNavigationController, navigator.modalNavigationController)
+ }
+
+ func test_currentNavigationController_mainNavigation_isRoot() {
+ navigator.route(VisitProposal(path: "/one", context: .modal))
+ navigator.route(VisitProposal(path: "/two"))
+ XCTAssertIdentical(navigator.currentNavigationController, navigator.navigationController)
+ }
+
// MARK: Private
private enum Context {