turbo_overlay 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +436 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +330 -0
  5. data/Rakefile +35 -0
  6. data/app/assets/stylesheets/turbo_overlay.css +234 -0
  7. data/app/javascript/turbo_overlay/dialog_utils.js +46 -0
  8. data/app/javascript/turbo_overlay/hint.js +670 -0
  9. data/app/javascript/turbo_overlay/history.js +184 -0
  10. data/app/javascript/turbo_overlay/index.js +53 -0
  11. data/app/javascript/turbo_overlay/options.js +152 -0
  12. data/app/javascript/turbo_overlay/overlay_controller.js +882 -0
  13. data/app/javascript/turbo_overlay/popover_position.js +64 -0
  14. data/app/javascript/turbo_overlay/setup.js +885 -0
  15. data/app/javascript/turbo_overlay/stack_controller.js +131 -0
  16. data/app/javascript/turbo_overlay/submit_close.js +49 -0
  17. data/app/javascript/turbo_overlay/visit.js +52 -0
  18. data/app/views/layouts/turbo_overlay/drawer.html.erb +5 -0
  19. data/app/views/layouts/turbo_overlay/hint.html.erb +10 -0
  20. data/app/views/layouts/turbo_overlay/modal.html.erb +5 -0
  21. data/app/views/layouts/turbo_overlay/popover.html.erb +5 -0
  22. data/app/views/turbo_overlay/_drawer.html.erb +49 -0
  23. data/app/views/turbo_overlay/_hint.html.erb +6 -0
  24. data/app/views/turbo_overlay/_loading.html.erb +12 -0
  25. data/app/views/turbo_overlay/_modal.html.erb +46 -0
  26. data/app/views/turbo_overlay/_popover.html.erb +54 -0
  27. data/config/importmap.rb +11 -0
  28. data/lib/generators/turbo_overlay/eject_generator.rb +115 -0
  29. data/lib/generators/turbo_overlay/install_generator.rb +443 -0
  30. data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_confirm.html.erb +13 -0
  31. data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_drawer.html.erb +50 -0
  32. data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_hint.html.erb +9 -0
  33. data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_loading.html.erb +9 -0
  34. data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_modal.html.erb +49 -0
  35. data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_popover.html.erb +54 -0
  36. data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_confirm.html.erb +13 -0
  37. data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_drawer.html.erb +55 -0
  38. data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_hint.html.erb +9 -0
  39. data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_loading.html.erb +9 -0
  40. data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_modal.html.erb +58 -0
  41. data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_popover.html.erb +53 -0
  42. data/lib/generators/turbo_overlay/templates/chrome/plain/_confirm.html.erb +14 -0
  43. data/lib/generators/turbo_overlay/templates/chrome/tailwind/_confirm.html.erb +17 -0
  44. data/lib/generators/turbo_overlay/templates/chrome/tailwind/_drawer.html.erb +55 -0
  45. data/lib/generators/turbo_overlay/templates/chrome/tailwind/_hint.html.erb +6 -0
  46. data/lib/generators/turbo_overlay/templates/chrome/tailwind/_loading.html.erb +9 -0
  47. data/lib/generators/turbo_overlay/templates/chrome/tailwind/_modal.html.erb +46 -0
  48. data/lib/generators/turbo_overlay/templates/chrome/tailwind/_popover.html.erb +54 -0
  49. data/lib/generators/turbo_overlay/templates/initializer.rb.tt +67 -0
  50. data/lib/turbo_overlay/configuration.rb +226 -0
  51. data/lib/turbo_overlay/controller.rb +405 -0
  52. data/lib/turbo_overlay/engine.rb +52 -0
  53. data/lib/turbo_overlay/helpers/stream_helper.rb +77 -0
  54. data/lib/turbo_overlay/helpers/view_helper.rb +651 -0
  55. data/lib/turbo_overlay/version.rb +3 -0
  56. data/lib/turbo_overlay.rb +20 -0
  57. metadata +161 -0
data/README.md ADDED
@@ -0,0 +1,330 @@
1
+ # Turbo Overlay
2
+
3
+ Render any Rails view inside a stackable modal, drawer, or popover
4
+ using Turbo Streams — without duplicating templates, hand-rolling
5
+ Stimulus controllers, or coupling your domain to a CSS framework.
6
+
7
+ - **Four overlay types**: modal, drawer, popover, hover hint.
8
+ - **Stacking by default.** Open an overlay from inside another and the
9
+ new one slides on top instead of replacing it. Dismissal affects
10
+ only the topmost layer.
11
+ - **Themes ship in the gem** for Tailwind, Bootstrap 5, Bootstrap 3,
12
+ and plain CSS. Switch with one config option.
13
+ - **Native `<dialog>`** for every theme — top-layer stacking, focus
14
+ trap, ESC/backdrop dismiss come from the browser. No `window.bootstrap`,
15
+ no jQuery, no z-index wars.
16
+ - **Hover hints** with Turbo prefetch coordination. One fetch warms the
17
+ navigation *and* seeds a preview popover.
18
+ - **Themed `data-turbo-confirm`** prompts that match your overlay look
19
+ and stack like one.
20
+
21
+ ## Requirements
22
+
23
+ - Rails ≥ 6.1
24
+ - turbo-rails ≥ 2.0 (Turbo 8) — required for `data-turbo-stream="true"` on GET links
25
+
26
+ ## Installation
27
+
28
+ ```ruby
29
+ # Gemfile
30
+ gem "turbo_overlay"
31
+ ```
32
+
33
+ ```bash
34
+ bundle install
35
+ bin/rails generate turbo_overlay:install --theme tailwind
36
+ ```
37
+
38
+ Themes: `plain` (default), `tailwind`, `bootstrap5`, `bootstrap3`.
39
+
40
+ The generator wires the host app: it copies the theme's chrome
41
+ partials into `app/views/turbo_overlay/`, writes
42
+ `config/initializers/turbo_overlay.rb`, injects `overlay_stack_tag`
43
+ into your application layout, and registers the Stimulus / asset
44
+ wiring appropriate to your build setup (importmap, propshaft,
45
+ sprockets, jsbundling, cssbundling).
46
+
47
+ ### Required: `overlay_stack_tag` in your layout
48
+
49
+ The gem **will not render overlays** without `overlay_stack_tag`
50
+ present in your application layout. The generator injects it
51
+ automatically when it finds `app/views/layouts/application.html.erb`,
52
+ but verify it's there — and add it manually if you use a non-standard
53
+ layout, run the generator with `--skip-layout-inject`, or the
54
+ generator prints a yellow warning about it:
55
+
56
+ ```erb
57
+ <%# app/views/layouts/application.html.erb %>
58
+ <body>
59
+ <%= yield %>
60
+ <%= overlay_stack_tag %>
61
+ </body>
62
+ ```
63
+
64
+ This renders the slots overlays mount into. Without it, modal /
65
+ drawer / popover / hint links navigate full-page instead of opening
66
+ as overlays.
67
+
68
+ ### Required: `TurboOverlay::Controller` concern
69
+
70
+ The final wiring step is your `ApplicationController`. Include the
71
+ concern:
72
+
73
+ ```ruby
74
+ class ApplicationController < ActionController::Base
75
+ include TurboOverlay::Controller
76
+ end
77
+ ```
78
+
79
+ Including the concern installs a `layout` proc that swaps to the
80
+ matching overlay layout on overlay requests. Plain turbo-frame
81
+ requests keep their turbo-rails layout.
82
+
83
+ Overlay layouts **replace** your application layout for overlay
84
+ requests — only the view content is wrapped in the dialog markup,
85
+ not your nav, header, or footer.
86
+
87
+ ### Custom layouts
88
+
89
+ If your controller uses its own layout method, call
90
+ `turbo_overlay_layout` from it:
91
+
92
+ ```ruby
93
+ layout :custom_layout
94
+
95
+ def custom_layout
96
+ turbo_overlay_layout || "my_app_layout"
97
+ end
98
+ ```
99
+
100
+ A static `layout "admin"` declaration needs to be a method to thread
101
+ `turbo_overlay_layout` through.
102
+
103
+ If you skip the install generator the gem falls back to a plain
104
+ `<dialog>` chrome so modals and drawers still work, just unstyled
105
+ beyond the gem's CSS.
106
+
107
+ See [docs/installation.md](docs/installation.md) for the full
108
+ install generator output, bundling-app setup, and the `eject`
109
+ generator.
110
+
111
+ ## Usage
112
+
113
+ ### Open a view in an overlay
114
+
115
+ ```erb
116
+ <%= modal_link_to "New User", new_user_path %>
117
+ <%= drawer_link_to "Filters", filters_path %>
118
+ <%= popover_link_to "Edit", edit_user_path(@user) %>
119
+ ```
120
+
121
+ Modals and drawers stack. Popovers anchor to the trigger and replace
122
+ each other; modals and drawers still stack on top of an open popover.
123
+ See [docs/popovers.md](docs/popovers.md) for per-link options
124
+ (`position:`, `align:`, `offset:`, `backdrop:`) and the
125
+ single-popover behavior.
126
+
127
+ For non-GET triggers — deleting an item, creating a record, kicking
128
+ off a wizard — use the `button_to` counterparts:
129
+
130
+ ```erb
131
+ <%= modal_button_to "Delete", widget_path(@w), method: :delete %>
132
+ <%= drawer_button_to "Start wizard", wizards_path, method: :post %>
133
+ <%= popover_button_to "Quick edit", widget_path(@w), method: :patch %>
134
+ ```
135
+
136
+ The form submits with the same `X-Turbo-Overlay-*` headers the link
137
+ helpers send on click, so the controller renders identically.
138
+
139
+ ### Open an overlay from JavaScript
140
+
141
+ For triggers that aren't anchors — a Google Maps marker, an SVG hit
142
+ region, a custom element — call `TurboOverlay.visit(url, options)`.
143
+ Full option parity with the link helpers, exposed as both a named
144
+ export and a `window.TurboOverlay` global.
145
+
146
+ ```js
147
+ // Modal from a map marker
148
+ google.maps.event.addListener(marker, "click", () => {
149
+ TurboOverlay.visit("/places/123")
150
+ })
151
+
152
+ // Drawer with URL advance
153
+ TurboOverlay.visit("/cart", { type: "drawer", advance: true })
154
+
155
+ // Popover anchored to a non-anchor element — `anchor` is required
156
+ button.addEventListener("click", (event) => {
157
+ TurboOverlay.visit("/preview/9", {
158
+ type: "popover",
159
+ anchor: event.currentTarget,
160
+ position: "top",
161
+ })
162
+ })
163
+ ```
164
+
165
+ Prefer the Rails link helpers for ordinary navigation; reach for
166
+ `TurboOverlay.visit` only when the trigger isn't a link. See
167
+ [docs/reference.md](docs/reference.md#javascript-api) for the full
168
+ option list.
169
+
170
+ ### Customize what the overlay renders
171
+
172
+ The chrome yields a body and reads two `content_for` blocks. The
173
+ keys are generic so the same view renders correctly in a modal or
174
+ a drawer:
175
+
176
+ ```erb
177
+ <%# app/views/users/new.html.erb %>
178
+ <% overlay_title "New User" %>
179
+
180
+ <%= form_with(model: @user) do |f| %>
181
+ <%= f.text_field :name %>
182
+ <% end %>
183
+
184
+ <% overlay_footer do %>
185
+ <%= modal_dismiss_link_to "Cancel", users_path, class: "btn btn-secondary" %>
186
+ <button type="submit" class="btn btn-primary">Save</button>
187
+ <% end %>
188
+ ```
189
+
190
+ Variant templates pick different markup per chrome:
191
+
192
+ ```
193
+ app/views/users/show.html.erb # full-page
194
+ app/views/users/show.html+modal.erb # in a modal
195
+ app/views/users/show.html+drawer.erb # in a drawer
196
+ app/views/users/show.html+popover.erb # in a popover
197
+ ```
198
+
199
+ See [docs/customization.md](docs/customization.md) for the
200
+ overlay-template footgun, chrome partial structure, close-button
201
+ suppression, and stable overlay ids.
202
+
203
+ ### Close the overlay
204
+
205
+ Two paths, both supported.
206
+
207
+ **Implicit (the default).** A form submission inside an overlay that
208
+ redirects closes the overlay and visits the redirect target. Most
209
+ Rails CRUD actions need no overlay-specific code:
210
+
211
+ ```ruby
212
+ def create
213
+ @user = User.new(user_params)
214
+ if @user.save
215
+ redirect_to users_path # overlay closes; browser lands on /users
216
+ else
217
+ render :new, status: :unprocessable_entity # form re-renders in place
218
+ end
219
+ end
220
+ ```
221
+
222
+ Validation failures (`:unprocessable_entity`, 422) don't redirect, so
223
+ the form re-renders in the overlay with errors in place. If the
224
+ redirect goes back to the page the overlay was opened from, the host
225
+ page morphs in place behind the closing overlay so there's no
226
+ flash-of-stale-content — no app configuration needed.
227
+
228
+ **Explicit.** `turbo_stream.overlay(:close)` closes the top overlay
229
+ from any non-redirect response. Useful when the action wants to
230
+ update other parts of the page in the same response:
231
+
232
+ ```ruby
233
+ render turbo_stream: [
234
+ turbo_stream.update("flash", partial: "shared/flash"),
235
+ turbo_stream.overlay(:close)
236
+ ]
237
+ ```
238
+
239
+ See [docs/close-on-redirect.md](docs/close-on-redirect.md) for
240
+ opt-outs (`keep_overlay_open_on_redirect`, per-form data attribute),
241
+ the smooth-same-page-redirect mechanics, and stack-scoped close
242
+ variants.
243
+
244
+ ESC and clicking the backdrop dismiss the top overlay out of the
245
+ box. Opt a specific overlay out with
246
+ `data-turbo-overlay-backdrop-dismiss-value="false"`.
247
+
248
+ ### URL advance
249
+
250
+ Pass `advance: true` on a modal or drawer link to push the link's
251
+ target URL into the browser history bar when the overlay opens.
252
+ Browser-back closes the top overlay instead of navigating away.
253
+ Default off.
254
+
255
+ ```erb
256
+ <%= modal_link_to "Edit", edit_user_path(@user), advance: true %>
257
+ <%= drawer_link_to "Filter", filters_path, advance: "/users?filtering" %>
258
+ ```
259
+
260
+ Per-link `advance:` accepts `true` (push `link.href`), a String (push
261
+ a custom URL), or `false` (opt out when a type default is on). Set
262
+ the type default in the initializer:
263
+
264
+ ```ruby
265
+ TurboOverlay.configure do |c|
266
+ c.modal { |m| m.advance = true }
267
+ c.drawer { |d| d.advance = true }
268
+ end
269
+ ```
270
+
271
+ Popovers and hints never advance — they're ephemeral and shouldn't
272
+ churn browser history.
273
+
274
+ Note: the pushed URL is not guaranteed to re-open the overlay on a
275
+ fresh visit; how the app routes that URL (full page, redirect, or a
276
+ controller that itself opens the overlay) is the host app's call.
277
+
278
+ ### Loading state, themed confirms, and hover hints
279
+
280
+ - [docs/loading-and-confirm.md](docs/loading-and-confirm.md) —
281
+ loading placeholders and themed `data-turbo-confirm`.
282
+ - [docs/hints.md](docs/hints.md) — hover-preview popovers and the
283
+ `+hint` variant template.
284
+
285
+ ## Themes
286
+
287
+ | Theme | Modal | Drawer | Popover | Hint | Notes |
288
+ |--------------|:-----:|:------:|:-------:|:----:|-------------------------------------------------------------|
289
+ | `plain` | ✓ | ✓ | ✓ | ✓ | Native `<dialog>`, minimal vanilla CSS |
290
+ | `tailwind` | ✓ | ✓ | ✓ | ✓ | Native `<dialog>`, Tailwind classes |
291
+ | `bootstrap5` | ✓ | ✓ | ✓ | ✓ | Native `<dialog>` wrapping BS5 modal/offcanvas/popover markup |
292
+ | `bootstrap3` | ✓ | ✓ | ✓ | ✓ | Native `<dialog>` wrapping BS3 modal/popover markup; vanilla drawer |
293
+
294
+ Every theme uses the same JavaScript and CSS — only the chrome
295
+ partial Rails renders inside the dialog varies. Animations honor
296
+ `prefers-reduced-motion: reduce`. Stacking is handled by the
297
+ browser's `<dialog>` top layer regardless of theme.
298
+
299
+ ## Documentation
300
+
301
+ - [Installation](docs/installation.md) — generator details, bundling
302
+ apps, eject.
303
+ - [Popovers](docs/popovers.md) — per-link positioning, single-popover
304
+ behavior, link targeting inside popovers.
305
+ - [Hints](docs/hints.md) — hover previews, prefetch coordination,
306
+ the `+hint` variant template.
307
+ - [Loading & confirm](docs/loading-and-confirm.md) — loading
308
+ placeholders and themed confirm dialogs.
309
+ - [Customization](docs/customization.md) — chrome partials, variant
310
+ templates, stable ids, the full-page-render footgun.
311
+ - [Third-party form widgets](docs/third-party-widgets.md) — using
312
+ Tom Select, flatpickr, Select2, Tippy inside overlays.
313
+ - [Reference](docs/reference.md) — full configuration, helper
314
+ reference, JavaScript events.
315
+ - [Accessibility](docs/accessibility.md) — what the gem gives you,
316
+ what you provide, known limitations.
317
+ - [Architecture](docs/architecture.md) — request lifecycle, headers,
318
+ hint internals, JS module layout. Optional reading.
319
+
320
+ ## Development
321
+
322
+ ```bash
323
+ bundle install
324
+ bundle exec rake test # Ruby suite
325
+ node --test test/js/*.test.js # JS pure-function tests
326
+ ```
327
+
328
+ ## License
329
+
330
+ MIT — see `LICENSE.txt`.
data/Rakefile ADDED
@@ -0,0 +1,35 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ # Unit tests only — system tests bootstrap a Rails app and run
8
+ # under a separate `rake test:system` task so the dummy app doesn't
9
+ # have to load for every unit-test run.
10
+ t.test_files = FileList["test/**/*_test.rb"].exclude("test/system/**/*")
11
+ t.warning = false
12
+ end
13
+
14
+ namespace :test do
15
+ Rake::TestTask.new(:system) do |t|
16
+ t.libs << "test"
17
+ t.libs << "lib"
18
+ t.test_files = FileList["test/system/**/*_test.rb"]
19
+ t.warning = false
20
+ end
21
+ end
22
+
23
+ namespace :dummy do
24
+ desc "Boot the test/dummy Rails app on localhost so you can poke at it in a real browser (PORT=4001 by default)"
25
+ task :serve do
26
+ ENV["RAILS_ENV"] = "test" # only env the dummy has configured
27
+ require_relative "test/dummy/config/environment"
28
+ require "rack/handler/puma"
29
+ port = ENV.fetch("PORT", "4001").to_i
30
+ puts "→ Dummy app on http://localhost:#{port} (Ctrl-C to stop)"
31
+ Rack::Handler::Puma.run(Rails.application, Port: port, Silent: true)
32
+ end
33
+ end
34
+
35
+ task default: :test
@@ -0,0 +1,234 @@
1
+ /* === Bootstrap-style scaffold (transparent full-viewport <dialog>) === */
2
+ dialog.turbo-overlay-scaffold {
3
+ border: 0; padding: 0; margin: 0; background: transparent;
4
+ max-width: none; max-height: none;
5
+ }
6
+ dialog.turbo-overlay-scaffold::backdrop { background: rgba(0, 0, 0, 0.5); }
7
+ dialog.turbo-overlay-scaffold.turbo-overlay--modal {
8
+ width: 100%; height: 100%; overflow-y: auto;
9
+ }
10
+ dialog.turbo-overlay-scaffold.turbo-overlay--drawer { overflow: hidden; }
11
+ dialog.turbo-overlay-scaffold.turbo-overlay--drawer-right { inset: 0 0 0 auto; height: 100vh; width: 400px; max-width: 100vw; }
12
+ dialog.turbo-overlay-scaffold.turbo-overlay--drawer-left { inset: 0 auto 0 0; height: 100vh; width: 400px; max-width: 100vw; }
13
+ dialog.turbo-overlay-scaffold.turbo-overlay--drawer-top { inset: 0 0 auto 0; width: 100vw; max-height: 50vh; }
14
+ dialog.turbo-overlay-scaffold.turbo-overlay--drawer-bottom { inset: auto 0 0 0; width: 100vw; max-height: 50vh; }
15
+ dialog.turbo-overlay-scaffold .offcanvas {
16
+ position: static; visibility: visible; transform: none;
17
+ width: 100%; height: 100%; max-width: none; max-height: none;
18
+ }
19
+ dialog.turbo-overlay-scaffold .turbo-drawer-panel {
20
+ margin: 0; border-radius: 0; height: 100%; display: flex; flex-direction: column;
21
+ border: 0; box-shadow: 0 0 30px rgba(0, 0, 0, 0.15);
22
+ }
23
+ dialog.turbo-overlay-scaffold .turbo-drawer-body { flex: 1; overflow-y: auto; }
24
+
25
+ /* === Plain theme ===
26
+ Baseline dialog visuals are wrapped in `:where()` so they have
27
+ zero specificity. The plain theme partials (which only set the
28
+ `.turbo-modal` / `.turbo-drawer` / `.turbo-popover` class) still
29
+ pick them up, but tailwind/bootstrap themes that add their own
30
+ classes to the dialog (e.g. `bg-white dark:bg-gray-800`) win
31
+ without a specificity battle — regardless of which stylesheet
32
+ loads later in the cascade.
33
+ */
34
+ :where(dialog.turbo-modal) {
35
+ border: 0; padding: 0; border-radius: 8px;
36
+ max-width: 32rem; width: 90vw;
37
+ max-height: 90vh;
38
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
39
+ }
40
+ :where(dialog.turbo-modal)::backdrop { background: rgba(0, 0, 0, 0.5); }
41
+ .turbo-modal__content { display: flex; flex-direction: column; max-height: 90vh; position: relative; }
42
+ .turbo-modal__header {
43
+ display: flex; align-items: center; justify-content: space-between;
44
+ padding: 1rem 1.25rem; border-bottom: 1px solid #e5e7eb;
45
+ flex-shrink: 0;
46
+ }
47
+ .turbo-modal__title { margin: 0; font-size: 1.125rem; font-weight: 600; }
48
+ .turbo-modal__close { background: none; border: 0; font-size: 1.5rem; cursor: pointer; line-height: 1; }
49
+ .turbo-modal__close:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; border-radius: 2px; }
50
+ .turbo-modal__close--floating { position: absolute; top: 0.5rem; right: 0.75rem; z-index: 1; }
51
+ .turbo-modal__body { padding: 1.25rem; flex: 1; overflow-y: auto; }
52
+ .turbo-modal__footer {
53
+ padding: 1rem 1.25rem; border-top: 1px solid #e5e7eb;
54
+ display: flex; justify-content: flex-end; gap: 0.5rem;
55
+ flex-shrink: 0;
56
+ }
57
+
58
+ :where(dialog.turbo-drawer) {
59
+ border: 0; padding: 0; margin: 0; background: white;
60
+ box-shadow: 0 0 30px rgba(0, 0, 0, 0.15);
61
+ max-width: 100vw; max-height: 100vh;
62
+ }
63
+ :where(dialog.turbo-drawer)::backdrop { background: rgba(0, 0, 0, 0.5); }
64
+ :where(dialog.turbo-drawer.turbo-overlay--drawer-right) { inset: 0 0 0 auto; height: 100vh; width: 24rem; }
65
+ :where(dialog.turbo-drawer.turbo-overlay--drawer-left) { inset: 0 auto 0 0; height: 100vh; width: 24rem; }
66
+ :where(dialog.turbo-drawer.turbo-overlay--drawer-top) { inset: 0 0 auto 0; width: 100vw; max-height: 50vh; }
67
+ :where(dialog.turbo-drawer.turbo-overlay--drawer-bottom) { inset: auto 0 0 0; width: 100vw; max-height: 50vh; }
68
+ .turbo-drawer__content { display: flex; flex-direction: column; height: 100%; position: relative; }
69
+ .turbo-drawer__header {
70
+ display: flex; align-items: center; justify-content: space-between;
71
+ padding: 1rem 1.25rem; border-bottom: 1px solid #e5e7eb;
72
+ }
73
+ .turbo-drawer__title { margin: 0; font-size: 1.125rem; font-weight: 600; }
74
+ .turbo-drawer__close { background: none; border: 0; font-size: 1.5rem; cursor: pointer; line-height: 1; }
75
+ .turbo-drawer__close:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; border-radius: 2px; }
76
+ .turbo-drawer__close--floating { position: absolute; top: 0.5rem; right: 0.75rem; z-index: 1; }
77
+ .turbo-drawer__body { padding: 1.25rem; flex: 1; overflow-y: auto; }
78
+ .turbo-drawer__footer {
79
+ padding: 1rem 1.25rem; border-top: 1px solid #e5e7eb;
80
+ display: flex; justify-content: flex-end; gap: 0.5rem;
81
+ }
82
+
83
+ /* === Popover (anchored to trigger, non-modal) === */
84
+ :where(dialog.turbo-popover) {
85
+ border: 0; padding: 0; margin: 0; background: white;
86
+ border-radius: 6px;
87
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
88
+ max-width: 22rem; max-height: 80vh; overflow: auto;
89
+ }
90
+ /* Popovers opened from inside an open modal dialog use showModal()
91
+ themselves to avoid the parent modal's inertness blocking them.
92
+ Make the resulting ::backdrop transparent so they still look like
93
+ anchored popovers, not modals. The :modal pseudo matches only when
94
+ entered via showModal. */
95
+ dialog.turbo-overlay--popover:modal::backdrop { background: transparent; }
96
+ .turbo-popover__content { display: flex; flex-direction: column; position: relative; }
97
+ .turbo-popover__header {
98
+ display: flex; align-items: center; justify-content: space-between;
99
+ padding: 0.5rem 0.75rem; border-bottom: 1px solid #e5e7eb;
100
+ }
101
+ .turbo-popover__title { margin: 0; font-size: 0.95rem; font-weight: 600; }
102
+ .turbo-popover__close { background: none; border: 0; font-size: 1.25rem; cursor: pointer; line-height: 1; }
103
+ .turbo-popover__close:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; border-radius: 2px; }
104
+ .turbo-popover__close--floating { position: absolute; top: 0.25rem; right: 0.5rem; z-index: 1; }
105
+ .turbo-popover__body { padding: 0.75rem; }
106
+ .turbo-popover__footer {
107
+ padding: 0.5rem 0.75rem; border-top: 1px solid #e5e7eb;
108
+ display: flex; justify-content: flex-end; gap: 0.5rem;
109
+ }
110
+
111
+ /* === Hint (hover-triggered preview, non-modal, non-dialog) === */
112
+ /* Structural rules apply to every hint regardless of theme — JS sets
113
+ top/left via the same anchor math as the popover, and the
114
+ animation/closing classes drive the fade in/out. */
115
+ .turbo-overlay-hint {
116
+ position: fixed; z-index: 1000;
117
+ pointer-events: auto;
118
+ }
119
+ .turbo-overlay-hint[data-state="entering"] { animation: turbo-overlay-fade-in 0.12s ease-out; }
120
+ .turbo-overlay-hint[data-state="leaving"] { animation: turbo-overlay-fade-out 0.12s ease-out forwards; }
121
+
122
+ /* Plain-theme visuals — opt in by adding `turbo-hint` to the chrome
123
+ partial. The tailwind/bootstrap themes drop this class so their
124
+ own utility classes drive background/border/shadow without
125
+ collisions with the gem's plain defaults. (Tailwind v4 wraps its
126
+ utilities in `@layer utilities`; the gem's CSS is unlayered, which
127
+ wins over any layered rule regardless of specificity — including
128
+ `:where()`. Separating classes is the only reliable fix.) */
129
+ .turbo-hint {
130
+ max-width: 20rem; padding: 0.75rem 1rem;
131
+ background: white;
132
+ border: 1px solid #e5e7eb;
133
+ border-radius: 6px;
134
+ box-shadow: 0 8px 24px rgba(0,0,0,0.12);
135
+ font-size: 0.875rem;
136
+ }
137
+
138
+ /* === Animations (apply to every theme via shared classes) === */
139
+ dialog.turbo-overlay--modal[open] { animation: turbo-overlay-fade-in 0.15s ease-out; }
140
+ dialog.turbo-overlay--modal[open]::backdrop { animation: turbo-overlay-backdrop-in 0.15s ease-out; }
141
+ dialog.turbo-overlay--modal.turbo-overlay-closing { animation: turbo-overlay-fade-out 0.15s ease-out forwards; }
142
+ dialog.turbo-overlay--modal.turbo-overlay-closing::backdrop { animation: turbo-overlay-backdrop-out 0.15s ease-out forwards; }
143
+
144
+ dialog.turbo-overlay--drawer-right[open] { animation: turbo-overlay-slide-in-right 0.3s ease-out; }
145
+ dialog.turbo-overlay--drawer-left[open] { animation: turbo-overlay-slide-in-left 0.3s ease-out; }
146
+ dialog.turbo-overlay--drawer-top[open] { animation: turbo-overlay-slide-in-top 0.3s ease-out; }
147
+ dialog.turbo-overlay--drawer-bottom[open] { animation: turbo-overlay-slide-in-bottom 0.3s ease-out; }
148
+ dialog.turbo-overlay--drawer[open]::backdrop { animation: turbo-overlay-backdrop-in 0.3s ease-out; }
149
+ dialog.turbo-overlay--drawer-right.turbo-overlay-closing { animation: turbo-overlay-slide-out-right 0.3s ease-out forwards; }
150
+ dialog.turbo-overlay--drawer-left.turbo-overlay-closing { animation: turbo-overlay-slide-out-left 0.3s ease-out forwards; }
151
+ dialog.turbo-overlay--drawer-top.turbo-overlay-closing { animation: turbo-overlay-slide-out-top 0.3s ease-out forwards; }
152
+ dialog.turbo-overlay--drawer-bottom.turbo-overlay-closing { animation: turbo-overlay-slide-out-bottom 0.3s ease-out forwards; }
153
+ dialog.turbo-overlay--drawer.turbo-overlay-closing::backdrop { animation: turbo-overlay-backdrop-out 0.3s ease-out forwards; }
154
+
155
+ /* Popovers position themselves by writing `transform: translate(...)`
156
+ so the placement stays in sync with compositor-thread scroll. The
157
+ open/close keyframes also use `transform`, so we set
158
+ `animation-composition: add` on the dialog — keyframe transforms
159
+ then compose with (rather than replace) the positioning transform. */
160
+ dialog.turbo-overlay--popover { animation-composition: add; }
161
+ dialog.turbo-overlay--popover:popover-open { animation: turbo-overlay-popover-in 0.12s ease-out; }
162
+ dialog.turbo-overlay--popover.turbo-overlay-closing { animation: turbo-overlay-popover-out 0.12s ease-out forwards; }
163
+
164
+ @keyframes turbo-overlay-fade-in { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: scale(1); } }
165
+ @keyframes turbo-overlay-fade-out { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.97); } }
166
+ @keyframes turbo-overlay-backdrop-in { from { background: rgba(0,0,0,0); } to { background: rgba(0,0,0,0.5); } }
167
+ @keyframes turbo-overlay-backdrop-out { from { background: rgba(0,0,0,0.5); } to { background: rgba(0,0,0,0); } }
168
+ @keyframes turbo-overlay-slide-in-right { from { transform: translateX(100%); } to { transform: translateX(0); } }
169
+ @keyframes turbo-overlay-slide-in-left { from { transform: translateX(-100%); } to { transform: translateX(0); } }
170
+ @keyframes turbo-overlay-slide-in-top { from { transform: translateY(-100%); } to { transform: translateY(0); } }
171
+ @keyframes turbo-overlay-slide-in-bottom { from { transform: translateY(100%); } to { transform: translateY(0); } }
172
+ @keyframes turbo-overlay-slide-out-right { from { transform: translateX(0); } to { transform: translateX(100%); } }
173
+ @keyframes turbo-overlay-slide-out-left { from { transform: translateX(0); } to { transform: translateX(-100%); } }
174
+ @keyframes turbo-overlay-slide-out-top { from { transform: translateY(0); } to { transform: translateY(-100%); } }
175
+ @keyframes turbo-overlay-slide-out-bottom { from { transform: translateY(0); } to { transform: translateY(100%); } }
176
+ @keyframes turbo-overlay-popover-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
177
+ @keyframes turbo-overlay-popover-out { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-4px); } }
178
+
179
+ @media (prefers-reduced-motion: reduce) {
180
+ dialog.turbo-overlay,
181
+ dialog.turbo-overlay::backdrop,
182
+ dialog.turbo-overlay.turbo-overlay-closing,
183
+ dialog.turbo-overlay.turbo-overlay-closing::backdrop,
184
+ .turbo-overlay-hint[data-state] { animation: none !important; }
185
+ }
186
+
187
+ /* === Loading state (cloned client-side while a request is in flight) === */
188
+ /* Wrapped in :where() so themes that drop their own classes on the
189
+ loading partial win without specificity battles. */
190
+ :where(.turbo-overlay-loading__body) {
191
+ display: flex; align-items: center; justify-content: center;
192
+ min-height: 8rem; padding: 2rem;
193
+ }
194
+ :where(.turbo-overlay-loading__spinner) {
195
+ display: inline-block;
196
+ width: 1.5rem; height: 1.5rem;
197
+ border: 3px solid currentColor;
198
+ border-right-color: transparent;
199
+ border-radius: 50%;
200
+ opacity: 0.6;
201
+ animation: turbo-overlay-loading-spin 0.8s linear infinite;
202
+ }
203
+ :where(.turbo-overlay-loading__sr-only) {
204
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
205
+ overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
206
+ }
207
+ @keyframes turbo-overlay-loading-spin { to { transform: rotate(360deg); } }
208
+
209
+ /* Drawer loading should fill its panel rather than rely on min-height. */
210
+ :where(dialog.turbo-overlay--drawer.turbo-overlay--loading .turbo-overlay-loading__body) {
211
+ min-height: 100%;
212
+ }
213
+
214
+ /* Hint loading sits inside a tooltip-sized chrome — collapse the body's
215
+ default min-height/padding so the spinner doesn't bloat the hint. */
216
+ :where(.turbo-overlay-hint.turbo-overlay--loading .turbo-overlay-loading__body) {
217
+ min-height: 0;
218
+ padding: 0;
219
+ }
220
+
221
+ @media (prefers-reduced-motion: reduce) {
222
+ .turbo-overlay-loading__spinner { animation: none !important; }
223
+ }
224
+
225
+ /* === Non-modal drawer (opened via dialog.show(), no ::backdrop) === */
226
+ /* Non-modal <dialog> defaults to position: absolute (anchored to the
227
+ document), which breaks the side-pinned inset rules above. Force
228
+ fixed so the drawer stays anchored to the viewport. */
229
+ dialog.turbo-overlay--no-backdrop { position: fixed; }
230
+
231
+ /* === Scroll lock when any modal overlay is open === */
232
+ /* Skip non-backdrop drawers — they're meant to coexist with page
233
+ interaction, including scrolling. */
234
+ html:has(dialog.turbo-overlay[open]:not(.turbo-overlay--no-backdrop)) { overflow: hidden; }
@@ -0,0 +1,46 @@
1
+ // Small DOM helpers shared between the setup module, the per-dialog
2
+ // Stimulus controller, and the hint state machine. Kept pure-DOM (no
3
+ // imports from setup.js or controllers) so any module can depend on
4
+ // it without creating cycles.
5
+
6
+ // Close a <dialog> that may already be closed. The native close()
7
+ // throws InvalidStateError when the dialog isn't open; we strip the
8
+ // `open` attribute as a fallback so the dialog is closed after this
9
+ // returns even when close() raises (e.g. a dialog left with [open]
10
+ // but no top-layer entry, which a browser may refuse to close).
11
+ export function safelyCloseDialog(dialog) {
12
+ if (!dialog) return
13
+ try { dialog.close() } catch (_) { dialog.removeAttribute("open") }
14
+ }
15
+
16
+ // Dismiss a popover-mode dialog. hidePopover() is only available in
17
+ // browsers that support the Popover API, and throws when the dialog
18
+ // isn't showing as a popover. Both are non-fatal — callers want
19
+ // "ensure popover is hidden."
20
+ export function safelyHidePopover(dialog) {
21
+ if (!dialog || typeof dialog.hidePopover !== "function") return
22
+ try { dialog.hidePopover() } catch (_) { /* not currently a popover */ }
23
+ }
24
+
25
+ // Reset a <dialog>'s positioning styles so popover-positioning math
26
+ // can place it via a single `transform: translate(...)`. Default
27
+ // <dialog> styles (margin: auto, right/bottom set, position relative
28
+ // or absolute depending on open state) and the `dialog:modal` UA
29
+ // `inset: 0` interfere with anchored placement.
30
+ //
31
+ // We pin the dialog at viewport origin (top: 0, left: 0) and carry
32
+ // the actual placement on `transform`. Transforms run on the
33
+ // compositor thread, so they stay in sync with scroll-induced repaint
34
+ // instead of trailing by a frame (which produces a "springy" feel on
35
+ // momentum scrolling). The CSS for popovers sets
36
+ // `animation-composition: add` so the entry/exit keyframes compose
37
+ // with our positioning transform instead of overriding it.
38
+ export function normalizePopoverDialogStyles(dialog) {
39
+ if (!dialog) return
40
+ dialog.style.position = "fixed"
41
+ dialog.style.top = "0"
42
+ dialog.style.left = "0"
43
+ dialog.style.right = "auto"
44
+ dialog.style.bottom = "auto"
45
+ dialog.style.margin = "0"
46
+ }