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,226 @@
1
+ module TurboOverlay
2
+ # Per-overlay-type config. `variant` is the Rails partial-variant
3
+ # symbol the type renders as.
4
+ class OverlayTypeConfig
5
+ attr_accessor :variant
6
+
7
+ def initialize(variant:)
8
+ @variant = variant
9
+ end
10
+ end
11
+
12
+ # Modal config. Adds `advance` (URL advance / history.pushState on
13
+ # open; browser-back closes the top overlay). Default off.
14
+ # Per-link override via `advance: true | "/custom" | false` on
15
+ # `modal_link_to`.
16
+ class ModalConfig < OverlayTypeConfig
17
+ attr_accessor :advance
18
+
19
+ def initialize(advance: false, **kwargs)
20
+ super(**kwargs)
21
+ @advance = advance
22
+ end
23
+ end
24
+
25
+ # Drawer config extends OverlayTypeConfig with a default position
26
+ # (`:left`, `:right`, `:top`, `:bottom`) used by shipped layouts.
27
+ # Per-instance position can be overridden by editing the layout.
28
+ # Also exposes `advance` (same semantics as ModalConfig).
29
+ class DrawerConfig < OverlayTypeConfig
30
+ attr_accessor :position, :advance
31
+
32
+ def initialize(position:, advance: false, **kwargs)
33
+ super(**kwargs)
34
+ @position = position
35
+ @advance = advance
36
+ end
37
+ end
38
+
39
+ # Confirm prompt config. Controls how `data-turbo-confirm` is
40
+ # rendered when the gem's themed confirm hook is registered via
41
+ # `register(application, { confirm: true })`.
42
+ #
43
+ # - `style`: `:modal` (default) renders the prompt centered in the
44
+ # modal chrome. `:popover` renders it anchored to the submitter
45
+ # element (the clicked link or button), which is often friendlier
46
+ # for destructive actions next to a row or button. Per-link
47
+ # override via `data-turbo-confirm-style="popover"` (or `"modal"`)
48
+ # on the link/form.
49
+ class ConfirmConfig
50
+ attr_accessor :style
51
+
52
+ def initialize(style:)
53
+ @style = style
54
+ end
55
+ end
56
+
57
+ # Hint config. Hover-triggered preview overlays. Hint attributes
58
+ # (`data-turbo-overlay-hint`, `data-turbo-overlay-hint-url`) compose
59
+ # with every overlay link helper and with plain `link_to`/`hint_link_to`.
60
+ #
61
+ # - `show_delay_ms`: hover must persist this long before the hint
62
+ # shows (default 250ms).
63
+ # - `hide_delay_ms`: grace window after mouseleave before dismissing,
64
+ # so the user can move the cursor into the hint (default 120ms).
65
+ class HintConfig < OverlayTypeConfig
66
+ attr_accessor :show_delay_ms, :hide_delay_ms
67
+
68
+ def initialize(show_delay_ms:, hide_delay_ms:, **kwargs)
69
+ super(**kwargs)
70
+ @show_delay_ms = show_delay_ms
71
+ @hide_delay_ms = hide_delay_ms
72
+ end
73
+ end
74
+
75
+ # Popover config extends OverlayTypeConfig with anchored-positioning
76
+ # defaults. Popovers attach to their trigger element rather than
77
+ # centering (modal) or pinning to an edge (drawer).
78
+ #
79
+ # - `position`: side of the trigger to attach to (`:top`, `:bottom`,
80
+ # `:left`, `:right`). Default `:bottom`.
81
+ # - `align`: cross-axis alignment relative to the trigger
82
+ # (`:start`, `:center`, `:end`). Default `:start`.
83
+ # - `offset`: pixels between trigger edge and dialog edge. Default `4`.
84
+ # - `auto_flip`: flip to the opposite side when the preferred
85
+ # placement would overflow the viewport. Default `true`.
86
+ class PopoverConfig < OverlayTypeConfig
87
+ attr_accessor :position, :align, :offset, :auto_flip
88
+
89
+ def initialize(position:, align:, offset:, auto_flip:, **kwargs)
90
+ super(**kwargs)
91
+ @position = position
92
+ @align = align
93
+ @offset = offset
94
+ @auto_flip = auto_flip
95
+ end
96
+ end
97
+
98
+ class Configuration
99
+ # DOM id of the host-page stack container that receives appended
100
+ # overlays. Emit it in your application layout via
101
+ # `<%= overlay_stack_tag %>`.
102
+ attr_accessor :stack_id
103
+
104
+ # CSS selectors whose clicks should not dismiss an open overlay.
105
+ # The list is consulted by the JS dismissal guard for backdrop
106
+ # clicks (modal/drawer) and outside-clicks (popover). Use this for
107
+ # body-appended widgets — flatpickr calendars, Select2 dropdowns,
108
+ # Tippy tooltips, Tom Select — that render their UI as siblings of
109
+ # the overlay in `<body>` and would otherwise read as
110
+ # outside-the-dialog clicks.
111
+ #
112
+ # This is developer config, not request data: never populate from
113
+ # user input. Entries are interpreted as CSS selectors and matched
114
+ # against any element in the DOM.
115
+ attr_reader :allowed_click_outside_selectors
116
+
117
+ # Selectors that would allowlist the entire document and pin
118
+ # overlays open forever. Reject at config time so misuse fails at
119
+ # boot, not at dismiss time.
120
+ FORBIDDEN_ALLOWLIST_SELECTORS = %w[* body html :root].freeze
121
+ private_constant :FORBIDDEN_ALLOWLIST_SELECTORS
122
+
123
+ def allowed_click_outside_selectors=(list)
124
+ list = Array(list)
125
+ list.each do |selector|
126
+ unless selector.is_a?(String)
127
+ raise ArgumentError,
128
+ "allowed_click_outside_selectors entries must be String CSS selectors; got #{selector.inspect}"
129
+ end
130
+ if FORBIDDEN_ALLOWLIST_SELECTORS.include?(selector.strip)
131
+ raise ArgumentError,
132
+ "allowed_click_outside_selectors entry #{selector.inspect} would allowlist the entire document " \
133
+ "and pin overlays open forever. Use a more specific selector."
134
+ end
135
+ end
136
+ @allowed_click_outside_selectors = list
137
+ end
138
+
139
+ def initialize
140
+ @stack_id = "turbo_overlay_stack"
141
+ @allowed_click_outside_selectors = []
142
+
143
+ @modal = ModalConfig.new(variant: :modal)
144
+
145
+ @drawer = DrawerConfig.new(
146
+ variant: :drawer,
147
+ position: :right
148
+ )
149
+
150
+ @popover = PopoverConfig.new(
151
+ variant: :popover,
152
+ position: :bottom,
153
+ align: :start,
154
+ offset: 4,
155
+ auto_flip: true
156
+ )
157
+
158
+ @confirm = ConfirmConfig.new(style: :modal)
159
+
160
+ @hint = HintConfig.new(
161
+ variant: :hint,
162
+ show_delay_ms: 250,
163
+ hide_delay_ms: 120
164
+ )
165
+ end
166
+
167
+ # Modal config. With a block, yields the type config for setters;
168
+ # without a block, returns it for direct access.
169
+ def modal
170
+ yield @modal if block_given?
171
+ @modal
172
+ end
173
+
174
+ # Drawer config. Same shape as modal, plus a `position` attribute
175
+ # (`:left`, `:right`, `:top`, `:bottom`).
176
+ #
177
+ # TurboOverlay.configure do |c|
178
+ # c.drawer do |d|
179
+ # d.position = :left
180
+ # end
181
+ # end
182
+ def drawer
183
+ yield @drawer if block_given?
184
+ @drawer
185
+ end
186
+
187
+ # Popover config. Anchored to the trigger element. Same shape as
188
+ # modal, plus `position`, `align`, `offset`, and `auto_flip`.
189
+ #
190
+ # TurboOverlay.configure do |c|
191
+ # c.popover do |p|
192
+ # p.position = :top
193
+ # p.align = :center
194
+ # end
195
+ # end
196
+ def popover
197
+ yield @popover if block_given?
198
+ @popover
199
+ end
200
+
201
+ # Confirm-prompt config. Controls how `data-turbo-confirm` renders.
202
+ #
203
+ # TurboOverlay.configure do |c|
204
+ # c.confirm do |cf|
205
+ # cf.style = :popover # default :modal
206
+ # end
207
+ # end
208
+ def confirm
209
+ yield @confirm if block_given?
210
+ @confirm
211
+ end
212
+
213
+ # Hint config. Hover-triggered preview overlays.
214
+ #
215
+ # TurboOverlay.configure do |c|
216
+ # c.hint do |h|
217
+ # h.show_delay_ms = 400
218
+ # h.enabled = false # turn the feature off entirely
219
+ # end
220
+ # end
221
+ def hint
222
+ yield @hint if block_given?
223
+ @hint
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,405 @@
1
+ require "active_support/concern"
2
+ require "securerandom"
3
+
4
+ module TurboOverlay
5
+ # Controller concern. Include in `ApplicationController` (or any
6
+ # controller you want overlay-aware):
7
+ #
8
+ # class ApplicationController < ActionController::Base
9
+ # include TurboOverlay::Controller
10
+ # end
11
+ #
12
+ # The concern auto-installs a `layout` proc that swaps in the
13
+ # matching overlay layout for overlay requests and preserves
14
+ # `"turbo_rails/frame"` for plain turbo-frame requests (so including
15
+ # this concern does not regress Turbo's frame-layout optimization).
16
+ # For apps with a custom layout method, call `turbo_overlay_layout`
17
+ # from your method — see the README "A note on custom layouts"
18
+ # section.
19
+ module Controller
20
+ extend ActiveSupport::Concern
21
+
22
+ OVERLAY_FRAME_PREFIX = "turbo_overlay_".freeze
23
+ OVERLAY_TYPE_HEADER = "X-Turbo-Overlay".freeze
24
+ OVERLAY_ID_HEADER = "X-Turbo-Overlay-Id".freeze
25
+ OVERLAY_POSITION_HEADER = "X-Turbo-Overlay-Position".freeze
26
+ OVERLAY_ALIGN_HEADER = "X-Turbo-Overlay-Align".freeze
27
+ OVERLAY_OFFSET_HEADER = "X-Turbo-Overlay-Offset".freeze
28
+ OVERLAY_BACKDROP_HEADER = "X-Turbo-Overlay-Backdrop".freeze
29
+ OVERLAY_CLOSE_HEADER = "X-Turbo-Overlay-Close".freeze
30
+ OVERLAY_KEEP_OPEN_HEADER = "X-Turbo-Overlay-Keep-Open".freeze
31
+
32
+ # Whitelists for values that originate from request headers and get
33
+ # reflected into rendered chrome (CSS class tokens, DOM ids,
34
+ # frame names). Constraining them at the resolver protects every
35
+ # downstream consumer — gem partials, generator templates, JS data
36
+ # attributes — without each having to re-validate.
37
+ ALLOWED_POSITIONS = %i[left right top bottom].freeze
38
+ ALLOWED_ALIGNS = %i[start center end].freeze
39
+ OVERLAY_ID_FORMAT = /\A[A-Za-z0-9_-]{1,64}\z/.freeze
40
+ OFFSET_RANGE = (-10_000..10_000).freeze
41
+
42
+ included do
43
+ prepend_before_action :_turbo_overlay_force_html_format
44
+ prepend_before_action :_turbo_overlay_set_variant
45
+ after_action :_turbo_overlay_set_stream_content_type
46
+
47
+ layout -> { turbo_overlay_layout }
48
+
49
+ helper_method :modal_request?,
50
+ :drawer_request?,
51
+ :popover_request?,
52
+ :hint_request?,
53
+ :overlay_request?, :turbo_overlay_id, :turbo_overlay_type,
54
+ :turbo_overlay_position, :turbo_overlay_align,
55
+ :turbo_overlay_offset, :turbo_overlay_backdrop?,
56
+ :turbo_overlay_close?, :turbo_overlay_keep_open_on_redirect?,
57
+ :overlay_prefetch_request?, :overlay_hintable_request?
58
+ end
59
+
60
+ # ----- modal -----
61
+
62
+ def modal_request?
63
+ turbo_overlay_type == :modal
64
+ end
65
+
66
+ # ----- drawer -----
67
+
68
+ def drawer_request?
69
+ turbo_overlay_type == :drawer
70
+ end
71
+
72
+ # ----- popover -----
73
+
74
+ def popover_request?
75
+ turbo_overlay_type == :popover
76
+ end
77
+
78
+ # ----- hint -----
79
+
80
+ def hint_request?
81
+ turbo_overlay_type == :hint
82
+ end
83
+
84
+ # ----- layout -----
85
+
86
+ # Layout name for the current request. Returns the matching overlay
87
+ # layout for overlay requests, `"turbo_rails/frame"` for plain
88
+ # turbo-frame requests (preserving Turbo's optimization, since our
89
+ # auto-installed `layout` proc replaces Turbo's), or `nil`
90
+ # otherwise so Rails picks the default layout.
91
+ #
92
+ # Apps with a custom layout method should call this first:
93
+ #
94
+ # def custom_layout
95
+ # turbo_overlay_layout || "my_app_layout"
96
+ # end
97
+ def turbo_overlay_layout
98
+ case turbo_overlay_type
99
+ when :modal then "turbo_overlay/modal"
100
+ when :drawer then "turbo_overlay/drawer"
101
+ when :popover then "turbo_overlay/popover"
102
+ when :hint then "turbo_overlay/hint"
103
+ else
104
+ "turbo_rails/frame" if respond_to?(:turbo_frame_request?) && turbo_frame_request?
105
+ end
106
+ end
107
+
108
+ # ----- generic -----
109
+
110
+ # True if the current request targets *any* configured overlay
111
+ # (initial open or in-overlay form re-render). Useful in shared
112
+ # partials.
113
+ def overlay_request?
114
+ !turbo_overlay_type.nil?
115
+ end
116
+
117
+ # True if the current request is a Turbo hover prefetch. Detected
118
+ # via the `X-Sec-Purpose: prefetch` request header that Turbo
119
+ # sends — the W3C `Sec-Purpose` is a Forbidden Header for
120
+ # JS-initiated `fetch()` requests, so Turbo prepends `X-`. Used
121
+ # by `overlay_stack_tag` to decide whether to render the action's
122
+ # `+hint` variant template inline.
123
+ def overlay_prefetch_request?
124
+ return false unless respond_to?(:request) && request
125
+ request.headers["X-Sec-Purpose"].to_s.include?("prefetch")
126
+ end
127
+
128
+ # True if the current request will use the hint template — either
129
+ # a hover prefetch (which the gem's JS extracts the template from)
130
+ # or an explicit `:hint` variant fetch. Gates the `+hint` variant
131
+ # auto-render in `overlay_stack_tag`.
132
+ def overlay_hintable_request?
133
+ hint_request? || overlay_prefetch_request?
134
+ end
135
+
136
+ # Returns `:modal`, `:drawer`, `:popover`, or `nil`. Detected from
137
+ # the `X-Turbo-Overlay` request header (initial open) or the
138
+ # `Turbo-Frame: turbo_overlay_<type>_<id>` header (form re-render
139
+ # inside an open overlay).
140
+ def turbo_overlay_type
141
+ return @_turbo_overlay_type if defined?(@_turbo_overlay_type)
142
+ @_turbo_overlay_type = _resolve_overlay_type
143
+ end
144
+
145
+ # The overlay id for the current request. Resolution order:
146
+ #
147
+ # 1. `X-Turbo-Overlay-Id` request header (caller supplied
148
+ # `overlay_id:` on the link helper)
149
+ # 2. The `<id>` segment parsed from a
150
+ # `Turbo-Frame: turbo_overlay_<type>_<id>` header (form re-render)
151
+ # 3. A freshly generated `SecureRandom.alphanumeric(8)` id,
152
+ # memoized for the duration of the request
153
+ #
154
+ # Available in the controller and in views (e.g. for
155
+ # `turbo_stream.overlay(:close, id: turbo_overlay_id)`).
156
+ def turbo_overlay_id
157
+ return @_turbo_overlay_id if defined?(@_turbo_overlay_id)
158
+ @_turbo_overlay_id = _resolve_overlay_id
159
+ end
160
+
161
+ # The per-link position override for the current overlay request,
162
+ # parsed from the `X-Turbo-Overlay-Position` header. Returns a
163
+ # Symbol (`:left`, `:right`, `:top`, `:bottom`) or `nil` when the
164
+ # link didn't supply one. Drawer partials use this with a
165
+ # fallback to `TurboOverlay.configuration.drawer.position`;
166
+ # popover partials use it with a fallback to
167
+ # `TurboOverlay.configuration.popover.position`.
168
+ def turbo_overlay_position
169
+ return @_turbo_overlay_position if defined?(@_turbo_overlay_position)
170
+ @_turbo_overlay_position = _resolve_overlay_position
171
+ end
172
+
173
+ # The per-link cross-axis alignment for popovers, parsed from the
174
+ # `X-Turbo-Overlay-Align` header. Returns a Symbol (`:start`,
175
+ # `:center`, `:end`) or `nil`. Popover partials fall back to
176
+ # `TurboOverlay.configuration.popover.align`.
177
+ def turbo_overlay_align
178
+ return @_turbo_overlay_align if defined?(@_turbo_overlay_align)
179
+ @_turbo_overlay_align = _resolve_overlay_align
180
+ end
181
+
182
+ # The per-link pixel offset between trigger and popover, parsed
183
+ # from the `X-Turbo-Overlay-Offset` header. Returns an Integer or
184
+ # `nil`. Popover partials fall back to
185
+ # `TurboOverlay.configuration.popover.offset`.
186
+ def turbo_overlay_offset
187
+ return @_turbo_overlay_offset if defined?(@_turbo_overlay_offset)
188
+ @_turbo_overlay_offset = _resolve_overlay_offset
189
+ end
190
+
191
+ # Whether the current overlay request should render with a
192
+ # backdrop. Defaults to `true`; only `false` when the link helper
193
+ # explicitly passed `backdrop: false` (carried in the
194
+ # `X-Turbo-Overlay-Backdrop` header). Drawer partials switch the
195
+ # `<dialog>` open mode and CSS based on this.
196
+ def turbo_overlay_backdrop?
197
+ return @_turbo_overlay_backdrop if defined?(@_turbo_overlay_backdrop)
198
+ @_turbo_overlay_backdrop = _resolve_overlay_backdrop
199
+ end
200
+
201
+ # Whether the current overlay request should render the chrome's
202
+ # default close ("×") button. Defaults to `true`; only `false`
203
+ # when the link helper explicitly passed `close: false` (carried in
204
+ # the `X-Turbo-Overlay-Close` header). Chrome partials consult
205
+ # `overlay_close?` (view helper) which folds this into the full
206
+ # opt-out precedence chain.
207
+ def turbo_overlay_close?
208
+ return @_turbo_overlay_close if defined?(@_turbo_overlay_close)
209
+ @_turbo_overlay_close = _resolve_overlay_close
210
+ end
211
+
212
+ # Whether the overlay should stay open when a form descendant
213
+ # submits and the response is a redirect. Defaults to `false`
214
+ # (close-on-redirect is the gem's default); becomes `true` when
215
+ # the trigger link passed `keep_overlay_open_on_redirect: true`
216
+ # (carried in the `X-Turbo-Overlay-Keep-Open` header). The chrome
217
+ # partials reflect this onto the `<dialog>` as a data attribute
218
+ # the per-dialog submit-end listener reads.
219
+ def turbo_overlay_keep_open_on_redirect?
220
+ return @_turbo_overlay_keep_open if defined?(@_turbo_overlay_keep_open)
221
+ @_turbo_overlay_keep_open = _resolve_overlay_keep_open_on_redirect
222
+ end
223
+
224
+ # True for the initial open of an overlay (an `X-Turbo-Overlay`
225
+ # request that is not a form re-render inside an existing
226
+ # overlay frame). Used internally to decide between turbo-stream
227
+ # append wrapping and turbo-frame replace wrapping.
228
+ def turbo_overlay_initial_open?
229
+ return false unless turbo_overlay_type
230
+ !turbo_overlay_frame_re_render?
231
+ end
232
+
233
+ # True when this is a form/link response targeting an existing
234
+ # overlay's turbo-frame (form re-render in place). Mirrors the
235
+ # same prefetch guard as `_resolve_overlay_type`: a hover prefetch
236
+ # carries the enclosing frame's id in `Turbo-Frame` but is NOT a
237
+ # re-render — treating it as one would flip the response
238
+ # Content-Type to `text/vnd.turbo-stream.html` (via
239
+ # `_turbo_overlay_set_stream_content_type`) for a body that's a
240
+ # plain `<turbo-frame>`, an incoherent mismatch.
241
+ def turbo_overlay_frame_re_render?
242
+ return false unless respond_to?(:request) && request
243
+ return false if overlay_prefetch_request?
244
+ request.headers["Turbo-Frame"].to_s.start_with?(OVERLAY_FRAME_PREFIX)
245
+ end
246
+
247
+ private
248
+
249
+ def _resolve_overlay_type
250
+ return nil unless respond_to?(:request) && request
251
+
252
+ header = request.headers[OVERLAY_TYPE_HEADER].to_s.downcase
253
+ return :modal if header == "modal"
254
+ return :drawer if header == "drawer"
255
+ return :popover if header == "popover"
256
+ return :hint if header == "hint"
257
+
258
+ # Turbo's hover prefetch carries the enclosing frame's id in the
259
+ # `Turbo-Frame` header. Falling into the frame-parse branch on a
260
+ # prefetch would route the prefetched URL through the overlay
261
+ # layout and return a frame-replace turbo-stream — which Turbo
262
+ # applies on receipt, replacing the open overlay's contents with
263
+ # whatever the prefetched URL renders. Skip the fallback so
264
+ # prefetches render as normal pages and Turbo just caches them.
265
+ # Explicit overlay opens (`X-Turbo-Overlay` header set by the
266
+ # gem's JS) are unaffected — those are matched above.
267
+ return nil if overlay_prefetch_request?
268
+
269
+ frame = request.headers["Turbo-Frame"].to_s
270
+ if frame.start_with?(OVERLAY_FRAME_PREFIX)
271
+ rest = frame[OVERLAY_FRAME_PREFIX.length..]
272
+ return :modal if rest.start_with?("modal_")
273
+ return :drawer if rest.start_with?("drawer_")
274
+ return :popover if rest.start_with?("popover_")
275
+ end
276
+
277
+ nil
278
+ end
279
+
280
+ def _resolve_overlay_id
281
+ return nil unless turbo_overlay_type
282
+
283
+ supplied = request.headers[OVERLAY_ID_HEADER].to_s
284
+ return supplied if supplied.match?(OVERLAY_ID_FORMAT)
285
+
286
+ frame = request.headers["Turbo-Frame"].to_s
287
+ if frame.start_with?(OVERLAY_FRAME_PREFIX)
288
+ rest = frame[OVERLAY_FRAME_PREFIX.length..]
289
+ underscore = rest.index("_")
290
+ if underscore
291
+ parsed = rest[(underscore + 1)..]
292
+ return parsed if parsed.match?(OVERLAY_ID_FORMAT)
293
+ end
294
+ end
295
+
296
+ SecureRandom.alphanumeric(8)
297
+ end
298
+
299
+ def _resolve_overlay_position
300
+ return nil unless respond_to?(:request) && request
301
+
302
+ value = request.headers[OVERLAY_POSITION_HEADER].to_s
303
+ return nil if value.empty?
304
+ sym = value.to_sym
305
+ ALLOWED_POSITIONS.include?(sym) ? sym : nil
306
+ end
307
+
308
+ def _resolve_overlay_align
309
+ return nil unless respond_to?(:request) && request
310
+
311
+ value = request.headers[OVERLAY_ALIGN_HEADER].to_s
312
+ return nil if value.empty?
313
+ sym = value.to_sym
314
+ ALLOWED_ALIGNS.include?(sym) ? sym : nil
315
+ end
316
+
317
+ def _resolve_overlay_offset
318
+ return nil unless respond_to?(:request) && request
319
+
320
+ value = request.headers[OVERLAY_OFFSET_HEADER].to_s
321
+ return nil if value.empty?
322
+ n = Integer(value, exception: false)
323
+ n && n.clamp(OFFSET_RANGE.min, OFFSET_RANGE.max)
324
+ end
325
+
326
+ def _resolve_overlay_backdrop
327
+ return true unless respond_to?(:request) && request
328
+ request.headers[OVERLAY_BACKDROP_HEADER].to_s != "false"
329
+ end
330
+
331
+ def _resolve_overlay_close
332
+ return true unless respond_to?(:request) && request
333
+ request.headers[OVERLAY_CLOSE_HEADER].to_s != "false"
334
+ end
335
+
336
+ def _resolve_overlay_keep_open_on_redirect
337
+ return false unless respond_to?(:request) && request
338
+ request.headers[OVERLAY_KEEP_OPEN_HEADER].to_s == "true"
339
+ end
340
+
341
+ def _turbo_overlay_set_variant
342
+ type = turbo_overlay_type
343
+ return unless type
344
+
345
+ config = TurboOverlay.configuration
346
+ type_config = case type
347
+ when :modal then config.modal
348
+ when :drawer then config.drawer
349
+ when :popover then config.popover
350
+ when :hint then config.hint
351
+ end
352
+ variant = type_config.variant
353
+ if request.variant.is_a?(Array)
354
+ request.variant << variant unless request.variant.include?(variant)
355
+ else
356
+ request.variant = variant
357
+ end
358
+ end
359
+
360
+ # Initial overlay opens are GET requests with Accept including
361
+ # `text/vnd.turbo-stream.html`. Force html format so Rails
362
+ # resolves `*.html.erb` templates normally (no need for the user
363
+ # to provide `*.turbo_stream.erb` variants); we override the
364
+ # response Content-Type after the action so Turbo still processes
365
+ # the embedded `<turbo-stream>` tags.
366
+ #
367
+ # Frame re-renders deliberately do NOT force the format. Apps
368
+ # typically branch the action on format
369
+ # (`respond_to { |format| format.turbo_stream { … }; format.html
370
+ # { redirect_to … } }`); forcing html here would pick the
371
+ # redirect branch on every successful save, and the redirected
372
+ # page would itself render through this concern's overlay
373
+ # layout — morphing the entire next page into the open dialog.
374
+ # Implicit renders (`render :edit` with no `respond_to`) still
375
+ # work: `request.format` stays `:turbo_stream`, Rails' template
376
+ # resolution walks `request.formats` and falls back to
377
+ # `<action>.html.erb`.
378
+ #
379
+ # Hint requests skip both: they're fetched by the gem's JS via
380
+ # plain `fetch()` (not Turbo), the Accept header is `text/html`
381
+ # already, and the response is parsed via DOMParser. Forcing the
382
+ # turbo-stream content type would make Turbo try to process the
383
+ # response if it ever did intercept it.
384
+ def _turbo_overlay_force_html_format
385
+ return unless turbo_overlay_initial_open?
386
+ return if turbo_overlay_type == :hint
387
+ request.format = :html
388
+ end
389
+
390
+ # Frame re-renders DO need the turbo-stream Content-Type even
391
+ # though the action template is `*.html.erb`: the overlay layout
392
+ # wraps the body in a `<turbo-stream action="replace"
393
+ # method="morph">` tag, and Turbo only processes stream actions
394
+ # when the response Content-Type is the turbo-stream mime. Rails
395
+ # would otherwise set it to `text/html` (matched-template mime),
396
+ # which makes Turbo treat the response as a frame body, find no
397
+ # matching `<turbo-frame>` at the top level, and silently drop it.
398
+ def _turbo_overlay_set_stream_content_type
399
+ return unless turbo_overlay_initial_open? || turbo_overlay_frame_re_render?
400
+ return if turbo_overlay_type == :hint
401
+ return unless response
402
+ response.content_type = "text/vnd.turbo-stream.html; charset=utf-8"
403
+ end
404
+ end
405
+ end
@@ -0,0 +1,52 @@
1
+ require "rails/engine"
2
+ require "turbo-rails"
3
+
4
+ module TurboOverlay
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace TurboOverlay
7
+
8
+ initializer "turbo_overlay.action_view" do
9
+ ActiveSupport.on_load(:action_view) do
10
+ require "turbo_overlay/helpers/view_helper"
11
+ include TurboOverlay::Helpers::ViewHelper
12
+ end
13
+ end
14
+
15
+ initializer "turbo_overlay.turbo_stream_actions" do
16
+ require "turbo_overlay/helpers/stream_helper"
17
+ Turbo::Streams::TagBuilder.include(TurboOverlay::Helpers::StreamHelper)
18
+ end
19
+
20
+ # Eager-load the controller concern so `include TurboOverlay::Controller`
21
+ # works in user code without an explicit require.
22
+ initializer "turbo_overlay.controller_autoload" do
23
+ require "turbo_overlay/controller"
24
+ end
25
+
26
+ # Make the gem's `app/javascript` available to sprockets/propshaft
27
+ # so importmap-rails can serve the controllers + entry point.
28
+ initializer "turbo_overlay.assets" do |app|
29
+ if app.config.respond_to?(:assets)
30
+ app.config.assets.paths << root.join("app/javascript").to_s
31
+ end
32
+ end
33
+
34
+ # Pin the gem's JS for importmap-rails apps. Apps then write:
35
+ #
36
+ # import { register } from "turbo_overlay"
37
+ # register(application)
38
+ #
39
+ # in their Stimulus entry point. jsbundling apps either eject the
40
+ # JS via `bin/rails g turbo_overlay:eject --js` or add the gem's
41
+ # `app/javascript` dir to their bundler's resolve paths.
42
+ #
43
+ # Implementation: append the gem's `config/importmap.rb` to the
44
+ # host app's importmap paths so importmap-rails draws the pins
45
+ # during its own `importmap` initializer.
46
+ initializer "turbo_overlay.importmap", before: "importmap" do |app|
47
+ if app.config.respond_to?(:importmap) && app.config.importmap.respond_to?(:paths)
48
+ app.config.importmap.paths << root.join("config/importmap.rb")
49
+ end
50
+ end
51
+ end
52
+ end