"Double-click to submit" pattern with Stimulus

While tinkering around with a home lab and CasaOS, I encountered this pattern where certain actions require a "double click", like this:

I wanted to see how hard it was to copy this with Stimulus, and it turns out it is not that hard.

Let's start with a controller:

import { Controller } from '@hotwired/stimulus'

// Connects to data-controller="double-confirm"
export default class extends Controller {
  static targets = ['text'] // The text we want to switch
  static values = { clicked: Boolean }

  connect () {
    this.clickedValue = false
    this.originalText = this.textTarget.innerText
  }

  submit (event) {
    event.preventDefault()

    if (this.clickedValue) {
      this.element.requestSubmit()
    } else {
      this.clickedValue = true
      this.textTarget.innerText = 'Are you sure?'
    }
  }
}

Upon submission, we first prevent the default behavior so we can either swap the text or submit the form.

Now, connecting it to our form:

<%= form_with url: user_path(user), method: :delete, data: { controller: "double-confirm" } do |form| %>
  <% form.button type: :submit, data: { action: "click->double-confirm#submit" }, class: "button success" do %>
    <span data-double-confirm-target="text">Delete</span>
  <% end %>
<% end %>

And that's almost it. There is no way to cancel the action. Once clicked, there's no coming back; you either have to submit the form by clicking it again or refresh the page so that the button resets to its initial state.

Let's go with a simple solution: cancel the action whenever the user clicks outside the button. We start by adding stimulus-use:

yarn add stimulus-use @hotwired/stimulus

and then we make the controller aware of any clicks outside its element:

import { Controller } from '@hotwired/stimulus'
++ import { useClickOutside } from 'stimulus-use'

// Connects to data-controller="double-confirm"
export default class extends Controller {
  static targets = ['text']
  static values = { clicked: Boolean }

  connect () {
    this.clickedValue = false
    this.originalText = this.textTarget.innerText
++  useClickOutside(this, { element: this.element })
  }

  submit (event) {
    event.preventDefault()

    if (this.clickedValue) {
      this.element.requestSubmit()
    } else {
      this.clickedValue = true
      this.textTarget.innerText = 'Are you sure?'
    }
  }

++  clickOutside (event) {
++    if (this.clickedValue) {
++      this.clickedValue = false
++      this.textTarget.innerText = this.originalText
++    }
++  }
}

And this is the final result