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.
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 |
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.
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 |
--------------------------------------------------------------------------------