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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +436 -0
- data/LICENSE.txt +21 -0
- data/README.md +330 -0
- data/Rakefile +35 -0
- data/app/assets/stylesheets/turbo_overlay.css +234 -0
- data/app/javascript/turbo_overlay/dialog_utils.js +46 -0
- data/app/javascript/turbo_overlay/hint.js +670 -0
- data/app/javascript/turbo_overlay/history.js +184 -0
- data/app/javascript/turbo_overlay/index.js +53 -0
- data/app/javascript/turbo_overlay/options.js +152 -0
- data/app/javascript/turbo_overlay/overlay_controller.js +882 -0
- data/app/javascript/turbo_overlay/popover_position.js +64 -0
- data/app/javascript/turbo_overlay/setup.js +885 -0
- data/app/javascript/turbo_overlay/stack_controller.js +131 -0
- data/app/javascript/turbo_overlay/submit_close.js +49 -0
- data/app/javascript/turbo_overlay/visit.js +52 -0
- data/app/views/layouts/turbo_overlay/drawer.html.erb +5 -0
- data/app/views/layouts/turbo_overlay/hint.html.erb +10 -0
- data/app/views/layouts/turbo_overlay/modal.html.erb +5 -0
- data/app/views/layouts/turbo_overlay/popover.html.erb +5 -0
- data/app/views/turbo_overlay/_drawer.html.erb +49 -0
- data/app/views/turbo_overlay/_hint.html.erb +6 -0
- data/app/views/turbo_overlay/_loading.html.erb +12 -0
- data/app/views/turbo_overlay/_modal.html.erb +46 -0
- data/app/views/turbo_overlay/_popover.html.erb +54 -0
- data/config/importmap.rb +11 -0
- data/lib/generators/turbo_overlay/eject_generator.rb +115 -0
- data/lib/generators/turbo_overlay/install_generator.rb +443 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_confirm.html.erb +13 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_drawer.html.erb +50 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_hint.html.erb +9 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_loading.html.erb +9 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_modal.html.erb +49 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_popover.html.erb +54 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_confirm.html.erb +13 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_drawer.html.erb +55 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_hint.html.erb +9 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_loading.html.erb +9 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_modal.html.erb +58 -0
- data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_popover.html.erb +53 -0
- data/lib/generators/turbo_overlay/templates/chrome/plain/_confirm.html.erb +14 -0
- data/lib/generators/turbo_overlay/templates/chrome/tailwind/_confirm.html.erb +17 -0
- data/lib/generators/turbo_overlay/templates/chrome/tailwind/_drawer.html.erb +55 -0
- data/lib/generators/turbo_overlay/templates/chrome/tailwind/_hint.html.erb +6 -0
- data/lib/generators/turbo_overlay/templates/chrome/tailwind/_loading.html.erb +9 -0
- data/lib/generators/turbo_overlay/templates/chrome/tailwind/_modal.html.erb +46 -0
- data/lib/generators/turbo_overlay/templates/chrome/tailwind/_popover.html.erb +54 -0
- data/lib/generators/turbo_overlay/templates/initializer.rb.tt +67 -0
- data/lib/turbo_overlay/configuration.rb +226 -0
- data/lib/turbo_overlay/controller.rb +405 -0
- data/lib/turbo_overlay/engine.rb +52 -0
- data/lib/turbo_overlay/helpers/stream_helper.rb +77 -0
- data/lib/turbo_overlay/helpers/view_helper.rb +651 -0
- data/lib/turbo_overlay/version.rb +3 -0
- data/lib/turbo_overlay.rb +20 -0
- 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,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)
|