├── .env.example ├── VERSION ├── demo-app ├── log │ └── .keep ├── tmp │ ├── .keep │ └── pids │ │ └── .keep ├── vendor │ └── .keep ├── lib │ ├── assets │ │ └── .keep │ └── tasks │ │ └── .keep ├── public │ ├── favicon.ico │ ├── apple-touch-icon.png │ ├── apple-touch-icon-precomposed.png │ ├── icon.png │ ├── img │ │ ├── unicat.jpg │ │ ├── vanilla.png │ │ └── bootstrap-logo-shadow.png │ ├── robots.txt │ ├── icon.svg │ ├── 404.html │ ├── 400.html │ ├── 406-unsupported-browser.html │ ├── 500.html │ └── 422.html ├── app │ ├── assets │ │ ├── builds │ │ │ └── .keep │ │ ├── images │ │ │ └── .keep │ │ └── stylesheets │ │ │ └── application.css │ ├── models │ │ ├── concerns │ │ │ └── .keep │ │ ├── application_record.rb │ │ └── post.rb │ ├── controllers │ │ ├── concerns │ │ │ ├── .keep │ │ │ └── set_flavor.rb │ │ ├── welcome_controller.rb │ │ ├── modal_controller.rb │ │ ├── application_controller.rb │ │ ├── hide_from_backends_controller.rb │ │ └── posts_controller.rb │ ├── javascript │ │ ├── application.js │ │ └── controllers │ │ │ ├── hello_controller.js │ │ │ ├── flash_controller.js │ │ │ ├── application.js │ │ │ ├── index.js │ │ │ └── dark_mode_controller.js │ ├── views │ │ ├── posts │ │ │ ├── new.html.erb │ │ │ ├── _post.html.erb │ │ │ ├── edit.html.erb │ │ │ ├── index.html.erb │ │ │ ├── show.html.erb │ │ │ └── _form.html.erb │ │ ├── hide_from_backends │ │ │ ├── create.turbo_stream.erb │ │ │ ├── _notice.html.erb │ │ │ └── new.html.erb │ │ ├── shared │ │ │ └── _flash.html.erb │ │ ├── modal │ │ │ ├── show.html.erb │ │ │ └── index.html.erb │ │ ├── layouts │ │ │ └── application.html.erb │ │ └── welcome │ │ │ └── index.html.erb │ └── helpers │ │ └── application_helper.rb ├── .ruby-version ├── .tool-versions ├── bin │ ├── rake │ ├── rails │ ├── setup │ ├── dev │ └── bundle ├── config │ ├── boot.rb │ ├── initializers │ │ ├── ultimate_turbo_modal_custom.rb │ │ ├── ultimate_turbo_modal_tailwind.rb │ │ ├── ultimate_turbo_modal_vanilla.rb │ │ ├── ultimate_turbo_modal_tailwind3.rb │ │ ├── assets.rb │ │ ├── ultimate_turbo_modal.rb │ │ ├── permissions_policy.rb │ │ ├── filter_parameter_logging.rb │ │ ├── inflections.rb │ │ ├── content_security_policy.rb │ │ └── new_framework_defaults_8_0.rb │ ├── environment.rb │ ├── routes.rb │ ├── credentials.yml.enc │ ├── database.yml │ ├── locales │ │ └── en.yml │ ├── application.rb │ ├── environments │ │ ├── test.rb │ │ ├── development.rb │ │ └── production.rb │ └── puma.rb ├── Procfile.dev ├── postcss.config.js ├── config.ru ├── db │ ├── migrate │ │ ├── 20231128141054_add_publish_on_to_posts.rb │ │ ├── 20230331002502_create_posts.rb │ │ └── 20231031012703_add_post.rb │ └── schema.rb ├── Rakefile ├── .gitattributes ├── README.md ├── .gitignore ├── package.json ├── Gemfile └── Gemfile.lock ├── .taskmaster ├── CLAUDE.md ├── templates │ └── example_prd.txt └── config.json ├── .claude ├── TM_COMMANDS_GUIDE.md └── commands │ └── tm │ ├── help.md │ ├── learn.md │ ├── tm-main.md │ ├── list │ ├── list-tasks.md │ ├── list-tasks-by-status.md │ └── list-tasks-with-subtasks.md │ ├── next │ └── next-task.md │ ├── show │ └── show-task.md │ ├── add-task │ └── add-task.md │ ├── expand │ ├── expand-task.md │ └── expand-all-tasks.md │ ├── init │ ├── init-project.md │ └── init-project-quick.md │ ├── models │ ├── setup-models.md │ └── view-models.md │ ├── parse-prd │ ├── parse-prd.md │ └── parse-prd-with-research.md │ ├── set-status │ ├── to-done.md │ ├── to-review.md │ ├── to-cancelled.md │ ├── to-deferred.md │ ├── to-in-progress.md │ └── to-pending.md │ ├── update │ ├── update-task.md │ ├── update-single-task.md │ └── update-tasks-from-id.md │ ├── add-subtask │ ├── add-subtask.md │ └── convert-task-to-subtask.md │ ├── generate │ └── generate-tasks.md │ ├── remove-task │ └── remove-task.md │ ├── setup │ ├── install-taskmaster.md │ └── quick-install-taskmaster.md │ ├── status │ └── project-status.md │ ├── sync-readme │ └── sync-readme.md │ ├── utils │ └── analyze-project.md │ ├── workflows │ ├── smart-workflow.md │ ├── auto-implement-tasks.md │ └── command-pipeline.md │ ├── add-dependency │ └── add-dependency.md │ ├── clear-subtasks │ ├── clear-subtasks.md │ └── clear-all-subtasks.md │ ├── remove-subtask │ └── remove-subtask.md │ ├── complexity-report │ └── complexity-report.md │ ├── fix-dependencies │ └── fix-dependencies.md │ ├── remove-dependency │ └── remove-dependency.md │ ├── analyze-complexity │ └── analyze-complexity.md │ └── validate-dependencies │ └── validate-dependencies.md ├── .ruby-version ├── .tool-versions ├── sig └── ultimate_turbo_modal.rbs ├── yarn.lock ├── Rakefile ├── bin ├── setup └── console ├── lib ├── ultimate_turbo_modal │ ├── version.rb │ ├── helpers │ │ ├── view_helper.rb │ │ ├── controller_helper.rb │ │ └── stream_helper.rb │ ├── railtie.rb │ ├── configuration.rb │ └── base.rb ├── phlex │ └── deferred_render_with_main_content.rb ├── generators │ └── ultimate_turbo_modal │ │ ├── templates │ │ ├── ultimate_turbo_modal.rb │ │ └── flavors │ │ │ ├── custom.rb │ │ │ ├── vanilla.rb │ │ │ ├── tailwind3.rb │ │ │ └── tailwind.rb │ │ ├── update_generator.rb │ │ ├── base.rb │ │ └── install_generator.rb └── ultimate_turbo_modal.rb ├── .rubocop.yml ├── .standard.yml ├── Gemfile ├── .gitattributes ├── .gitignore ├── javascript ├── scripts │ ├── release-npm.sh │ └── update-version.js ├── rollup.config.js ├── package.json ├── index.js ├── styles │ └── vanilla.css └── modal_controller.js ├── LICENSE.txt ├── ultimate_turbo_modal.gemspec ├── script └── build_and_release.sh ├── UPGRADING.md ├── CHANGELOG.md ├── Gemfile.lock ├── README.md └── CLAUDE.md /.env.example: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 2.2.1 2 | -------------------------------------------------------------------------------- /demo-app/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo-app/tmp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.taskmaster/CLAUDE.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo-app/tmp/pids/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo-app/vendor/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/TM_COMMANDS_GUIDE.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/help.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/learn.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.2.0 2 | -------------------------------------------------------------------------------- /demo-app/lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo-app/lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo-app/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/tm-main.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo-app/app/assets/builds/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo-app/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/list/list-tasks.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/next/next-task.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/show/show-task.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.taskmaster/templates/example_prd.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo-app/.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.3.9 2 | -------------------------------------------------------------------------------- /demo-app/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo-app/public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/add-task/add-task.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/expand/expand-task.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/init/init-project.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/models/setup-models.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/models/view-models.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/parse-prd/parse-prd.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/set-status/to-done.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/set-status/to-review.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/update/update-task.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo-app/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/add-subtask/add-subtask.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/expand/expand-all-tasks.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/generate/generate-tasks.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/init/init-project-quick.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/list/list-tasks-by-status.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/remove-task/remove-task.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/set-status/to-cancelled.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/set-status/to-deferred.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/set-status/to-in-progress.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/set-status/to-pending.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/setup/install-taskmaster.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/status/project-status.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/sync-readme/sync-readme.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/update/update-single-task.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/utils/analyze-project.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/workflows/smart-workflow.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.3.9 2 | nodejs 22.14.0 3 | -------------------------------------------------------------------------------- /demo-app/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/add-dependency/add-dependency.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/clear-subtasks/clear-subtasks.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/list/list-tasks-with-subtasks.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/remove-subtask/remove-subtask.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/setup/quick-install-taskmaster.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/update/update-tasks-from-id.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/workflows/auto-implement-tasks.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/workflows/command-pipeline.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/add-subtask/convert-task-to-subtask.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/clear-subtasks/clear-all-subtasks.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/complexity-report/complexity-report.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/fix-dependencies/fix-dependencies.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/parse-prd/parse-prd-with-research.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/remove-dependency/remove-dependency.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo-app/.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.3.9 2 | nodejs 22.14.0 3 | -------------------------------------------------------------------------------- /.claude/commands/tm/analyze-complexity/analyze-complexity.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.claude/commands/tm/validate-dependencies/validate-dependencies.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sig/ultimate_turbo_modal.rbs: -------------------------------------------------------------------------------- 1 | module UltimateTurboModal 2 | VERSION: String 3 | end 4 | -------------------------------------------------------------------------------- /demo-app/app/controllers/welcome_controller.rb: -------------------------------------------------------------------------------- 1 | class WelcomeController < ApplicationController 2 | end 3 | -------------------------------------------------------------------------------- /demo-app/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmer/ultimate_turbo_modal/HEAD/demo-app/public/icon.png -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | -------------------------------------------------------------------------------- /demo-app/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /demo-app/public/img/unicat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmer/ultimate_turbo_modal/HEAD/demo-app/public/img/unicat.jpg -------------------------------------------------------------------------------- /demo-app/public/img/vanilla.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmer/ultimate_turbo_modal/HEAD/demo-app/public/img/vanilla.png -------------------------------------------------------------------------------- /demo-app/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /demo-app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "standard/rake" 5 | 6 | task default: :standard 7 | -------------------------------------------------------------------------------- /demo-app/app/javascript/application.js: -------------------------------------------------------------------------------- 1 | import { Turbo } from "@hotwired/turbo-rails"; 2 | import "./controllers"; 3 | 4 | window.Turbo = Turbo; 5 | -------------------------------------------------------------------------------- /demo-app/app/models/post.rb: -------------------------------------------------------------------------------- 1 | class Post < ApplicationRecord 2 | validates :title, presence: true 3 | validates :body, presence: true 4 | end 5 | -------------------------------------------------------------------------------- /demo-app/public/img/bootstrap-logo-shadow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmer/ultimate_turbo_modal/HEAD/demo-app/public/img/bootstrap-logo-shadow.png -------------------------------------------------------------------------------- /demo-app/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /demo-app/config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /demo-app/Procfile.dev: -------------------------------------------------------------------------------- 1 | web: bin/rails server -p 3000 2 | css: yarn build:css --watch 3 | js: yarn build --watch 4 | lib: bash -lc 'cd ../javascript && yarn build:watch' 5 | -------------------------------------------------------------------------------- /demo-app/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /demo-app/config/initializers/ultimate_turbo_modal_custom.rb: -------------------------------------------------------------------------------- 1 | /Users/carl/code/ultimate_turbo_modal/demo-app/bin/../../lib/generators/ultimate_turbo_modal/templates/flavors/custom.rb -------------------------------------------------------------------------------- /demo-app/config/initializers/ultimate_turbo_modal_tailwind.rb: -------------------------------------------------------------------------------- 1 | /Users/carl/code/ultimate_turbo_modal/demo-app/bin/../../lib/generators/ultimate_turbo_modal/templates/flavors/tailwind.rb -------------------------------------------------------------------------------- /demo-app/config/initializers/ultimate_turbo_modal_vanilla.rb: -------------------------------------------------------------------------------- 1 | /Users/carl/code/ultimate_turbo_modal/demo-app/bin/../../lib/generators/ultimate_turbo_modal/templates/flavors/vanilla.rb -------------------------------------------------------------------------------- /lib/ultimate_turbo_modal/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module UltimateTurboModal 4 | VERSION = File.read(File.join(__dir__, "../../VERSION")).strip 5 | end 6 | -------------------------------------------------------------------------------- /demo-app/config/initializers/ultimate_turbo_modal_tailwind3.rb: -------------------------------------------------------------------------------- 1 | /Users/carl/code/ultimate_turbo_modal/demo-app/bin/../../lib/generators/ultimate_turbo_modal/templates/flavors/tailwind3.rb -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 3.2 3 | 4 | require: 5 | - standard 6 | inherit_gem: 7 | standard: config/base.yml 8 | AllCops: 9 | SuggestExtensions: false 10 | -------------------------------------------------------------------------------- /demo-app/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-import'), 4 | require('autoprefixer'), 5 | require('@tailwindcss/postcss') 6 | ], 7 | } 8 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | ruby_version: 3.2.0 2 | parallel: true 3 | formatter: progress 4 | plugins: 5 | - standard-rails: 6 | target_rails_version: 7.0 7 | 8 | ignore: 9 | - 'vendor/**/*' 10 | -------------------------------------------------------------------------------- /demo-app/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /demo-app/db/migrate/20231128141054_add_publish_on_to_posts.rb: -------------------------------------------------------------------------------- 1 | class AddPublishOnToPosts < ActiveRecord::Migration[7.0] 2 | def change 3 | add_column :posts, :publish_on, :date 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /demo-app/app/views/posts/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= modal(padding: @padding, advance: @override_url || @advance, title: @modal_title, close_button: @close_button) do %> 2 | <%= render "form", post: @post %> 3 | <% end %> 4 | -------------------------------------------------------------------------------- /demo-app/app/javascript/controllers/hello_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | export default class extends Controller { 4 | connect() { 5 | this.element.textContent = "Hello World!" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /demo-app/app/views/hide_from_backends/create.turbo_stream.erb: -------------------------------------------------------------------------------- 1 | <%= turbo_stream.modal(:hide) %> 2 | <%= turbo_stream.replace "ts-notice", partial: "notice", locals: { message: "Modal close was triggered by the backend via Turbo Stream @ #{Time.now}!" } %> 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in ultimate_turbo_modal.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | 10 | gem "standard" 11 | gem "standard-rails" 12 | -------------------------------------------------------------------------------- /demo-app/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /lib/ultimate_turbo_modal/helpers/view_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module UltimateTurboModal::Helpers 4 | module ViewHelper 5 | def modal(**, &) 6 | render(UltimateTurboModal.new(request:, **), &) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /demo-app/app/javascript/controllers/flash_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | export default class extends Controller { 4 | connect() { 5 | setTimeout(() => { 6 | this.element.remove() 7 | }, 3000) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /demo-app/app/views/posts/_post.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <% if !inside_modal? %> 3 |

<%= @post.title %>

4 | <% end %> 5 | 6 |

7 | <%= post.body %> 8 |

9 |
10 | -------------------------------------------------------------------------------- /demo-app/db/migrate/20230331002502_create_posts.rb: -------------------------------------------------------------------------------- 1 | class CreatePosts < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :posts do |t| 4 | t.string :title 5 | t.string :body 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files. 2 | 3 | # Mark the database schema as having been generated. 4 | db/schema.rb linguist-generated 5 | 6 | # Mark any vendored files as having been vendored. 7 | vendor/* linguist-vendored 8 | -------------------------------------------------------------------------------- /demo-app/app/controllers/modal_controller.rb: -------------------------------------------------------------------------------- 1 | class ModalController < ApplicationController 2 | before_action :set_modal_properties 3 | 4 | def index 5 | @post = Post.first 6 | end 7 | 8 | def show 9 | @header = !(params[:id] == "photo") 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /demo-app/db/migrate/20231031012703_add_post.rb: -------------------------------------------------------------------------------- 1 | class AddPost < ActiveRecord::Migration[7.0] 2 | def up 3 | Post.create(title: "Hello, world!", body: "This is my first post.") 4 | end 5 | 6 | def down 7 | raise ActiveRecord::IrreversibleMigration 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /demo-app/.gitattributes: -------------------------------------------------------------------------------- 1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files. 2 | 3 | # Mark the database schema as having been generated. 4 | db/schema.rb linguist-generated 5 | 6 | # Mark any vendored files as having been vendored. 7 | vendor/* linguist-vendored 8 | -------------------------------------------------------------------------------- /demo-app/app/views/posts/edit.html.erb: -------------------------------------------------------------------------------- 1 | <%= modal(title: "Editing Post") do |m| %> 2 |
3 | <% if !inside_modal? %> 4 |

Editing post

5 | <% end %> 6 | 7 | <%= render "form", post: @post, modal: m %> 8 |
9 | <% end %> 10 | -------------------------------------------------------------------------------- /lib/phlex/deferred_render_with_main_content.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Phlex 4 | module DeferredRenderWithMainContent 5 | def view_template(&block) 6 | output = capture(&block) 7 | super { respond_to?(:unsafe_raw) ? unsafe_raw(output) : raw(output) } 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /demo-app/app/controllers/concerns/set_flavor.rb: -------------------------------------------------------------------------------- 1 | module SetFlavor 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | before_action do 6 | UltimateTurboModal.flavor = request.params[:flavor] || cookies[:flavor] || "tailwind" 7 | cookies.permanent[:flavor] = UltimateTurboModal.flavor 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /demo-app/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "rails/all" 6 | require "ultimate_turbo_modal" 7 | 8 | # You can add fixtures and/or initialization code here to make experimenting 9 | # with your gem easier. You can also use a different console, if you like. 10 | 11 | require "irb" 12 | IRB.start(__FILE__) 13 | -------------------------------------------------------------------------------- /demo-app/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | 7 | begin 8 | # Load seeds on bootup 9 | unless Post.any? 10 | puts "Seeding database..." 11 | Rails.application.load_seed 12 | end 13 | rescue ActiveRecord::StatementInvalid 14 | end 15 | -------------------------------------------------------------------------------- /demo-app/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | include SetFlavor 3 | 4 | def set_modal_properties 5 | %i[padding advance close_button].each do |it| 6 | instance_variable_set("@#{it}", params[it] == "1" || params[it].nil?) 7 | end 8 | @override_url = "/custom-advance-history-url" if @advance 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /demo-app/app/views/hide_from_backends/_notice.html.erb: -------------------------------------------------------------------------------- 1 | <% if local_assigns[:message] %> 2 |
3 | <%= local_assigns[:message] %> 4 |
5 | Close 6 |
7 |
8 | <% else %> 9 |
10 | <% end %> 11 | -------------------------------------------------------------------------------- /lib/ultimate_turbo_modal/helpers/controller_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module UltimateTurboModal::Helpers 4 | module ControllerHelper 5 | extend ActiveSupport::Concern 6 | 7 | def inside_modal? 8 | request.headers["Turbo-Frame"] == "modal" 9 | end 10 | 11 | included do 12 | helper_method :inside_modal? 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /demo-app/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | resources :modal 3 | resources :posts 4 | resource :hide_from_backend, only: [:new, :create] 5 | # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html 6 | 7 | get "/custom-advance-history-url", to: redirect("/") 8 | 9 | # Defines the root path route ("/") 10 | root to: "welcome#index" 11 | end 12 | -------------------------------------------------------------------------------- /demo-app/app/javascript/controllers/application.js: -------------------------------------------------------------------------------- 1 | import { Application } from "@hotwired/stimulus" 2 | 3 | const application = Application.start() 4 | 5 | // Configure Stimulus development experience 6 | application.debug = false 7 | window.Stimulus = application 8 | 9 | // Flatpickr 10 | import Flatpickr from "stimulus-flatpickr" 11 | application.register('flatpickr', Flatpickr) 12 | 13 | export { application } 14 | -------------------------------------------------------------------------------- /demo-app/config/initializers/ultimate_turbo_modal.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | UltimateTurboModal.configure do |config| 4 | config.flavor = :tailwind 5 | # config.close_button = true 6 | # config.advance = true 7 | # config.padding = true 8 | # config.header = true 9 | # config.header_divider = true 10 | # config.footer_divider = true 11 | # config.allowed_click_outside_selector = [] 12 | end 13 | -------------------------------------------------------------------------------- /demo-app/README.md: -------------------------------------------------------------------------------- 1 | # The Ultimate Turbo Modal for Rails - Demo 2 | 3 | This is a demo application. You can find the Ruby Gem and instructions on how to use 4 | it in your own application at https://github.com/cmer/ultimate_turbo_modal. 5 | 6 |   7 |   8 | # Getting up and running... 9 | 10 | ```sh 11 | bin/rails db:create db:migrate db:seed 12 | yarn install 13 | bin/dev 14 | open http://localhost:3000 15 | ``` 16 | -------------------------------------------------------------------------------- /lib/generators/ultimate_turbo_modal/templates/ultimate_turbo_modal.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | UltimateTurboModal.configure do |config| 4 | config.flavor = FLAVOR 5 | # config.close_button = true 6 | # config.advance = true 7 | # config.padding = true 8 | # config.header = true 9 | # config.header_divider = true 10 | # config.footer_divider = true 11 | # config.allowed_click_outside_selector = [] 12 | end 13 | -------------------------------------------------------------------------------- /lib/ultimate_turbo_modal/helpers/stream_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module UltimateTurboModal::Helpers 4 | module StreamHelper 5 | def modal(message) 6 | case message.to_s.downcase.to_sym 7 | when :close, :hide 8 | turbo_stream_action_tag "modal", message: "hide" 9 | else 10 | raise ArgumentError, "Unknown modal message: #{message}" 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /demo-app/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | def h1_classes 3 | "text-2xl font-bold tracking-tight text-gray-900 sm:text-4xl border-b mb-2 dark:text-white" 4 | end 5 | 6 | def button_classes 7 | "rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /demo-app/config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /demo-app/config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | a5bdfDeJOIdOPg9vQawREE40DSR+QV0uJ08GB8VeEzc1lxUuV4q8V0kkjJyVIyjreqrt7U3J4c0K1B0n1Au/Z/K2b1t3tfOlQJe1WdMdg7UNrPCrXEaXorEj6N95D1fiMWmqf16pRwbzKASHXCt8JSq1kT1UQnUpkk70mHKVaugRjYNKKYEpyFopSCnP26rOWtixmD0R0w6UUheb0hdmfP3wyiKmXnSxUsvGl04bWvFqFz85A4jRnj+Ou7UFFprtvC0fO/yVpkjFWh6otqZVd9a0WW1O6bAbFwJ57PWA3k5OBsbkv+o08LfWNpgykOSSyL3ha+fSirdGyMWhI9pIU+s5zYKjn+puRvy0F9KUbFzxsdyD60BxkkLxhS8y4LFEO4ilB4GuzLqixh5EZ0MT4YXVgARv89VGQIc+--pKR/h8Ok35Flu/uI--4gHuVGG4C07WVcLYRE71iA== -------------------------------------------------------------------------------- /demo-app/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. 4 | # Use this to limit dissemination of sensitive information. 5 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc 8 | ] 9 | -------------------------------------------------------------------------------- /demo-app/app/views/posts/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <% if notice.present? %> 3 |

<%= notice %>

4 | <% end %> 5 | 6 |
7 |

Posts

8 | <%= link_to 'New post', new_post_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %> 9 |
10 | 11 |
12 | <%= render @posts %> 13 |
14 |
15 | -------------------------------------------------------------------------------- /demo-app/app/views/shared/_flash.html.erb: -------------------------------------------------------------------------------- 1 | <% if flash.any? %> 2 |
3 | <% flash.each do |key, value| %> 4 | <% if key == "alert" %> 5 | 13 | <% end %> 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | node_modules 10 | javascript/dist 11 | 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | dev-debug.log 19 | # Dependency directories 20 | node_modules/ 21 | # Environment variables 22 | .env 23 | # Editor directories and files 24 | .idea 25 | .vscode 26 | *.suo 27 | *.ntvs* 28 | *.njsproj 29 | *.sln 30 | *.sw? 31 | # OS specific 32 | .DS_Store 33 | 34 | # Task files 35 | tasks.json 36 | tasks/ 37 | .taskmaster/state.json 38 | docs/ 39 | .serena 40 | *.backup* 41 | *.local.* 42 | 43 | -------------------------------------------------------------------------------- /demo-app/app/javascript/controllers/index.js: -------------------------------------------------------------------------------- 1 | // This file is auto-generated by ./bin/rails stimulus:manifest:update 2 | // Run that command whenever you add a new controller or create them with 3 | // ./bin/rails generate stimulus controllerName 4 | 5 | import { application } from "./application" 6 | 7 | import DarkModeController from "./dark_mode_controller" 8 | application.register("dark-mode", DarkModeController) 9 | 10 | import FlashController from "./flash_controller" 11 | application.register("flash", FlashController) 12 | 13 | import { UltimateTurboModalController } from "ultimate_turbo_modal" 14 | application.register("modal", UltimateTurboModalController) 15 | -------------------------------------------------------------------------------- /demo-app/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | ActiveSupport::Inflector.inflections(:en) { |inflect| inflect.acronym "UI" } 15 | -------------------------------------------------------------------------------- /demo-app/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /javascript/scripts/release-npm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Check for uncommitted changes 5 | if ! git diff --quiet; then 6 | echo "There are uncommitted changes. Aborting." 7 | exit 1 8 | fi 9 | 10 | # Update version 11 | echo "Updating version in package.json..." 12 | yarn update-version 13 | 14 | # Install dependencies 15 | echo "Installing dependencies..." 16 | yarn install 17 | 18 | # Build project 19 | echo "Building project..." 20 | yarn build 21 | 22 | # Add, commit, and push changes 23 | VERSION=$(cat ../VERSION) 24 | echo "Adding changes to git..." 25 | git add . 26 | echo "Committing changes (Release NPM v$VERSION)..." 27 | git commit -m "Release NPM v$VERSION" 28 | echo "Pushing changes..." 29 | git push 30 | 31 | # Publish to npm 32 | echo "Publishing to npm..." 33 | yarn publish 34 | 35 | echo "Release complete!" 36 | -------------------------------------------------------------------------------- /demo-app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore the default SQLite database. 11 | /db/*.sqlite3 12 | /db/*.sqlite3-* 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/* 16 | /tmp/* 17 | !/log/.keep 18 | !/tmp/.keep 19 | 20 | # Ignore pidfiles, but keep the directory. 21 | /tmp/pids/* 22 | !/tmp/pids/ 23 | !/tmp/pids/.keep 24 | 25 | 26 | /public/assets 27 | 28 | # Ignore master key for decrypting credentials and more. 29 | /config/master.key 30 | 31 | /app/assets/builds/* 32 | !/app/assets/builds/.keep 33 | 34 | /node_modules 35 | -------------------------------------------------------------------------------- /demo-app/app/views/posts/show.html.erb: -------------------------------------------------------------------------------- 1 | <%= modal(padding: @padding, advance: @override_url || @advance, title: @modal_title, close_button: @close_button) do |m| %> 2 | <%= m.title do %> 3 |
Custom Header Block
4 | <% end %> 5 | 6 |
7 | <% if notice.present? %> 8 |

<%= notice %>

9 | <% end %> 10 | 11 | <%= render @post %> 12 |
13 | 14 | <%= m.footer do %> 15 | <%= link_to 'Edit this post', edit_post_path(@post), class: "rounded-lg py-3 px-5 bg-blue-800 text-white inline-block font-medium" %> 16 | 17 |
18 | <%= button_to 'Destroy this post', post_path(@post), method: :delete, class: "py-3 px-5 bg-transparent text-red-600" %> 19 |
20 | <% end %> 21 | <% end %> 22 | -------------------------------------------------------------------------------- /lib/ultimate_turbo_modal/railtie.rb: -------------------------------------------------------------------------------- 1 | require "rails" 2 | require "rails/railtie" 3 | require "phlex-rails" 4 | require "turbo-rails" 5 | require "ultimate_turbo_modal/helpers/controller_helper" 6 | require "ultimate_turbo_modal/helpers/view_helper" 7 | require "ultimate_turbo_modal/helpers/stream_helper" 8 | 9 | module UltimateTurboModal 10 | class Railtie < Rails::Railtie 11 | initializer "ultimate_turbo_modal.action_controller" do 12 | ActiveSupport.on_load(:action_controller_base) do 13 | include UltimateTurboModal::Helpers::ControllerHelper 14 | end 15 | end 16 | 17 | initializer "ultimate_turbo_modal.action_view" do 18 | ActiveSupport.on_load(:action_view) do 19 | include UltimateTurboModal::Helpers::ViewHelper 20 | end 21 | Turbo::Streams::TagBuilder.include(UltimateTurboModal::Helpers::StreamHelper) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /javascript/scripts/update-version.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const versionFilePath = path.resolve(__dirname, '..', '..', 'VERSION'); 5 | const packageJsonPath = path.resolve(__dirname, '..', 'package.json'); 6 | 7 | // Read version from VERSION file 8 | const version = fs.readFileSync(versionFilePath, 'utf8').trim(); 9 | 10 | // Read package.json 11 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); 12 | 13 | // Update version if it's different 14 | if (packageJson.version !== version) { 15 | packageJson.version = version; 16 | // Write updated package.json, preserving indentation (2 spaces) 17 | fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); 18 | console.log(`Updated package.json version to ${version}`); 19 | } else { 20 | console.log(`package.json version (${packageJson.version}) is already up to date.`); 21 | } 22 | -------------------------------------------------------------------------------- /javascript/rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import css from 'rollup-plugin-css-only'; 3 | import { terser } from 'rollup-plugin-terser'; 4 | import replace from '@rollup/plugin-replace'; 5 | import { readFileSync } from 'fs'; 6 | 7 | const pkg = JSON.parse(readFileSync('./package.json', 'utf8')); 8 | const packageVersion = pkg.version; 9 | 10 | export default { 11 | input: './index.js', 12 | output: [ 13 | { 14 | file: 'dist/ultimate_turbo_modal.js', 15 | format: 'esm' 16 | }, 17 | { 18 | file: 'dist/ultimate_turbo_modal.min.js', 19 | format: 'esm', 20 | plugins: [terser()] 21 | } 22 | ], 23 | external: ['@hotwired/stimulus'], 24 | inlineDynamicImports: true, 25 | plugins: [ 26 | resolve(), 27 | css({ output: 'vanilla.css' }), 28 | replace({ 29 | preventAssignment: true, 30 | values: { 31 | '__PACKAGE_VERSION__': packageVersion 32 | } 33 | }) 34 | ] 35 | }; 36 | -------------------------------------------------------------------------------- /lib/ultimate_turbo_modal.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "ultimate_turbo_modal/version" 4 | require "phlex/deferred_render_with_main_content" 5 | require "ultimate_turbo_modal/configuration" 6 | require "ultimate_turbo_modal/railtie" 7 | require "ultimate_turbo_modal/base" 8 | require "generators/ultimate_turbo_modal/base" 9 | require "generators/ultimate_turbo_modal/install_generator" 10 | require "generators/ultimate_turbo_modal/update_generator" 11 | 12 | module UltimateTurboModal 13 | extend self 14 | 15 | def new(**) 16 | modal_class.new(**) 17 | end 18 | 19 | def modal_class 20 | "UltimateTurboModal::Flavors::#{flavor.to_s.classify}".constantize 21 | rescue NameError 22 | raise Error, "Flavor `#{flavor.downcase}` not found. Please check your initializer file at `config/initializers/ultimate_turbo_modal.rb` and make sure to run `rails generate ultimate_turbo_modal:install`." 23 | end 24 | 25 | class Error < StandardError; end 26 | end 27 | -------------------------------------------------------------------------------- /demo-app/app/javascript/controllers/dark_mode_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | export default class extends Controller { 4 | static targets = ["toggle"]; 5 | 6 | toggleTheme() { 7 | // Toggle the dark class on the html element 8 | if (document.documentElement.classList.contains('dark')) { 9 | document.documentElement.classList.remove('dark'); 10 | this.setCookie('theme', 'light'); 11 | } else { 12 | document.documentElement.classList.add('dark'); 13 | this.setCookie('theme', 'dark'); 14 | } 15 | } 16 | 17 | setCookie(name, value, days = 7) { 18 | const expires = new Date(Date.now() + days * 864e5).toUTCString(); 19 | document.cookie = `${name}=${value};expires=${expires};path=/`; 20 | } 21 | 22 | getCookie(name) { 23 | const value = `; ${document.cookie}`; 24 | const parts = value.split(`; ${name}=`); 25 | if (parts.length === 2) return parts.pop().split(';').shift(); 26 | return null; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /demo-app/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # "true": "foo" 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /demo-app/app/controllers/hide_from_backends_controller.rb: -------------------------------------------------------------------------------- 1 | # Sometimes it might be desirable to close the modal from the backend. 2 | # This can be done with Turbo Stream as such: 3 | # 4 | # <%= turbo_stream.modal(:hide) %> 5 | # 6 | # This controller is an example of how to do that. 7 | class HideFromBackendsController < ApplicationController 8 | def new 9 | # Show form 10 | end 11 | 12 | def create 13 | form_is_valid? ? close_modal : render(:new, status: :unprocessable_entity) 14 | end 15 | 16 | private 17 | 18 | def close_modal 19 | if inside_modal? 20 | # `create.turbo_stream.erb` will be rendered. 21 | # A message will appear in the browser, and the backend 22 | # will trigger a modal close via turbo stream. 23 | else 24 | # if not inside a modal, simply redirect 25 | redirect_to("/") 26 | end 27 | end 28 | 29 | def form_is_valid? 30 | return true if request.params[:action] == 'new' 31 | params[:email].present? 32 | end 33 | helper_method :form_is_valid? 34 | end 35 | -------------------------------------------------------------------------------- /.taskmaster/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "models": { 3 | "main": { 4 | "provider": "claude-code", 5 | "modelId": "sonnet", 6 | "maxTokens": 64000, 7 | "temperature": 0.2 8 | }, 9 | "research": { 10 | "provider": "claude-code", 11 | "modelId": "opus", 12 | "maxTokens": 32000, 13 | "temperature": 0.1 14 | }, 15 | "fallback": { 16 | "provider": "claude-code", 17 | "modelId": "sonnet", 18 | "maxTokens": 64000, 19 | "temperature": 0.2 20 | } 21 | }, 22 | "global": { 23 | "logLevel": "info", 24 | "debug": false, 25 | "defaultNumTasks": 10, 26 | "defaultSubtasks": 5, 27 | "defaultPriority": "medium", 28 | "projectName": "ultimate_turbo_modal", 29 | "ollamaBaseURL": "http://localhost:11434/api", 30 | "bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com", 31 | "responseLanguage": "English", 32 | "defaultTag": "master", 33 | "azureOpenaiBaseURL": "https://your-endpoint.openai.azure.com/", 34 | "userId": "1234567890" 35 | }, 36 | "claudeCode": {} 37 | } -------------------------------------------------------------------------------- /demo-app/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema[7.0].define(version: 2023_11_28_141054) do 14 | create_table "posts", force: :cascade do |t| 15 | t.string "title" 16 | t.string "body" 17 | t.datetime "created_at", null: false 18 | t.datetime "updated_at", null: false 19 | t.date "publish_on" 20 | end 21 | 22 | end 23 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Carl Mercier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /demo-app/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | APP_ROOT = File.expand_path("..", __dir__) 5 | 6 | def system!(*args) 7 | system(*args, exception: true) 8 | end 9 | 10 | FileUtils.chdir APP_ROOT do 11 | # This script is a way to set up or update your development environment automatically. 12 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 13 | # Add necessary setup steps to this file. 14 | 15 | puts "== Installing dependencies ==" 16 | system("bundle check") || system!("bundle install") 17 | 18 | # puts "\n== Copying sample files ==" 19 | # unless File.exist?("config/database.yml") 20 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 21 | # end 22 | 23 | puts "\n== Preparing database ==" 24 | system! "bin/rails db:prepare" 25 | 26 | puts "\n== Removing old logs and tempfiles ==" 27 | system! "bin/rails log:clear tmp:clear" 28 | 29 | unless ARGV.include?("--skip-server") 30 | puts "\n== Starting development server ==" 31 | STDOUT.flush # flush the output before exec(2) so that it displays 32 | exec "bin/dev" 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /demo-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "private": "true", 4 | "dependencies": { 5 | "@hotwired/stimulus": "^3.2.2", 6 | "@hotwired/turbo-rails": "^8.0.0", 7 | "@tailwindcss/aspect-ratio": "^0.4.2", 8 | "@tailwindcss/forms": "^0.5.6", 9 | "@tailwindcss/postcss": "^4.1.3", 10 | "@tailwindcss/typography": "^0.5.10", 11 | "autoprefixer": "^10.4.16", 12 | "el-transition": "^0.0.7", 13 | "esbuild": "^0.19.5", 14 | "flatpickr": "^4.6.13", 15 | "idiomorph": "^0.7.3", 16 | "postcss": "^8.4.31", 17 | "postcss-cli": "^10.1.0", 18 | "postcss-import": "^15.1.0", 19 | "stimulus-flatpickr": "^3.0.0-0", 20 | "tailwindcss": "^4.1.3", 21 | "ultimate_turbo_modal": "link:../javascript" 22 | }, 23 | "scripts": { 24 | "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets", 25 | "build:css": "postcss ./app/assets/stylesheets/application.css -o ./app/assets/builds/application.css" 26 | }, 27 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 28 | } 29 | -------------------------------------------------------------------------------- /demo-app/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @import 'ultimate_turbo_modal/dist/vanilla.css' layer(components); 3 | @import 'flatpickr/dist/flatpickr.css' layer(base); 4 | 5 | @plugin '@tailwindcss/aspect-ratio'; 6 | @plugin '@tailwindcss/forms'; 7 | @plugin '@tailwindcss/typography'; 8 | 9 | @custom-variant dark (&:is(.dark *)); 10 | 11 | @theme { 12 | --font-sans: 13 | Inter var, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 14 | 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 15 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; 16 | } 17 | 18 | @layer base { 19 | 20 | *, 21 | ::after, 22 | ::before, 23 | ::backdrop, 24 | ::file-selector-button { 25 | border-color: var(--color-gray-200, currentcolor); 26 | } 27 | } 28 | 29 | #ts-notice:not(:empty) { 30 | position: fixed; 31 | top: 0.5rem; 32 | right: 0.5rem; 33 | width: auto; 34 | border: 1px solid; 35 | padding: 1rem; 36 | margin-top: 2.5rem; 37 | margin-bottom: 2.5rem; 38 | color: black; 39 | border-color: rgba(0, 148, 0, 0.8); 40 | background-color: rgba(176, 255, 176, 0.6); 41 | max-width: 32rem; 42 | } 43 | -------------------------------------------------------------------------------- /demo-app/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap, inline scripts, and inline styles. 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src style-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /demo-app/bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Build the ultimate_turbo_modal JavaScript package 4 | echo "Building ultimate_turbo_modal JavaScript package..." 5 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 6 | DEMO_APP_DIR="$SCRIPT_DIR/.." 7 | 8 | # Symlink flavor files from gem to demo app 9 | echo "Symlinking flavor files to demo app..." 10 | FLAVOR_SOURCE_DIR="$DEMO_APP_DIR/../lib/generators/ultimate_turbo_modal/templates/flavors" 11 | FLAVOR_DEST_DIR="$DEMO_APP_DIR/config/initializers" 12 | 13 | # Symlink each flavor file with the appropriate naming (force overwrite) 14 | ln -sf "$FLAVOR_SOURCE_DIR/tailwind.rb" "$FLAVOR_DEST_DIR/ultimate_turbo_modal_tailwind.rb" 15 | ln -sf "$FLAVOR_SOURCE_DIR/tailwind3.rb" "$FLAVOR_DEST_DIR/ultimate_turbo_modal_tailwind3.rb" 16 | ln -sf "$FLAVOR_SOURCE_DIR/vanilla.rb" "$FLAVOR_DEST_DIR/ultimate_turbo_modal_vanilla.rb" 17 | ln -sf "$FLAVOR_SOURCE_DIR/custom.rb" "$FLAVOR_DEST_DIR/ultimate_turbo_modal_custom.rb" 18 | 19 | (cd "$DEMO_APP_DIR" && bundle install) 20 | 21 | (cd "$DEMO_APP_DIR" && bundle install) 22 | 23 | # Start the development servers 24 | if command -v overmind &> /dev/null; then 25 | overmind start -f $DEMO_APP_DIR/Procfile.dev "$@" 26 | else 27 | foreman start -f $DEMO_APP_DIR/Procfile.dev "$@" 28 | fi 29 | -------------------------------------------------------------------------------- /lib/generators/ultimate_turbo_modal/templates/flavors/custom.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Custom 4 | # TODO: define the classes for each HTML element. 5 | module UltimateTurboModal::Flavors 6 | class Custom < UltimateTurboModal::Base 7 | DIV_MODAL_CONTAINER_CLASSES = "" 8 | DIV_OVERLAY_CLASSES = "" 9 | DIV_DIALOG_CLASSES = "" 10 | DIV_INNER_CLASSES = "" 11 | DIV_CONTENT_CLASSES = "" 12 | DIV_MAIN_CLASSES = "" 13 | DIV_HEADER_CLASSES = "" 14 | DIV_TITLE_CLASSES = "" 15 | DIV_TITLE_H_CLASSES = "" 16 | DIV_FOOTER_CLASSES = "" 17 | BUTTON_CLOSE_CLASSES = "" 18 | BUTTON_CLOSE_SR_ONLY_CLASSES = "" 19 | CLOSE_BUTTON_TAG_CLASSES = "" 20 | ICON_CLOSE_CLASSES = "" 21 | 22 | TRANSITIONS = { 23 | overlay: { 24 | enter: { 25 | animation: "", 26 | start: "", 27 | end: "" 28 | }, 29 | leave: { 30 | animation: "", 31 | start: "", 32 | end: "" 33 | } 34 | }, 35 | dialog: { 36 | enter: { 37 | animation: "", 38 | start: "", 39 | end: "" 40 | }, 41 | leave: { 42 | animation: "", 43 | start: "", 44 | end: "" 45 | } 46 | } 47 | } 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /demo-app/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | ruby file: ".tool-versions" 5 | 6 | # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" 7 | gem "rails", "~> 8.0.2" 8 | 9 | # The modern asset pipeline for Rails [https://github.com/rails/propshaft] 10 | gem "propshaft" 11 | 12 | # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] 13 | gem "turbo-rails" 14 | 15 | # Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] 16 | gem "stimulus-rails" 17 | 18 | # Use sqlite3 as the database for Active Record 19 | gem "sqlite3" 20 | 21 | # Use the Puma web server [https://github.com/puma/puma] 22 | gem "puma", "~> 6.6" 23 | 24 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 25 | gem "tzinfo-data", platforms: %i[ mingw mswin x64_mingw jruby ] 26 | 27 | group :development, :test do 28 | # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem 29 | gem "debug", platforms: %i[ mri mingw x64_mingw ] 30 | gem "web-console" 31 | end 32 | 33 | group :development do 34 | # Speed up commands on slow machines / big apps [https://github.com/rails/spring] 35 | # gem "spring" 36 | end 37 | 38 | gem "cssbundling-rails" 39 | gem "jsbundling-rails" 40 | gem "faker" 41 | gem "ultimate_turbo_modal", path: "../" 42 | -------------------------------------------------------------------------------- /demo-app/config/initializers/new_framework_defaults_8_0.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | # 3 | # This file eases your Rails 8.0 framework defaults upgrade. 4 | # 5 | # Uncomment each configuration one by one to switch to the new default. 6 | # Once your application is ready to run with all new defaults, you can remove 7 | # this file and set the `config.load_defaults` to `8.0`. 8 | # 9 | # Read the Guide for Upgrading Ruby on Rails for more info on each option. 10 | # https://guides.rubyonrails.org/upgrading_ruby_on_rails.html 11 | 12 | ### 13 | # Specifies whether `to_time` methods preserve the UTC offset of their receivers or preserves the timezone. 14 | # If set to `:zone`, `to_time` methods will use the timezone of their receivers. 15 | # If set to `:offset`, `to_time` methods will use the UTC offset. 16 | # If `false`, `to_time` methods will convert to the local system UTC offset instead. 17 | #++ 18 | # Rails.application.config.active_support.to_time_preserves_timezone = :zone 19 | 20 | ### 21 | # When both `If-Modified-Since` and `If-None-Match` are provided by the client 22 | # only consider `If-None-Match` as specified by RFC 7232 Section 6. 23 | # If set to `false` both conditions need to be satisfied. 24 | #++ 25 | # Rails.application.config.action_dispatch.strict_freshness = true 26 | 27 | ### 28 | # Set `Regexp.timeout` to `1`s by default to improve security over Regexp Denial-of-Service attacks. 29 | #++ 30 | # Regexp.timeout = 1 31 | -------------------------------------------------------------------------------- /ultimate_turbo_modal.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/ultimate_turbo_modal/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "ultimate_turbo_modal" 7 | spec.version = UltimateTurboModal::VERSION 8 | spec.authors = ["Carl Mercier"] 9 | spec.email = ["foss@carlmercier.com"] 10 | 11 | spec.summary = "UTMR aims to be the be-all and end-all of Turbo Modals." 12 | spec.description = "An easy-to-use, flexible, and powerful Turbo Modal solution for Rails 7+ built with Stimulus.js, Tailwind CSS (or vanilla CSS) and Hotwire." 13 | spec.homepage = "https://github.com/cmer/ultimate_turbo_modal" 14 | spec.license = "MIT" 15 | spec.required_ruby_version = ">= 3.0" 16 | 17 | spec.metadata["homepage_uri"] = spec.homepage 18 | spec.metadata["source_code_uri"] = "https://github.com/cmer/ultimate_turbo_modal" 19 | spec.metadata["changelog_uri"] = "https://github.com/cmer/ultimate_turbo_modal/CHANGELOG.md" 20 | 21 | spec.files = Dir.chdir(__dir__) do 22 | `git ls-files -z`.split("\x0").reject do |f| 23 | (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor demo-app/]) 24 | end 25 | end 26 | spec.require_paths = ["lib"] 27 | 28 | spec.add_dependency "phlex-rails" 29 | spec.add_dependency "actionpack" 30 | spec.add_dependency "activesupport" 31 | spec.add_dependency "railties" 32 | spec.add_dependency "stimulus-rails" 33 | spec.add_dependency "turbo-rails" 34 | end 35 | -------------------------------------------------------------------------------- /demo-app/app/views/hide_from_backends/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= modal(title: "My Glorious Form") do %> 2 | <% if !inside_modal? %> 3 |
4 |

My Glorious Form

5 | <% end %> 6 | 7 |
8 |

Upon submission, the modal will be closed from the backend.

9 |

If no email is entered, the form will be redisplayed with errors.

10 |
11 | 12 | <% if !form_is_valid? %> 13 |
14 | Email is required! 15 |
16 | <% end %> 17 | 18 | <%= form_with url: hide_from_backend_path, method: :post, class: "mt-5 sm:flex sm:items-center" do %> 19 |
20 | 21 | 22 |
23 | 24 | 25 | <% end %> 26 | 27 | <% if !inside_modal? %> 28 |
29 | <% end %> 30 | <% end %> 31 | -------------------------------------------------------------------------------- /demo-app/app/views/posts/_form.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= form_with(model: post, class: "contents") do |form| %> 3 | <% if post.errors.any? %> 4 |
5 |

<%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:

6 |
    7 | <% post.errors.each do |error| %> 8 |
  • <%= error.full_message %>
  • 9 | <% end %> 10 |
11 |
12 | <% end %> 13 |
14 | <%= form.label :title %> 15 | <%= form.text_field :title, class: "block shadow-sm rounded-md border border-gray-200 outline-hidden px-3 py-2 mt-2 w-full" %> 16 |
17 |
18 | <%= form.label :body %> 19 | <%= form.text_field :body, class: "block shadow-sm rounded-md border border-gray-200 outline-hidden px-3 py-2 mt-2 w-full" %> 20 |
21 |
22 | <%= form.label :publish_on %> 23 |
Modal will not close when clicking the calendar, even when outside of the modal boundaries.
24 | <%= form.text_field :publish_on, 25 | class: "block shadow-sm rounded-md border border-gray-200 outline-hidden px-3 py-2 mt-2 w-full", 26 | data: { 27 | controller: "flatpickr", 28 | flatpickr_min_date: Date.current.strftime("%Y-%m-%d"), 29 | } 30 | %> 31 |
32 |
33 | <%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %> 34 |
35 | <% end %> 36 |
37 | -------------------------------------------------------------------------------- /demo-app/app/views/modal/show.html.erb: -------------------------------------------------------------------------------- 1 | <%= modal(padding: @padding, advance: @override_url || @advance, header: @header, close_button: @close_button) do %> 2 | <% unless inside_modal? %> 3 |

This is not inside a modal

4 | <% end %> 5 | 6 | <% if params[:id] == "long" %> 7 |
8 | <% 30.times do %> 9 |

<%= Faker::Lorem.paragraph(sentence_count: 50) %>

10 | <% end %> 11 |
12 | <% elsif params[:id] == "photo" %> 13 |
14 | 15 |
16 | <% elsif params[:id] == "photo_no_advance" %> 17 |
18 | 19 |
20 | <% elsif params[:id] == "form" %> 21 |
22 |
23 |
24 | 25 | 26 |
27 |
28 | 29 | 30 |
31 | 32 |
33 |
34 | <% else %> 35 |
36 | Not sure what you're looking for! 37 |
38 | <% end %> 39 | <% end %> 40 | -------------------------------------------------------------------------------- /javascript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ultimate_turbo_modal", 3 | "version": "2.2.1", 4 | "description": "The ultimate Turbo / Stimulus / Hotwire modal window for Rails", 5 | "main": "dist/ultimate_turbo_modal.min.js", 6 | "module": "dist/ultimate_turbo_modal.min.js", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "update-version": "node scripts/update-version.js", 12 | "build": "yarn install && rollup -c", 13 | "build:watch": "rollup -c -w", 14 | "release": "bash scripts/release-npm.sh" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/cmer/ultimate_turbo_modal.git" 19 | }, 20 | "keywords": [ 21 | "hotwire", 22 | "turbo", 23 | "stimulus", 24 | "tailwind", 25 | "modal", 26 | "rubyonrails", 27 | "rails" 28 | ], 29 | "author": "Carl Mercier", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/cmer/ultimate_turbo_modal/issues" 33 | }, 34 | "homepage": "https://github.com/cmer/ultimate_turbo_modal#readme", 35 | "dependencies": { 36 | "@hotwired/stimulus": "^3.2.2", 37 | "@hotwired/turbo-rails": "^8.0.0", 38 | "el-transition": "^0.0.7", 39 | "focus-trap": "^7.6.5", 40 | "idiomorph": "^0.7.3" 41 | }, 42 | "devDependencies": { 43 | "@rollup/plugin-node-resolve": "^15.2.3", 44 | "@rollup/plugin-replace": "^6.0.2", 45 | "rollup": "^2.79.1", 46 | "rollup-plugin-copy": "^3.5.0", 47 | "rollup-plugin-css-only": "^4.3.0", 48 | "rollup-plugin-terser": "^7.0.2" 49 | }, 50 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 51 | } 52 | -------------------------------------------------------------------------------- /demo-app/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails" 4 | # Pick the frameworks you want: 5 | require "active_model/railtie" 6 | # require "active_job/railtie" 7 | require "active_record/railtie" 8 | # require "active_storage/engine" 9 | require "action_controller/railtie" 10 | # require "action_mailer/railtie" 11 | # require "action_mailbox/engine" 12 | # require "action_text/engine" 13 | require "action_view/railtie" 14 | # require "action_cable/engine" 15 | # require "rails/test_unit/railtie" 16 | 17 | # Require the gems listed in Gemfile, including any gems 18 | # you've limited to :test, :development, or :production. 19 | Bundler.require(*Rails.groups) 20 | 21 | module UltimateTurboModal 22 | class Application < Rails::Application 23 | config.autoload_paths << Rails.root.join("app", "views", "components") 24 | # Initialize configuration defaults for originally generated Rails version. 25 | config.load_defaults 7.0 26 | 27 | # Please, add to the `ignore` list any other `lib` subdirectories that do 28 | # not contain `.rb` files, or that should not be reloaded or eager loaded. 29 | # Common ones are `templates`, `generators`, or `middleware`, for example. 30 | config.autoload_lib(ignore: %w[assets tasks]) 31 | 32 | # Configuration for the application, engines, and railties goes here. 33 | # 34 | # These settings can be overridden in specific environments using the files 35 | # in config/environments, which are processed later. 36 | # 37 | # config.time_zone = "Central Time (US & Canada)" 38 | # config.eager_load_paths << Rails.root.join("extras") 39 | 40 | # Don't generate system test files. 41 | config.generators.system_tests = nil 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /demo-app/app/controllers/posts_controller.rb: -------------------------------------------------------------------------------- 1 | class PostsController < ApplicationController 2 | before_action :set_post, only: %i[show edit update destroy] 3 | before_action :set_modal_properties 4 | 5 | # GET /posts 6 | def index 7 | @posts = Post.all 8 | end 9 | 10 | # GET /posts/1 11 | def show 12 | @modal_title = @post.title 13 | end 14 | 15 | # GET /posts/new 16 | def new 17 | @post = Post.new 18 | @modal_title = "New Post" 19 | end 20 | 21 | # GET /posts/1/edit 22 | def edit 23 | @modal_title = "Edit Post" 24 | end 25 | 26 | # POST /posts 27 | def create 28 | @post = Post.new(post_params) 29 | @modal_title = "New Post" 30 | 31 | if @post.save 32 | redirect_to "/", 33 | notice: "Post was successfully created.", 34 | status: :see_other 35 | else 36 | render :new, status: :unprocessable_entity 37 | end 38 | end 39 | 40 | # PATCH/PUT /posts/1 41 | def update 42 | if @post.update(post_params) 43 | redirect_to "/", 44 | notice: "Post was successfully updated.", 45 | status: :see_other 46 | else 47 | render :edit, status: :unprocessable_entity 48 | end 49 | end 50 | 51 | # DELETE /posts/1 52 | def destroy 53 | redirect_to "/", 54 | notice: "Post was successfully destroyed. (but not really)", 55 | status: :see_other 56 | end 57 | 58 | private 59 | 60 | # Use callbacks to share common setup or constraints between actions. 61 | def set_post 62 | @post = Post.find(params[:id]) 63 | end 64 | 65 | # Only allow a list of trusted parameters through. 66 | def post_params 67 | params.require(:post).permit(:title, :body) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /javascript/index.js: -------------------------------------------------------------------------------- 1 | import UltimateTurboModalController from './modal_controller.js'; 2 | import { Idiomorph } from 'idiomorph'; 3 | import './styles/vanilla.css'; 4 | 5 | Turbo.StreamActions.modal = function () { 6 | const message = this.getAttribute("message"); 7 | if (message == "hide") window.modal?.hide(); 8 | if (message == "close") window.modal?.hide(); 9 | }; 10 | 11 | // Check if the event target is one of our modal Turbo Frames 12 | const isModalFrameTarget = (event) => { 13 | const target = event?.target; 14 | return ( 15 | target instanceof Element && 16 | target.tagName.toLowerCase() === 'turbo-frame' && 17 | (target.id === 'modal' || target.id === 'modal-inner') 18 | ); 19 | }; 20 | 21 | // Escape modal from the backend on redirects 22 | const handleTurboFrameMissing = (event) => { 23 | if (event.detail.response.redirected && isModalFrameTarget(event)) { 24 | event.preventDefault() 25 | event.detail.visit(event.detail.response) 26 | } 27 | }; 28 | 29 | // Morph the innerHTML of the modal to prevent flickering and transition animations 30 | const handleTurboBeforeFrameRender = (event) => { 31 | if (isModalFrameTarget(event)) { 32 | event.detail.render = (currentElement, newElement) => { 33 | Idiomorph.morph(currentElement, newElement, { 34 | morphstyle: 'innerHTML' 35 | }) 36 | } 37 | } 38 | }; 39 | 40 | document.removeEventListener("turbo:frame-missing", handleTurboFrameMissing); 41 | document.addEventListener("turbo:frame-missing", handleTurboFrameMissing); 42 | 43 | document.removeEventListener("turbo:before-frame-render", handleTurboBeforeFrameRender); 44 | document.addEventListener("turbo:before-frame-render", handleTurboBeforeFrameRender); 45 | 46 | export { UltimateTurboModalController }; 47 | -------------------------------------------------------------------------------- /script/build_and_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | cd $(dirname $0)/.. 4 | 5 | if [ "$1" == "--help" ]; then 6 | echo "Usage: $0 [--skip-gem] [--skip-js]" 7 | echo "" 8 | echo "Options:" 9 | echo " --skip-gem Skip building and releasing the gem." 10 | echo " --skip-js Skip building and releasing the JavaScript." 11 | echo " --help Show this help message." 12 | exit 0 13 | fi 14 | 15 | # Check for uncommitted changes 16 | if ! git diff --quiet; then 17 | echo "There are uncommitted changes. Aborting." 18 | exit 1 19 | fi 20 | 21 | 22 | if [ "$1" != "--skip-gem" ]; then 23 | echo "Building and releasing gem..." 24 | bundle exec rake build 25 | 26 | # Update demo app with latest gem and JavaScript 27 | echo "Updating demo app with latest code..." 28 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 29 | DEMO_APP_DIR="$SCRIPT_DIR/../demo-app" 30 | 31 | # Build JavaScript package first 32 | echo "Building ultimate_turbo_modal JavaScript package..." 33 | (cd "$SCRIPT_DIR/../javascript" && yarn build) 34 | 35 | # Update demo app dependencies 36 | echo "Installing latest ultimate_turbo_modal in demo app..." 37 | (cd "$DEMO_APP_DIR" && bundle install) 38 | (cd "$DEMO_APP_DIR" && yarn install --force) 39 | 40 | # Check if Gemfile.lock or demo-app files are git dirty 41 | if ! git diff --quiet Gemfile.lock demo-app/Gemfile.lock demo-app/yarn.lock; then 42 | echo "Lock files are dirty. Adding, committing, and pushing." 43 | git add Gemfile.lock demo-app/Gemfile.lock demo-app/yarn.lock 44 | git commit -m "Update lock files for demo app" 45 | fi 46 | 47 | bundle exec rake build 48 | bundle exec rake release 49 | else 50 | echo "Skipping gem build and release..." 51 | fi 52 | 53 | if [ "$1" != "--skip-js" ]; then 54 | echo "Building JavaScript..." 55 | cd javascript 56 | ./scripts/release-npm.sh 57 | else 58 | echo "Skipping JavaScript build..." 59 | fi 60 | 61 | echo "Done!" 62 | -------------------------------------------------------------------------------- /lib/generators/ultimate_turbo_modal/templates/flavors/vanilla.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Vanilla CSS 4 | module UltimateTurboModal::Flavors 5 | class Vanilla < UltimateTurboModal::Base 6 | DIV_MODAL_CONTAINER_CLASSES = "modal-container" 7 | # Include enter-start classes so initial paint is hidden and transitions can animate smoothly 8 | DIV_OVERLAY_CLASSES = "modal-overlay modal-transition-overlay-enter-start" 9 | DIV_DIALOG_CLASSES = "modal-outer modal-transition-dialog-enter-start" 10 | DIV_INNER_CLASSES = "modal-inner" 11 | DIV_CONTENT_CLASSES = "modal-content" 12 | DIV_MAIN_CLASSES = "modal-main" 13 | DIV_HEADER_CLASSES = "modal-header" 14 | DIV_TITLE_CLASSES = "modal-title" 15 | DIV_TITLE_H_CLASSES = "modal-title-h" 16 | DIV_FOOTER_CLASSES = "modal-footer" 17 | BUTTON_CLOSE_CLASSES = "modal-close" 18 | BUTTON_CLOSE_SR_ONLY_CLASSES = "sr-only" 19 | CLOSE_BUTTON_TAG_CLASSES = "modal-close-button" 20 | ICON_CLOSE_CLASSES = "modal-close-icon" 21 | 22 | TRANSITIONS = { 23 | overlay: { 24 | enter: { 25 | animation: "modal-transition-overlay-enter-animation", 26 | start: "modal-transition-overlay-enter-start", 27 | end: "modal-transition-overlay-enter-end" 28 | }, 29 | leave: { 30 | animation: "modal-transition-overlay-leave-animation", 31 | start: "modal-transition-overlay-leave-start", 32 | end: "modal-transition-overlay-leave-end" 33 | } 34 | }, 35 | dialog: { 36 | enter: { 37 | animation: "modal-transition-dialog-enter-animation", 38 | start: "modal-transition-dialog-enter-start", 39 | end: "modal-transition-dialog-enter-end" 40 | }, 41 | leave: { 42 | animation: "modal-transition-dialog-leave-animation", 43 | start: "modal-transition-dialog-leave-start", 44 | end: "modal-transition-dialog-leave-end" 45 | } 46 | } 47 | } 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /demo-app/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # The test environment is used exclusively to run your application's 2 | # test suite. You never need to work with it otherwise. Remember that 3 | # your test database is "scratch space" for the test suite and is wiped 4 | # and recreated between test runs. Don't rely on the data there! 5 | 6 | Rails.application.configure do 7 | # Settings specified here will take precedence over those in config/application.rb. 8 | 9 | # While tests run files are not watched, reloading is not necessary. 10 | config.enable_reloading = false 11 | 12 | # Eager loading loads your entire application. When running a single test locally, 13 | # this is usually not necessary, and can slow down your test suite. However, it's 14 | # recommended that you enable it in continuous integration systems to ensure eager 15 | # loading is working properly before deploying your code. 16 | config.eager_load = ENV["CI"].present? 17 | 18 | # Configure public file server for tests with cache-control for performance. 19 | config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } 20 | 21 | # Show full error reports. 22 | config.consider_all_requests_local = true 23 | config.cache_store = :null_store 24 | 25 | # Render exception templates for rescuable exceptions and raise for other exceptions. 26 | config.action_dispatch.show_exceptions = :rescuable 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | 31 | # Print deprecation notices to the stderr. 32 | config.active_support.deprecation = :stderr 33 | 34 | # Raises error for missing translations. 35 | # config.i18n.raise_on_missing_translations = true 36 | 37 | # Annotate rendered view with file names. 38 | # config.action_view.annotate_rendered_view_with_filenames = true 39 | 40 | # Raise error when a before_action's only/except options reference missing actions. 41 | config.action_controller.raise_on_missing_callback_actions = true 42 | end 43 | -------------------------------------------------------------------------------- /demo-app/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Make code changes take effect immediately without server restart. 7 | config.enable_reloading = true 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable server timing. 16 | config.server_timing = true 17 | 18 | # Enable/disable Action Controller caching. By default Action Controller caching is disabled. 19 | # Run rails dev:cache to toggle Action Controller caching. 20 | if Rails.root.join("tmp/caching-dev.txt").exist? 21 | config.action_controller.perform_caching = true 22 | config.action_controller.enable_fragment_cache_logging = true 23 | config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } 24 | else 25 | config.action_controller.perform_caching = false 26 | end 27 | 28 | # Change to :null_store to avoid any caching. 29 | config.cache_store = :memory_store 30 | 31 | # Print deprecation notices to the Rails logger. 32 | config.active_support.deprecation = :log 33 | 34 | # Raise an error on page load if there are pending migrations. 35 | config.active_record.migration_error = :page_load 36 | 37 | # Highlight code that triggered database queries in logs. 38 | config.active_record.verbose_query_logs = true 39 | 40 | # Append comments with runtime information tags to SQL queries in logs. 41 | config.active_record.query_log_tags_enabled = true 42 | 43 | # Raises error for missing translations. 44 | # config.i18n.raise_on_missing_translations = true 45 | 46 | # Annotate rendered view with file names. 47 | config.action_view.annotate_rendered_view_with_filenames = true 48 | 49 | # Raise error when a before_action's only/except options reference missing actions. 50 | config.action_controller.raise_on_missing_callback_actions = true 51 | end 52 | -------------------------------------------------------------------------------- /demo-app/config/puma.rb: -------------------------------------------------------------------------------- 1 | # This configuration file will be evaluated by Puma. The top-level methods that 2 | # are invoked here are part of Puma's configuration DSL. For more information 3 | # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. 4 | # 5 | # Puma starts a configurable number of processes (workers) and each process 6 | # serves each request in a thread from an internal thread pool. 7 | # 8 | # You can control the number of workers using ENV["WEB_CONCURRENCY"]. You 9 | # should only set this value when you want to run 2 or more workers. The 10 | # default is already 1. 11 | # 12 | # The ideal number of threads per worker depends both on how much time the 13 | # application spends waiting for IO operations and on how much you wish to 14 | # prioritize throughput over latency. 15 | # 16 | # As a rule of thumb, increasing the number of threads will increase how much 17 | # traffic a given process can handle (throughput), but due to CRuby's 18 | # Global VM Lock (GVL) it has diminishing returns and will degrade the 19 | # response time (latency) of the application. 20 | # 21 | # The default is set to 3 threads as it's deemed a decent compromise between 22 | # throughput and latency for the average Rails application. 23 | # 24 | # Any libraries that use a connection pool or another resource pool should 25 | # be configured to provide at least as many connections as the number of 26 | # threads. This includes Active Record's `pool` parameter in `database.yml`. 27 | threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) 28 | threads threads_count, threads_count 29 | 30 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 31 | port ENV.fetch("PORT", 3000) 32 | 33 | # Allow puma to be restarted by `bin/rails restart` command. 34 | plugin :tmp_restart 35 | 36 | # Run the Solid Queue supervisor inside of Puma for single-server deployments 37 | plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] 38 | 39 | # Specify the PID file. Defaults to tmp/pids/server.pid in development. 40 | # In other environments, only set the PID file if requested. 41 | pidfile ENV["PIDFILE"] if ENV["PIDFILE"] 42 | -------------------------------------------------------------------------------- /demo-app/app/views/modal/index.html.erb: -------------------------------------------------------------------------------- 1 | <%= render partial: "hide_from_backends/notice" %> 2 |
3 | Rendered at <%= Time.now %> 4 |
5 | 6 |
7 | 8 |
9 |
10 |

CRUD modals

11 |
    12 |
  • <%= link_to "Show Post", post_path(@post, close_button: 1), data: {turbo_frame: "modal"} %> 13 |
  • <%= link_to "New Post", new_post_path(Post.new, close_button: 1), data: {turbo_frame: "modal"} %> 14 |
  • <%= link_to "Show Post (w/ advance history)", post_path(@post, advance: "1", close_button: 1), data: {turbo_frame: "modal"} %> 15 |
16 |
17 | 18 |
19 |

More modals

20 |
    21 |
  • Photo: <%= link_to "without header or padding", modal_path(:photo, padding: 0, advance: 1, close_button: 1), data: {turbo_frame: "modal"} %> | <%= link_to "without close button", modal_path(:photo, padding: 0, advance: 1, close_button: 0), data: {turbo_frame: "modal"} %> 22 |
  • <%= link_to "Long scrolling content", modal_path(:long, advance: 1, close_button: 1), data: {turbo_frame: "modal"} %> 23 |
  • <%= link_to "Form closing from backend on success", new_hide_from_backend_path(close_button: 1), data: {turbo_frame: "modal"} %> 24 | 25 |
26 |
27 |
28 |
29 | 30 | 31 |
32 | <% 30.times do %> 33 |
34 | This is a long page! 35 |
36 | <% end %> 37 | 38 |
39 | 40 |
41 |
42 | 43 |
44 | <%= link_to "Click for a unicat", modal_path(:photo, padding: 0, advance: 1), class: button_classes, data: {turbo_frame: "modal"} %> 45 |
46 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # Upgrading from 1.x to 2.x 2 | 3 | Version 2.0 of Ultimate Turbo Modal introduces some significant changes to simplify the setup and usage. Follow these steps to upgrade from a 1.x version. 4 | 5 | ## 1. Gem and Package Update 6 | 7 | 1. Update the gem in your `Gemfile`: 8 | ```ruby 9 | gem 'ultimate_turbo_modal', '~> 2.0' 10 | ``` 11 | 2. Update the npm package in your `package.json`: 12 | ```json 13 | "ultimate-turbo-modal": "^2.0" 14 | ``` 15 | 3. Run `bundle install` and `yarn install` (or `npm install`). 16 | 17 | ## 2. JavaScript Changes 18 | 19 | The biggest change in v2 is the removal of the `setupUltimateTurboModal` initializer. The modal controller now handles everything automatically. 20 | 21 | ### Remove Initializer 22 | 23 | - Remove the two `setupUltimateTurboModal`-related lines from `app/javascript/controllers/index.js`. 24 | 25 | Your `index.js` should no longer import `setupUltimateTurboModal` or call it. The Stimulus controller will be automatically loaded. 26 | 27 | ### Remove Idiomorph Tweaks (if you used them) 28 | 29 | If you were using the optional Idiomorph tweaks for better morphing, you can remove them as this is now handled differently. 30 | 31 | - Remove `` from your application layout. 32 | - Remove the `turbo:before-frame-render` event listener from your `application.js`. 33 | 34 | ## 3. Tailwind CSS Changes 35 | 36 | - Remove any `ultimate_turbo_modal` specific paths from your `tailwind.config.js`. The modal's classes are now self-contained and don't require scanning the gem's view files. 37 | 38 | A typical `tailwind.config.js` in a Rails app should have its `content` array look something like this, without mentioning the gem: 39 | 40 | ```js 41 | module.exports = { 42 | content: [ 43 | './app/views/**/*.html.erb', 44 | './app/helpers/**/*.rb', 45 | './app/assets/stylesheets/**/*.css', 46 | './app/javascript/**/*.js' 47 | ] 48 | } 49 | ``` 50 | 51 | ## 4. Review Usage 52 | 53 | Version 2.0 aims for backward compatibility in how you render modals from your Rails views and controllers. However, it's always a good idea to test your modals after upgrading to ensure they behave as expected. 54 | 55 | That's it! You should now be running on version 2.0. 56 | -------------------------------------------------------------------------------- /lib/generators/ultimate_turbo_modal/templates/flavors/tailwind3.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Tailwind CSS v3 4 | module UltimateTurboModal::Flavors 5 | class Tailwind3 < UltimateTurboModal::Base 6 | DIV_MODAL_CONTAINER_CLASSES = "relative z-50" 7 | DIV_OVERLAY_CLASSES = "fixed inset-0 bg-gray-900 bg-opacity-70 transition-opacity dark:bg-gray-900 dark:bg-opacity-80" 8 | DIV_DIALOG_CLASSES = "fixed inset-0 overflow-y-auto sm:max-w-[80%] md:max-w-3xl sm:mx-auto m-4" 9 | DIV_INNER_CLASSES = "flex min-h-full items-start justify-center pt-[10vh] sm:p-4" 10 | DIV_CONTENT_CLASSES = "relative transform max-h-screen overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:max-w-3xl dark:bg-gray-800 dark:text-white" 11 | DIV_MAIN_CLASSES = "p-4 pt-2 overflow-y-auto max-h-[75vh]" 12 | DIV_HEADER_CLASSES = "flex justify-between items-center w-full py-4 rounded-t dark:border-gray-600 border-b absolute" 13 | DIV_TITLE_CLASSES = "pl-4" 14 | DIV_TITLE_H_CLASSES = "text-lg font-semibold text-gray-900 dark:text-white" 15 | DIV_FOOTER_CLASSES = "flex p-4 rounded-b dark:border-gray-600 border-t" 16 | BUTTON_CLOSE_CLASSES = "mr-4 hidden" 17 | BUTTON_CLOSE_SR_ONLY_CLASSES = "sr-only" 18 | CLOSE_BUTTON_TAG_CLASSES = "text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white" 19 | ICON_CLOSE_CLASSES = "w-5 h-5" 20 | 21 | TRANSITIONS = { 22 | overlay: { 23 | enter: { 24 | animation: "ease-out duration-300", 25 | start: "opacity-0", 26 | end: "opacity-100" 27 | }, 28 | leave: { 29 | animation: "ease-in duration-200", 30 | start: "opacity-100", 31 | end: "opacity-0" 32 | } 33 | }, 34 | dialog: { 35 | enter: { 36 | animation: "ease-out duration-300", 37 | start: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", 38 | end: "opacity-100 translate-y-0 sm:scale-100" 39 | }, 40 | leave: { 41 | animation: "ease-in duration-200", 42 | start: "opacity-100 translate-y-0 sm:scale-100", 43 | end: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" 44 | } 45 | } 46 | } 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/generators/ultimate_turbo_modal/templates/flavors/tailwind.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Tailwind CSS v4 4 | module UltimateTurboModal::Flavors 5 | class Tailwind < UltimateTurboModal::Base 6 | DIV_MODAL_CONTAINER_CLASSES = "relative group z-50" 7 | DIV_OVERLAY_CLASSES = "fixed inset-0 bg-gray-900/70 transition-opacity dark:bg-gray-900/80 opacity-0" 8 | DIV_DIALOG_CLASSES = "fixed inset-0 overflow-y-auto sm:max-w-[80%] md:max-w-3xl sm:mx-auto m-4 opacity-0" 9 | DIV_INNER_CLASSES = "flex min-h-full items-start justify-center pt-[10vh] sm:p-4" 10 | DIV_CONTENT_CLASSES = "relative transform max-h-screen overflow-hidden rounded-lg bg-white text-left shadow-lg transition-all sm:my-8 sm:max-w-3xl dark:bg-gray-800 dark:text-white" 11 | DIV_MAIN_CLASSES = "group-data-[padding=true]:p-4 group-data-[padding=true]:pt-2 overflow-y-auto max-h-[75vh]" 12 | DIV_HEADER_CLASSES = "flex justify-between items-center w-full py-4 rounded-t dark:border-gray-600 group-data-[header-divider=true]:border-b group-data-[header=false]:absolute" 13 | DIV_TITLE_CLASSES = "pl-4" 14 | DIV_TITLE_H_CLASSES = "group-data-[title=false]:hidden text-lg font-semibold text-gray-900 dark:text-white" 15 | DIV_FOOTER_CLASSES = "flex p-4 rounded-b dark:border-gray-600 group-data-[footer-divider=true]:border-t" 16 | BUTTON_CLOSE_CLASSES = "mr-4 group-data-[close-button=false]:hidden" 17 | BUTTON_CLOSE_SR_ONLY_CLASSES = "sr-only" 18 | CLOSE_BUTTON_TAG_CLASSES = "text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white" 19 | ICON_CLOSE_CLASSES = "w-5 h-5" 20 | 21 | TRANSITIONS = { 22 | overlay: { 23 | enter: { 24 | animation: "ease-out duration-300", 25 | start: "opacity-0", 26 | end: "opacity-100" 27 | }, 28 | leave: { 29 | animation: "ease-in duration-200", 30 | start: "opacity-100", 31 | end: "opacity-0" 32 | } 33 | }, 34 | dialog: { 35 | enter: { 36 | animation: "ease-out duration-300", 37 | start: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", 38 | end: "opacity-100 translate-y-0 sm:scale-100" 39 | }, 40 | leave: { 41 | animation: "ease-in duration-200", 42 | start: "opacity-100 translate-y-0 sm:scale-100", 43 | end: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" 44 | } 45 | } 46 | } 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/ultimate_turbo_modal/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module UltimateTurboModal 4 | class << self 5 | attr_accessor :configuration 6 | end 7 | 8 | def self.configure 9 | self.configuration ||= Configuration.new 10 | yield(configuration) if block_given? 11 | end 12 | 13 | delegate :flavor, :flavor=, :close_button, :close_button=, 14 | :advance, :advance=, :padding, :padding=, 15 | :allowed_click_outside_selector, :allowed_click_outside_selector=, to: :configuration 16 | 17 | class Configuration 18 | attr_reader :flavor, :close_button, :advance, :padding, :header, :header_divider, :footer_divider 19 | attr_accessor :allowed_click_outside_selector 20 | 21 | def initialize 22 | @flavor = :tailwind 23 | @close_button = true 24 | @advance = true 25 | @padding = true 26 | @header = true 27 | @header_divider = true 28 | @footer_divider = true 29 | @allowed_click_outside_selector = [] 30 | end 31 | 32 | def flavor=(flavor) 33 | raise ArgumentError.new("Value must be a symbol.") unless flavor.is_a?(Symbol) || flavor.is_a?(String) 34 | @flavor = flavor.to_sym 35 | end 36 | 37 | def close_button=(close_button) 38 | raise ArgumentError.new("Value must be a boolean.") unless [true, false].include?(close_button) 39 | @close_button = close_button 40 | end 41 | 42 | def advance=(advance) 43 | raise ArgumentError.new("Value must be a boolean.") unless [true, false].include?(advance) 44 | @advance = advance 45 | end 46 | 47 | def padding=(padding) 48 | if [true, false].include?(padding) || padding.is_a?(String) 49 | @padding = padding 50 | else 51 | raise ArgumentError.new("Value must be a boolean or a String.") 52 | end 53 | end 54 | 55 | def header=(header) 56 | raise ArgumentError.new("Value must be a boolean.") unless [true, false].include?(header) 57 | @header = header 58 | end 59 | 60 | def header_divider=(header_divider) 61 | raise ArgumentError.new("Value must be a boolean.") unless [true, false].include?(header_divider) 62 | @header_divider = header_divider 63 | end 64 | 65 | def footer_divider=(footer_divider) 66 | raise ArgumentError.new("Value must be a boolean.") unless [true, false].include?(footer_divider) 67 | @footer_divider = footer_divider 68 | end 69 | 70 | end 71 | end 72 | 73 | # Make sure the configuration object is set up when the gem is loaded. 74 | UltimateTurboModal.configure 75 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.2.1] - 2025-08-08 2 | 3 | - Added `rails generate ultimate_turbo_modal:update` for easy updates 4 | - Exclude demo-app directory from gem package 5 | 6 | ## [2.2.0] - 2025-08-07 7 | 8 | - BREAKING: Make sure to re-run the generator `rails generate ultimate_turbo_modal:install` after install. 9 | - Fixed transistions that were sometimes not showing 10 | - Improved demo app to make it easier to use for development 11 | 12 | ## [2.1.2] - 2025-08-06 13 | 14 | - Fixed scroll lock 15 | 16 | ## [2.1.1] - 2025-08-05 17 | 18 | - Reduce Rails dependency to only required components (actionpack, activesupport, railties) (#22) 19 | - Added focus trap (#27) 20 | 21 | ## [2.1.0] - 2025-08-05 22 | - Borked! 23 | 24 | ## [2.0.4] - 2025-06-19 25 | 26 | - Fix modal closing when clicked element is removed from DOM (#24) 27 | 28 | ## [2.0.3] - 2025-04-11 29 | 30 | - Warn if the NPM package and Ruby Gem versions don't match. 31 | 32 | ## [2.0.1] - 2025-04-11 33 | 34 | - Properly call `raw` for Phlex 2, and `unsafe_raw` for Phlex 1. Thanks @cavenk! 35 | 36 | ## [2.0.0] - 2025-04-07 - Breaking changes! 37 | 38 | - Much simplified installation with a Rails generator 39 | - Support for Turbo 8 40 | - Support for Phlex 2 41 | - Support for Tailwind v4 (use the `tailwind3` flavor if you're still on Tailwind v3) 42 | 43 | ## [1.7.0] - 2024-12-28 44 | 45 | - Fix Phlex deprecation warning 46 | 47 | ## [1.6.1] - 2024-01-10 48 | 49 | - Added ability to specify data attributes for the content div within the modal. Useful to specify a Stimulus controller, for example. 50 | 51 | ## [1.6.0] - 2023-12-25 52 | 53 | - Support for Ruby 3.3 54 | 55 | ## [1.5.0] - 2023-11-28 56 | 57 | - Allow whitelisting out-of-modal CSS selectors to not dismiss modal when clicked 58 | 59 | ## [1.4.1] - 2023-11-26 60 | 61 | - Make Tailwind transition smoother on pages with multiple z-index 62 | 63 | ## [1.4.0] - 2023-11-23 64 | 65 | - Added ability to specify custom `data-action` for the close button. 66 | - Code cleanup, deduplication 67 | 68 | ## [1.3.1] - 2023-11-23 69 | 70 | - Bug fixes 71 | 72 | ## [1.3.0] - 2023-11-14 73 | 74 | - Added ability to pass in a `title` block. 75 | 76 | ## [1.2.1] - 2023-11-11 77 | 78 | - Fix footer divider not showing 79 | 80 | ## [1.2.0] - 2023-11-05 81 | 82 | - Dark mode support 83 | - Added header divider (configurable) 84 | - Added footer section with divider (configurable) 85 | - Tailwind flavor now uses data attributes to style elements 86 | - Updated look and feel 87 | - Simplified code a bit 88 | 89 | ## [1.1.3] - 2023-11-01 90 | 91 | - Added configuration block 92 | 93 | ## [1.1.2] - 2023-10-31 94 | 95 | - Bug fix 96 | 97 | ## [1.1.0] - 2023-10-31 98 | 99 | - Added Vanilla CSS! 100 | 101 | ## [1.0.0] - 2023-10-31 102 | 103 | - Initial release as a Ruby Gem 104 | -------------------------------------------------------------------------------- /demo-app/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | "> 3 | 4 | Ultimate Turbo Modal 5 | 6 | <%= csrf_meta_tags %> 7 | <%= csp_meta_tag %> 8 | <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> 9 | <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %> 10 | 11 | 12 | 13 | <%= turbo_frame_tag "modal" %> 14 | <%= render partial: "shared/flash" %> 15 | 16 |
17 | <%= yield %> 18 |
19 | 20 |
21 | 30 |
31 | 32 | 33 | <% if UltimateTurboModal.flavor == :tailwind %> 34 | 35 | <% elsif UltimateTurboModal.flavor == :vanilla %> 36 | 37 | <% end %> 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /demo-app/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.enable_reloading = false 8 | 9 | # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). 10 | config.eager_load = true 11 | 12 | # Full error reports are disabled. 13 | config.consider_all_requests_local = false 14 | 15 | # Turn on fragment caching in view templates. 16 | config.action_controller.perform_caching = true 17 | 18 | # Cache assets for far-future expiry since they are all digest stamped. 19 | config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } 20 | 21 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 22 | # config.asset_host = "http://assets.example.com" 23 | 24 | # Assume all access to the app is happening through a SSL-terminating reverse proxy. 25 | config.assume_ssl = true 26 | 27 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 28 | config.force_ssl = true 29 | 30 | # Skip http-to-https redirect for the default health check endpoint. 31 | # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } 32 | 33 | # Log to STDOUT with the current request id as a default log tag. 34 | config.log_tags = [ :request_id ] 35 | config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) 36 | 37 | # Change to "debug" to log everything (including potentially personally-identifiable information!) 38 | config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") 39 | 40 | # Prevent health checks from clogging up the logs. 41 | config.silence_healthcheck_path = "/up" 42 | 43 | # Don't log any deprecations. 44 | config.active_support.report_deprecations = false 45 | 46 | # Replace the default in-process memory cache store with a durable alternative. 47 | # config.cache_store = :mem_cache_store 48 | 49 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 50 | # the I18n.default_locale when a translation cannot be found). 51 | config.i18n.fallbacks = true 52 | 53 | # Do not dump schema after migrations. 54 | config.active_record.dump_schema_after_migration = false 55 | 56 | # Only use :id for inspections in production. 57 | config.active_record.attributes_for_inspect = [ :id ] 58 | 59 | # Enable DNS rebinding protection and other `Host` header attacks. 60 | # config.hosts = [ 61 | # "example.com", # Allow requests from example.com 62 | # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` 63 | # ] 64 | # 65 | # Skip DNS rebinding protection for the default health check endpoint. 66 | # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } 67 | end 68 | -------------------------------------------------------------------------------- /demo-app/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../Gemfile", __dir__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, ".locked") 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_requirement 64 | @bundler_requirement ||= 65 | env_var_version || 66 | cli_arg_version || 67 | bundler_requirement_for(lockfile_version) 68 | end 69 | 70 | def bundler_requirement_for(version) 71 | return "#{Gem::Requirement.default}.a" unless version 72 | 73 | bundler_gem_version = Gem::Version.new(version) 74 | 75 | bundler_gem_version.approximate_recommendation 76 | end 77 | 78 | def load_bundler! 79 | ENV["BUNDLE_GEMFILE"] ||= gemfile 80 | 81 | activate_bundler 82 | end 83 | 84 | def activate_bundler 85 | gem_error = activation_error_handling do 86 | gem "bundler", bundler_requirement 87 | end 88 | return if gem_error.nil? 89 | require_error = activation_error_handling do 90 | require "bundler/version" 91 | end 92 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 93 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" 94 | exit 42 95 | end 96 | 97 | def activation_error_handling 98 | yield 99 | nil 100 | rescue StandardError, LoadError => e 101 | e 102 | end 103 | end 104 | 105 | m.load_bundler! 106 | 107 | if m.invoked_as_script? 108 | load Gem.bin_path("bundler", "bundle") 109 | end 110 | -------------------------------------------------------------------------------- /demo-app/app/views/welcome/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 | 7 |
8 | 9 | 18 |
19 |
20 | -------------------------------------------------------------------------------- /lib/generators/ultimate_turbo_modal/update_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators" 4 | require "json" 5 | require "pathname" 6 | require_relative "base" 7 | 8 | module UltimateTurboModal 9 | module Generators 10 | class UpdateGenerator < UltimateTurboModal::Generators::Base 11 | source_root File.expand_path("templates", __dir__) 12 | 13 | desc "Updates UltimateTurboModal: aligns npm package version to gem version and refreshes the configured flavor initializer." 14 | 15 | def update_npm_package_version 16 | package_json_path = rails_root_join("package.json") 17 | 18 | unless File.exist?(package_json_path) 19 | say "No package.json found. Skipping npm package version update.", :yellow 20 | return 21 | end 22 | 23 | begin 24 | json = JSON.parse(File.read(package_json_path)) 25 | rescue JSON::ParserError => e 26 | say "Unable to parse package.json: #{e.message}", :red 27 | return 28 | end 29 | 30 | package_name = "ultimate_turbo_modal" 31 | new_version = UltimateTurboModal::VERSION.to_s 32 | 33 | # Special case: demo app links to local JS package; never update its version 34 | if json.dig("dependencies", package_name) == "link:../javascript" || 35 | json.dig("devDependencies", package_name) == "link:../javascript" 36 | say "Detected local link for '#{package_name}' (link:../javascript). Skipping version update.", :blue 37 | return 38 | end 39 | 40 | updated = false 41 | 42 | %w[dependencies devDependencies].each do |section| 43 | next unless json.key?(section) && json[section].is_a?(Hash) 44 | 45 | if json[section].key?(package_name) 46 | old = json[section][package_name] 47 | json[section][package_name] = new_version 48 | updated = true if old != new_version 49 | end 50 | end 51 | 52 | if updated 53 | File.write(package_json_path, JSON.pretty_generate(json) + "\n") 54 | say "Updated #{package_name} version in package.json to #{new_version}.", :green 55 | else 56 | say "Did not find #{package_name} in package.json dependencies. Nothing to update.", :blue 57 | end 58 | end 59 | 60 | def install_js_dependencies 61 | install_all_js_dependencies 62 | end 63 | 64 | def copy_flavor_file 65 | flavor = detect_flavor 66 | unless flavor 67 | say "Could not determine UTMR flavor. Skipping flavor file copy.", :yellow 68 | return 69 | end 70 | 71 | template_rel = "flavors/#{flavor}.rb" 72 | template_abs = File.join(self.class.source_root, template_rel) 73 | 74 | unless File.exist?(template_abs) 75 | say "Flavor template not found for '#{flavor}' at #{template_abs}.", :red 76 | return 77 | end 78 | 79 | target_path = "config/initializers/ultimate_turbo_modal_#{flavor}.rb" 80 | copy_file template_rel, target_path, force: true 81 | say "Copied flavor initializer to #{target_path}.", :green 82 | end 83 | 84 | private 85 | 86 | def detect_flavor 87 | command = nil 88 | if File.exist?(rails_root_join("bin", "rails")) 89 | command = "#{rails_root_join("bin", "rails")} runner \"puts UltimateTurboModal.configuration.flavor\"" 90 | else 91 | command = "bundle exec rails runner \"puts UltimateTurboModal.configuration.flavor\"" 92 | end 93 | 94 | output = `#{command}` 95 | flavor = output.to_s.strip 96 | flavor.empty? ? nil : flavor 97 | rescue StandardError => e 98 | say "Error determining flavor via rails runner: #{e.message}", :red 99 | nil 100 | end 101 | 102 | def rails_root_join(*args) 103 | Pathname.new(destination_root).join(*args) 104 | end 105 | end 106 | end 107 | end 108 | 109 | 110 | -------------------------------------------------------------------------------- /lib/generators/ultimate_turbo_modal/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators" 4 | require "pathname" 5 | 6 | module UltimateTurboModal 7 | module Generators 8 | class Base < Rails::Generators::Base 9 | protected 10 | 11 | def package_name 12 | "ultimate_turbo_modal" 13 | end 14 | 15 | # Add JS dependency (for install flow) 16 | def add_js_dependency 17 | say "Attempting to set up JavaScript dependencies...", :yellow 18 | 19 | version_spec = "#{package_name}@#{UltimateTurboModal::VERSION}" 20 | 21 | if uses_importmaps? 22 | say "Detected Importmaps. Pinning #{version_spec}...", :green 23 | run "bin/importmap pin #{version_spec}" 24 | say "✅ Pinned '#{package_name}' via importmap.", :green 25 | return 26 | end 27 | 28 | if uses_javascript_bundler? 29 | say "Detected jsbundling-rails (Yarn/npm/Bun). Adding #{package_name} package...", :green 30 | if uses_yarn? 31 | run "yarn add #{version_spec}" 32 | say "✅ Added '#{package_name}' using Yarn.", :green 33 | elsif uses_npm? 34 | run "npm install --save #{version_spec}" 35 | say "✅ Added '#{package_name}' using npm.", :green 36 | elsif uses_bun? 37 | run "bun add #{version_spec}" 38 | say "✅ Added '#{package_name}' using Bun.", :green 39 | else 40 | say "Attempting to add with Yarn. If you use npm or Bun, please add manually.", :yellow 41 | run "yarn add #{version_spec}" 42 | say "If this failed or you use npm/bun, please run:", :yellow 43 | say "npm install --save #{version_spec}", :cyan 44 | say "# or", :cyan 45 | say "bun add #{version_spec}", :cyan 46 | end 47 | else 48 | say "Could not automatically detect Importmaps or jsbundling-rails.", :yellow 49 | say "Please manually add the '#{package_name}' JavaScript package.", :yellow 50 | say "If using Importmaps: bin/importmap pin #{version_spec}", :cyan 51 | say "If using Yarn: yarn add #{version_spec}", :cyan 52 | say "If using npm: npm install --save #{version_spec}", :cyan 53 | say "If using Bun: bun add #{version_spec}", :cyan 54 | say "Then, import it in your app/javascript/application.js:", :yellow 55 | say "import '#{package_name}'", :cyan 56 | end 57 | end 58 | 59 | # Install all JS dependencies (for update flow) 60 | def install_all_js_dependencies 61 | if uses_importmaps? 62 | version_spec = "#{package_name}@#{UltimateTurboModal::VERSION}" 63 | say "Detected Importmaps. Ensuring pin for #{version_spec}...", :green 64 | run "bin/importmap pin #{version_spec}" 65 | say "✅ Pinned '#{package_name}' via importmap.", :green 66 | return 67 | end 68 | 69 | unless uses_javascript_bundler? 70 | say "Could not detect Importmaps or jsbundling-rails. Skipping JS install step.", :yellow 71 | return 72 | end 73 | 74 | say "Installing JavaScript dependencies...", :yellow 75 | if uses_yarn? 76 | run "yarn install" 77 | say "✅ Installed dependencies with Yarn.", :green 78 | elsif uses_npm? 79 | run "npm install" 80 | say "✅ Installed dependencies with npm.", :green 81 | elsif uses_bun? 82 | run "bun install" 83 | say "✅ Installed dependencies with Bun.", :green 84 | else 85 | say "Attempting to install with Yarn. If you use npm or Bun, please run the appropriate command.", :yellow 86 | run "yarn install" 87 | end 88 | end 89 | 90 | def uses_importmaps? 91 | File.exist?(rails_root_join("config", "importmap.rb")) 92 | end 93 | 94 | def uses_javascript_bundler? 95 | File.exist?(rails_root_join("package.json")) 96 | end 97 | 98 | def uses_yarn? 99 | File.exist?(rails_root_join("yarn.lock")) 100 | end 101 | 102 | def uses_npm? 103 | File.exist?(rails_root_join("package-lock.json")) && !uses_yarn? && !uses_bun? 104 | end 105 | 106 | def uses_bun? 107 | File.exist?(rails_root_join("bun.lockb")) 108 | end 109 | 110 | def rails_root_join(*args) 111 | Pathname.new(destination_root).join(*args) 112 | end 113 | end 114 | end 115 | end 116 | 117 | 118 | -------------------------------------------------------------------------------- /demo-app/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The page you were looking for doesn’t exist (404 Not found) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

The page you were looking for doesn’t exist. You may have mistyped the address or the page may have moved. If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | ultimate_turbo_modal (2.2.1) 5 | actionpack 6 | activesupport 7 | phlex-rails 8 | railties 9 | stimulus-rails 10 | turbo-rails 11 | 12 | GEM 13 | remote: https://rubygems.org/ 14 | specs: 15 | actionpack (8.0.2) 16 | actionview (= 8.0.2) 17 | activesupport (= 8.0.2) 18 | nokogiri (>= 1.8.5) 19 | rack (>= 2.2.4) 20 | rack-session (>= 1.0.1) 21 | rack-test (>= 0.6.3) 22 | rails-dom-testing (~> 2.2) 23 | rails-html-sanitizer (~> 1.6) 24 | useragent (~> 0.16) 25 | actionview (8.0.2) 26 | activesupport (= 8.0.2) 27 | builder (~> 3.1) 28 | erubi (~> 1.11) 29 | rails-dom-testing (~> 2.2) 30 | rails-html-sanitizer (~> 1.6) 31 | activesupport (8.0.2) 32 | base64 33 | benchmark (>= 0.3) 34 | bigdecimal 35 | concurrent-ruby (~> 1.0, >= 1.3.1) 36 | connection_pool (>= 2.2.5) 37 | drb 38 | i18n (>= 1.6, < 2) 39 | logger (>= 1.4.2) 40 | minitest (>= 5.1) 41 | securerandom (>= 0.3) 42 | tzinfo (~> 2.0, >= 2.0.5) 43 | uri (>= 0.13.1) 44 | ast (2.4.3) 45 | base64 (0.3.0) 46 | benchmark (0.4.1) 47 | bigdecimal (3.2.2) 48 | builder (3.3.0) 49 | concurrent-ruby (1.3.5) 50 | connection_pool (2.5.3) 51 | crass (1.0.6) 52 | date (3.4.1) 53 | drb (2.2.3) 54 | erb (5.0.2) 55 | erubi (1.13.1) 56 | i18n (1.14.7) 57 | concurrent-ruby (~> 1.0) 58 | io-console (0.8.1) 59 | irb (1.15.2) 60 | pp (>= 0.6.0) 61 | rdoc (>= 4.0.0) 62 | reline (>= 0.4.2) 63 | json (2.13.2) 64 | language_server-protocol (3.17.0.5) 65 | lint_roller (1.1.0) 66 | logger (1.7.0) 67 | loofah (2.24.1) 68 | crass (~> 1.0.2) 69 | nokogiri (>= 1.12.0) 70 | minitest (5.25.5) 71 | nokogiri (1.18.9-arm64-darwin) 72 | racc (~> 1.4) 73 | parallel (1.27.0) 74 | parser (3.3.9.0) 75 | ast (~> 2.4.1) 76 | racc 77 | phlex (2.3.1) 78 | zeitwerk (~> 2.7) 79 | phlex-rails (2.3.1) 80 | phlex (~> 2.3.0) 81 | railties (>= 7.1, < 9) 82 | zeitwerk (~> 2.7) 83 | pp (0.6.2) 84 | prettyprint 85 | prettyprint (0.2.0) 86 | prism (1.4.0) 87 | psych (5.2.6) 88 | date 89 | stringio 90 | racc (1.8.1) 91 | rack (3.2.0) 92 | rack-session (2.1.1) 93 | base64 (>= 0.1.0) 94 | rack (>= 3.0.0) 95 | rack-test (2.2.0) 96 | rack (>= 1.3) 97 | rackup (2.2.1) 98 | rack (>= 3) 99 | rails-dom-testing (2.3.0) 100 | activesupport (>= 5.0.0) 101 | minitest 102 | nokogiri (>= 1.6) 103 | rails-html-sanitizer (1.6.2) 104 | loofah (~> 2.21) 105 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 106 | railties (8.0.2) 107 | actionpack (= 8.0.2) 108 | activesupport (= 8.0.2) 109 | irb (~> 1.13) 110 | rackup (>= 1.0.0) 111 | rake (>= 12.2) 112 | thor (~> 1.0, >= 1.2.2) 113 | zeitwerk (~> 2.6) 114 | rainbow (3.1.1) 115 | rake (13.3.0) 116 | rdoc (6.14.2) 117 | erb 118 | psych (>= 4.0.0) 119 | regexp_parser (2.11.0) 120 | reline (0.6.2) 121 | io-console (~> 0.5) 122 | rubocop (1.75.8) 123 | json (~> 2.3) 124 | language_server-protocol (~> 3.17.0.2) 125 | lint_roller (~> 1.1.0) 126 | parallel (~> 1.10) 127 | parser (>= 3.3.0.2) 128 | rainbow (>= 2.2.2, < 4.0) 129 | regexp_parser (>= 2.9.3, < 3.0) 130 | rubocop-ast (>= 1.44.0, < 2.0) 131 | ruby-progressbar (~> 1.7) 132 | unicode-display_width (>= 2.4.0, < 4.0) 133 | rubocop-ast (1.46.0) 134 | parser (>= 3.3.7.2) 135 | prism (~> 1.4) 136 | rubocop-performance (1.25.0) 137 | lint_roller (~> 1.1) 138 | rubocop (>= 1.75.0, < 2.0) 139 | rubocop-ast (>= 1.38.0, < 2.0) 140 | rubocop-rails (2.31.0) 141 | activesupport (>= 4.2.0) 142 | lint_roller (~> 1.1) 143 | rack (>= 1.1) 144 | rubocop (>= 1.75.0, < 2.0) 145 | rubocop-ast (>= 1.38.0, < 2.0) 146 | ruby-progressbar (1.13.0) 147 | securerandom (0.4.1) 148 | standard (1.50.0) 149 | language_server-protocol (~> 3.17.0.2) 150 | lint_roller (~> 1.0) 151 | rubocop (~> 1.75.5) 152 | standard-custom (~> 1.0.0) 153 | standard-performance (~> 1.8) 154 | standard-custom (1.0.2) 155 | lint_roller (~> 1.0) 156 | rubocop (~> 1.50) 157 | standard-performance (1.8.0) 158 | lint_roller (~> 1.1) 159 | rubocop-performance (~> 1.25.0) 160 | standard-rails (1.4.0) 161 | lint_roller (~> 1.0) 162 | rubocop-rails (~> 2.31.0) 163 | stimulus-rails (1.3.4) 164 | railties (>= 6.0.0) 165 | stringio (3.1.7) 166 | thor (1.4.0) 167 | turbo-rails (2.0.16) 168 | actionpack (>= 7.1.0) 169 | railties (>= 7.1.0) 170 | tzinfo (2.0.6) 171 | concurrent-ruby (~> 1.0) 172 | unicode-display_width (3.1.4) 173 | unicode-emoji (~> 4.0, >= 4.0.4) 174 | unicode-emoji (4.0.4) 175 | uri (1.0.3) 176 | useragent (0.16.11) 177 | zeitwerk (2.7.3) 178 | 179 | PLATFORMS 180 | arm64-darwin-22 181 | arm64-darwin-23 182 | arm64-darwin-24 183 | 184 | DEPENDENCIES 185 | rake (~> 13.0) 186 | standard 187 | standard-rails 188 | ultimate_turbo_modal! 189 | 190 | BUNDLED WITH 191 | 2.4.13 192 | -------------------------------------------------------------------------------- /javascript/styles/vanilla.css: -------------------------------------------------------------------------------- 1 | .hidden { 2 | display: none 3 | } 4 | 5 | .dark { 6 | .modal-overlay { 7 | background-color: rgba(17, 24, 39, 0.8) 8 | } 9 | 10 | .modal-header, 11 | .modal-footer { 12 | border-color: #4B5563; 13 | } 14 | 15 | .modal-content { 16 | background-color: #1F2937; 17 | color: #ffffff; 18 | } 19 | 20 | .modal-close-button:hover, 21 | .modal-close-icon:hover { 22 | background-color: #6B7280; 23 | color: #ffffff; 24 | } 25 | } 26 | 27 | :not(.dark) { 28 | 29 | .modal-close-button, 30 | .modal-close-icon { 31 | color: #9CA3AF; 32 | 33 | &:hover { 34 | color: #111827; 35 | background-color: #E5E7EB; 36 | } 37 | } 38 | } 39 | 40 | .modal-container { 41 | position: relative; 42 | 43 | &[data-header="false"] .modal-header { 44 | position: absolute; 45 | } 46 | 47 | &[data-header-divider="true"] .modal-header { 48 | border-bottom-width: 1px; 49 | } 50 | 51 | &[data-footer-divider="true"] .modal-footer { 52 | border-top-width: 1px; 53 | } 54 | 55 | &[data-padding="true"] .modal-main { 56 | padding: 1rem; 57 | padding-top: 0.5rem; 58 | } 59 | 60 | &[data-title="false"] .modal-title-h { 61 | display: none; 62 | } 63 | 64 | &[data-close-button="false"] .modal-close { 65 | display: none; 66 | } 67 | 68 | .sr-only { 69 | position: absolute; 70 | width: 1px; 71 | height: 1px; 72 | padding: 0; 73 | margin: -1px; 74 | overflow: hidden; 75 | clip: rect(0, 0, 0, 0); 76 | white-space: nowrap; 77 | border-width: 0; 78 | } 79 | } 80 | 81 | .modal-overlay { 82 | position: fixed; 83 | top: 0; 84 | right: 0; 85 | bottom: 0; 86 | left: 0; 87 | z-index: 40; 88 | background-color: rgba(17, 24, 39, 0.7); 89 | transition-property: opacity; 90 | } 91 | 92 | .modal-outer { 93 | overflow-y: auto; 94 | position: fixed; 95 | top: 0; 96 | right: 0; 97 | bottom: 0; 98 | left: 0; 99 | z-index: 50; 100 | margin: 1rem; 101 | 102 | @media (min-width: 640px) { 103 | margin-left: auto; 104 | margin-right: auto; 105 | } 106 | 107 | @media (min-width: 768px) { 108 | max-width: 48rem; 109 | } 110 | } 111 | 112 | .modal-inner { 113 | display: flex; 114 | padding: 0.25rem; 115 | justify-content: center; 116 | align-items: center; 117 | min-height: 100%; 118 | 119 | @media (min-width: 640px) { 120 | padding: 1rem; 121 | } 122 | } 123 | 124 | .modal-content { 125 | overflow: hidden; 126 | position: relative; 127 | background-color: #ffffff; 128 | transition-property: all; 129 | text-align: left; 130 | border-radius: 0.5rem; 131 | box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); 132 | 133 | @media (min-width: 640px) { 134 | margin-top: 2rem; 135 | margin-bottom: 2rem; 136 | max-width: 48rem; 137 | } 138 | } 139 | 140 | .modal-header { 141 | display: flex; 142 | padding-top: 1rem; 143 | padding-bottom: 1rem; 144 | justify-content: space-between; 145 | align-items: center; 146 | width: 100%; 147 | border-top-left-radius: 0.25rem; 148 | border-top-right-radius: 0.25rem; 149 | } 150 | 151 | .modal-title { 152 | line-height: 1.75rem; 153 | font-weight: 600; 154 | padding-left: 1rem; 155 | } 156 | 157 | .modal-footer { 158 | display: flex; 159 | padding: 1rem; 160 | border-bottom-right-radius: 0.25rem; 161 | border-bottom-left-radius: 0.25rem; 162 | border-top-width: 1px; 163 | } 164 | 165 | .modal-close { 166 | margin-right: 1rem; 167 | } 168 | 169 | .modal-close-button { 170 | display: inline-flex; 171 | padding: 0.375rem; 172 | margin-left: auto; 173 | background-color: transparent; 174 | font-size: 0.875rem; 175 | line-height: 1.25rem; 176 | align-items: center; 177 | border-radius: 0.5rem; 178 | } 179 | 180 | .modal-close-icon { 181 | width: 1.25rem; 182 | height: 1.25rem; 183 | } 184 | 185 | /* 186 | Transition utilities for Vanilla flavor 187 | These replicate the Tailwind transitions defined in tailwind.rb 188 | using plain CSS classes referenced in vanilla.rb 189 | */ 190 | 191 | /* Overlay: Enter */ 192 | .modal-transition-overlay-enter-animation { 193 | transition-property: opacity; 194 | transition-duration: 300ms; /* duration-300 */ 195 | transition-timing-function: ease-out; /* ease-out */ 196 | } 197 | 198 | .modal-transition-overlay-enter-start { 199 | opacity: 0; /* opacity-0 */ 200 | } 201 | 202 | .modal-transition-overlay-enter-end { 203 | opacity: 1; /* opacity-100 */ 204 | } 205 | 206 | /* Overlay: Leave */ 207 | .modal-transition-overlay-leave-animation { 208 | transition-property: opacity; 209 | transition-duration: 200ms; /* duration-200 */ 210 | transition-timing-function: ease-in; /* ease-in */ 211 | } 212 | 213 | .modal-transition-overlay-leave-start { 214 | opacity: 1; /* opacity-100 */ 215 | } 216 | 217 | .modal-transition-overlay-leave-end { 218 | opacity: 0; /* opacity-0 */ 219 | } 220 | 221 | /* Dialog: Enter */ 222 | .modal-transition-dialog-enter-animation { 223 | transition-property: opacity, transform; 224 | transition-duration: 300ms; /* duration-300 */ 225 | transition-timing-function: ease-out; /* ease-out */ 226 | } 227 | 228 | .modal-transition-dialog-enter-start { 229 | opacity: 0; /* opacity-0 */ 230 | transform: translateY(1rem) scale(1); /* translate-y-4 */ 231 | } 232 | 233 | @media (min-width: 640px) { 234 | .modal-transition-dialog-enter-start { 235 | transform: translateY(0) scale(0.95); /* sm:translate-y-0 sm:scale-95 */ 236 | } 237 | } 238 | 239 | .modal-transition-dialog-enter-end { 240 | opacity: 1; /* opacity-100 */ 241 | transform: translateY(0) scale(1); /* translate-y-0 scale-100 */ 242 | } 243 | 244 | /* Dialog: Leave */ 245 | .modal-transition-dialog-leave-animation { 246 | transition-property: opacity, transform; 247 | transition-duration: 200ms; /* duration-200 */ 248 | transition-timing-function: ease-in; /* ease-in */ 249 | } 250 | 251 | .modal-transition-dialog-leave-start { 252 | opacity: 1; /* opacity-100 */ 253 | transform: translateY(0) scale(1); /* translate-y-0 scale-100 */ 254 | } 255 | 256 | .modal-transition-dialog-leave-end { 257 | opacity: 0; /* opacity-0 */ 258 | transform: translateY(1rem) scale(1); /* translate-y-4 */ 259 | } 260 | 261 | @media (min-width: 640px) { 262 | .modal-transition-dialog-leave-end { 263 | transform: translateY(0) scale(0.95); /* sm:translate-y-0 sm:scale-95 */ 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /demo-app/Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | ultimate_turbo_modal (2.2.1) 5 | actionpack 6 | activesupport 7 | phlex-rails 8 | railties 9 | stimulus-rails 10 | turbo-rails 11 | 12 | GEM 13 | remote: https://rubygems.org/ 14 | specs: 15 | actioncable (8.0.2) 16 | actionpack (= 8.0.2) 17 | activesupport (= 8.0.2) 18 | nio4r (~> 2.0) 19 | websocket-driver (>= 0.6.1) 20 | zeitwerk (~> 2.6) 21 | actionmailbox (8.0.2) 22 | actionpack (= 8.0.2) 23 | activejob (= 8.0.2) 24 | activerecord (= 8.0.2) 25 | activestorage (= 8.0.2) 26 | activesupport (= 8.0.2) 27 | mail (>= 2.8.0) 28 | actionmailer (8.0.2) 29 | actionpack (= 8.0.2) 30 | actionview (= 8.0.2) 31 | activejob (= 8.0.2) 32 | activesupport (= 8.0.2) 33 | mail (>= 2.8.0) 34 | rails-dom-testing (~> 2.2) 35 | actionpack (8.0.2) 36 | actionview (= 8.0.2) 37 | activesupport (= 8.0.2) 38 | nokogiri (>= 1.8.5) 39 | rack (>= 2.2.4) 40 | rack-session (>= 1.0.1) 41 | rack-test (>= 0.6.3) 42 | rails-dom-testing (~> 2.2) 43 | rails-html-sanitizer (~> 1.6) 44 | useragent (~> 0.16) 45 | actiontext (8.0.2) 46 | actionpack (= 8.0.2) 47 | activerecord (= 8.0.2) 48 | activestorage (= 8.0.2) 49 | activesupport (= 8.0.2) 50 | globalid (>= 0.6.0) 51 | nokogiri (>= 1.8.5) 52 | actionview (8.0.2) 53 | activesupport (= 8.0.2) 54 | builder (~> 3.1) 55 | erubi (~> 1.11) 56 | rails-dom-testing (~> 2.2) 57 | rails-html-sanitizer (~> 1.6) 58 | activejob (8.0.2) 59 | activesupport (= 8.0.2) 60 | globalid (>= 0.3.6) 61 | activemodel (8.0.2) 62 | activesupport (= 8.0.2) 63 | activerecord (8.0.2) 64 | activemodel (= 8.0.2) 65 | activesupport (= 8.0.2) 66 | timeout (>= 0.4.0) 67 | activestorage (8.0.2) 68 | actionpack (= 8.0.2) 69 | activejob (= 8.0.2) 70 | activerecord (= 8.0.2) 71 | activesupport (= 8.0.2) 72 | marcel (~> 1.0) 73 | activesupport (8.0.2) 74 | base64 75 | benchmark (>= 0.3) 76 | bigdecimal 77 | concurrent-ruby (~> 1.0, >= 1.3.1) 78 | connection_pool (>= 2.2.5) 79 | drb 80 | i18n (>= 1.6, < 2) 81 | logger (>= 1.4.2) 82 | minitest (>= 5.1) 83 | securerandom (>= 0.3) 84 | tzinfo (~> 2.0, >= 2.0.5) 85 | uri (>= 0.13.1) 86 | base64 (0.2.0) 87 | benchmark (0.4.0) 88 | bigdecimal (3.1.9) 89 | bindex (0.8.1) 90 | builder (3.3.0) 91 | concurrent-ruby (1.3.5) 92 | connection_pool (2.5.0) 93 | crass (1.0.6) 94 | cssbundling-rails (1.4.3) 95 | railties (>= 6.0.0) 96 | date (3.4.1) 97 | debug (1.10.0) 98 | irb (~> 1.10) 99 | reline (>= 0.3.8) 100 | drb (2.2.1) 101 | erubi (1.13.1) 102 | faker (3.5.1) 103 | i18n (>= 1.8.11, < 2) 104 | globalid (1.2.1) 105 | activesupport (>= 6.1) 106 | i18n (1.14.7) 107 | concurrent-ruby (~> 1.0) 108 | io-console (0.8.0) 109 | irb (1.15.2) 110 | pp (>= 0.6.0) 111 | rdoc (>= 4.0.0) 112 | reline (>= 0.4.2) 113 | jsbundling-rails (1.3.1) 114 | railties (>= 6.0.0) 115 | logger (1.7.0) 116 | loofah (2.24.0) 117 | crass (~> 1.0.2) 118 | nokogiri (>= 1.12.0) 119 | mail (2.8.1) 120 | mini_mime (>= 0.1.1) 121 | net-imap 122 | net-pop 123 | net-smtp 124 | marcel (1.0.4) 125 | mini_mime (1.1.5) 126 | minitest (5.25.5) 127 | net-imap (0.5.6) 128 | date 129 | net-protocol 130 | net-pop (0.1.2) 131 | net-protocol 132 | net-protocol (0.2.2) 133 | timeout 134 | net-smtp (0.5.1) 135 | net-protocol 136 | nio4r (2.7.4) 137 | nokogiri (1.18.7-arm64-darwin) 138 | racc (~> 1.4) 139 | phlex (1.11.0) 140 | phlex-rails (1.2.2) 141 | phlex (>= 1.10, < 2) 142 | railties (>= 6.1, < 9) 143 | pp (0.6.2) 144 | prettyprint 145 | prettyprint (0.2.0) 146 | propshaft (1.1.0) 147 | actionpack (>= 7.0.0) 148 | activesupport (>= 7.0.0) 149 | rack 150 | railties (>= 7.0.0) 151 | psych (5.2.3) 152 | date 153 | stringio 154 | puma (6.6.0) 155 | nio4r (~> 2.0) 156 | racc (1.8.1) 157 | rack (3.1.12) 158 | rack-session (2.1.0) 159 | base64 (>= 0.1.0) 160 | rack (>= 3.0.0) 161 | rack-test (2.2.0) 162 | rack (>= 1.3) 163 | rackup (2.2.1) 164 | rack (>= 3) 165 | rails (8.0.2) 166 | actioncable (= 8.0.2) 167 | actionmailbox (= 8.0.2) 168 | actionmailer (= 8.0.2) 169 | actionpack (= 8.0.2) 170 | actiontext (= 8.0.2) 171 | actionview (= 8.0.2) 172 | activejob (= 8.0.2) 173 | activemodel (= 8.0.2) 174 | activerecord (= 8.0.2) 175 | activestorage (= 8.0.2) 176 | activesupport (= 8.0.2) 177 | bundler (>= 1.15.0) 178 | railties (= 8.0.2) 179 | rails-dom-testing (2.2.0) 180 | activesupport (>= 5.0.0) 181 | minitest 182 | nokogiri (>= 1.6) 183 | rails-html-sanitizer (1.6.2) 184 | loofah (~> 2.21) 185 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 186 | railties (8.0.2) 187 | actionpack (= 8.0.2) 188 | activesupport (= 8.0.2) 189 | irb (~> 1.13) 190 | rackup (>= 1.0.0) 191 | rake (>= 12.2) 192 | thor (~> 1.0, >= 1.2.2) 193 | zeitwerk (~> 2.6) 194 | rake (13.2.1) 195 | rdoc (6.13.1) 196 | psych (>= 4.0.0) 197 | reline (0.6.1) 198 | io-console (~> 0.5) 199 | securerandom (0.4.1) 200 | sqlite3 (2.6.0-arm64-darwin) 201 | stimulus-rails (1.3.4) 202 | railties (>= 6.0.0) 203 | stringio (3.1.6) 204 | thor (1.3.2) 205 | timeout (0.4.3) 206 | turbo-rails (2.0.13) 207 | actionpack (>= 7.1.0) 208 | railties (>= 7.1.0) 209 | tzinfo (2.0.6) 210 | concurrent-ruby (~> 1.0) 211 | uri (1.0.3) 212 | useragent (0.16.11) 213 | web-console (4.2.1) 214 | actionview (>= 6.0.0) 215 | activemodel (>= 6.0.0) 216 | bindex (>= 0.4.0) 217 | railties (>= 6.0.0) 218 | websocket-driver (0.7.7) 219 | base64 220 | websocket-extensions (>= 0.1.0) 221 | websocket-extensions (0.1.5) 222 | zeitwerk (2.7.2) 223 | 224 | PLATFORMS 225 | arm64-darwin-22 226 | arm64-darwin-23 227 | arm64-darwin-24 228 | 229 | DEPENDENCIES 230 | cssbundling-rails 231 | debug 232 | faker 233 | jsbundling-rails 234 | propshaft 235 | puma (~> 6.6) 236 | rails (~> 8.0.2) 237 | sqlite3 238 | stimulus-rails 239 | turbo-rails 240 | tzinfo-data 241 | ultimate_turbo_modal! 242 | web-console 243 | 244 | RUBY VERSION 245 | ruby 3.3.9p170 246 | 247 | BUNDLED WITH 248 | 2.6.7 249 | -------------------------------------------------------------------------------- /demo-app/public/400.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The server cannot process the request due to a client error (400 Bad Request) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

The server cannot process the request due to a client error. Please check the request and try again. If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Ultimate Turbo Modal for Rails (UTMR) 2 | 3 | There are MANY Turbo/Hotwire/Stimulus modal dialog implementations out there, and it seems like everyone goes about it a different way. However, as you may have learned the hard way, the majority fall short in different, often subtle ways. They generally cover the basics quite well, but do not check all the boxes for real-world use. 4 | 5 | UTMR aims to be the be-all and end-all of Turbo Modals. I believe it is the best (only?) full-featured implementation and checks all the boxes. It is feature-rich, yet extremely easy to use. 6 | 7 | Under the hood, it uses [Stimulus](https://stimulus.hotwired.dev), [Turbo](https://turbo.hotwired.dev/), [el-transition](https://github.com/mmccall10/el-transition), and [Idiomorph](https://github.com/bigskysoftware/idiomorph). 8 | 9 | It currently ships in a three flavors: Tailwind v3, Tailwind v4 and regular, vanilla CSS. It is easy to create your own variant to suit your needs. 10 | 11 | ## Installation 12 | 13 | ``` 14 | $ bundle add ultimate_turbo_modal 15 | $ bundle exec rails g ultimate_turbo_modal:install 16 | ``` 17 | 18 | ## Usage 19 | 20 | 1. Wrap your view inside a `modal` block as follow: 21 | 22 | ```erb 23 | <%= modal do %> 24 | Hello World! 25 | <% end %> 26 | ``` 27 | 28 | 2. Link to your view by specifying `modal` as the target Turbo Frame: 29 | 30 | ```erb 31 | <%= link_to "Open Modal", "/hello_world", data: { turbo_frame: "modal" } %> 32 | ``` 33 | 34 | Clicking on the link will automatically open the content of the view inside a modal. If you open the link in a new tab, it will render normally outside of the modal. Nothing to do! 35 | 36 | This is really all you should need to do for most use cases. 37 | 38 | ### Setting Title and Footer 39 | 40 | You can set a custom title and footer by passing a block. For example: 41 | 42 | ```erb 43 | <%= modal do |m| %> 44 | <% m.title do %> 45 |
My Title
46 | <% end %> 47 | 48 |

Your modal body

49 | <%= form_with url: "#", html: { id: "myform" } do |f| %> 50 |

..

51 | <% end %> 52 | 53 | <% m.footer do %> 54 | Submit 55 | <% end %> 56 | <% end %> 57 | ``` 58 | 59 | You can also set a title with options (see below). 60 | 61 | ### Detecting modal at render time 62 | 63 | If you need to do something a little bit more advanced when the view is shown outside of a modal, you can use the `#inside_modal?` method as such: 64 | 65 | ```erb 66 | <% if inside_modal? %> 67 |

Hello from modal

68 | <% else %> 69 |

Hello from a normal page render

70 | <% end %> 71 | ``` 72 | 73 | 74 | 75 |   76 |   77 | ## Options 78 | 79 | Do not get overwhelmed with all the options. The defaults are sensible. 80 | 81 | | name | default value | description | 82 | |------|---------------|-------------| 83 | | `advance` | `true` | When opening the modal, the URL in the URL bar will change to the URL of the view being shown in the modal. The Back button dismisses the modal and navigates back. If a URL is specified as a string (e.g. `advance: "/other-path"), the browser history will advance, and the URL shown in the URL bar will be replaced with the value specified. | 84 | | `close_button` | `true` | Shows or hide a close button (X) at the top right of the modal. | 85 | | `header` | `true` | Whether to display a modal header. | 86 | | `header_divider` | `true` | Whether to display a divider below the header. | 87 | | `padding` | `true` | Adds padding inside the modal. | 88 | | `title` | `nil` | Title to display in the modal header. Alternatively, you can set the title with a block. | 89 | 90 | ### Example usage with options 91 | 92 | ```erb 93 | <%= modal(padding: true, close_button: false, advance: false) do %> 94 | Hello World! 95 | <% end %> 96 | ``` 97 | 98 | ```erb 99 | <%= modal(padding: true, close_button: false, advance: "/foo/bar") do %> 100 | Hello World! 101 | <% end %> 102 | ``` 103 | 104 | ## Features and capabilities 105 | 106 | - Extremely easy to use 107 | - Fully responsive 108 | - Does not break if a user navigates directly to a page that is usually shown in a modal 109 | - Opening a modal in a new browser tab (ie: right click) gracefully degrades without having to code a modal and non-modal version of the same page 110 | - Automatically handles URL history (ie: pushState) for shareable URLs 111 | - pushState URL optionally overrideable 112 | - Seamless support for multi-page navigation within the modal 113 | - Seamless support for forms with validations 114 | - Seamless support for Rails flash messages 115 | - Enter/leave animation (fade in/out) 116 | - Support for long, scrollable modals 117 | - Properly locks the background page when scrolling a long modal 118 | - Click outside the modal to dismiss 119 | - Option to whitelist CSS selectors that won't dismiss the modal when clicked outside the modal (ie: datepicker) 120 | - Keyboard control; ESC to dismiss 121 | - Automatic (or not) close button 122 | - Focus trap for improved accessibility (Tab and Shift+Tab cycle through focusable elements within the modal only) 123 | 124 | 125 | ## Demo Video 126 | 127 | A video demo can be seen here: [https://youtu.be/BVRDXLN1I78](https://youtu.be/BVRDXLN1I78). 128 | 129 | ### Running the Demo Application 130 | 131 | The repository includes a demo application in the `demo-app` directory that showcases all the features of Ultimate Turbo Modal. To run it locally: 132 | 133 | ```bash 134 | # Navigate to the demo app directory 135 | cd demo-app 136 | 137 | # Install Ruby dependencies 138 | bundle install 139 | 140 | # Create and setup the database 141 | bin/rails db:create db:migrate db:seed 142 | 143 | # Install JavaScript dependencies 144 | yarn install 145 | 146 | # Start the development server 147 | bin/dev 148 | 149 | # Open your browser 150 | open http://localhost:3000 151 | ``` 152 | 153 | The demo app provides examples of: 154 | - Basic modal usage 155 | - Different modal configurations 156 | - Custom styling options 157 | - Various trigger methods 158 | - Advanced features like scrollable content and custom footers 159 | 160 | ## Updating between minor versions 161 | 162 | To upgrade within the same major version (for example 2.1 → 2.2): 163 | 164 | 1. Change the UTMR gem version in your `Gemfile`: 165 | 166 | ```ruby 167 | gem "ultimate_turbo_modal", "~> 2.2" 168 | ``` 169 | 170 | 2. Install updated dependencies: 171 | 172 | ```sh 173 | bundle install 174 | ``` 175 | 176 | 3. Run the update generator: 177 | 178 | ```sh 179 | bundle exec rails g ultimate_turbo_modal:update 180 | ``` 181 | 182 | ## Upgrading from 1.x 183 | 184 | Please see the [Upgrading Guide](UPGRADING.md) for detailed instructions on how to upgrade from version 1.x. 185 | 186 | ## Thanks 187 | 188 | Thanks to [@joeldrapper](https://github.com/joeldrapper) and [@konnorrogers](https://github.com/KonnorRogers) for all the help! 189 | 190 | 191 | ## Contributing 192 | 193 | Bug reports and pull requests are welcome on GitHub at https://github.com/cmer/ultimate_turbo_modal. 194 | 195 | 196 | ## License 197 | 198 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 199 | -------------------------------------------------------------------------------- /demo-app/public/406-unsupported-browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Your browser is not supported (406 Not Acceptable) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

Your browser is not supported.
Please upgrade your browser to continue.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /demo-app/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | We’re sorry, but something went wrong (500 Internal Server Error) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

We’re sorry, but something went wrong.
If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /lib/generators/ultimate_turbo_modal/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators" 4 | require "pathname" # Needed for Pathname helper 5 | require_relative "base" 6 | 7 | module UltimateTurboModal 8 | module Generators 9 | class InstallGenerator < UltimateTurboModal::Generators::Base 10 | source_root File.expand_path("templates", __dir__) 11 | 12 | desc "Installs UltimateTurboModal: copies initializer/flavor, sets up JS, registers Stimulus controller, adds Turbo Frame." 13 | 14 | # Step 1: Determine CSS framework flavor 15 | def determine_framework_flavor 16 | @framework = prompt_for_flavor 17 | end 18 | 19 | # Step 2: Setup Javascript Dependencies (Yarn/npm/Bun or Importmap) 20 | def setup_javascript_dependencies 21 | add_js_dependency 22 | end 23 | 24 | # Step 3: Register Stimulus Controller 25 | def setup_stimulus_controller 26 | stimulus_controller_path = rails_root_join("app", "javascript", "controllers", "index.js") 27 | controller_package = "ultimate_turbo_modal" # Package name where the controller is defined 28 | controller_name = "UltimateTurboModalController" # The exported controller class name 29 | stimulus_identifier = "modal" # The identifier for application.register 30 | 31 | import_line = "import { #{controller_name} } from \"#{controller_package}\"\n" 32 | register_line = "application.register(\"#{stimulus_identifier}\", #{controller_name})\n" 33 | 34 | say "\nAttempting to register Stimulus controller in #{stimulus_controller_path}...", :yellow 35 | 36 | unless File.exist?(stimulus_controller_path) 37 | say "❌ Stimulus controllers index file not found at #{stimulus_controller_path}.", :red 38 | say " Please manually add the following lines to your Stimulus setup:", :yellow 39 | say " #{import_line.strip}", :cyan 40 | say " #{register_line.strip}\n", :cyan 41 | return # Exit this method if the file doesn't exist 42 | end 43 | 44 | # Read the file content to check if lines already exist 45 | file_content = File.read(stimulus_controller_path) 46 | 47 | # Insert the import statement after the last existing import or a common marker 48 | # Using a regex to find the Stimulus import is often reliable 49 | import_anchor = /import .* from "@hotwired\/stimulus"\n/ 50 | if file_content.match?(import_anchor) && !file_content.include?(import_line) 51 | insert_into_file stimulus_controller_path, import_line, after: import_anchor 52 | say "✅ Added import statement.", :green 53 | elsif !file_content.include?(import_line) 54 | # Fallback: insert at the beginning if Stimulus import wasn't found (less ideal) 55 | insert_into_file stimulus_controller_path, import_line, before: /import/ 56 | say "✅ Added import statement (fallback position).", :green 57 | else 58 | say "⏩ Import statement already exists.", :blue 59 | end 60 | 61 | 62 | # Insert the register statement after Application.start() 63 | register_anchor = /Application\.start$$$$\n/ 64 | if file_content.match?(register_anchor) && !file_content.include?(register_line) 65 | insert_into_file stimulus_controller_path, register_line, after: register_anchor 66 | say "✅ Added controller registration.", :green 67 | elsif !file_content.include?(register_line) 68 | say "❌ Could not find `Application.start()` line to insert registration after.", :red 69 | say " Please manually add this line after your Stimulus application starts:", :yellow 70 | say " #{register_line.strip}\n", :cyan 71 | else 72 | say "⏩ Controller registration already exists.", :blue 73 | end 74 | end 75 | 76 | # Step 4: Add Turbo Frame to Layout 77 | def add_modal_turbo_frame 78 | layout_path = rails_root_join("app", "views", "layouts", "application.html.erb") 79 | frame_tag = "<%= turbo_frame_tag \"modal\" %>\n" 80 | body_tag_regex = /\s*\n?/ 81 | 82 | say "\nAttempting to add modal Turbo Frame to #{layout_path}...", :yellow 83 | 84 | unless File.exist?(layout_path) 85 | say "❌ Layout file not found at #{layout_path}.", :red 86 | say " Please manually add the following line inside the tag of your main layout:", :yellow 87 | say " #{frame_tag.strip}\n", :cyan 88 | return 89 | end 90 | 91 | file_content = File.read(layout_path) 92 | 93 | if file_content.include?(frame_tag.strip) 94 | say "⏩ Turbo Frame tag already exists.", :blue 95 | elsif file_content.match?(body_tag_regex) 96 | # Insert after the opening body tag 97 | insert_into_file layout_path, " #{frame_tag}", after: body_tag_regex # Add indentation 98 | say "✅ Added Turbo Frame tag inside the .", :green 99 | else 100 | say "❌ Could not find the opening tag in #{layout_path}.", :red 101 | say " Please manually add the following line inside the tag:", :yellow 102 | say " #{frame_tag.strip}\n", :cyan 103 | end 104 | end 105 | 106 | 107 | def copy_initializer_and_flavor 108 | say "\nCreating initializer for `#{@framework}` flavor...", :green 109 | copy_file "ultimate_turbo_modal.rb", "config/initializers/ultimate_turbo_modal.rb" 110 | gsub_file "config/initializers/ultimate_turbo_modal.rb", "FLAVOR", ":#{@framework}" 111 | say "✅ Initializer created at config/initializers/ultimate_turbo_modal.rb" 112 | 113 | say "Copying flavor file...", :green 114 | copy_file "flavors/#{@framework}.rb", "config/initializers/ultimate_turbo_modal_#{@framework}.rb" 115 | say "✅ Flavor file copied to config/initializers/ultimate_turbo_modal_#{@framework}.rb\n" 116 | end 117 | 118 | def show_readme 119 | say "\nUltimateTurboModal installation complete!\n", :magenta 120 | say "Please review the initializer files, ensure JS is set up, and check your layout file.", :magenta 121 | say "Don't forget to restart your Rails server!", :yellow 122 | end 123 | 124 | private 125 | 126 | def prompt_for_flavor 127 | say "Which CSS framework does your project use?\n", :blue 128 | options = [] 129 | flavors_dir = File.expand_path("templates/flavors", __dir__) 130 | 131 | options = Dir.glob(File.join(flavors_dir, "*.rb")).map { |file| File.basename(file, ".rb") }.sort 132 | if options.include?("custom") 133 | options.delete("custom") 134 | options << "custom" 135 | end 136 | 137 | if options.empty? 138 | raise Thor::Error, "No flavor templates found in #{flavors_dir}!" 139 | end 140 | 141 | say "Options:" 142 | options.each_with_index do |option, index| 143 | say "#{index + 1}. #{option}" 144 | end 145 | 146 | loop do 147 | print "\nEnter the number: " 148 | framework_choice = ask("").chomp.strip 149 | framework_id = framework_choice.to_i - 1 150 | 151 | if framework_id >= 0 && framework_id < options.size 152 | return options[framework_id] 153 | else 154 | say "\nInvalid option '#{framework_choice}'. Please enter a number between 1 and #{options.size}.", :red 155 | end 156 | end 157 | end 158 | 159 | def uses_importmaps? 160 | File.exist?(rails_root_join("config", "importmap.rb")) 161 | end 162 | 163 | def uses_javascript_bundler? 164 | File.exist?(rails_root_join("package.json")) 165 | end 166 | 167 | def uses_yarn? 168 | File.exist?(rails_root_join("yarn.lock")) 169 | end 170 | 171 | def uses_npm? 172 | File.exist?(rails_root_join("package-lock.json")) && !uses_yarn? && !uses_bun? 173 | end 174 | 175 | def uses_bun? 176 | File.exist?(rails_root_join("bun.lockb")) 177 | end 178 | 179 | def rails_root_join(*args) 180 | Pathname.new(destination_root).join(*args) 181 | end 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /javascript/modal_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import { enter, leave } from 'el-transition'; 3 | import { createFocusTrap } from 'focus-trap'; 4 | 5 | // This placeholder will be replaced by rollup 6 | const PACKAGE_VERSION = '__PACKAGE_VERSION__'; 7 | 8 | export default class extends Controller { 9 | static targets = ["container", "content", "overlay", "outer"] 10 | static values = { 11 | advanceUrl: String, 12 | allowedClickOutsideSelector: String 13 | } 14 | 15 | connect() { 16 | let _this = this; 17 | 18 | this.#checkVersions(); 19 | 20 | // Initialize focus trap instance variable 21 | this.focusTrapInstance = null; 22 | 23 | // Store original body styles for scroll lock 24 | this.originalBodyOverflow = null; 25 | this.scrollPosition = 0; 26 | 27 | this.showModal(); 28 | 29 | this.turboFrame = this.element.closest('turbo-frame'); 30 | 31 | // hide modal when back button is pressed 32 | window.addEventListener('popstate', function (event) { 33 | if (_this.#hasHistoryAdvanced()) _this.#resetModalElement(); 34 | }); 35 | 36 | window.modal = this; 37 | } 38 | 39 | disconnect() { 40 | // Clean up focus trap if it exists 41 | if (this.focusTrapInstance) { 42 | this.#deactivateFocusTrap(); 43 | } 44 | window.modal = undefined; 45 | } 46 | 47 | showModal() { 48 | // Lock body scroll 49 | this.#lockBodyScroll(); 50 | 51 | // Apply transitions to both overlay and outer elements 52 | Promise.all([ 53 | enter(this.overlayTarget), 54 | enter(this.outerTarget) 55 | ]).then(() => { 56 | // Activate focus trap after the modal transition is complete 57 | this.#activateFocusTrap(); 58 | }); 59 | 60 | if (this.advanceUrlValue && !this.#hasHistoryAdvanced()) { 61 | this.#setHistoryAdvanced(); 62 | history.pushState({}, "", this.advanceUrlValue); 63 | } 64 | } 65 | 66 | // if we advanced history, go back, which will trigger 67 | // hiding the model. Otherwise, hide the modal directly. 68 | hideModal() { 69 | // Prevent multiple calls to hideModal. 70 | // Sometimes some events are double-triggered. 71 | if (this.hidingModal) return 72 | this.hidingModal = true; 73 | 74 | let event = new Event('modal:closing', { cancelable: true }); 75 | this.turboFrame.dispatchEvent(event); 76 | if (event.defaultPrevented) { 77 | this.hidingModal = false; 78 | return 79 | } 80 | 81 | // Deactivate focus trap only after confirming modal will close 82 | if (this.focusTrapInstance) { 83 | this.#deactivateFocusTrap(); 84 | } 85 | 86 | this.#resetModalElement(); 87 | 88 | event = new Event('modal:closed', { cancelable: false }); 89 | this.turboFrame.dispatchEvent(event); 90 | 91 | if (this.#hasHistoryAdvanced()) 92 | history.back(); 93 | } 94 | 95 | hide() { 96 | this.hideModal(); 97 | } 98 | 99 | refreshPage() { 100 | window.Turbo.visit(window.location.href, { action: "replace" }); 101 | } 102 | 103 | // hide modal on successful form submission 104 | // action: "turbo:submit-end->modal#submitEnd" 105 | submitEnd(e) { 106 | if (e.detail.success) this.hideModal(); 107 | } 108 | 109 | // hide modal when clicking ESC 110 | // action: "keyup@window->modal#closeWithKeyboard" 111 | closeWithKeyboard(e) { 112 | if (e.code == "Escape") this.hideModal(); 113 | } 114 | 115 | // hide modal when clicking outside of modal 116 | // action: "click@window->modal#outsideModalClicked" 117 | outsideModalClicked(e) { 118 | let clickedInsideModal = !document.contains(e.target) || this.contentTarget.contains(e.target) || this.contentTarget == e.target; 119 | let clickedAllowedSelector = this.allowedClickOutsideSelectorValue && this.allowedClickOutsideSelectorValue !== '' && e.target.closest(this.allowedClickOutsideSelectorValue) != null; 120 | 121 | if (!clickedInsideModal && !clickedAllowedSelector) 122 | this.hideModal(); 123 | } 124 | 125 | #resetModalElement() { 126 | // Unlock body scroll 127 | this.#unlockBodyScroll(); 128 | 129 | // Apply leave transitions to both overlay and outer elements 130 | Promise.all([ 131 | leave(this.overlayTarget), 132 | leave(this.outerTarget) 133 | ]).then(() => { 134 | this.turboFrame.removeAttribute("src"); 135 | this.containerTarget.remove(); 136 | this.#resetHistoryAdvanced(); 137 | }); 138 | } 139 | 140 | #hasHistoryAdvanced() { 141 | return document.body.getAttribute("data-turbo-modal-history-advanced") == "true" 142 | } 143 | 144 | #setHistoryAdvanced() { 145 | return document.body.setAttribute("data-turbo-modal-history-advanced", "true") 146 | } 147 | 148 | #resetHistoryAdvanced() { 149 | document.body.removeAttribute("data-turbo-modal-history-advanced"); 150 | } 151 | 152 | #checkVersions() { 153 | const gemVersion = this.element.dataset.utmrVersion; 154 | 155 | if (!gemVersion) { 156 | // If the attribute isn't set (e.g., in production), skip the check. 157 | return; 158 | } 159 | 160 | if (gemVersion !== PACKAGE_VERSION) { 161 | console.warn( 162 | `[UltimateTurboModal] Version Mismatch!\n\nGem Version: ${gemVersion}\nJS Version: ${PACKAGE_VERSION}\n\nPlease ensure both the 'ultimate_turbo_modal' gem and the 'ultimate-turbo-modal' npm package are updated to the same version.\nElement:`, this.element 163 | ); 164 | } 165 | } 166 | 167 | #activateFocusTrap() { 168 | try { 169 | // Create focus trap if it doesn't exist 170 | if (!this.focusTrapInstance) { 171 | this.focusTrapInstance = createFocusTrap(this.contentTarget, { 172 | allowOutsideClick: true, 173 | escapeDeactivates: false, // Let our ESC handler manage this 174 | fallbackFocus: this.contentTarget, 175 | returnFocusOnDeactivate: true, 176 | clickOutsideDeactivates: false, // Let our click outside handler manage this 177 | preventScroll: false, 178 | initialFocus: () => { 179 | // Try to focus the first focusable element, or the modal itself 180 | const firstFocusable = this.contentTarget.querySelector( 181 | 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' 182 | ); 183 | return firstFocusable || this.contentTarget; 184 | } 185 | }); 186 | } 187 | 188 | // Activate the trap 189 | this.focusTrapInstance.activate(); 190 | } catch (error) { 191 | console.error('[UltimateTurboModal] Failed to activate focus trap:', error); 192 | // Don't break the modal if focus trap fails 193 | this.focusTrapInstance = null; 194 | } 195 | } 196 | 197 | #deactivateFocusTrap() { 198 | try { 199 | if (this.focusTrapInstance && this.focusTrapInstance.active) { 200 | this.focusTrapInstance.deactivate(); 201 | } 202 | } catch (error) { 203 | console.error('[UltimateTurboModal] Failed to deactivate focus trap:', error); 204 | } finally { 205 | this.focusTrapInstance = null; 206 | } 207 | } 208 | 209 | #lockBodyScroll() { 210 | // Store the current scroll position 211 | this.scrollPosition = window.pageYOffset || document.documentElement.scrollTop; 212 | 213 | // Store the original overflow style 214 | this.originalBodyOverflow = document.body.style.overflow; 215 | 216 | // Prevent scrolling on the body 217 | document.body.style.overflow = 'hidden'; 218 | document.body.style.position = 'fixed'; 219 | document.body.style.top = `-${this.scrollPosition}px`; 220 | document.body.style.width = '100%'; 221 | } 222 | 223 | #unlockBodyScroll() { 224 | // Restore the original overflow style 225 | if (this.originalBodyOverflow !== null) { 226 | document.body.style.overflow = this.originalBodyOverflow; 227 | } else { 228 | document.body.style.removeProperty('overflow'); 229 | } 230 | 231 | // Remove position styles 232 | document.body.style.removeProperty('position'); 233 | document.body.style.removeProperty('top'); 234 | document.body.style.removeProperty('width'); 235 | 236 | // Restore the scroll position 237 | window.scrollTo(0, this.scrollPosition); 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /demo-app/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The change you wanted was rejected (422 Unprocessable Entity) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

The change you wanted was rejected. Maybe you tried to change something you didn’t have access to. If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /lib/ultimate_turbo_modal/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UltimateTurboModal::Base < Phlex::HTML 4 | prepend Phlex::DeferredRenderWithMainContent 5 | # @param advance [Boolean] Whether to update the browser history when opening and closing the modal 6 | # @param allowed_click_outside_selector [String] CSS selectors for elements that are allowed to be clicked outside of the modal without dismissing the modal 7 | # @param close_button [Boolean] Whether to show a close button 8 | # @param close_button_data_action [String] `data-action` attribute for the close button 9 | # @param close_button_sr_label [String] Close button label for screen readers 10 | # @param footer_divider [Boolean] Whether to show a divider between the main content and the footer 11 | # @param header_divider [Boolean] Whether to show a divider between the header and the main content 12 | # @param padding [Boolean] Whether to add padding around the modal content 13 | # @param request [ActionDispatch::Request] The current Rails request object 14 | # @param content_div_data [Hash] `data` attribute for the div where the modal content will be rendered 15 | # @param title [String] The title of the modal 16 | def initialize( 17 | advance: UltimateTurboModal.configuration.advance, 18 | allowed_click_outside_selector: UltimateTurboModal.configuration.allowed_click_outside_selector, 19 | close_button: UltimateTurboModal.configuration.close_button, 20 | close_button_data_action: "modal#hideModal", 21 | close_button_sr_label: "Close modal", 22 | footer_divider: UltimateTurboModal.configuration.footer_divider, 23 | header: UltimateTurboModal.configuration.header, 24 | header_divider: UltimateTurboModal.configuration.header_divider, 25 | padding: UltimateTurboModal.configuration.padding, 26 | content_div_data: nil, 27 | request: nil, title: nil 28 | ) 29 | @advance = !!advance 30 | @advance_url = advance if advance.present? && advance.is_a?(String) 31 | @allowed_click_outside_selector = allowed_click_outside_selector 32 | @close_button = close_button 33 | @close_button_data_action = close_button_data_action 34 | @close_button_sr_label = close_button_sr_label 35 | @footer_divider = footer_divider 36 | @header = header 37 | @header_divider = header_divider 38 | @padding = padding 39 | @content_div_data = content_div_data 40 | @request = request 41 | @title = title 42 | 43 | unless self.class.include?(Turbo::FramesHelper) 44 | self.class.include Turbo::FramesHelper 45 | self.class.include Turbo::StreamsHelper 46 | self.class.include Phlex::Rails::Helpers::ContentTag 47 | self.class.include Phlex::Rails::Helpers::Routes 48 | self.class.include Phlex::Rails::Helpers::Tag 49 | end 50 | end 51 | 52 | def view_template(&block) 53 | if turbo_frame? 54 | turbo_frame_tag("modal") do 55 | modal(&block) 56 | end 57 | elsif turbo_stream? 58 | Turbo::StreamsHelper.turbo_stream_action_tag("update", target: "modal") do 59 | modal(&block) 60 | end 61 | else 62 | render block 63 | end 64 | end 65 | 66 | def title(&block) 67 | @title_block = block 68 | end 69 | 70 | def footer(&block) 71 | @footer = block 72 | end 73 | 74 | private 75 | 76 | attr_accessor :request, :allowed_click_outside_selector, :content_div_data 77 | 78 | def padding? = !!@padding 79 | 80 | def close_button? = !!@close_button 81 | 82 | def title_block? = !!@title_block 83 | 84 | def title? = !!@title 85 | 86 | def header? = !!@header 87 | 88 | def footer? = @footer.present? 89 | 90 | def header_divider? = !!@header_divider && (@title_block.present? || title?) 91 | 92 | def footer_divider? = !!@footer_divider && footer? 93 | 94 | def turbo_stream? = !!request&.format&.turbo_stream? 95 | 96 | def turbo_frame? = !!request&.headers&.key?("Turbo-Frame") 97 | 98 | def turbo? = turbo_stream? || turbo_frame? 99 | 100 | def advance? = !!@advance && !!@advance_url 101 | 102 | def advance_url 103 | return nil unless !!@advance 104 | @advance_url || request&.original_url 105 | end 106 | 107 | # Wraps yielded content in a Turbo Frame if the current request originated from a Turbo Frame 108 | def maybe_turbo_frame(frame_id, &block) 109 | if turbo_frame? 110 | turbo_frame_tag(frame_id, &block) 111 | else 112 | yield 113 | end 114 | end 115 | 116 | def respond_to_missing?(method, include_private = false) 117 | self.class.included_modules.any? { |mod| mod.instance_methods.include?(method) } || super 118 | end 119 | 120 | ## HTML components 121 | 122 | def modal(&block) 123 | styles 124 | outer_divs do 125 | div_content do 126 | div_header 127 | div_main(&block) 128 | div_footer if footer? 129 | end 130 | end 131 | end 132 | 133 | def styles 134 | style do 135 | str = "html:has(dialog[open]),html:has(#modal-container) {overflow: hidden;} html {scrollbar-gutter: stable;}".html_safe 136 | respond_to?(:unsafe_raw) ? unsafe_raw(str) : raw(str) 137 | end 138 | end 139 | 140 | def outer_divs(&block) 141 | div_dialog do 142 | div_overlay 143 | div_outer_dialog do 144 | div_inner(&block) 145 | end 146 | end 147 | end 148 | 149 | def div_dialog(&block) 150 | data_attributes = { 151 | controller: "modal", 152 | modal_target: "container", 153 | modal_advance_url_value: advance_url, 154 | modal_allowed_click_outside_selector_value: allowed_click_outside_selector, 155 | action: "turbo:submit-end->modal#submitEnd keyup@window->modal#closeWithKeyboard click@window->modal#outsideModalClicked click->modal#outsideModalClicked", 156 | padding: padding?.to_s, 157 | title: title?.to_s, 158 | header: header?.to_s, 159 | close_button: close_button?.to_s, 160 | header_divider: header_divider?.to_s, 161 | footer_divider: footer_divider?.to_s 162 | } 163 | 164 | if defined?(Rails) && (Rails.env.development? || Rails.env.test?) 165 | data_attributes[:utmr_version] = UltimateTurboModal::VERSION 166 | end 167 | 168 | div(id: "modal-container", 169 | class: self.class::DIV_MODAL_CONTAINER_CLASSES, 170 | role: "dialog", 171 | aria: { 172 | modal: true, 173 | labelledby: "modal-title-h" 174 | }, 175 | data: data_attributes, &block) 176 | end 177 | 178 | def div_overlay 179 | div(id: "modal-overlay", class: self.class::DIV_OVERLAY_CLASSES, data: { 180 | modal_target: "overlay", 181 | transition_enter: self.class::TRANSITIONS[:overlay][:enter][:animation], 182 | transition_enter_start: self.class::TRANSITIONS[:overlay][:enter][:start], 183 | transition_enter_end: self.class::TRANSITIONS[:overlay][:enter][:end], 184 | transition_leave: self.class::TRANSITIONS[:overlay][:leave][:animation], 185 | transition_leave_start: self.class::TRANSITIONS[:overlay][:leave][:start], 186 | transition_leave_end: self.class::TRANSITIONS[:overlay][:leave][:end] 187 | }) 188 | end 189 | 190 | def div_outer_dialog(&block) 191 | div(id: "modal-outer", class: self.class::DIV_DIALOG_CLASSES, data: { 192 | modal_target: "outer", 193 | transition_enter: self.class::TRANSITIONS[:dialog][:enter][:animation], 194 | transition_enter_start: self.class::TRANSITIONS[:dialog][:enter][:start], 195 | transition_enter_end: self.class::TRANSITIONS[:dialog][:enter][:end], 196 | transition_leave: self.class::TRANSITIONS[:dialog][:leave][:animation], 197 | transition_leave_start: self.class::TRANSITIONS[:dialog][:leave][:start], 198 | transition_leave_end: self.class::TRANSITIONS[:dialog][:leave][:end] 199 | }, &block) 200 | end 201 | 202 | def div_inner(&block) 203 | maybe_turbo_frame("modal-inner") do 204 | div(id: "modal-inner", class: self.class::DIV_INNER_CLASSES, data: content_div_data, &block) 205 | end 206 | end 207 | 208 | def div_content(&block) 209 | data = (content_div_data || {}).merge({modal_target: "content"}) 210 | div(id: "modal-content", class: self.class::DIV_CONTENT_CLASSES, data: data, &block) 211 | end 212 | 213 | def div_main(&block) 214 | div(id: "modal-main", class: self.class::DIV_MAIN_CLASSES, &block) 215 | end 216 | 217 | def div_header(&block) 218 | div(id: "modal-header", class: self.class::DIV_HEADER_CLASSES) do 219 | div_title 220 | button_close 221 | end 222 | end 223 | 224 | def div_title 225 | div(id: "modal-title", class: self.class::DIV_TITLE_CLASSES) do 226 | if @title_block.present? 227 | render @title_block 228 | else 229 | h3(id: "modal-title-h", class: self.class::DIV_TITLE_H_CLASSES) { @title } 230 | end 231 | end 232 | end 233 | 234 | def div_footer 235 | div(id: "modal-footer", class: self.class::DIV_FOOTER_CLASSES) do 236 | render @footer 237 | end 238 | end 239 | 240 | def button_close 241 | div(id: "modal-close", class: self.class::BUTTON_CLOSE_CLASSES) do 242 | close_button_tag do 243 | icon_close 244 | span(class: self.class::BUTTON_CLOSE_SR_ONLY_CLASSES) { @close_button_sr_label } 245 | end 246 | end 247 | end 248 | 249 | def close_button_tag(&block) 250 | button(type: "button", 251 | aria: {label: "close"}, 252 | class: self.class::CLOSE_BUTTON_TAG_CLASSES, 253 | data: { 254 | action: @close_button_data_action 255 | }, &block) 256 | end 257 | 258 | def icon_close 259 | svg(class: self.class::ICON_CLOSE_CLASSES, fill: "currentColor", viewBox: "0 0 20 20") do |s| 260 | s.path( 261 | fill_rule: "evenodd", 262 | d: "M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z", 263 | clip_rule: "evenodd" 264 | ) 265 | end 266 | end 267 | end 268 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Overview 6 | 7 | Ultimate Turbo Modal (UTMR) is a full-featured modal implementation for Rails applications using Turbo, Stimulus, and Hotwire. It consists of both a Ruby gem and an npm package that work together to provide seamless modal functionality with proper focus management, history manipulation, and customizable styling. 8 | 9 | ## Architecture 10 | 11 | ### High-Level Design 12 | 13 | The system follows a separation of concerns between server-side rendering (Ruby/Rails) and client-side behavior (JavaScript/Stimulus): 14 | 15 | 1. **Server-Side (Ruby Gem)**: Handles HTML generation, configuration management, and Rails integration 16 | 2. **Client-Side (JavaScript Package)**: Manages modal behavior, focus trapping, scroll locking, and Turbo interactions 17 | 3. **Communication Layer**: Uses Turbo Frames, Turbo Streams, and data attributes to coordinate between server and client 18 | 19 | ### Core Components 20 | 21 | #### Ruby Gem Architecture 22 | 23 | - **Module Structure**: `UltimateTurboModal` is the main module that delegates to configuration and instantiates modal classes 24 | - **Base Class**: `UltimateTurboModal::Base` extends `Phlex::HTML` for component-based HTML generation 25 | - **Configuration System**: Centralized configuration with validation and type checking 26 | - **Flavor System**: CSS framework-specific implementations (Tailwind, Vanilla, Custom) that define styling classes 27 | - **Rails Integration**: Via Railtie that injects helpers into ActionController and ActionView 28 | 29 | #### JavaScript Architecture 30 | 31 | - **Stimulus Controller**: `modal_controller.js` handles all modal interactions 32 | - **Dependencies**: 33 | - `el-transition`: For smooth enter/leave animations 34 | - `focus-trap`: For accessibility-compliant focus management 35 | - `idiomorph`: For intelligent DOM morphing to prevent flicker 36 | - **Global Registration**: Modal instance exposed as `window.modal` for programmatic access 37 | - **Turbo Integration**: Custom stream actions and frame handling 38 | 39 | ## Detailed Implementation 40 | 41 | ### Ruby Components 42 | 43 | #### `UltimateTurboModal` Module (`lib/ultimate_turbo_modal.rb`) 44 | - Entry point for the gem 45 | - Factory method `new` creates modal instances 46 | - `modal_class` method dynamically loads flavor classes based on configuration 47 | - Extends self for module-level methods 48 | 49 | #### `Base` Class (`lib/ultimate_turbo_modal/base.rb`) 50 | - **Inheritance**: `Phlex::HTML` for HTML generation with Ruby DSL 51 | - **Mixins**: 52 | - `Phlex::DeferredRenderWithMainContent` for content block handling 53 | - Dynamic inclusion of Turbo helpers (FramesHelper, StreamsHelper) 54 | - **Key Methods**: 55 | - `initialize`: Accepts configuration options with defaults from global config 56 | - `view_template`: Main rendering method that wraps content in appropriate Turbo tags 57 | - `modal`: Orchestrates HTML structure generation 58 | - `div_*` methods: Generate specific HTML elements with proper classes and attributes 59 | - **Data Attributes**: Passes configuration to JavaScript via data attributes on the container div 60 | 61 | #### `Configuration` Class (`lib/ultimate_turbo_modal/configuration.rb`) 62 | - **Options with Validation**: 63 | - `flavor`: Symbol/String for CSS framework (default: `:tailwind`) 64 | - `close_button`: Boolean for showing close button 65 | - `advance`: Boolean for browser history manipulation 66 | - `padding`: Boolean or String for content padding 67 | - `header`, `header_divider`, `footer_divider`: Boolean display options 68 | - `allowed_click_outside_selector`: Array of CSS selectors that won't dismiss modal 69 | - **Type Safety**: Each setter validates input types and raises `ArgumentError` on invalid values 70 | 71 | #### Rails Helpers 72 | 73 | ##### `ViewHelper` (`helpers/view_helper.rb`) 74 | - `modal` method: Renders modal component with current request context 75 | - Instantiates `UltimateTurboModal` with passed options 76 | 77 | ##### `ControllerHelper` (`helpers/controller_helper.rb`) 78 | - `inside_modal?` method: Detects if request is within modal context 79 | - Uses `Turbo-Frame` header to determine modal context 80 | - Exposed as helper method to views 81 | 82 | ##### `StreamHelper` (`helpers/stream_helper.rb`) 83 | - `modal` method: Generates Turbo Stream actions for modal control 84 | - Supports `:close` and `:hide` messages 85 | - Creates custom `modal` stream action with message attribute 86 | 87 | #### Flavor System 88 | - Located in generator templates (`lib/generators/ultimate_turbo_modal/templates/flavors/`) 89 | - Each flavor defines CSS class constants for modal elements: 90 | - `DIV_DIALOG_CLASSES`, `DIV_OVERLAY_CLASSES`, `DIV_OUTER_CLASSES`, etc. 91 | - Flavors inherit from `Base` and override class constants 92 | - Supports Tailwind (v3 and v4), Vanilla CSS, and Custom implementations 93 | 94 | ### JavaScript Components 95 | 96 | #### Modal Controller (`javascript/modal_controller.js`) 97 | 98 | ##### Stimulus Configuration 99 | - **Targets**: `container`, `content` 100 | - **Values**: `advanceUrl`, `allowedClickOutsideSelector` 101 | - **Actions**: Responds to keyboard, click, and Turbo events 102 | 103 | ##### Lifecycle Methods 104 | - **`connect()`**: 105 | - Initializes focus trap and scroll lock variables 106 | - Shows modal immediately 107 | - Sets up popstate listener for browser back button 108 | - Exposes controller as `window.modal` 109 | - **`disconnect()`**: Cleans up focus trap and global reference 110 | 111 | ##### Core Functionality 112 | 113 | ###### Modal Display 114 | - **`showModal()`**: 115 | - Locks body scroll 116 | - Triggers enter transition 117 | - Activates focus trap after transition 118 | - Pushes history state if `advance` is enabled 119 | - **`hideModal()`**: 120 | - Prevents double-hiding with `hidingModal` flag 121 | - Dispatches cancelable `modal:closing` event 122 | - Deactivates focus trap 123 | - Triggers leave transition 124 | - Cleans up DOM and history 125 | - Dispatches `modal:closed` event 126 | 127 | ###### Focus Management (`#activateFocusTrap()`, `#deactivateFocusTrap()`) 128 | - Creates focus trap with sensible defaults 129 | - Finds first focusable element or focuses modal itself 130 | - Handles errors gracefully without breaking modal 131 | - Respects modal's own keyboard/click handlers 132 | 133 | ###### Scroll Locking (`#lockBodyScroll()`, `#unlockBodyScroll()`) 134 | - Stores current scroll position 135 | - Sets body to `position: fixed` to prevent scroll 136 | - Restores original overflow and scroll position on unlock 137 | - Prevents layout shift during modal display 138 | 139 | ###### History Management 140 | - Uses data attribute on body to track history state 141 | - `#hasHistoryAdvanced()`, `#setHistoryAdvanced()`, `#resetHistoryAdvanced()` 142 | - Coordinates with browser back button via popstate listener 143 | 144 | ###### Event Handlers 145 | - **`submitEnd()`**: Closes modal on successful form submission 146 | - **`closeWithKeyboard()`**: ESC key handler 147 | - **`outsideModalClicked()`**: Dismisses modal on outside clicks unless allowed selector matches 148 | 149 | ###### Version Checking 150 | - `#checkVersions()`: Warns about gem/npm version mismatches in development 151 | - Helps developers keep packages in sync 152 | 153 | #### Main Package Entry (`javascript/index.js`) 154 | 155 | ##### Turbo Stream Actions 156 | - Registers custom `modal` stream action 157 | - Handles `hide` and `close` messages via `window.modal` reference 158 | 159 | ##### Turbo Frame Integration 160 | - **`handleTurboFrameMissing`**: Escapes modal on redirects 161 | - **`handleTurboBeforeFrameRender`**: Uses Idiomorph for intelligent morphing 162 | - Prevents flicker and unwanted animations 163 | - Morphs only innerHTML to preserve modal container 164 | 165 | ### Modal Lifecycle Flow 166 | 167 | 1. **Trigger**: Link/form targets `data-turbo-frame="modal"` 168 | 2. **Request**: Rails controller renders modal content 169 | 3. **Response**: 170 | - If Turbo Frame request: Wrapped in `` 171 | - If Turbo Stream: Wrapped in stream action targeting modal 172 | 4. **Client Processing**: 173 | - Turbo updates modal frame content 174 | - Stimulus controller connects and shows modal 175 | - Focus trap activates, scroll locks 176 | - History state pushed (if enabled) 177 | 5. **Interaction**: 178 | - User interacts with modal content 179 | - Form submissions handled via Turbo 180 | - ESC key, close button, or outside clicks trigger hiding 181 | 6. **Dismissal**: 182 | - `modal:closing` event fired (cancelable) 183 | - Focus trap deactivates 184 | - Leave transition plays 185 | - DOM cleaned up 186 | - History restored 187 | - `modal:closed` event fired 188 | 189 | ## Project Structure 190 | 191 | - **Ruby Gem**: Main gem code in `/lib/ultimate_turbo_modal/` 192 | - `base.rb`: Core modal component (Phlex-based) 193 | - `configuration.rb`: Global configuration management 194 | - `helpers/`: Rails helpers for views and controllers 195 | - `railtie.rb`: Rails integration setup 196 | - Generators in `/lib/generators/` for installation 197 | 198 | - **JavaScript Package**: Located in `/javascript/` 199 | - `modal_controller.js`: Stimulus controller for modal behavior 200 | - `index.js`: Main entry point with Turbo integration 201 | - `styles/`: CSS files for vanilla styling 202 | - Distributed files built to `/javascript/dist/` 203 | 204 | - **Demo Application**: Located in `/demo-app/` 205 | - `Procfile.dev`: Development process file for overmind/foreman 206 | - `bin/dev`: Development script for starting the demo app 207 | 208 | ## Common Development Commands 209 | 210 | ### JavaScript Development (run from `/javascript/` directory) 211 | ```bash 212 | # Install dependencies 213 | yarn install 214 | 215 | # Build the JavaScript package 216 | yarn build 217 | 218 | # Release to npm (updates version and publishes) 219 | yarn release 220 | ``` 221 | 222 | ### Ruby Gem Development (run from root) 223 | ```bash 224 | # Run tests 225 | bundle exec rake test 226 | 227 | # Build gem 228 | gem build ultimate_turbo_modal.gemspec 229 | 230 | # Release process (Ruby + JS) 231 | ./script/build_and_release.sh 232 | ``` 233 | 234 | ## Architecture & Key Concepts 235 | 236 | ### Modal Options System 237 | Options can be set at three levels: 238 | 1. **Global defaults** via `UltimateTurboModal.configure` in configuration.rb 239 | 2. **Instance options** passed to the `modal` helper 240 | 3. **Runtime values** via blocks (for title/footer) 241 | 242 | Current options: `advance`, `close_button`, `header`, `header_divider`, `padding`, `title` 243 | 244 | ### Stimulus Controller Values 245 | The modal controller uses Stimulus values to receive configuration: 246 | - `advanceUrl`: URL for browser history manipulation 247 | - `allowedClickOutsideSelector`: CSS selectors that won't dismiss modal when clicked 248 | 249 | ### Modal Lifecycle 250 | 1. Link clicked with `data-turbo-frame="modal"` 251 | 2. Turbo loads content into the modal frame 252 | 3. Stimulus controller connects and shows modal 253 | 4. Modal can be dismissed via: ESC key, close button, clicking outside, or programmatically 254 | 255 | ### Adding New Configuration Options 256 | When adding a new option: 257 | 258 | 1. Add to `Configuration` class with getter/setter methods 259 | 2. Add to `UltimateTurboModal` delegators 260 | 3. Add to `Base#initialize` parameters with default from configuration 261 | 4. Pass to JavaScript via data attributes in `Base#div_dialog` 262 | 5. Add as Stimulus value in `modal_controller.js` 263 | 6. Update README.md options table 264 | 265 | ## Testing Approach 266 | - JavaScript: No test framework currently set up 267 | - Ruby: Use standard Rails testing practices 268 | - Manual testing via the demo app (located in `./demo-app`) 269 | --------------------------------------------------------------------------------