javascript erb 93 lines · 3 tabs

Custom confirm dialog with Stimulus (better than window.confirm)

Shared by codesnips Jan 2026
3 tabs
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["dialog", "message"]

  connect() {
    this.pendingElement = null
  }

  confirm(event) {
    event.preventDefault()
    event.stopImmediatePropagation()

    this.pendingElement = event.currentTarget
    const message = this.pendingElement.dataset.confirmMessage || "Are you sure?"
    this.messageTarget.textContent = message
    this.dialogTarget.showModal()
  }

  accept() {
    const el = this.pendingElement
    this.dialogTarget.close()
    if (!el) return

    const form = el.closest("form")
    if (form && el.type === "submit") {
      form.requestSubmit(el)
    } else {
      el.removeAttribute("data-confirm-message")
      el.dataset.action = (el.dataset.action || "").replace("click->confirm#confirm", "").trim()
      el.click()
    }
    this.pendingElement = null
  }

  cancel() {
    this.dialogTarget.close()
    this.pendingElement = null
  }
}
3 files · javascript, erb Explain with highlit

The default window.confirm blocks the main thread, cannot be styled, and does not integrate with Turbo's link/form handling. This snippet replaces it with a reusable <dialog>-based confirmation flow driven by a Stimulus controller, so destructive actions get a real, accessible, themeable modal while still working with Turbo.

In confirm_controller.js, the controller registers a single shared dialogTarget and a messageTarget. When any element opts into confirmation, confirm(event) first calls event.preventDefault() and event.stopImmediatePropagation() — the second call is important because it stops Turbo's own click handler from firing before the user answers. The originating element is stashed in this.pendingElement, the dialog's message is populated from the element's data-confirm-message attribute, and the native dialog.showModal() opens a top-layer modal with a proper backdrop and focus trap for free.

The resolution logic lives in accept() and cancel(). On accept, the controller re-dispatches the action on the stored element: a form is submitted with requestSubmit() (which respects validation and Turbo), while a link's click() is re-triggered after temporarily removing the data-confirm marker so the handler does not loop. Using requestSubmit() rather than submit() matters because it fires the submit event Turbo listens for.

The Confirmable partial renders the dialog markup once per page and wires the buttons to the controller with data-action. The <dialog> element is inert until opened, and aria-labelledby/aria-describedby keep it announced to screen readers.

Finally posts/index.html.erb shows the consumer side: a button_to and a plain link_to both get data: { action: 'click->confirm#confirm', confirm_message: '...' }. Any element can now request confirmation without knowing anything about the dialog.

The main trade-off is that <dialog> requires a modern browser and the interception depends on event ordering, so stopImmediatePropagation is load-bearing. The payoff is a consistent, styled, accessible confirm that removes every raw window.confirm from the app while remaining fully compatible with Turbo Drive.


Related snips

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
class Post < ApplicationRecord
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy

  scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
  scope :draft, -> { where(published_at: nil) }

ActiveRecord scopes for reusable query logic

rails activerecord patterns
by Alex Kumar 1 tab
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
class PostsController < ApplicationController
  def index
    @posts = Post.includes(:author)
                 .order(created_at: :desc)
                 .page(params[:page])
                 .per(10)

Turbo Frames: infinite scroll with lazy-loading frame

rails turbo hotwire
by codesnips 4 tabs
html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Semantic HTML Example</title>

Semantic HTML5 elements and accessibility best practices

html html5 semantics
by Alex Chang 2 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs

Share this code

Here's the card — post it anywhere.

Custom confirm dialog with Stimulus (better than window.confirm) — share card
Link copied