Turbo Confirmation Bias

How to hijack every link on a page with a custom confirm dialog

Turbo has a knack for adding cool, little-but-useful features, resulting in a smoother experience for the user and developer alike. One lesser known capability within Turbo is the ability to easily customize the confirm process for a button. Although documented briefly in the Turbo Handbook, in order to really get an appreciation for the potential, watch this excellent GoRails Videocast. In this blog post, we will take things a step further and use turbo event listeners to automatically add turbo confirm dialog modals for every link on a page!

First you might want to take a look at the basic ConfirmMethod, presented by Chris Oliver at GoRails. By using the Turbo.setConfirmedMethod api method, we’re able to change the default confirm method that Turbo would use for any form containing the data-confirm-method attribute assigned to it prior to submission.

We have adapted this version slightly to afford a bit more control over the content of our custom dialog:

Turbo.setConfirmMethod((message, element, submitter) => {
  let dialog = document.getElementById("turbo-confirm")
  let [messageText, buttonText, confirmEvent] = message.split(';')
  dialog.querySelector("form").method = 'dialog'
  dialog.querySelector("#message").innerHTML = messageText
  dialog.querySelector("#turbo-confirm").textContent =
    submitter?.dataset.confirmButton || buttonText || 'Confirm'
  dialog.showModal()

  return new Promise((resolve, reject) => {
    dialog.addEventListener("close", () => {
      let resolved = dialog.returnValue == "confirm"
      if (resolved && confirmEvent) {
        window.dispatchEvent(new CustomEvent(`${confirmEvent}`))
      }
      resolve(resolved)
    }, { once: true })
  })
})

In our application, we’re doing some unconventional Turbo get requests that require <a> tags. While Turbo does support the data-turbo-confirm attribute on anchor tags, it unfortunately does not yet support access to the submitter — which is why we use string splitting to get more details out of our message value. We’ve also added an optional confirmEvent variable parsed from the end of the message string, triggering a dispatchEvent for those rare cases when some sort of cleanup action is needed on the page after confirmation.

With that all in place, we can add a data-turbo-confirm attribute to any link, button, or form on our page and get a feature rich custom styled modal dialogue that looks something like this:

Now, what happens when you have a page that you really don’t want your user to casually leave, perhaps via a cancel link, breadcrumbs, or any other menu navigation item on the page? You probably don’t want to litter your code with logic and conditionals for confirm attributes, which can become a lot to manage and keep up with as your design system and UI evolves over time. We found a clever hack for just this situation using the following Stimulus controller:

//confirm_leave_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ['confirmMessage']

  connect() {
    this.clickedLinks = []
    this.boundAddConfirmDialog = this.addConfirmDialog.bind(this)
    document.addEventListener('turbo:click', this.boundAddConfirmDialog)
  }

  disconnect() {
    this.clickedLinks.forEach((link) => {
      link.removeAttribute('data-turbo-method')
    })
    document.removeEventListener('turbo:click', this.boundAddConfirmDialog)
  }

  addConfirmDialog(event) {
    const { turboConfirm, turboMethod } = this.element.dataset
    event.target.dataset.turboConfirm = turboConfirm
    event.target.dataset.turboMethod = turboMethod
    event.detail.originalEvent.preventDefault()
    event.preventDefault()
    this.clickedLinks.push(event.target)
    event.target.click()
  }
}

This controller solves the challenge of adding a data-turbo-confirm attribute on any link right after being clicked but before Turbo performs the visit. Turbo provides event lifecycle hooks just for these sort of use cases and we are making use of the turbo:click event to essentially hijack the event cycle entirely.

In addConfirmDialog, we first prevent the link’s original click event and then prevent the default turbo-click event itself in order to continue processing. That gives us a tick to add our turboConfirm data attribute while simultaneously tracking our clicked links on the page. We also ensure that our links have the turboMethod attribute required to trigger a Turbo click visit. Finally, we end by programmatically clicking the link again so that the turbo confirm process can happen. Note that detecting the turboConfirm attribute occurs much too early in the visit process, which is why we need to abandon and then reboot the click event for the link.

You probably notice that we do a little cleanup on the clicked links removing the turboMethod attribute on links that have been clicked. This is because we might have refreshed part of the page with a Turbo Frame or a Turbo Stream and have therefore intentionally disconnected our confirm-leave controller since the state of the page no longer requires a confirm dialog — and we don’t want any links outside of the frame to continue bothering our user. Turbo does a lot of magic observing and converting our links into forms. Adding and then removing the data-turbo-method seems to be required to both initiate the confirm process after our hijack and to release it when we no longer want the confirm behavior.

Developers and users alike have long detested the clunky default confirm dialog messages offered by browsers, and we’re thankful that Turbo has provided us a way to improve this user experience. In addition, Turbo provides just enough methods, settings, and hooks to customize that experience further and — with a bit of tinkering — we can build enhancements like our confirm-leave controller, providing even richer experiences and greater developer ease going forward.

If you’re looking for a team to help you discover the right thing to build and help you build it, get in touch.

Published on January 25, 2024