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
@@ -0,0 +1,651 @@
1
+ module TurboOverlay
2
+ module Helpers
3
+ module ViewHelper
4
+ # The per-request predicates and accessors (`modal_request?`,
5
+ # `drawer_request?`, `popover_request?`, `hint_request?`,
6
+ # `turbo_overlay_id`, `turbo_overlay_type`,
7
+ # `turbo_overlay_position`, `turbo_overlay_align`,
8
+ # `turbo_overlay_offset`, `turbo_overlay_backdrop?`,
9
+ # `turbo_overlay_close?`) live on `TurboOverlay::Controller` and
10
+ # are exposed to views via `helper_method`. This module only
11
+ # defines helpers that build markup or wrap content_for. The
12
+ # install generator wires the concern into ApplicationController.
13
+
14
+ # ----- Modal-specific link helpers -----
15
+
16
+ # Build a link that opens its target as a modal overlay. The
17
+ # response is appended to the host-page stack container, so a
18
+ # modal opened from inside another modal stacks on top rather
19
+ # than replacing.
20
+ #
21
+ # <%= modal_link_to "New User", new_user_path %>
22
+ # <%= modal_link_to "Edit", edit_user_path(@user),
23
+ # overlay_id: "edit_user_#{@user.id}" %>
24
+ #
25
+ # `overlay_id:` is optional. When omitted the server generates a
26
+ # random id; supply your own when you want to close the overlay
27
+ # later from server code via
28
+ # `turbo_stream.overlay(:close, id: "...")`.
29
+ #
30
+ # `close: false` opens the modal without the default close ("×")
31
+ # button rendered by the chrome partial. Useful when the body
32
+ # provides its own dismiss controls (the confirm partial uses
33
+ # this internally). ESC and backdrop click still close.
34
+ def modal_link_to(name = nil, options = nil, html_options = nil, &block)
35
+ _overlay_link_to(:modal, name, options, html_options, &block)
36
+ end
37
+
38
+ # Inside a modal, render a link styled as a "dismiss" trigger.
39
+ # Outside a modal, behaves like a normal `link_to`.
40
+ #
41
+ # <%= modal_dismiss_link_to "Cancel", cancel_path %>
42
+ def modal_dismiss_link_to(name = nil, options = nil, html_options = nil, &block)
43
+ _overlay_dismiss_link_to(:modal, name, options, html_options, &block)
44
+ end
45
+
46
+ # Render a `button_to` form whose POST/DELETE/PATCH/PUT response
47
+ # opens a modal overlay. Use this when opening the modal is the
48
+ # result of a non-GET action — creating a record, deleting an
49
+ # item, kicking off a wizard. The form carries the same overlay
50
+ # data attributes a `modal_link_to` link would, so the server
51
+ # sees identical `X-Turbo-Overlay-*` request headers and wraps
52
+ # the response in the modal layout.
53
+ #
54
+ # <%= modal_button_to "Delete", widget_path(@w), method: :delete %>
55
+ # <%= modal_button_to "Start wizard", wizards_path, method: :post %>
56
+ #
57
+ # Accepts the same overlay options as `modal_link_to`
58
+ # (`overlay_id:`, `close:`, `keep_overlay_open_on_redirect:`).
59
+ # `advance:` is not exposed — non-GET requests don't push history.
60
+ def modal_button_to(name = nil, options = nil, html_options = nil, &block)
61
+ _overlay_button_to(:modal, name, options, html_options, &block)
62
+ end
63
+
64
+ # ----- Drawer-specific link helpers -----
65
+
66
+ # Build a link that opens its target as a drawer overlay. Same
67
+ # stacking and `overlay_id:` semantics as `modal_link_to`.
68
+ #
69
+ # <%= drawer_link_to "Filters", filters_path %>
70
+ #
71
+ # `position:` overrides the configured drawer side for this one
72
+ # link (`:left`, `:right`, `:top`, `:bottom`). When omitted the
73
+ # drawer opens on the side set by
74
+ # `TurboOverlay.configuration.drawer.position`.
75
+ #
76
+ # <%= drawer_link_to "Nav", nav_path, position: :left %>
77
+ #
78
+ # `backdrop: false` opens the drawer non-modally: no dimmed
79
+ # backdrop, the page stays scrollable, and text on the page
80
+ # remains selectable so users can copy/paste between the page
81
+ # and the drawer. Click outside the drawer is also ignored
82
+ # (no backdrop to click). ESC still closes.
83
+ #
84
+ # <%= drawer_link_to "Inspector", inspect_path, backdrop: false %>
85
+ #
86
+ # `close: false` opens the drawer without the default close
87
+ # ("×") button. ESC still closes; the backdrop is unaffected.
88
+ def drawer_link_to(name = nil, options = nil, html_options = nil, &block)
89
+ _overlay_link_to(:drawer, name, options, html_options, &block)
90
+ end
91
+
92
+ # Inside a drawer, render a link styled as a "dismiss" trigger.
93
+ def drawer_dismiss_link_to(name = nil, options = nil, html_options = nil, &block)
94
+ _overlay_dismiss_link_to(:drawer, name, options, html_options, &block)
95
+ end
96
+
97
+ # `button_to` counterpart to `drawer_link_to` — see
98
+ # `modal_button_to`. Accepts `position:`, `backdrop:`, `close:`,
99
+ # and `keep_overlay_open_on_redirect:`.
100
+ def drawer_button_to(name = nil, options = nil, html_options = nil, &block)
101
+ _overlay_button_to(:drawer, name, options, html_options, &block)
102
+ end
103
+
104
+ # ----- Popover-specific link helpers -----
105
+
106
+ # Build a link that opens its target as a popover overlay
107
+ # anchored to the clicked link. Same stacking and `overlay_id:`
108
+ # semantics as `modal_link_to`.
109
+ #
110
+ # <%= popover_link_to "Edit", edit_user_path(@user) %>
111
+ #
112
+ # `position:` overrides the configured side (`:top`, `:bottom`,
113
+ # `:left`, `:right`). `align:` overrides the cross-axis
114
+ # alignment (`:start`, `:center`, `:end`). `offset:` overrides
115
+ # the pixel gap between trigger and popover.
116
+ #
117
+ # <%= popover_link_to "Info", info_path,
118
+ # position: :top, align: :center, offset: 8 %>
119
+ #
120
+ # Popovers are non-modal (no backdrop, page stays interactive)
121
+ # and dismiss on outside click or ESC. Opening a second popover
122
+ # automatically closes any other open popover; modals and
123
+ # drawers still stack on top.
124
+ def popover_link_to(name = nil, options = nil, html_options = nil, &block)
125
+ _overlay_link_to(:popover, name, options, html_options, &block)
126
+ end
127
+
128
+ # Inside a popover, render a link styled as a "dismiss" trigger.
129
+ def popover_dismiss_link_to(name = nil, options = nil, html_options = nil, &block)
130
+ _overlay_dismiss_link_to(:popover, name, options, html_options, &block)
131
+ end
132
+
133
+ # `button_to` counterpart to `popover_link_to` — see
134
+ # `modal_button_to`. Accepts `position:`, `align:`, `offset:`,
135
+ # `close:`. The popover anchors to the rendered form (which
136
+ # wraps the button), so positioning works the same as for a
137
+ # `popover_link_to` link.
138
+ def popover_button_to(name = nil, options = nil, html_options = nil, &block)
139
+ _overlay_button_to(:popover, name, options, html_options, &block)
140
+ end
141
+
142
+ # ----- Hint-specific helpers -----
143
+
144
+ # Decorate any `<a>` so the gem's JS shows a hover-hint preview.
145
+ # `hint_link_to` is a thin wrapper around `link_to` that sets the
146
+ # two data attributes the JS reads. Compose freely with overlay
147
+ # helpers (`modal_link_to "Edit", path, hint: true, hint_url: …`)
148
+ # or set the data attributes directly on any `link_to`.
149
+ #
150
+ # <%= hint_link_to "User", user_path(@user) %>
151
+ # <%= hint_link_to "User", user_path(@user), hint_url: hint_user_path(@user) %>
152
+ #
153
+ # Without `hint_url:`, the gem extracts a `<template id="...">`
154
+ # from the page's own response when Turbo prefetches it on hover.
155
+ # With `hint_url:`, the gem fetches the alternate URL with the
156
+ # `:hint` request variant on hover. Overlay links
157
+ # (`modal_link_to` etc.) are excluded from Turbo's hover prefetch
158
+ # — provide `hint_url:` for them.
159
+ #
160
+ # `show_delay:` / `hide_delay:` (ms) override the configured
161
+ # `TurboOverlay.configuration.hint.show_delay_ms` /
162
+ # `hide_delay_ms` for this one link. Useful for dense lists
163
+ # (datatables, menus) where a longer show delay keeps hints from
164
+ # flickering during scroll/keyboard navigation, or for a single
165
+ # high-signal link that wants a near-zero delay.
166
+ #
167
+ # <%= hint_link_to "User", user_path(@user), show_delay: 600 %>
168
+ def hint_link_to(name = nil, options = nil, html_options = nil, &block)
169
+ if block_given?
170
+ html_options = options || {}
171
+ options = name
172
+ options, html_options = _hint_normalize_link_args(options, html_options)
173
+ link_to(options, html_options, &block)
174
+ else
175
+ html_options = (html_options || {}).dup
176
+ options, html_options = _hint_normalize_link_args(options, html_options)
177
+ link_to(name, options, html_options)
178
+ end
179
+ end
180
+
181
+ # ----- Generic in-view helpers (shared across overlay types) -----
182
+
183
+ # Emit the receiving stack container for overlays. Drop this in
184
+ # your application layout (typically just before `</body>`)
185
+ # once.
186
+ #
187
+ # <%= overlay_stack_tag %>
188
+ #
189
+ # When the host app has confirm chrome partials in
190
+ # `app/views/turbo_overlay/`, emits sibling `<template>` elements
191
+ # per variant the JS confirm hook clones from:
192
+ #
193
+ # <template id="turbo_overlay_confirm_modal_template">…</template>
194
+ # <template id="turbo_overlay_confirm_popover_template">…</template>
195
+ #
196
+ # Partial resolution per variant prefers `_confirm.html+<variant>.erb`,
197
+ # then falls back to a shared `_confirm.html.erb`. The shared
198
+ # partial — if it's the only one present — is rendered once for
199
+ # both modal and popover styles so apps that don't want
200
+ # chrome-specific variants can ship a single file.
201
+ #
202
+ # The same shape applies to the loading partials:
203
+ #
204
+ # <template id="turbo_overlay_loading_modal_template">…</template>
205
+ # <template id="turbo_overlay_loading_drawer_template">…</template>
206
+ # <template id="turbo_overlay_loading_popover_template">…</template>
207
+ # <template id="turbo_overlay_loading_hint_template">…</template>
208
+ #
209
+ # rendered from `_loading.html+<type>.erb` with `_loading.html.erb`
210
+ # as the shared fallback. Cloned by the JS at click time to give
211
+ # users immediate feedback while the real response is in flight.
212
+ #
213
+ # The configured default confirm style is exposed as a data
214
+ # attribute on the stack container so the JS can read it without
215
+ # a separate config plumbing pass.
216
+ def overlay_stack_tag
217
+ stack_id = TurboOverlay.configuration.stack_id
218
+ confirm_style = TurboOverlay.configuration.confirm.style.to_s
219
+ hint_cfg = TurboOverlay.configuration.hint
220
+
221
+ data_attrs = {
222
+ controller: "turbo-overlay-stack",
223
+ "turbo-overlay-confirm-style": confirm_style,
224
+ "turbo-overlay-hint-show-delay": hint_cfg.show_delay_ms,
225
+ "turbo-overlay-hint-hide-delay": hint_cfg.hide_delay_ms,
226
+ "turbo-overlay-advance-modal": TurboOverlay.configuration.modal.advance.to_s,
227
+ "turbo-overlay-advance-drawer": TurboOverlay.configuration.drawer.advance.to_s
228
+ }
229
+
230
+ allowed_click_outside = TurboOverlay.configuration.allowed_click_outside_selectors
231
+ if allowed_click_outside.any?
232
+ data_attrs["turbo-overlay-stack-allowed-click-outside-selectors-value"] =
233
+ allowed_click_outside.to_json
234
+ end
235
+
236
+ stack = content_tag(:div, "".html_safe,
237
+ id: stack_id,
238
+ class: "turbo-overlay-stack",
239
+ data: data_attrs)
240
+
241
+ return stack unless respond_to?(:lookup_context) && lookup_context
242
+
243
+ parts = [stack]
244
+
245
+ [:modal, :popover].each do |variant|
246
+ rendered = _render_overlay_chrome_partial(
247
+ "turbo_overlay/confirm", variant,
248
+ chrome: variant, locals: { close: false }
249
+ )
250
+ next unless rendered
251
+ parts << content_tag(:template, rendered,
252
+ id: "turbo_overlay_confirm_#{variant}_template")
253
+ end
254
+
255
+ [:modal, :drawer, :popover, :hint].each do |variant|
256
+ rendered = _render_overlay_chrome_partial(
257
+ "turbo_overlay/loading", variant,
258
+ chrome: variant, locals: { loading: true, close: false }
259
+ )
260
+ next unless rendered
261
+ parts << content_tag(:template, rendered,
262
+ id: "turbo_overlay_loading_#{variant}_template")
263
+ end
264
+
265
+ # On a hintable request, render the action's `+hint.erb`
266
+ # variant template if one exists. Gated on
267
+ # `_overlay_hintable_request?` so a regular page render
268
+ # doesn't pay the cost.
269
+ body = _turbo_overlay_resolved_hint_body
270
+ if body
271
+ hint_body = if lookup_context.exists?("turbo_overlay/hint", [], true)
272
+ # Render the partial as a layout so the user's body lands
273
+ # at `<%= yield %>`. Same pattern used by the modal/drawer
274
+ # /popover layouts.
275
+ render(layout: "turbo_overlay/hint") { body }
276
+ else
277
+ # No chrome partial in the app yet; emit the body unwrapped
278
+ # so the JS still has something to extract.
279
+ body
280
+ end
281
+ parts << content_tag(:template, hint_body, id: "turbo-overlay-hint")
282
+ end
283
+
284
+ return stack if parts.size == 1
285
+ safe_join(parts)
286
+ end
287
+
288
+ # Deprecated. Aliased to `overlay_stack_tag` for one minor cycle.
289
+ # The previous frame-per-type model has been replaced by a
290
+ # single shared stack container.
291
+ def overlay_frame_tags(*_types)
292
+ ActiveSupport::Deprecation.new("0.4", "turbo_overlay").warn(
293
+ "overlay_frame_tags is deprecated; use overlay_stack_tag instead."
294
+ )
295
+ overlay_stack_tag
296
+ end
297
+
298
+ # The DOM id of the per-overlay turbo-frame for the current
299
+ # request: `turbo_overlay_<type>_<id>`. Used by overlay layouts
300
+ # to tag the wrapping frame.
301
+ def turbo_overlay_frame_id(type = nil)
302
+ type ||= controller.turbo_overlay_type
303
+ return nil unless type && turbo_overlay_id
304
+ "turbo_overlay_#{type}_#{turbo_overlay_id}"
305
+ end
306
+
307
+ # Wraps the given block in the appropriate response primitive
308
+ # for the current overlay request:
309
+ #
310
+ # - Initial open (`X-Turbo-Overlay` header): emits a
311
+ # `<turbo-stream action="append" target="<stack_id>">` whose
312
+ # template contains a `<turbo-frame id="<frame_id>">` around
313
+ # the dialog.
314
+ # - Form re-render inside an open overlay
315
+ # (`Turbo-Frame: turbo_overlay_<type>_<id>`): emits a
316
+ # `<turbo-stream action="replace" method="morph">` targeting
317
+ # the open frame. Morphing preserves the `<dialog>` node
318
+ # identity (top-layer membership, popover anchor, stack
319
+ # registration, ESC / outside-click handlers, focus, and
320
+ # scroll position) and just updates the children to show the
321
+ # new markup — the error messages, the populated form fields.
322
+ # Plain frame replacement would tear the dialog down and
323
+ # re-attach a fresh one, which detaches popovers from their
324
+ # anchor and leaks document-level handlers.
325
+ #
326
+ # Used by the modal/drawer layouts to keep them readable.
327
+ def overlay_response_wrapper(type, &block)
328
+ frame_id = "turbo_overlay_#{type}_#{turbo_overlay_id}"
329
+ frame_html = turbo_frame_tag(frame_id, class: "turbo-overlay-frame", &block)
330
+
331
+ if controller.turbo_overlay_frame_re_render?
332
+ turbo_stream.replace(frame_id, method: :morph) { frame_html }
333
+ else
334
+ stack_id = TurboOverlay.configuration.stack_id
335
+ turbo_stream.append(stack_id) { frame_html }
336
+ end
337
+ end
338
+
339
+ # Set the overlay header title.
340
+ def overlay_title(value = nil, &block)
341
+ content_for(:overlay_title, value, &block)
342
+ end
343
+
344
+ # Set the overlay footer content.
345
+ def overlay_footer(value = nil, &block)
346
+ content_for(:overlay_footer, value, &block)
347
+ end
348
+
349
+ # Toggle the chrome's default close ("×") button for the current
350
+ # overlay render. Defaults to on; call with `false` from inside an
351
+ # overlay view to suppress it:
352
+ #
353
+ # <% overlay_close false %>
354
+ #
355
+ # Precedence (highest first): partial local `close:` on `render
356
+ # "turbo_overlay/modal"`, this helper, the link option `close:
357
+ # false` (carried as a request header and exposed via
358
+ # `turbo_overlay_close?`), then the default `true`.
359
+ def overlay_close(show = true)
360
+ @_overlay_close = show
361
+ end
362
+
363
+ # Whether the chrome should render its default close button.
364
+ # Resolves the precedence described on `overlay_close`.
365
+ def overlay_close?
366
+ return @_overlay_close != false if defined?(@_overlay_close)
367
+ controller.turbo_overlay_close?
368
+ end
369
+
370
+ private
371
+
372
+ # Resolve the hint body for `overlay_stack_tag`. On a hintable
373
+ # request (Turbo prefetch or explicit `:hint` variant fetch),
374
+ # render the action's `+hint` variant template if one exists.
375
+ # Apps that want hint previews drop a `show.html+hint.erb` next
376
+ # to `show.html.erb` and the gem auto-emits its content.
377
+ def _turbo_overlay_resolved_hint_body
378
+ return nil unless _overlay_hintable_request?
379
+ return nil unless _turbo_overlay_action_hint_variant_exists?
380
+ render(
381
+ template: _turbo_overlay_action_template_path,
382
+ variants: [:hint],
383
+ layout: false
384
+ )
385
+ end
386
+
387
+ def _turbo_overlay_action_template_path
388
+ "#{controller.controller_path}/#{controller.action_name}"
389
+ end
390
+
391
+ # Returns true only when a `+hint` variant template exists on
392
+ # disk — NOT when `exists?(variants: [:hint])` falls back to the
393
+ # plain template. Rails' variant resolution treats no-variant as
394
+ # an acceptable match for any variant query, which would make us
395
+ # auto-render the entire page as the hint body whenever the
396
+ # action has any view at all (e.g. show.html.erb without a
397
+ # +hint sibling). We inspect the resolved templates' identifiers
398
+ # and require an actual `+hint.` segment in the filename.
399
+ def _turbo_overlay_action_hint_variant_exists?
400
+ path = _turbo_overlay_action_template_path
401
+ return false unless path
402
+ templates = lookup_context.find_all(path, [], false, [], variants: [:hint])
403
+ templates.any? { |t| t.respond_to?(:identifier) && t.identifier.to_s.include?("+hint.") }
404
+ end
405
+
406
+ # Whether the hint template should be rendered for this request.
407
+ # True for prefetches and explicit `:hint` variant fetches. The
408
+ # detection lives on the controller concern; the view helper just
409
+ # delegates so chrome-rendering code paths can read it.
410
+ def _overlay_hintable_request?
411
+ controller.overlay_hintable_request?
412
+ end
413
+
414
+ # Render `turbo_overlay/<name>` for the given variant, preferring
415
+ # `_<name>.html+<variant>.erb` and falling back to a shared
416
+ # `_<name>.html.erb` when no variant-specific override exists.
417
+ # Returns nil when neither file is present so callers can skip
418
+ # emitting an empty `<template>` wrapper.
419
+ #
420
+ # When `chrome:` is supplied, the rendered body is wrapped in the
421
+ # `turbo_overlay/<chrome>` chrome partial (modal/drawer/popover/hint)
422
+ # so confirm and loading body partials don't have to repeat
423
+ # `<%= render "turbo_overlay/modal" do %>...<% end %>` boilerplate.
424
+ # `locals:` flows to both the body and the chrome.
425
+ def _render_overlay_chrome_partial(name, variant, chrome: nil, locals: {})
426
+ unless lookup_context.exists?(name, [], true, [], variants: [variant]) ||
427
+ lookup_context.exists?(name, [], true)
428
+ return nil
429
+ end
430
+
431
+ if chrome
432
+ render(partial: name, layout: "turbo_overlay/#{chrome}",
433
+ variants: [variant], locals: locals)
434
+ else
435
+ render(partial: name, variants: [variant], locals: locals)
436
+ end
437
+ end
438
+
439
+ def _overlay_link_to(type, name, options, html_options, &block)
440
+ if block_given?
441
+ html_options = options || {}
442
+ options = name
443
+ options, html_options = _overlay_normalize_link_args(type, options, html_options)
444
+ link_to(options, html_options, &block)
445
+ else
446
+ html_options = (html_options || {}).dup
447
+ options, html_options = _overlay_normalize_link_args(type, options, html_options)
448
+ link_to(name, options, html_options)
449
+ end
450
+ end
451
+
452
+ def _overlay_button_to(type, name, options, html_options, &block)
453
+ if block_given?
454
+ html_options = options || {}
455
+ options = name
456
+ options, html_options = _overlay_normalize_button_args(type, options, html_options)
457
+ button_to(options, html_options, &block)
458
+ else
459
+ html_options = (html_options || {}).dup
460
+ options, html_options = _overlay_normalize_button_args(type, options, html_options)
461
+ button_to(name, options, html_options)
462
+ end
463
+ end
464
+
465
+ def _overlay_dismiss_link_to(type, name, options, html_options, &block)
466
+ html_options = (html_options || {}).dup
467
+
468
+ in_overlay = case type
469
+ when :modal then modal_request?
470
+ when :drawer then drawer_request?
471
+ when :popover then popover_request?
472
+ end
473
+ if in_overlay
474
+ html_options["data-action"] ||= "click->turbo-overlay#close:prevent"
475
+ html_options["data-turbo-#{type}-dismiss"] = "true"
476
+ # Click is preventDefault'd by the Stimulus action above —
477
+ # the link's href is decorative (no-JS fallback). Suppress
478
+ # Turbo's hover prefetch so we don't fire a wasted request
479
+ # for a URL the user will never actually navigate to.
480
+ html_options["data-turbo-prefetch"] = "false" unless html_options.key?("data-turbo-prefetch") ||
481
+ (html_options[:data].is_a?(Hash) && html_options[:data].key?(:turbo_prefetch))
482
+ end
483
+
484
+ if block_given?
485
+ link_to(options || "#", html_options, &block)
486
+ else
487
+ link_to(name, options, html_options)
488
+ end
489
+ end
490
+
491
+ def _overlay_normalize_link_args(type, options, html_options)
492
+ html_options = (html_options || {}).dup
493
+ _, overlay_id = _pop_option(html_options, :overlay_id)
494
+ _, position = _pop_option(html_options, :position)
495
+ _, align = _pop_option(html_options, :align)
496
+ _, offset = _pop_option(html_options, :offset)
497
+ has_backdrop, backdrop = _pop_option(html_options, :backdrop)
498
+ has_close, close_value = _pop_option(html_options, :close)
499
+ has_hint, hint_value = _pop_option(html_options, :hint)
500
+ _, hint_url = _pop_option(html_options, :hint_url)
501
+ _, show_delay = _pop_option(html_options, :show_delay)
502
+ _, hide_delay = _pop_option(html_options, :hide_delay)
503
+ has_advance, advance_val = _pop_option(html_options, :advance)
504
+ has_keep_open, keep_open_val = _pop_option(html_options, :keep_overlay_open_on_redirect)
505
+
506
+ data = (html_options[:data] || {}).dup
507
+ _assign_overlay_data(data, html_options, :turbo_stream, "data-turbo-stream", true)
508
+ _assign_overlay_data(data, html_options, :turbo_overlay, "data-turbo-overlay", type.to_s)
509
+ _assign_overlay_data(data, html_options, :turbo_overlay_id, "data-turbo-overlay-id", overlay_id&.to_s)
510
+ _assign_overlay_data(data, html_options, :turbo_overlay_position, "data-turbo-overlay-position", position&.to_s)
511
+ _assign_overlay_data(data, html_options, :turbo_overlay_align, "data-turbo-overlay-align", align&.to_s)
512
+ _assign_overlay_data(data, html_options, :turbo_overlay_offset, "data-turbo-overlay-offset", offset&.to_s)
513
+ _assign_overlay_data(data, html_options, :turbo_overlay_backdrop, "data-turbo-overlay-backdrop", "false") if has_backdrop && backdrop == false
514
+ _assign_overlay_data(data, html_options, :turbo_overlay_close, "data-turbo-overlay-close", "false") if has_close && close_value == false
515
+ _assign_overlay_data(data, html_options, :turbo_overlay_keep_open_on_redirect, "data-turbo-overlay-keep-open-on-redirect", "true") if has_keep_open && keep_open_val == true
516
+ _assign_overlay_data(data, html_options, :turbo_overlay_hint, "data-turbo-overlay-hint", "true") if has_hint && hint_value
517
+ _assign_overlay_data(data, html_options, :turbo_overlay_hint_url, "data-turbo-overlay-hint-url", hint_url&.to_s)
518
+ _assign_overlay_data(data, html_options, :turbo_overlay_hint_show_delay, "data-turbo-overlay-hint-show-delay", show_delay&.to_s)
519
+ _assign_overlay_data(data, html_options, :turbo_overlay_hint_hide_delay, "data-turbo-overlay-hint-hide-delay", hide_delay&.to_s)
520
+ # URL advance — only modal and drawer participate. Popover and
521
+ # hint configs deliberately don't expose `advance`, and stray
522
+ # `:advance` keys on those link helpers are dropped silently.
523
+ if has_advance && (type == :modal || type == :drawer)
524
+ advance_string = case advance_val
525
+ when true then "true"
526
+ when false then "false"
527
+ when String then advance_val
528
+ else (advance_val.to_s if advance_val.respond_to?(:to_str))
529
+ end
530
+ _assign_overlay_data(data, html_options, :turbo_overlay_advance, "data-turbo-overlay-advance", advance_string)
531
+ end
532
+ # Break out of any enclosing per-overlay turbo-frame so a click
533
+ # on a modal/drawer/popover link from inside an open overlay
534
+ # opens a new (stacked) overlay instead of replacing the current one.
535
+ _assign_overlay_data(data, html_options, :turbo_frame, "data-turbo-frame", "_top")
536
+ html_options[:data] = data unless data.empty?
537
+ _merge_aria_haspopup(html_options, "dialog")
538
+
539
+ [options, html_options]
540
+ end
541
+
542
+ # Counterpart to `_overlay_normalize_link_args` for `button_to`.
543
+ # Same overlay-trigger contract — same data attribute names, same
544
+ # encoding rules — but the data lives on the synthesized
545
+ # `<form>` (via `form: { data: { ... } }`) so the submit hook in
546
+ # setup.js picks it up. The button itself only carries
547
+ # `aria-haspopup="dialog"`.
548
+ #
549
+ # `advance:` and the hint options are intentionally not exposed:
550
+ # non-GET requests don't push history, and hints are a
551
+ # hover-on-link mechanism that doesn't translate to forms.
552
+ def _overlay_normalize_button_args(type, options, html_options)
553
+ html_options = (html_options || {}).dup
554
+ _, overlay_id = _pop_option(html_options, :overlay_id)
555
+ _, position = _pop_option(html_options, :position)
556
+ _, align = _pop_option(html_options, :align)
557
+ _, offset = _pop_option(html_options, :offset)
558
+ has_backdrop, backdrop = _pop_option(html_options, :backdrop)
559
+ has_close, close_value = _pop_option(html_options, :close)
560
+ has_keep_open, keep_open_val = _pop_option(html_options, :keep_overlay_open_on_redirect)
561
+
562
+ form_options = (html_options[:form] || {}).dup
563
+ form_data = (form_options[:data] || {}).dup
564
+
565
+ # `_assign_overlay_data` writes through to `html_options` for
566
+ # the dashed-name fallback when no nested `data:` hash exists.
567
+ # We always nest under `form: { data: }` here, so just write
568
+ # the rails-style symbol keys directly.
569
+ form_data[:turbo_stream] = true unless form_data.key?(:turbo_stream)
570
+ form_data[:turbo_overlay] = type.to_s unless form_data.key?(:turbo_overlay)
571
+ form_data[:turbo_overlay_id] = overlay_id.to_s if overlay_id
572
+ form_data[:turbo_overlay_position] = position.to_s if position
573
+ form_data[:turbo_overlay_align] = align.to_s if align
574
+ form_data[:turbo_overlay_offset] = offset.to_s if offset
575
+ form_data[:turbo_overlay_backdrop] = "false" if has_backdrop && backdrop == false
576
+ form_data[:turbo_overlay_close] = "false" if has_close && close_value == false
577
+ form_data[:turbo_overlay_keep_open_on_redirect] = "true" if has_keep_open && keep_open_val == true
578
+ form_data[:turbo_frame] = "_top" unless form_data.key?(:turbo_frame)
579
+
580
+ form_options[:data] = form_data
581
+ html_options[:form] = form_options
582
+ _merge_aria_haspopup(html_options, "dialog")
583
+
584
+ [options, html_options]
585
+ end
586
+
587
+ # Decorate a plain link with the gem's hint data attributes. Unlike
588
+ # `_overlay_normalize_link_args` this does NOT set data-turbo-stream
589
+ # or data-turbo-frame=_top — hint_link_to behaves as a regular link
590
+ # (Turbo prefetch can still apply); the hint is just hover preview.
591
+ def _hint_normalize_link_args(options, html_options)
592
+ html_options = (html_options || {}).dup
593
+ _, hint_url = _pop_option(html_options, :hint_url)
594
+ _, show_delay = _pop_option(html_options, :show_delay)
595
+ _, hide_delay = _pop_option(html_options, :hide_delay)
596
+
597
+ data = (html_options[:data] || {}).dup
598
+ _assign_overlay_data(data, html_options, :turbo_overlay_hint, "data-turbo-overlay-hint", "true")
599
+ _assign_overlay_data(data, html_options, :turbo_overlay_hint_url, "data-turbo-overlay-hint-url", hint_url&.to_s)
600
+ _assign_overlay_data(data, html_options, :turbo_overlay_hint_show_delay, "data-turbo-overlay-hint-show-delay", show_delay&.to_s)
601
+ _assign_overlay_data(data, html_options, :turbo_overlay_hint_hide_delay, "data-turbo-overlay-hint-hide-delay", hide_delay&.to_s)
602
+ html_options[:data] = data unless data.empty?
603
+ _merge_aria_haspopup(html_options, "tooltip")
604
+
605
+ [options, html_options]
606
+ end
607
+
608
+ # Delete `key` from `html_options` accepting either Symbol or
609
+ # String form. Returns `[present?, value]`.
610
+ # - present? is true when the key existed under either form
611
+ # - value is the value, even when explicitly nil/false
612
+ def _pop_option(html_options, key)
613
+ sym = key.to_sym
614
+ str = key.to_s
615
+ present = html_options.key?(sym) || html_options.key?(str)
616
+ value = html_options.delete(sym)
617
+ value = html_options.delete(str) if value.nil? && present
618
+ [present, value]
619
+ end
620
+
621
+ # Gated assignment to `data` honoring caller-wins precedence: an
622
+ # explicit `data[:foo]` or `"data-foo"` in `html_options` is left
623
+ # alone. Skips assignment when `value` is nil/false (treat nil as
624
+ # "no override supplied").
625
+ def _assign_overlay_data(data, html_options, data_key, html_attr, value)
626
+ return if value.nil?
627
+ return if data.key?(data_key)
628
+ return if html_options.key?(html_attr)
629
+ data[data_key] = value
630
+ end
631
+
632
+ # Signal to assistive tech that activating the link opens a
633
+ # dialog/tooltip. Caller wins: an explicit `aria: { haspopup: ... }`
634
+ # or `"aria-haspopup"` key passes through unchanged (including
635
+ # `false`/nil for opt-out).
636
+ def _merge_aria_haspopup(html_options, value)
637
+ return if html_options.key?("aria-haspopup")
638
+ aria = html_options[:aria] || html_options["aria"]
639
+ if aria.is_a?(Hash)
640
+ return if aria.key?(:haspopup) || aria.key?("haspopup")
641
+ aria = aria.dup
642
+ aria[:haspopup] = value
643
+ html_options[:aria] = aria
644
+ else
645
+ html_options[:aria] = { haspopup: value }
646
+ end
647
+ end
648
+
649
+ end
650
+ end
651
+ end
@@ -0,0 +1,3 @@
1
+ module TurboOverlay
2
+ VERSION = "0.3.0"
3
+ end
@@ -0,0 +1,20 @@
1
+ require "turbo_overlay/version"
2
+ require "turbo_overlay/configuration"
3
+
4
+ module TurboOverlay
5
+ class << self
6
+ def configuration
7
+ @configuration ||= Configuration.new
8
+ end
9
+
10
+ def configure
11
+ yield configuration
12
+ end
13
+
14
+ def reset_configuration!
15
+ @configuration = Configuration.new
16
+ end
17
+ end
18
+ end
19
+
20
+ require "turbo_overlay/engine" if defined?(Rails::Engine)