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