turbo_overlay 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +436 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +330 -0
  5. data/Rakefile +35 -0
  6. data/app/assets/stylesheets/turbo_overlay.css +234 -0
  7. data/app/javascript/turbo_overlay/dialog_utils.js +46 -0
  8. data/app/javascript/turbo_overlay/hint.js +670 -0
  9. data/app/javascript/turbo_overlay/history.js +184 -0
  10. data/app/javascript/turbo_overlay/index.js +53 -0
  11. data/app/javascript/turbo_overlay/options.js +152 -0
  12. data/app/javascript/turbo_overlay/overlay_controller.js +882 -0
  13. data/app/javascript/turbo_overlay/popover_position.js +64 -0
  14. data/app/javascript/turbo_overlay/setup.js +885 -0
  15. data/app/javascript/turbo_overlay/stack_controller.js +131 -0
  16. data/app/javascript/turbo_overlay/submit_close.js +49 -0
  17. data/app/javascript/turbo_overlay/visit.js +52 -0
  18. data/app/views/layouts/turbo_overlay/drawer.html.erb +5 -0
  19. data/app/views/layouts/turbo_overlay/hint.html.erb +10 -0
  20. data/app/views/layouts/turbo_overlay/modal.html.erb +5 -0
  21. data/app/views/layouts/turbo_overlay/popover.html.erb +5 -0
  22. data/app/views/turbo_overlay/_drawer.html.erb +49 -0
  23. data/app/views/turbo_overlay/_hint.html.erb +6 -0
  24. data/app/views/turbo_overlay/_loading.html.erb +12 -0
  25. data/app/views/turbo_overlay/_modal.html.erb +46 -0
  26. data/app/views/turbo_overlay/_popover.html.erb +54 -0
  27. data/config/importmap.rb +11 -0
  28. data/lib/generators/turbo_overlay/eject_generator.rb +115 -0
  29. data/lib/generators/turbo_overlay/install_generator.rb +443 -0
  30. data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_confirm.html.erb +13 -0
  31. data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_drawer.html.erb +50 -0
  32. data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_hint.html.erb +9 -0
  33. data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_loading.html.erb +9 -0
  34. data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_modal.html.erb +49 -0
  35. data/lib/generators/turbo_overlay/templates/chrome/bootstrap3/_popover.html.erb +54 -0
  36. data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_confirm.html.erb +13 -0
  37. data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_drawer.html.erb +55 -0
  38. data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_hint.html.erb +9 -0
  39. data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_loading.html.erb +9 -0
  40. data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_modal.html.erb +58 -0
  41. data/lib/generators/turbo_overlay/templates/chrome/bootstrap5/_popover.html.erb +53 -0
  42. data/lib/generators/turbo_overlay/templates/chrome/plain/_confirm.html.erb +14 -0
  43. data/lib/generators/turbo_overlay/templates/chrome/tailwind/_confirm.html.erb +17 -0
  44. data/lib/generators/turbo_overlay/templates/chrome/tailwind/_drawer.html.erb +55 -0
  45. data/lib/generators/turbo_overlay/templates/chrome/tailwind/_hint.html.erb +6 -0
  46. data/lib/generators/turbo_overlay/templates/chrome/tailwind/_loading.html.erb +9 -0
  47. data/lib/generators/turbo_overlay/templates/chrome/tailwind/_modal.html.erb +46 -0
  48. data/lib/generators/turbo_overlay/templates/chrome/tailwind/_popover.html.erb +54 -0
  49. data/lib/generators/turbo_overlay/templates/initializer.rb.tt +67 -0
  50. data/lib/turbo_overlay/configuration.rb +226 -0
  51. data/lib/turbo_overlay/controller.rb +405 -0
  52. data/lib/turbo_overlay/engine.rb +52 -0
  53. data/lib/turbo_overlay/helpers/stream_helper.rb +77 -0
  54. data/lib/turbo_overlay/helpers/view_helper.rb +651 -0
  55. data/lib/turbo_overlay/version.rb +3 -0
  56. data/lib/turbo_overlay.rb +20 -0
  57. metadata +161 -0
@@ -0,0 +1,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()