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,885 @@
|
|
|
1
|
+
import { computePopoverPosition } from "turbo_overlay/popover_position"
|
|
2
|
+
import {
|
|
3
|
+
safelyCloseDialog, safelyHidePopover, normalizePopoverDialogStyles
|
|
4
|
+
} from "turbo_overlay/dialog_utils"
|
|
5
|
+
import {
|
|
6
|
+
setAdvanceUrl, clearAdvanceUrl, resetHistoryState, registerPopstateHandler,
|
|
7
|
+
expectedPopstateCount, hasPushedOverlayOnStack
|
|
8
|
+
} from "turbo_overlay/history"
|
|
9
|
+
import { emitOverlayHeaders } from "turbo_overlay/options"
|
|
10
|
+
|
|
11
|
+
// Global wiring for turbo_overlay.
|
|
12
|
+
//
|
|
13
|
+
// This module owns everything that's not bound to a single overlay
|
|
14
|
+
// element: the Turbo.StreamActions.overlay action, the document-level
|
|
15
|
+
// click/submit/turbo:before-fetch-request hooks that inject overlay
|
|
16
|
+
// request headers and spawn loading placeholders, the loading-frame
|
|
17
|
+
// morph/cleanup lifecycle, the back/forward cache teardown, and the
|
|
18
|
+
// shared registries that the Stimulus controllers read from.
|
|
19
|
+
//
|
|
20
|
+
// Self-bootstraps on import: every register*() is idempotent (guarded
|
|
21
|
+
// by a window._turboOverlay*Registered flag), so importing this module
|
|
22
|
+
// from `index.js` (which `register(application)` does for you) is
|
|
23
|
+
// enough. The Stimulus controllers don't re-run setup on connect.
|
|
24
|
+
|
|
25
|
+
// Module-scoped registry mapping a popover's overlay id to the element
|
|
26
|
+
// that triggered it. The dialog's controller looks up its anchor here
|
|
27
|
+
// on connect so the trigger reference never has to round-trip to the
|
|
28
|
+
// server.
|
|
29
|
+
const popoverTriggers = new Map()
|
|
30
|
+
|
|
31
|
+
// Overlay ids whose loading placeholder the user dismissed before the
|
|
32
|
+
// server response arrived. We always try to abort the in-flight fetch
|
|
33
|
+
// via AbortController so the response never lands, but the set is a
|
|
34
|
+
// belt-and-suspenders fallback for the (theoretical) case where the
|
|
35
|
+
// response is already in `before-stream-render` when the user clicks
|
|
36
|
+
// dismiss.
|
|
37
|
+
const dismissedLoadingIds = new Set()
|
|
38
|
+
|
|
39
|
+
// AbortController per overlay-id keyed in-flight request. Lives long
|
|
40
|
+
// enough to cancel the fetch when the user closes the loading
|
|
41
|
+
// placeholder; cleaned up on response (success or failure), on visit,
|
|
42
|
+
// and on morph-in.
|
|
43
|
+
const inflightAborts = new Map()
|
|
44
|
+
|
|
45
|
+
// Tracks whether `turbo:before-cache` is firing inside a real visit
|
|
46
|
+
// (link click, form submit, Turbo.visit, popstate that carries
|
|
47
|
+
// Turbo's restoration state) or inside Turbo Drive's
|
|
48
|
+
// `historyPoppedWithEmptyState` path (popstate over an entry that
|
|
49
|
+
// lacks `state.turbo` — e.g. one the gem pushed for an advance
|
|
50
|
+
// overlay). Only the visit path should run `tearDownAllOverlays`.
|
|
51
|
+
let _realVisitInProgress = false
|
|
52
|
+
|
|
53
|
+
export function getPopoverTrigger(id) {
|
|
54
|
+
return popoverTriggers.get(id) || null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function clearPopoverTrigger(id) {
|
|
58
|
+
popoverTriggers.delete(id)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 8-char base36 suffix for DOM ids the gem assigns at runtime
|
|
62
|
+
// (overlays, hint bubbles, ad-hoc confirm dialogs). Not cryptographic
|
|
63
|
+
// — just needs to be unique among concurrently-live ids on the page.
|
|
64
|
+
export function randomIdSuffix() {
|
|
65
|
+
return Math.random().toString(36).slice(2, 10)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function generateOverlayId() {
|
|
69
|
+
return "ov-" + randomIdSuffix()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function cssEscape(value) {
|
|
73
|
+
return (window.CSS && typeof window.CSS.escape === "function")
|
|
74
|
+
? window.CSS.escape(value)
|
|
75
|
+
: String(value).replace(/[^a-zA-Z0-9_-]/g, "\\$&")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Resolve the URL the gem should push for an advance-eligible trigger.
|
|
79
|
+
// Modal/drawer triggers participate; popovers and hints never do.
|
|
80
|
+
//
|
|
81
|
+
// Resolution order:
|
|
82
|
+
// 1. Type is "popover"/"hint" → null (hard rule).
|
|
83
|
+
// 2. data-turbo-overlay-advance="false" → null (explicit opt-out).
|
|
84
|
+
// 3. data-turbo-overlay-advance="true" → link.href.
|
|
85
|
+
// 4. data-turbo-overlay-advance="/foo" → "/foo".
|
|
86
|
+
// 5. Attribute absent → consult the stack's per-type default
|
|
87
|
+
// attribute (data-turbo-overlay-advance-modal /
|
|
88
|
+
// -advance-drawer): "true" → link.href; anything else → null.
|
|
89
|
+
function resolveAdvanceUrl(link) {
|
|
90
|
+
const type = link && link.dataset && link.dataset.turboOverlay
|
|
91
|
+
if (type !== "modal" && type !== "drawer") return null
|
|
92
|
+
|
|
93
|
+
const explicit = link.dataset.turboOverlayAdvance
|
|
94
|
+
if (explicit === "false") return null
|
|
95
|
+
if (explicit === "true") return link.href || null
|
|
96
|
+
if (typeof explicit === "string" && explicit.length > 0) return explicit
|
|
97
|
+
|
|
98
|
+
const stack = document.querySelector("[data-controller~='turbo-overlay-stack']")
|
|
99
|
+
if (!stack) return null
|
|
100
|
+
const fromStack = stack.dataset[`turboOverlayAdvance${type === "modal" ? "Modal" : "Drawer"}`]
|
|
101
|
+
if (fromStack === "true") return link.href || null
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function findLoadingFrame(id) {
|
|
106
|
+
if (!id) return null
|
|
107
|
+
return document.querySelector(`[data-turbo-overlay-loading-id="${cssEscape(id)}"]`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function removeLoadingOverlay(id) {
|
|
111
|
+
const frame = findLoadingFrame(id)
|
|
112
|
+
if (!frame) return
|
|
113
|
+
const dialog = frame.querySelector("dialog")
|
|
114
|
+
if (dialog && dialog.open) safelyCloseDialog(dialog)
|
|
115
|
+
frame.remove()
|
|
116
|
+
inflightAborts.delete(id)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function clearAllLoadingOverlays() {
|
|
120
|
+
const frames = document.querySelectorAll("[data-turbo-overlay-loading-id]")
|
|
121
|
+
frames.forEach((frame) => {
|
|
122
|
+
const dialog = frame.querySelector("dialog")
|
|
123
|
+
if (dialog && dialog.open) safelyCloseDialog(dialog)
|
|
124
|
+
frame.remove()
|
|
125
|
+
})
|
|
126
|
+
inflightAborts.forEach((aborter) => {
|
|
127
|
+
try { aborter.abort() } catch (_) { /* ignore */ }
|
|
128
|
+
})
|
|
129
|
+
inflightAborts.clear()
|
|
130
|
+
dismissedLoadingIds.clear()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Rip out every overlay frame (live + loading) before Turbo snapshots
|
|
134
|
+
// the page for its cache. Dialog `showModal()` top-layer membership is
|
|
135
|
+
// per-document and lost across navigations; without this teardown, a
|
|
136
|
+
// back/forward restore would bring back a `<dialog open>` whose top-
|
|
137
|
+
// layer state is gone — rendering as an inline block with no backdrop,
|
|
138
|
+
// no focus trap, and an ESC key that no longer fires native `cancel`.
|
|
139
|
+
//
|
|
140
|
+
// We also clear the popover trigger registry: it points at <a> elements
|
|
141
|
+
// in the live DOM, and after a navigation those references would either
|
|
142
|
+
// be stale (detached nodes from a previous page) or actively wrong (a
|
|
143
|
+
// re-used overlay id resolving to the prior page's anchor). The hover/
|
|
144
|
+
// loading registries follow the same rationale via clearAllLoadingOverlays.
|
|
145
|
+
function tearDownAllOverlays() {
|
|
146
|
+
const frames = document.querySelectorAll("turbo-frame.turbo-overlay-frame")
|
|
147
|
+
frames.forEach((frame) => {
|
|
148
|
+
const dialog = frame.querySelector("dialog")
|
|
149
|
+
if (dialog && dialog.open) safelyCloseDialog(dialog)
|
|
150
|
+
frame.remove()
|
|
151
|
+
})
|
|
152
|
+
inflightAborts.forEach((aborter) => {
|
|
153
|
+
try { aborter.abort() } catch (_) { /* ignore */ }
|
|
154
|
+
})
|
|
155
|
+
inflightAborts.clear()
|
|
156
|
+
dismissedLoadingIds.clear()
|
|
157
|
+
popoverTriggers.clear()
|
|
158
|
+
resetHistoryState()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Tear down a same-id overlay frame still in the DOM so a re-click on
|
|
162
|
+
// its trigger can spawn a fresh placeholder without colliding on the
|
|
163
|
+
// `turbo_overlay_<type>_<id>` frame id. Drives the registered
|
|
164
|
+
// controller's close path (synchronous unregister + listener cleanup),
|
|
165
|
+
// aborts any in-flight load for that id, and force-removes the frame
|
|
166
|
+
// so we don't have to wait on a 400ms close animation.
|
|
167
|
+
function teardownExistingOverlayFrame(frame, id) {
|
|
168
|
+
window.dispatchEvent(new CustomEvent("turbo-overlay:close", { detail: { id } }))
|
|
169
|
+
|
|
170
|
+
const aborter = inflightAborts.get(id)
|
|
171
|
+
if (aborter) {
|
|
172
|
+
try { aborter.abort() } catch (_) { /* ignore */ }
|
|
173
|
+
inflightAborts.delete(id)
|
|
174
|
+
}
|
|
175
|
+
dismissedLoadingIds.delete(id)
|
|
176
|
+
|
|
177
|
+
if (frame.parentNode) {
|
|
178
|
+
const dialog = frame.querySelector("dialog")
|
|
179
|
+
if (dialog && dialog.open) safelyCloseDialog(dialog)
|
|
180
|
+
frame.remove()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Clone the matching `turbo_overlay_loading_<type>_template` into the
|
|
185
|
+
// stack and open it immediately. Inherits the trigger's link options
|
|
186
|
+
// (backdrop, drawer position, close button suppression, popover
|
|
187
|
+
// position/align/offset) so the placeholder reads visually the same as
|
|
188
|
+
// the eventual chrome.
|
|
189
|
+
//
|
|
190
|
+
// The placeholder dialog is wrapped in a `<turbo-frame>` that matches
|
|
191
|
+
// the id the server will eventually render. When the real response
|
|
192
|
+
// arrives, `before-stream-render` morphs the new dialog's attributes
|
|
193
|
+
// and children INTO this same dialog node so the overlay never closes
|
|
194
|
+
// and re-opens — the entry animation only plays once, when the
|
|
195
|
+
// placeholder first appears.
|
|
196
|
+
function spawnLoadingOverlay(link) {
|
|
197
|
+
const type = link.dataset.turboOverlay
|
|
198
|
+
const id = link.dataset.turboOverlayId
|
|
199
|
+
if (!type || !id) return
|
|
200
|
+
|
|
201
|
+
const template = document.getElementById(`turbo_overlay_loading_${type}_template`)
|
|
202
|
+
if (!template || !template.content) return
|
|
203
|
+
|
|
204
|
+
const stack = document.querySelector("[data-controller~='turbo-overlay-stack']")
|
|
205
|
+
if (!stack) return
|
|
206
|
+
|
|
207
|
+
const fragment = template.content.cloneNode(true)
|
|
208
|
+
const root = fragment.firstElementChild
|
|
209
|
+
if (!root) return
|
|
210
|
+
|
|
211
|
+
const backdrop = link.dataset.turboOverlayBackdrop !== "false"
|
|
212
|
+
if (!backdrop) root.classList.add("turbo-overlay--no-backdrop")
|
|
213
|
+
|
|
214
|
+
if (type === "drawer") {
|
|
215
|
+
const position = link.dataset.turboOverlayPosition
|
|
216
|
+
if (position) {
|
|
217
|
+
root.classList.remove(
|
|
218
|
+
"turbo-overlay--drawer-right",
|
|
219
|
+
"turbo-overlay--drawer-left",
|
|
220
|
+
"turbo-overlay--drawer-top",
|
|
221
|
+
"turbo-overlay--drawer-bottom"
|
|
222
|
+
)
|
|
223
|
+
root.classList.add(`turbo-overlay--drawer-${position}`)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (link.dataset.turboOverlayClose === "false") {
|
|
228
|
+
root.dataset.turboOverlayClose = "false"
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const frame = document.createElement("turbo-frame")
|
|
232
|
+
frame.id = `turbo_overlay_${type}_${id}`
|
|
233
|
+
frame.className = "turbo-overlay-frame"
|
|
234
|
+
frame.dataset.turboOverlayLoadingId = id
|
|
235
|
+
frame.dataset.turboOverlayLoadingType = type
|
|
236
|
+
frame.appendChild(root)
|
|
237
|
+
|
|
238
|
+
stack.appendChild(frame)
|
|
239
|
+
|
|
240
|
+
if (root.tagName === "DIALOG") {
|
|
241
|
+
const useModal = (type === "modal") || (type === "drawer" && backdrop)
|
|
242
|
+
// Popovers opened from inside an existing modal dialog must
|
|
243
|
+
// themselves be modal. The HTML inertness algorithm blocks every
|
|
244
|
+
// non-descendant of the topmost modal dialog from receiving input,
|
|
245
|
+
// even top-layer popovers added afterwards. Switching to showModal
|
|
246
|
+
// makes the popover the topmost modal so it stays interactive;
|
|
247
|
+
// transparent ::backdrop CSS preserves the non-modal visual feel.
|
|
248
|
+
//
|
|
249
|
+
// Non-modal drawers are *not* auto-promoted: the UA `dialog:modal`
|
|
250
|
+
// stylesheet would override the gem's `.turbo-overlay--drawer-right`
|
|
251
|
+
// (etc.) inset rules and re-center the drawer in the viewport.
|
|
252
|
+
// Opening a non-modal drawer from inside a modal is documented as
|
|
253
|
+
// unsupported — the parent modal blocks page interaction anyway, so
|
|
254
|
+
// the "non-modal" semantic doesn't really apply in that context.
|
|
255
|
+
const popoverNeedsModal = type === "popover" &&
|
|
256
|
+
!!document.querySelector("dialog:modal")
|
|
257
|
+
try {
|
|
258
|
+
if (useModal || popoverNeedsModal) root.showModal()
|
|
259
|
+
else if (type === "popover") root.showPopover()
|
|
260
|
+
else root.show()
|
|
261
|
+
} catch (_) {
|
|
262
|
+
root.setAttribute("open", "")
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
attachLoadingDismissHandlers(root, frame, id)
|
|
267
|
+
|
|
268
|
+
if (type === "popover") {
|
|
269
|
+
positionLoadingPopover(root, link)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Loading placeholders have no Stimulus controller (the chrome partial
|
|
274
|
+
// is rendered with `loading: true`, which strips data-controller and
|
|
275
|
+
// data-action). Dismissal is wired directly here: ESC fires native
|
|
276
|
+
// `cancel` on modal dialogs; clicking the dialog itself
|
|
277
|
+
// (target === dialog) is a backdrop click. On dismiss we abort the
|
|
278
|
+
// in-flight fetch so the response never lands. The class guard makes
|
|
279
|
+
// the listeners no-op after a morph swaps the placeholder into the
|
|
280
|
+
// live overlay (the live dialog handles its own ESC/backdrop via the
|
|
281
|
+
// Stimulus controller).
|
|
282
|
+
function attachLoadingDismissHandlers(dialog, frame, id) {
|
|
283
|
+
if (!dialog || !frame || !id) return
|
|
284
|
+
|
|
285
|
+
const dismiss = (event) => {
|
|
286
|
+
if (!dialog.classList.contains("turbo-overlay--loading")) return
|
|
287
|
+
if (event && typeof event.preventDefault === "function") event.preventDefault()
|
|
288
|
+
|
|
289
|
+
const aborter = inflightAborts.get(id)
|
|
290
|
+
if (aborter) {
|
|
291
|
+
try { aborter.abort() } catch (_) { /* ignore */ }
|
|
292
|
+
inflightAborts.delete(id)
|
|
293
|
+
}
|
|
294
|
+
dismissedLoadingIds.add(id)
|
|
295
|
+
|
|
296
|
+
if (dialog.tagName === "DIALOG") {
|
|
297
|
+
if (dialog.classList.contains("turbo-overlay--popover")) {
|
|
298
|
+
safelyHidePopover(dialog)
|
|
299
|
+
} else if (dialog.open) {
|
|
300
|
+
safelyCloseDialog(dialog)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
frame.remove()
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
dialog.addEventListener("cancel", dismiss)
|
|
307
|
+
|
|
308
|
+
if (dialog.tagName === "DIALOG") {
|
|
309
|
+
dialog.addEventListener("click", (event) => {
|
|
310
|
+
if (event.target === dialog) dismiss(event)
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function positionLoadingPopover(root, link) {
|
|
316
|
+
// Normalize the dialog's positioning BEFORE measuring — see comment
|
|
317
|
+
// in overlay_controller.js#_positionPopover. UA [popover] /
|
|
318
|
+
// dialog:modal styles set inset:0 with width:auto, which stretches
|
|
319
|
+
// the dialog and corrupts the auto-flip math if measured first.
|
|
320
|
+
normalizePopoverDialogStyles(root)
|
|
321
|
+
|
|
322
|
+
const anchorRect = link.getBoundingClientRect()
|
|
323
|
+
const dialogRect = root.getBoundingClientRect()
|
|
324
|
+
const viewport = {
|
|
325
|
+
width: document.documentElement.clientWidth,
|
|
326
|
+
height: document.documentElement.clientHeight
|
|
327
|
+
}
|
|
328
|
+
const position = link.dataset.turboOverlayPosition || "bottom"
|
|
329
|
+
const align = link.dataset.turboOverlayAlign || "start"
|
|
330
|
+
const offsetRaw = link.dataset.turboOverlayOffset
|
|
331
|
+
const offset = offsetRaw == null ? 4 : parseInt(offsetRaw, 10) || 0
|
|
332
|
+
|
|
333
|
+
const { top, left } = computePopoverPosition({
|
|
334
|
+
anchor: anchorRect,
|
|
335
|
+
dialog: dialogRect,
|
|
336
|
+
viewport,
|
|
337
|
+
position, align, offset,
|
|
338
|
+
autoFlip: true
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
root.style.top = `${top}px`
|
|
342
|
+
root.style.left = `${left}px`
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Morph the placeholder dialog so it becomes the live overlay:
|
|
346
|
+
// transfer every attribute from the incoming dialog (except `open`,
|
|
347
|
+
// which the placeholder already has) and replace the children. The
|
|
348
|
+
// dialog DOM node never closes — it stays in the top layer (or at its
|
|
349
|
+
// fixed position) the entire time — so the overlay's open animation
|
|
350
|
+
// only plays once, when the placeholder first appeared.
|
|
351
|
+
//
|
|
352
|
+
// Stimulus's MutationObserver picks up `data-controller="turbo-overlay"`
|
|
353
|
+
// landing on the dialog and connects the controller. The controller's
|
|
354
|
+
// connect path already handles "dialog is already open" by skipping
|
|
355
|
+
// showModal/show.
|
|
356
|
+
function morphDialogInPlace(existing, incoming) {
|
|
357
|
+
// `open` already drives the placeholder's open state — re-applying
|
|
358
|
+
// it from the server payload is a no-op at best and breaks top-layer
|
|
359
|
+
// identity at worst. `style` holds the popover's inline anchor
|
|
360
|
+
// positioning, which we want to keep until the controller re-runs
|
|
361
|
+
// _positionPopover; the partial never emits a style attribute, so
|
|
362
|
+
// skipping it costs nothing.
|
|
363
|
+
const incomingAttrNames = new Set()
|
|
364
|
+
for (const attr of Array.from(incoming.attributes)) {
|
|
365
|
+
if (attr.name === "open" || attr.name === "style") continue
|
|
366
|
+
incomingAttrNames.add(attr.name)
|
|
367
|
+
if (existing.getAttribute(attr.name) !== attr.value) {
|
|
368
|
+
existing.setAttribute(attr.name, attr.value)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
for (const attr of Array.from(existing.attributes)) {
|
|
372
|
+
if (attr.name === "open" || attr.name === "style") continue
|
|
373
|
+
if (!incomingAttrNames.has(attr.name)) existing.removeAttribute(attr.name)
|
|
374
|
+
}
|
|
375
|
+
existing.replaceChildren(...incoming.childNodes)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function _readOverlayIdFromFetchEvent(event) {
|
|
379
|
+
const detail = event.detail || {}
|
|
380
|
+
const request = detail.fetchRequest || detail.request
|
|
381
|
+
if (!request) return null
|
|
382
|
+
const headers = (request.fetchOptions && request.fetchOptions.headers) ||
|
|
383
|
+
(typeof request.headers === "object" ? request.headers : null)
|
|
384
|
+
if (!headers) return null
|
|
385
|
+
return headers["X-Turbo-Overlay-Id"] || headers["x-turbo-overlay-id"] || null
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function registerStreamAction() {
|
|
389
|
+
if (typeof window === "undefined") return
|
|
390
|
+
const Turbo = window.Turbo
|
|
391
|
+
if (!Turbo || !Turbo.StreamActions || Turbo.StreamActions.overlay) return
|
|
392
|
+
|
|
393
|
+
Turbo.StreamActions.overlay = function () {
|
|
394
|
+
const message = this.getAttribute("message") || "close"
|
|
395
|
+
const detail = {
|
|
396
|
+
scope: this.getAttribute("scope") || "top",
|
|
397
|
+
type: this.getAttribute("type") || null,
|
|
398
|
+
id: this.getAttribute("overlay-id") || null,
|
|
399
|
+
visit: this.getAttribute("visit") || null,
|
|
400
|
+
visitAction: this.getAttribute("visit-action") || null
|
|
401
|
+
}
|
|
402
|
+
window.dispatchEvent(new CustomEvent(`turbo-overlay:${message}`, { detail }))
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Drop the loading placeholder when:
|
|
407
|
+
// - the matching server-rendered frame arrives in a turbo-stream
|
|
408
|
+
// - the request errors out (network failure, 4xx/5xx)
|
|
409
|
+
// - the user navigates away while a request is in flight
|
|
410
|
+
function registerLoadingHook() {
|
|
411
|
+
if (typeof document === "undefined") return
|
|
412
|
+
if (window._turboOverlayLoadingHookRegistered) return
|
|
413
|
+
window._turboOverlayLoadingHookRegistered = true
|
|
414
|
+
|
|
415
|
+
document.addEventListener("turbo:before-stream-render", (event) => {
|
|
416
|
+
const stream = event.detail && event.detail.newStream
|
|
417
|
+
if (!stream || !stream.templateElement) return
|
|
418
|
+
const incomingFrames = stream.templateElement.content.querySelectorAll(
|
|
419
|
+
"turbo-frame[id^='turbo_overlay_']"
|
|
420
|
+
)
|
|
421
|
+
incomingFrames.forEach((incomingFrame) => {
|
|
422
|
+
const rest = incomingFrame.id.substring("turbo_overlay_".length)
|
|
423
|
+
const underscore = rest.indexOf("_")
|
|
424
|
+
if (underscore < 0) return
|
|
425
|
+
const id = rest.substring(underscore + 1)
|
|
426
|
+
|
|
427
|
+
if (dismissedLoadingIds.has(id)) {
|
|
428
|
+
dismissedLoadingIds.delete(id)
|
|
429
|
+
incomingFrame.remove()
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const placeholderFrame = findLoadingFrame(id)
|
|
434
|
+
if (!placeholderFrame) return
|
|
435
|
+
|
|
436
|
+
const incomingDialog = incomingFrame.querySelector("dialog.turbo-overlay")
|
|
437
|
+
const placeholderDialog = placeholderFrame.querySelector("dialog.turbo-overlay")
|
|
438
|
+
if (!incomingDialog || !placeholderDialog) {
|
|
439
|
+
removeLoadingOverlay(id)
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
morphDialogInPlace(placeholderDialog, incomingDialog)
|
|
444
|
+
delete placeholderFrame.dataset.turboOverlayLoadingId
|
|
445
|
+
delete placeholderFrame.dataset.turboOverlayLoadingType
|
|
446
|
+
inflightAborts.delete(id)
|
|
447
|
+
|
|
448
|
+
incomingFrame.remove()
|
|
449
|
+
})
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
document.addEventListener("turbo:fetch-request-error", (event) => {
|
|
453
|
+
const id = _readOverlayIdFromFetchEvent(event)
|
|
454
|
+
if (id) {
|
|
455
|
+
removeLoadingOverlay(id)
|
|
456
|
+
clearAdvanceUrl(id)
|
|
457
|
+
}
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
document.addEventListener("turbo:before-fetch-response", (event) => {
|
|
461
|
+
const id = _readOverlayIdFromFetchEvent(event)
|
|
462
|
+
if (!id) return
|
|
463
|
+
const response = event.detail && event.detail.fetchResponse
|
|
464
|
+
if (response && response.succeeded) return
|
|
465
|
+
removeLoadingOverlay(id)
|
|
466
|
+
clearAdvanceUrl(id)
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
// `turbo:before-visit` fires only for proposed visits (link clicks,
|
|
470
|
+
// form submits, programmatic `Turbo.visit`). Popstate-triggered
|
|
471
|
+
// restoration visits go through `Navigator#startVisit` directly and
|
|
472
|
+
// skip `before-visit` entirely. Use it to set the "real visit"
|
|
473
|
+
// flag, which `before-cache` reads to decide whether to tear down.
|
|
474
|
+
document.addEventListener("turbo:before-visit", () => {
|
|
475
|
+
_realVisitInProgress = true
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
// `turbo:visit` fires for every visit (including restores). Handle
|
|
479
|
+
// both responsibilities in a single listener so they run in the
|
|
480
|
+
// right order against shared state:
|
|
481
|
+
//
|
|
482
|
+
// 1. Detect a restore visit triggered by Turbo Drive's popstate
|
|
483
|
+
// handler over an advance-pushed overlay (either one the gem
|
|
484
|
+
// just rolled back via `history.back()` on close, or a user
|
|
485
|
+
// browser-back over the gem's pushed URL) and cancel it.
|
|
486
|
+
// Otherwise Turbo loads the cached snapshot for the popped
|
|
487
|
+
// URL — replacing the page underneath and tearing down every
|
|
488
|
+
// open overlay in the process. Visit cancellation aborts the
|
|
489
|
+
// queued requestAnimationFrame inside `Visit#render` before
|
|
490
|
+
// it fires `cacheSnapshot()` → `before-cache`, so no teardown
|
|
491
|
+
// side effects.
|
|
492
|
+
//
|
|
493
|
+
// 2. For non-overlay visits, run the existing cleanup
|
|
494
|
+
// (`clearAllLoadingOverlays`, `popoverTriggers.clear`,
|
|
495
|
+
// `resetHistoryState`) AND mark the visit as real so
|
|
496
|
+
// `before-cache` runs the teardown.
|
|
497
|
+
//
|
|
498
|
+
// The cancel branch must come first: `resetHistoryState` clears
|
|
499
|
+
// `pushedEntries`, which `hasPushedOverlayOnStack` reads, so
|
|
500
|
+
// running it before the check would always observe an empty Map.
|
|
501
|
+
document.addEventListener("turbo:visit", (event) => {
|
|
502
|
+
const action = event.detail && event.detail.action
|
|
503
|
+
|
|
504
|
+
if (action === "restore" &&
|
|
505
|
+
(expectedPopstateCount() > 0 || hasPushedOverlayOnStack())) {
|
|
506
|
+
// Turbo exposes the navigator at `window.Turbo.navigator`
|
|
507
|
+
// (flat, not under session). `currentVisit` is set inside
|
|
508
|
+
// `Navigator#startVisit` before `turbo:visit` dispatches.
|
|
509
|
+
const turbo = window.Turbo
|
|
510
|
+
const visit = turbo && turbo.navigator && turbo.navigator.currentVisit
|
|
511
|
+
if (visit && typeof visit.cancel === "function") {
|
|
512
|
+
// `FetchRequest#perform` already started (synchronously,
|
|
513
|
+
// inside `issueRequest`) and is suspended at an `await`
|
|
514
|
+
// boundary. When that microtask resumes — after our handler
|
|
515
|
+
// returns — it calls `visit.requestStarted()`, which schedules
|
|
516
|
+
// a 500ms timer to show Turbo Drive's `.turbo-progress-bar`.
|
|
517
|
+
// Visit cancellation aborts the underlying fetch but doesn't
|
|
518
|
+
// suppress that `requestStarted` callback, so the timer would
|
|
519
|
+
// still fire and the bar would never hide (no
|
|
520
|
+
// `visitCompleted` for a canceled visit). Stub the callback
|
|
521
|
+
// to a no-op before canceling.
|
|
522
|
+
visit.requestStarted = function () {}
|
|
523
|
+
try { visit.cancel() } catch (_) { /* ignore */ }
|
|
524
|
+
}
|
|
525
|
+
// `Session#visitStarted` also called `markAsBusy(documentElement)`
|
|
526
|
+
// before we got control. Without `visitCompleted`/`visitFailed`
|
|
527
|
+
// running through Turbo's normal lifecycle, `aria-busy` would
|
|
528
|
+
// stay stuck on `<html>`. Clear it explicitly.
|
|
529
|
+
if (typeof document !== "undefined" && document.documentElement) {
|
|
530
|
+
document.documentElement.removeAttribute("aria-busy")
|
|
531
|
+
}
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
clearAllLoadingOverlays()
|
|
536
|
+
popoverTriggers.clear()
|
|
537
|
+
resetHistoryState()
|
|
538
|
+
_realVisitInProgress = true
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
// `turbo:before-cache` fires in two distinct paths:
|
|
542
|
+
//
|
|
543
|
+
// 1. A real Turbo visit (link click, form submit, programmatic
|
|
544
|
+
// `Turbo.visit`, or a Turbo Drive restoration). The visit
|
|
545
|
+
// caches the current snapshot before navigating away — we
|
|
546
|
+
// want to tear down here so the cached snapshot doesn't
|
|
547
|
+
// contain stale `<dialog open>` elements.
|
|
548
|
+
//
|
|
549
|
+
// 2. Turbo Drive's `historyPoppedWithEmptyState` path. When a
|
|
550
|
+
// popstate fires for an entry that lacks `state.turbo` (e.g.
|
|
551
|
+
// one the gem pushed for an advance overlay), Turbo's
|
|
552
|
+
// `Session#historyPoppedWithEmptyState` calls
|
|
553
|
+
// `view.cacheSnapshot()` synchronously — which fires
|
|
554
|
+
// `before-cache` but does NOT start a visit. Tearing down here
|
|
555
|
+
// would close every open overlay on every overlay-related
|
|
556
|
+
// popstate.
|
|
557
|
+
//
|
|
558
|
+
// `_realVisitInProgress` is set above for path (1). Skipping the
|
|
559
|
+
// teardown when the flag is false preserves overlays for path (2).
|
|
560
|
+
document.addEventListener("turbo:before-cache", () => {
|
|
561
|
+
if (!_realVisitInProgress) return
|
|
562
|
+
_realVisitInProgress = false
|
|
563
|
+
tearDownAllOverlays()
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function registerFetchHook() {
|
|
568
|
+
if (typeof document === "undefined") return
|
|
569
|
+
if (window._turboOverlayFetchHookRegistered) return
|
|
570
|
+
window._turboOverlayFetchHookRegistered = true
|
|
571
|
+
|
|
572
|
+
// For navigation visits (data-turbo-stream="true" GET links),
|
|
573
|
+
// turbo:before-fetch-request's event.target is documentElement, not
|
|
574
|
+
// the clicked link. Capture the link on its click event and read it
|
|
575
|
+
// back when the fetch is about to fly.
|
|
576
|
+
let pendingTrigger = null
|
|
577
|
+
|
|
578
|
+
// Mirror Turbo's own clickEventIsSignificant predicate. For
|
|
579
|
+
// data-turbo-stream links (what modal_link_to / drawer_link_to
|
|
580
|
+
// produce) Turbo routes through FormLinkClickObserver and never
|
|
581
|
+
// fires turbo:click, so we can't hook that. We instead capture on
|
|
582
|
+
// plain click and skip clicks Turbo itself wouldn't intercept —
|
|
583
|
+
// modifier keys, middle button, contenteditable. Without this skip,
|
|
584
|
+
// a cmd+click (which opens in a new tab and never produces a fetch
|
|
585
|
+
// in this tab) would leave pendingTrigger stale and leak the
|
|
586
|
+
// X-Turbo-Overlay header onto the next unrelated fetch.
|
|
587
|
+
document.addEventListener("click", (event) => {
|
|
588
|
+
if (event.defaultPrevented) return
|
|
589
|
+
if (event.button !== 0) return
|
|
590
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
|
591
|
+
if (event.target && event.target.isContentEditable) return
|
|
592
|
+
const link = event.target && event.target.closest
|
|
593
|
+
? event.target.closest("a[data-turbo-overlay], [data-turbo-overlay]")
|
|
594
|
+
: null
|
|
595
|
+
if (!link) return
|
|
596
|
+
|
|
597
|
+
if (!link.dataset.turboOverlayId) {
|
|
598
|
+
link.dataset.turboOverlayId = generateOverlayId()
|
|
599
|
+
}
|
|
600
|
+
if (link.dataset.turboOverlay === "popover") {
|
|
601
|
+
// Stash the click coordinates on the element so the popover
|
|
602
|
+
// positioner can disambiguate which line of a wrapped inline
|
|
603
|
+
// trigger to anchor to. `getBoundingClientRect()` on a multi-line
|
|
604
|
+
// anchor returns the union of all line boxes, which is too wide
|
|
605
|
+
// to position against meaningfully.
|
|
606
|
+
//
|
|
607
|
+
// For JS-initiated opens via `TurboOverlay.visit`, the actual
|
|
608
|
+
// trigger is invisible — `visit.js` stashes the caller's anchor
|
|
609
|
+
// element on `__turboOverlayAnchor`. Substitute it here so
|
|
610
|
+
// positioning uses the anchor's rect and the popover_triggers
|
|
611
|
+
// registry points at the real element the user clicked.
|
|
612
|
+
const anchor = link.__turboOverlayAnchor
|
|
613
|
+
if (anchor) {
|
|
614
|
+
const rect = anchor.getBoundingClientRect()
|
|
615
|
+
anchor.__turboOverlayClickPoint = {
|
|
616
|
+
x: rect.left + rect.width / 2,
|
|
617
|
+
y: rect.top + rect.height / 2,
|
|
618
|
+
}
|
|
619
|
+
popoverTriggers.set(link.dataset.turboOverlayId, anchor)
|
|
620
|
+
} else {
|
|
621
|
+
link.__turboOverlayClickPoint = { x: event.clientX, y: event.clientY }
|
|
622
|
+
popoverTriggers.set(link.dataset.turboOverlayId, link)
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const adv = resolveAdvanceUrl(link)
|
|
627
|
+
if (adv) setAdvanceUrl(link.dataset.turboOverlayId, adv)
|
|
628
|
+
|
|
629
|
+
pendingTrigger = link
|
|
630
|
+
}, true)
|
|
631
|
+
|
|
632
|
+
document.addEventListener("submit", (event) => {
|
|
633
|
+
const form = event.target && event.target.closest
|
|
634
|
+
? event.target.closest("form[data-turbo-overlay]")
|
|
635
|
+
: null
|
|
636
|
+
if (form) pendingTrigger = form
|
|
637
|
+
}, true)
|
|
638
|
+
|
|
639
|
+
document.addEventListener("turbo:before-fetch-request", (event) => {
|
|
640
|
+
let trigger = event.target && event.target.dataset && event.target.dataset.turboOverlay
|
|
641
|
+
? event.target
|
|
642
|
+
: null
|
|
643
|
+
if (!trigger && pendingTrigger) trigger = pendingTrigger
|
|
644
|
+
pendingTrigger = null
|
|
645
|
+
|
|
646
|
+
if (!trigger || !trigger.dataset || !trigger.dataset.turboOverlay) return
|
|
647
|
+
|
|
648
|
+
const headers = event.detail.fetchOptions.headers
|
|
649
|
+
headers["X-Turbo-Overlay"] = trigger.dataset.turboOverlay
|
|
650
|
+
emitOverlayHeaders(trigger.dataset, headers)
|
|
651
|
+
|
|
652
|
+
// Sticky `data-turbo-overlay-id` on the trigger means a re-click
|
|
653
|
+
// (or a re-submit) reuses the previous overlay id. If the previous
|
|
654
|
+
// overlay is still in the DOM (popover still open, or a prior load
|
|
655
|
+
// still in flight), spawning a new placeholder for this fetch
|
|
656
|
+
// would produce two `<turbo-frame>` nodes with the same id; the
|
|
657
|
+
// new controller's connect() would then take the "frame re-render"
|
|
658
|
+
// branch and orphan the prior controller (no ESC, no
|
|
659
|
+
// outside-click). Tear the existing frame down here — before the
|
|
660
|
+
// new fetch's AbortController is registered — so any old in-flight
|
|
661
|
+
// load is aborted via `inflightAborts.get(id)` while it still
|
|
662
|
+
// holds the prior aborter.
|
|
663
|
+
const overlayId = trigger.dataset.turboOverlayId
|
|
664
|
+
const overlayType = trigger.dataset.turboOverlay
|
|
665
|
+
if (overlayId && overlayType) {
|
|
666
|
+
const existing = document.getElementById(`turbo_overlay_${overlayType}_${overlayId}`)
|
|
667
|
+
if (existing) {
|
|
668
|
+
teardownExistingOverlayFrame(existing, overlayId)
|
|
669
|
+
if (overlayType === "popover") popoverTriggers.set(overlayId, trigger)
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (overlayId) {
|
|
674
|
+
dismissedLoadingIds.delete(overlayId)
|
|
675
|
+
if (typeof AbortController === "function") {
|
|
676
|
+
const aborter = new AbortController()
|
|
677
|
+
event.detail.fetchOptions.signal = aborter.signal
|
|
678
|
+
inflightAborts.set(overlayId, aborter)
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
spawnLoadingOverlay(trigger)
|
|
683
|
+
})
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Replaces window.confirm for `data-turbo-confirm` links/forms with
|
|
687
|
+
// the gem's themed overlay. Opt-in via
|
|
688
|
+
// `register(application, { confirm: true })`.
|
|
689
|
+
//
|
|
690
|
+
// Two styles are supported via the per-variant templates
|
|
691
|
+
// `<template id="turbo_overlay_confirm_modal_template">` and
|
|
692
|
+
// `<template id="turbo_overlay_confirm_popover_template">` rendered by
|
|
693
|
+
// `overlay_stack_tag` from the host app's `_confirm.html+modal.erb`
|
|
694
|
+
// and `_confirm.html+popover.erb` partials.
|
|
695
|
+
//
|
|
696
|
+
// Style resolution (per call):
|
|
697
|
+
// 1. submitter element's `data-turbo-confirm-style`
|
|
698
|
+
// 2. form element's `data-turbo-confirm-style`
|
|
699
|
+
// 3. stack container's `data-turbo-overlay-confirm-style` (configured
|
|
700
|
+
// default — `TurboOverlay.configuration.confirm.style`)
|
|
701
|
+
// 4. fallback "modal"
|
|
702
|
+
//
|
|
703
|
+
// Popover style needs a submitter element to anchor to. If the call
|
|
704
|
+
// doesn't carry one (programmatic form submission), we fall back to
|
|
705
|
+
// modal silently. We also fall back to modal if only the modal
|
|
706
|
+
// template was generated, and vice versa.
|
|
707
|
+
//
|
|
708
|
+
// If neither template is in the DOM (host app hasn't run install yet),
|
|
709
|
+
// we fall back to the browser-native `window.confirm`.
|
|
710
|
+
|
|
711
|
+
// Captured click trigger for the most recent `[data-turbo-confirm]`
|
|
712
|
+
// element the user clicked. Turbo's link-method path
|
|
713
|
+
// (`<a data-turbo-method="delete" data-turbo-confirm="…">`) synthesizes
|
|
714
|
+
// a hidden form and submits it without a submitter argument, so the
|
|
715
|
+
// confirm hook receives `submitter = null` and popover-style anchoring
|
|
716
|
+
// has nothing to attach to. Tracking the actual clicked element here
|
|
717
|
+
// lets `promptConfirm` recover the anchor when Turbo loses it.
|
|
718
|
+
let lastConfirmTrigger = null
|
|
719
|
+
|
|
720
|
+
export function registerConfirm() {
|
|
721
|
+
if (typeof window === "undefined") return
|
|
722
|
+
const Turbo = window.Turbo
|
|
723
|
+
if (!Turbo || !Turbo.config || !Turbo.config.forms) return
|
|
724
|
+
if (window._turboOverlayConfirmRegistered) return
|
|
725
|
+
window._turboOverlayConfirmRegistered = true
|
|
726
|
+
|
|
727
|
+
document.addEventListener("click", (event) => {
|
|
728
|
+
if (event.defaultPrevented) return
|
|
729
|
+
if (event.button !== 0) return
|
|
730
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
|
731
|
+
const trigger = event.target && event.target.closest
|
|
732
|
+
? event.target.closest("[data-turbo-confirm]")
|
|
733
|
+
: null
|
|
734
|
+
lastConfirmTrigger = trigger ? { element: trigger, at: Date.now() } : null
|
|
735
|
+
}, true)
|
|
736
|
+
|
|
737
|
+
Turbo.config.forms.confirm = (message, formElement, submitter) =>
|
|
738
|
+
promptConfirm(message, formElement, submitter)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Recover the clicked trigger when Turbo's submitter is null. Turbo's
|
|
742
|
+
// link-method path synthesizes a hidden form that bears no DOM
|
|
743
|
+
// relationship to the clicked link (it's appended directly to <body>),
|
|
744
|
+
// so we can't validate by containment. The freshness window is the
|
|
745
|
+
// safety net: confirm submissions are sequential — Turbo `await`s the
|
|
746
|
+
// hook — so an unrelated click can't slip in between the capture and
|
|
747
|
+
// the confirm callback in practice.
|
|
748
|
+
function recoverConfirmTrigger() {
|
|
749
|
+
if (!lastConfirmTrigger) return null
|
|
750
|
+
if (Date.now() - lastConfirmTrigger.at > 2000) return null
|
|
751
|
+
const el = lastConfirmTrigger.element
|
|
752
|
+
if (!el || !el.isConnected) return null
|
|
753
|
+
return el
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function resolveConfirmStyle(formElement, submitter, recoveredTrigger) {
|
|
757
|
+
const explicit = (el) => el && el.dataset && el.dataset.turboConfirmStyle
|
|
758
|
+
const fromTrigger = explicit(submitter) || explicit(recoveredTrigger) || explicit(formElement)
|
|
759
|
+
if (fromTrigger === "modal" || fromTrigger === "popover") return fromTrigger
|
|
760
|
+
|
|
761
|
+
const stack = document.querySelector("[data-controller~='turbo-overlay-stack']")
|
|
762
|
+
const fromStack = stack && stack.dataset.turboOverlayConfirmStyle
|
|
763
|
+
return fromStack === "popover" ? "popover" : "modal"
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function findConfirmTemplate(preferredStyle) {
|
|
767
|
+
const preferred = document.getElementById(`turbo_overlay_confirm_${preferredStyle}_template`)
|
|
768
|
+
if (preferred) return { template: preferred, style: preferredStyle }
|
|
769
|
+
const fallbackStyle = preferredStyle === "popover" ? "modal" : "popover"
|
|
770
|
+
const fallback = document.getElementById(`turbo_overlay_confirm_${fallbackStyle}_template`)
|
|
771
|
+
if (fallback) return { template: fallback, style: fallbackStyle }
|
|
772
|
+
return null
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function promptConfirm(message, formElement, submitter) {
|
|
776
|
+
const recovered = submitter ? null : recoverConfirmTrigger()
|
|
777
|
+
const anchor = submitter || recovered
|
|
778
|
+
|
|
779
|
+
const requestedStyle = resolveConfirmStyle(formElement, submitter, recovered)
|
|
780
|
+
const targetStyle = (requestedStyle === "popover" && anchor) ? "popover" : "modal"
|
|
781
|
+
|
|
782
|
+
const found = findConfirmTemplate(targetStyle)
|
|
783
|
+
const stack = document.querySelector("[data-controller~='turbo-overlay-stack']")
|
|
784
|
+
const dialog = found && found.template.content && found.template.content.querySelector("dialog")
|
|
785
|
+
if (!found || !stack || !dialog) {
|
|
786
|
+
return Promise.resolve(window.confirm(message))
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const style = found.style
|
|
790
|
+
const clone = dialog.cloneNode(true)
|
|
791
|
+
const id = "confirm-" + randomIdSuffix()
|
|
792
|
+
clone.setAttribute("data-turbo-overlay-id-value", id)
|
|
793
|
+
|
|
794
|
+
const titlePrefix = style === "popover" ? "turbo-popover-title-" : "turbo-modal-title-"
|
|
795
|
+
clone.setAttribute("aria-labelledby", titlePrefix + id)
|
|
796
|
+
const title = clone.querySelector(`[id^='${titlePrefix}']`)
|
|
797
|
+
if (title) title.id = titlePrefix + id
|
|
798
|
+
|
|
799
|
+
if (style === "popover" && anchor) {
|
|
800
|
+
popoverTriggers.set(id, anchor)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const messageEl = clone.querySelector("[data-turbo-overlay-confirm-message]")
|
|
804
|
+
if (messageEl) messageEl.textContent = message
|
|
805
|
+
|
|
806
|
+
const frame = document.createElement("turbo-frame")
|
|
807
|
+
frame.id = `turbo_overlay_${style}_${id}`
|
|
808
|
+
frame.className = "turbo-overlay-frame"
|
|
809
|
+
frame.appendChild(clone)
|
|
810
|
+
|
|
811
|
+
return new Promise((resolve) => {
|
|
812
|
+
let settled = false
|
|
813
|
+
const settleOnly = (value) => {
|
|
814
|
+
if (settled) return
|
|
815
|
+
settled = true
|
|
816
|
+
resolve(value)
|
|
817
|
+
}
|
|
818
|
+
const dismiss = (value) => {
|
|
819
|
+
const dispatchClose = !settled
|
|
820
|
+
settleOnly(value)
|
|
821
|
+
if (dispatchClose) {
|
|
822
|
+
window.dispatchEvent(new CustomEvent("turbo-overlay:close", { detail: { id } }))
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const accept = clone.querySelector("[data-turbo-overlay-confirm-accept]")
|
|
827
|
+
const cancel = clone.querySelector("[data-turbo-overlay-confirm-cancel]")
|
|
828
|
+
if (accept) accept.addEventListener("click", (e) => { e.preventDefault(); dismiss(true) })
|
|
829
|
+
if (cancel) cancel.addEventListener("click", (e) => { e.preventDefault(); dismiss(false) })
|
|
830
|
+
clone.addEventListener("cancel", () => settleOnly(false))
|
|
831
|
+
clone.addEventListener("close", () => settleOnly(false))
|
|
832
|
+
|
|
833
|
+
stack.appendChild(frame)
|
|
834
|
+
})
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Morph re-renders (form validation failure inside an open overlay)
|
|
838
|
+
// preserve dialog node identity so the overlay never closes/reopens.
|
|
839
|
+
// Idiomorph by default removes attributes not present in the incoming
|
|
840
|
+
// HTML — that's correct for normal markup but lethal for three
|
|
841
|
+
// attributes the gem's JS owns on overlay dialogs:
|
|
842
|
+
//
|
|
843
|
+
// - `open` — drives top-layer membership. The chrome partial never
|
|
844
|
+
// emits it (showModal()/showPopover()/show() set it at
|
|
845
|
+
// runtime), so a naive morph would strip it and the
|
|
846
|
+
// overlay would close mid-edit.
|
|
847
|
+
// - `style` — popover positioning is computed in JS and written as
|
|
848
|
+
// inline styles. The chrome partial doesn't emit a
|
|
849
|
+
// style attribute, so a naive morph would erase the
|
|
850
|
+
// anchor coordinates and the popover would jump back
|
|
851
|
+
// to its UA-default position.
|
|
852
|
+
// - `data-turbo-overlay-opener-url` — set by the overlay controller
|
|
853
|
+
// on first connect to record where the overlay was
|
|
854
|
+
// opened from. Drives the smooth same-page redirect
|
|
855
|
+
// path. The chrome partial doesn't emit it, so a naive
|
|
856
|
+
// morph would erase it and the next form submit would
|
|
857
|
+
// fall back to the non-morph close path.
|
|
858
|
+
//
|
|
859
|
+
// Block these attribute mutations on overlay dialogs. Everything else
|
|
860
|
+
// (other data-* values, class, children) morphs normally so the form
|
|
861
|
+
// re-render shows error messages, repopulated fields, etc.
|
|
862
|
+
function registerMorphPreservationHook() {
|
|
863
|
+
if (typeof document === "undefined") return
|
|
864
|
+
if (window._turboOverlayMorphHookRegistered) return
|
|
865
|
+
window._turboOverlayMorphHookRegistered = true
|
|
866
|
+
|
|
867
|
+
document.addEventListener("turbo:before-morph-attribute", (event) => {
|
|
868
|
+
const target = event.target
|
|
869
|
+
if (!target || !target.classList) return
|
|
870
|
+
if (target.tagName !== "DIALOG") return
|
|
871
|
+
if (!target.classList.contains("turbo-overlay")) return
|
|
872
|
+
const attributeName = event.detail && event.detail.attributeName
|
|
873
|
+
if (attributeName === "open" ||
|
|
874
|
+
attributeName === "style" ||
|
|
875
|
+
attributeName === "data-turbo-overlay-opener-url") {
|
|
876
|
+
event.preventDefault()
|
|
877
|
+
}
|
|
878
|
+
})
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
registerStreamAction()
|
|
882
|
+
registerFetchHook()
|
|
883
|
+
registerLoadingHook()
|
|
884
|
+
registerMorphPreservationHook()
|
|
885
|
+
registerPopstateHandler()
|