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,882 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { computePopoverPosition } from "turbo_overlay/popover_position"
3
+ import { safelyCloseDialog, safelyHidePopover, normalizePopoverDialogStyles } from "turbo_overlay/dialog_utils"
4
+ import { shouldCloseOnRedirect, isSamePageRedirect } from "turbo_overlay/submit_close"
5
+ import {
6
+ getAdvanceUrl, clearAdvanceUrl,
7
+ markPushed, isPushed, clearPushed, livePushedCount,
8
+ pushOverlayState, reverseHistoryForClose,
9
+ getStackController
10
+ } from "turbo_overlay/history"
11
+
12
+ // Per-overlay controller for turbo_overlay. Drives a native
13
+ // <dialog> regardless of theme — themes contribute markup and CSS
14
+ // only. Registers with the stack controller, opens the dialog, and
15
+ // runs an exit animation before tearing the frame down on close.
16
+ //
17
+ // Animation hooks (CSS in the layout supplies the keyframes):
18
+ // - On open: the layout's `dialog[open]` rule animates entry.
19
+ // - On close: this controller adds a `turbo-overlay-closing`
20
+ // class, waits for `animationend` (or a 400ms safety timeout),
21
+ // then calls `dialog.close()` and removes the frame.
22
+ //
23
+ // Respects `prefers-reduced-motion: reduce` by skipping the close
24
+ // animation wait.
25
+
26
+ const CLOSING_CLASS = "turbo-overlay-closing"
27
+ const CLOSE_ANIMATION_TIMEOUT_MS = 400
28
+
29
+ export default class extends Controller {
30
+ static values = {
31
+ id: String,
32
+ type: String,
33
+ backdrop: { type: Boolean, default: true },
34
+ backdropDismiss: { type: Boolean, default: true },
35
+ position: { type: String, default: "" },
36
+ align: { type: String, default: "" },
37
+ offset: { type: Number, default: 4 }
38
+ }
39
+
40
+ connect() {
41
+ this.stack = this._findStack()
42
+ this.dialog = this.element.tagName === "DIALOG"
43
+ ? this.element
44
+ : this.element.querySelector("dialog")
45
+
46
+ // Track every mousedown's target so the dismissal guard can
47
+ // distinguish "user clicked the backdrop" from "user dragged a
48
+ // text selection out of the dialog and released on the backdrop"
49
+ // (W3C clicks resolve to the LCA of mousedown and mouseup —
50
+ // dialog itself, in the drag case). Capture phase so we see the
51
+ // event before any other handler.
52
+ this._mousedownTracker = (event) => { this._lastMousedownTarget = event.target }
53
+ document.addEventListener("mousedown", this._mousedownTracker, true)
54
+
55
+ this._captureOpenerUrl()
56
+
57
+ if (this.stack && this.stack.has(this.idValue)) {
58
+ // Frame re-render — form submission inside an open overlay
59
+ // (the most common case is a validation failure re-rendering
60
+ // the form). Turbo's default frame replacement swaps the
61
+ // `<dialog>` node entirely; the new dialog has no `open`
62
+ // attribute and is detached from the top layer. Re-open it in
63
+ // the same mode the original used so the overlay stays visible.
64
+ this.stack.updateController(this.idValue, this)
65
+ if (this.dialog && !this._isShown()) {
66
+ if (this.backdropValue) {
67
+ try { this.dialog.showModal() } catch (_) { this.dialog.setAttribute("open", "") }
68
+ } else if (this.typeValue === "popover") {
69
+ if (this._needsModalStacking()) {
70
+ try { this.dialog.showModal() } catch (_) { this.dialog.setAttribute("open", "") }
71
+ } else {
72
+ try { this.dialog.showPopover() } catch (_) { this.dialog.setAttribute("open", "") }
73
+ }
74
+ this._installEscHandler()
75
+ } else {
76
+ try { this.dialog.show() } catch (_) { this.dialog.setAttribute("open", "") }
77
+ this._installEscHandler()
78
+ }
79
+ }
80
+ if (this.typeValue === "popover") {
81
+ this._targetLinksTop()
82
+ this._positionPopover()
83
+ }
84
+ return
85
+ }
86
+
87
+ if (this.typeValue === "popover") {
88
+ this._connectPopover()
89
+ return
90
+ }
91
+
92
+ const registered = this.stack
93
+ ? this.stack.register({ id: this.idValue, type: this.typeValue, controller: this })
94
+ : true
95
+
96
+ if (!registered) return
97
+
98
+ if (this.dialog && !this.dialog.open) {
99
+ if (this.backdropValue) {
100
+ try { this.dialog.showModal() } catch (_) { this.dialog.setAttribute("open", "") }
101
+ } else {
102
+ // Non-modal: page remains interactive (no backdrop, no focus
103
+ // trap, scrollable). Native `cancel` doesn't fire on ESC for
104
+ // non-modal dialogs, so synthesize it via keydown.
105
+ try { this.dialog.show() } catch (_) { this.dialog.setAttribute("open", "") }
106
+ this._installEscHandler()
107
+ }
108
+ } else if (this.dialog && this.dialog.open && !this.backdropValue) {
109
+ // Non-modal path after a morph from loading: dialog is already
110
+ // open in non-modal mode, but the ESC handler hasn't been
111
+ // installed yet (the placeholder didn't have a controller).
112
+ this._installEscHandler()
113
+ }
114
+
115
+ this._dispatch("shown")
116
+ this._maybeAdvanceHistory()
117
+ this._installSubmitEndHandler()
118
+ }
119
+
120
+ disconnect() {
121
+ if (this._submitEndHandler && this.dialog) {
122
+ this.dialog.removeEventListener("turbo:submit-end", this._submitEndHandler)
123
+ this._submitEndHandler = null
124
+ }
125
+ if (this._beforeFetchResponseHandler && this.dialog) {
126
+ this.dialog.removeEventListener("turbo:before-fetch-response", this._beforeFetchResponseHandler)
127
+ this._beforeFetchResponseHandler = null
128
+ }
129
+ if (this._escHandler) {
130
+ document.removeEventListener("keydown", this._escHandler)
131
+ this._escHandler = null
132
+ }
133
+ if (this._outsideClickHandler) {
134
+ document.removeEventListener("mousedown", this._outsideClickHandler, true)
135
+ this._outsideClickHandler = null
136
+ }
137
+ if (this._mousedownTracker) {
138
+ document.removeEventListener("mousedown", this._mousedownTracker, true)
139
+ this._mousedownTracker = null
140
+ }
141
+ this._lastMousedownTarget = null
142
+ this._allowedSelectors = null
143
+ if (this._reflowHandler) {
144
+ window.removeEventListener("scroll", this._reflowHandler, true)
145
+ window.removeEventListener("resize", this._reflowHandler)
146
+ this._reflowHandler = null
147
+ }
148
+ if (this._reflowFrame) {
149
+ cancelAnimationFrame(this._reflowFrame)
150
+ this._reflowFrame = null
151
+ }
152
+ if (this._anchorObserver) {
153
+ this._anchorObserver.disconnect()
154
+ this._anchorObserver = null
155
+ }
156
+ if (this._anchorOutTimer) {
157
+ clearTimeout(this._anchorOutTimer)
158
+ this._anchorOutTimer = null
159
+ }
160
+ queueMicrotask(() => {
161
+ if (!document.body.contains(this.element) && this.stack) {
162
+ this.stack.unregister(this.idValue)
163
+ }
164
+ })
165
+ }
166
+
167
+ _installEscHandler() {
168
+ this._escHandler = (event) => {
169
+ if (event.key !== "Escape" || event.defaultPrevented) return
170
+ if (this.stack && this.stack.topEntry() && this.stack.topEntry().id !== this.idValue) return
171
+ event.preventDefault()
172
+ this.cancel(event)
173
+ }
174
+ document.addEventListener("keydown", this._escHandler)
175
+ }
176
+
177
+ // Record the URL the overlay was opened from so the submit-end
178
+ // handler can decide whether a redirect target is "same page"
179
+ // (morph the page behind, then animate close) or different
180
+ // (await close, then Turbo.visit). Captured once per dialog node —
181
+ // `advance` pushes happen after `connect`, and frame re-renders
182
+ // morph the dialog in place with the data attribute preserved by
183
+ // the morph-attribute hook in setup.js, so the capture-once guard
184
+ // keeps the opener URL stable through validation re-renders.
185
+ // Modal/drawer only — popovers/hints don't host redirect-y forms.
186
+ _captureOpenerUrl() {
187
+ if (this.typeValue !== "modal" && this.typeValue !== "drawer") return
188
+ if (!this.dialog) return
189
+ if (this.dialog.dataset.turboOverlayOpenerUrl) return
190
+ if (typeof window === "undefined" || !window.location) return
191
+ this.dialog.dataset.turboOverlayOpenerUrl = window.location.href
192
+ }
193
+
194
+ // Close-on-redirect: when a descendant form submits and Turbo
195
+ // followed a redirect to the final response, dismiss this overlay
196
+ // and navigate. The listener is scoped to this dialog (not
197
+ // document) so stacking works — only the dialog containing the
198
+ // form closes — and so the listener auto-cleans on disconnect.
199
+ //
200
+ // Two paths after `shouldCloseOnRedirect` returns true:
201
+ //
202
+ // - Same-page redirect (pathname matches the URL the overlay was
203
+ // opened from) on a lone overlay → `_morphAndClose`: fetch the
204
+ // redirect target, morph the host page behind the overlay,
205
+ // then animate the close. No `Turbo.visit` — page is correct.
206
+ //
207
+ // - Different page, or sibling overlay in the stack → await the
208
+ // close animation, then `Turbo.visit`. Awaiting avoids the
209
+ // flash where the new page paints behind a still-closing
210
+ // overlay.
211
+ //
212
+ // Pure decisions live in submit_close.js for testability.
213
+ _installSubmitEndHandler() {
214
+ if (!this.dialog) return
215
+
216
+ // Stop Turbo from rendering a close-bound redirect response into
217
+ // the open overlay. Fetch follows the redirect transparently and
218
+ // carries the original `Turbo-Frame` / `X-Turbo-Overlay` headers
219
+ // on the same-origin follow, so the controller concern wraps the
220
+ // redirect target in overlay layout — Turbo would then morph that
221
+ // payload into the open dialog, briefly showing the wrong content
222
+ // before our submit-end handler closes (or morph-closes) it.
223
+ //
224
+ // `preventDefault` alone isn't enough: Turbo's StreamObserver
225
+ // listens on the same event at the window level and does its own
226
+ // `receiveMessageResponse` (which processes the
227
+ // `<turbo-stream method="morph">` action and morphs the frame)
228
+ // independent of `defaultPrevented`. The fix is to stop the
229
+ // event before it reaches the window. The dialog listener fires
230
+ // in the bubble phase before window's, so
231
+ // `stopImmediatePropagation` keeps StreamObserver from receiving
232
+ // it. `preventDefault` is still needed so FormSubmission's own
233
+ // success path takes the `requestPreventedHandlingResponse`
234
+ // branch (no frame replace).
235
+ //
236
+ // Same predicate as the submit-end handler — when we'd close the
237
+ // overlay, we own the response. The keep-open opt-outs naturally
238
+ // pass through: `shouldCloseOnRedirect` returns false →
239
+ // pass-through → Turbo renders the response normally
240
+ // (wizard-style flows). Non-redirect responses (validation
241
+ // 422s, raw 200s) likewise pass through, so morph re-renders are
242
+ // unaffected.
243
+ this._beforeFetchResponseHandler = (event) => {
244
+ if (!shouldCloseOnRedirect({
245
+ form: event.target,
246
+ dialog: this.dialog,
247
+ fetchResponse: event.detail && event.detail.fetchResponse
248
+ })) return
249
+ event.preventDefault()
250
+ event.stopImmediatePropagation()
251
+ }
252
+ this.dialog.addEventListener("turbo:before-fetch-response", this._beforeFetchResponseHandler)
253
+
254
+ this._submitEndHandler = async (event) => {
255
+ const fetchResponse = event.detail && event.detail.fetchResponse
256
+ if (!shouldCloseOnRedirect({
257
+ form: event.target,
258
+ dialog: this.dialog,
259
+ fetchResponse
260
+ })) return
261
+ const url = fetchResponse.response && fetchResponse.response.url
262
+ const canMorph = !this._stackHasSiblings() &&
263
+ isSamePageRedirect({ dialog: this.dialog, fetchResponse })
264
+ if (canMorph && url) {
265
+ const morphed = await this._morphAndClose(url, event)
266
+ if (morphed) return
267
+ // fall through on morph failure
268
+ }
269
+ await this.close(event)
270
+ if (url && typeof window !== "undefined" && window.Turbo && typeof window.Turbo.visit === "function") {
271
+ window.Turbo.visit(url)
272
+ }
273
+ }
274
+ this.dialog.addEventListener("turbo:submit-end", this._submitEndHandler)
275
+ }
276
+
277
+ // True when another overlay is open in the same stack. Used to
278
+ // skip the morph-behind path: morphing the body while a sibling
279
+ // overlay is rendered (and the URL bar belongs to a sibling's
280
+ // advanced entry) would clobber state we can't safely reconstruct.
281
+ _stackHasSiblings() {
282
+ const stack = getStackController()
283
+ return !!(stack && stack.entries && stack.entries.length > 1)
284
+ }
285
+
286
+ // Fetch the redirect target, morph the host page behind the
287
+ // overlay (preserving the open dialog), update the URL bar to the
288
+ // redirect URL, then animate the close.
289
+ //
290
+ // Returns true on success, false on any failure (caller falls back
291
+ // to the await-close-then-visit path). Never throws.
292
+ async _morphAndClose(url, event) {
293
+ if (typeof window === "undefined" || !window.Turbo) return false
294
+ if (typeof window.Turbo.morphChildren !== "function") return false
295
+
296
+ let html
297
+ try {
298
+ html = await this._fetchOpenerHTML(url)
299
+ } catch (_) {
300
+ return false
301
+ }
302
+ if (!html) return false
303
+
304
+ let doc
305
+ try {
306
+ doc = new DOMParser().parseFromString(html, "text/html")
307
+ } catch (_) {
308
+ return false
309
+ }
310
+ if (!doc || !doc.body) return false
311
+
312
+ // Morph first; only update history and close if the morph
313
+ // succeeds. If morph throws, the URL bar is unchanged so the
314
+ // caller's fallback `Turbo.visit(url)` can navigate cleanly.
315
+ try {
316
+ window.Turbo.morphChildren(document.body, doc.body, {
317
+ ignoreActiveValue: true,
318
+ callbacks: {
319
+ beforeNodeMorphed: (oldNode) => {
320
+ if (!oldNode || !oldNode.closest) return true
321
+ // Exclude the open overlay + sibling overlays from morph.
322
+ return !oldNode.closest("[data-controller~='turbo-overlay-stack']")
323
+ }
324
+ }
325
+ })
326
+ } catch (_) {
327
+ return false
328
+ }
329
+
330
+ try {
331
+ window.history.replaceState(null, "", url)
332
+ } catch (_) {
333
+ // cross-origin URL or other replaceState rejection — leave the
334
+ // URL bar pointing at the prior entry. The body is correctly
335
+ // morphed; the URL mismatch is acceptable degradation.
336
+ }
337
+
338
+ // Suppress `_syncHistoryOnClose` from running `history.back()` —
339
+ // we already replaced the current history entry to land on the
340
+ // redirect URL directly, regardless of whether the overlay was
341
+ // advanced.
342
+ this._closedByBack = true
343
+ await this.close(event)
344
+ return true
345
+ }
346
+
347
+ async _fetchOpenerHTML(url) {
348
+ const init = {
349
+ headers: { "Accept": "text/html" },
350
+ credentials: "same-origin"
351
+ }
352
+ if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
353
+ init.signal = AbortSignal.timeout(3000)
354
+ }
355
+ const response = await fetch(url, init)
356
+ if (!response.ok) throw new Error(`fetch ${response.status}`)
357
+ return await response.text()
358
+ }
359
+
360
+ _connectPopover() {
361
+ this.anchor = this.stack ? this.stack.getPopoverTrigger(this.idValue) : null
362
+
363
+ const registered = this.stack
364
+ ? this.stack.register({ id: this.idValue, type: this.typeValue, controller: this, anchor: this.anchor })
365
+ : true
366
+ if (!registered) return
367
+
368
+ if (this.dialog && !this._isShown()) {
369
+ // See _popoverNeedsModal — popovers opened above an existing
370
+ // modal dialog must themselves be modal, otherwise the HTML
371
+ // inertness algorithm makes the popover unresponsive to clicks
372
+ // even while it renders above the modal.
373
+ if (this._needsModalStacking()) {
374
+ try { this.dialog.showModal() } catch (_) { this.dialog.setAttribute("open", "") }
375
+ } else {
376
+ try { this.dialog.showPopover() } catch (_) { this.dialog.setAttribute("open", "") }
377
+ }
378
+ }
379
+
380
+ this._targetLinksTop()
381
+ this._positionPopover()
382
+ // The dialog was just morphed in from a loading placeholder
383
+ // (small spinner). The first _positionPopover above used whatever
384
+ // the dialog measured immediately after Stimulus connected, which
385
+ // can lag the actual content layout by a frame. Re-run on the
386
+ // next animation frame so we measure against the final content
387
+ // size and reposition (auto-flip) accordingly.
388
+ requestAnimationFrame(() => {
389
+ if (this.dialog && this._isShown()) this._positionPopover()
390
+ })
391
+ // The anchor may still be moving (e.g. opened a popover from
392
+ // inside a drawer that's mid-slide-in). Re-position on subsequent
393
+ // frames until the anchor's left edge stabilizes, with a safety
394
+ // cap so animations longer than ~500ms don't pin the CPU.
395
+ this._trackAnchorUntilSettled()
396
+
397
+ this._installEscHandler()
398
+
399
+ // Click-outside dismissal. Use mousedown capture so we fire
400
+ // before any link inside the popover triggers its own navigation.
401
+ // `target === dialog` catches clicks on the ::backdrop for the
402
+ // showModal'd path (popovers inside a modal context); descendant
403
+ // checks handle the showPopover'd path.
404
+ this._outsideClickHandler = (event) => {
405
+ if (!this.dialog) return
406
+ const target = event.target
407
+ if (target === this.dialog) {
408
+ if (this._shouldSuppressDismiss(target)) return
409
+ this.cancel(event); return
410
+ }
411
+ if (this.dialog.contains(target)) return
412
+ if (this.anchor && this.anchor.contains && this.anchor.contains(target)) return
413
+ if (this._shouldSuppressDismiss(target)) return
414
+ this.cancel(event)
415
+ }
416
+ document.addEventListener("mousedown", this._outsideClickHandler, true)
417
+
418
+ // Reposition on scroll/resize so the popover tracks its anchor.
419
+ this._reflowHandler = () => {
420
+ if (this._reflowFrame) return
421
+ this._reflowFrame = requestAnimationFrame(() => {
422
+ this._reflowFrame = null
423
+ this._positionPopover()
424
+ })
425
+ }
426
+ window.addEventListener("scroll", this._reflowHandler, true)
427
+ window.addEventListener("resize", this._reflowHandler)
428
+
429
+ this._installAnchorVisibilityObserver()
430
+
431
+ this._dispatch("shown")
432
+ this._installSubmitEndHandler()
433
+ }
434
+
435
+ // Auto-close the popover when its anchor scrolls out of view. A
436
+ // popover whose trigger isn't visible reads as a floating widget
437
+ // with no obvious connection to anything — Bootstrap, MUI, Floating
438
+ // UI, and native iOS UIPopover all collapse on this signal. A short
439
+ // debounce avoids closing on momentum-scroll frames that briefly
440
+ // clip the anchor edge before settling back into view.
441
+ //
442
+ // The reflow handler keeps repositioning the popover during the
443
+ // debounce and the close animation — that's deliberate. Top-layer
444
+ // popovers are positioned in viewport coordinates; without
445
+ // continuous updates the popover stays glued to the screen while
446
+ // the anchor scrolls past it (the "sticky-nav" look). Continuing to
447
+ // track means the popover scrolls offscreen alongside the anchor,
448
+ // and the close animation plays as it goes.
449
+ //
450
+ // 50ms = ~3 frames at 60Hz, enough to ride out a one-frame inertial
451
+ // overshoot but short enough that the dismissal feels responsive.
452
+ _installAnchorVisibilityObserver() {
453
+ if (typeof IntersectionObserver === "undefined") return
454
+ if (!this.anchor || typeof this.anchor.getBoundingClientRect !== "function") return
455
+
456
+ this._anchorObserver = new IntersectionObserver((entries) => {
457
+ const entry = entries[entries.length - 1]
458
+ if (!entry) return
459
+ if (entry.isIntersecting) {
460
+ if (this._anchorOutTimer) {
461
+ clearTimeout(this._anchorOutTimer)
462
+ this._anchorOutTimer = null
463
+ }
464
+ } else {
465
+ if (this._anchorOutTimer) return
466
+ this._anchorOutTimer = setTimeout(() => {
467
+ this._anchorOutTimer = null
468
+ if (this.dialog && this._isShown()) this.cancel()
469
+ }, 50)
470
+ }
471
+ }, { threshold: 0 })
472
+ this._anchorObserver.observe(this.anchor)
473
+ }
474
+
475
+ // Inside a popover, a plain `link_to` would otherwise navigate
476
+ // inside the popover's turbo-frame and replace the popover's
477
+ // contents. Default such links to `_top`. Overlay-opening links
478
+ // (modal/drawer/popover_link_to) already carry data-turbo-frame=_top
479
+ // and data-turbo-overlay; skip them so they keep their stacking
480
+ // behavior. Forms inside the popover are untouched so they can
481
+ // still re-render in place on validation failure.
482
+ _targetLinksTop() {
483
+ if (!this.dialog) return
484
+ const links = this.dialog.querySelectorAll(
485
+ "a[href]:not([data-turbo-frame]):not([data-turbo-overlay])"
486
+ )
487
+ links.forEach((a) => { a.dataset.turboFrame = "_top" })
488
+ }
489
+
490
+ // When a modal dialog is already open, popovers added to the top
491
+ // layer via `showPopover()` are still rendered above the modal but
492
+ // become inert per the HTML inertness algorithm — only descendants
493
+ // of the topmost modal dialog (or the modal itself) receive input.
494
+ // Detect that case so the popover can use `showModal()` instead
495
+ // and become the topmost modal itself; a transparent `::backdrop`
496
+ // CSS rule preserves the non-modal visual feel.
497
+ //
498
+ // Non-modal drawers are intentionally NOT auto-promoted: the UA
499
+ // `dialog:modal` stylesheet overrides the gem's drawer-position
500
+ // inset rules and re-centers the drawer in the viewport. Opening a
501
+ // non-modal drawer from inside a modal is documented as unsupported.
502
+ //
503
+ // The check excludes our own dialog: when called from the frame
504
+ // re-render branch the popover may already be open via showModal()
505
+ // and would otherwise match `:modal` against itself.
506
+ _needsModalStacking() {
507
+ if (typeof document === "undefined") return false
508
+ const modals = document.querySelectorAll("dialog:modal")
509
+ for (const m of modals) {
510
+ if (m !== this.dialog) return true
511
+ }
512
+ return false
513
+ }
514
+
515
+ _trackAnchorUntilSettled() {
516
+ if (!this.anchor || typeof this.anchor.getBoundingClientRect !== "function") return
517
+ let lastRect = this.anchor.getBoundingClientRect()
518
+ let stableFrames = 0
519
+ let totalFrames = 0
520
+ const tick = () => {
521
+ if (!this.dialog || !this._isShown()) return
522
+ const rect = this.anchor.getBoundingClientRect()
523
+ if (Math.abs(rect.left - lastRect.left) < 0.5 &&
524
+ Math.abs(rect.top - lastRect.top) < 0.5) {
525
+ if (++stableFrames >= 2) return
526
+ } else {
527
+ stableFrames = 0
528
+ this._positionPopover()
529
+ }
530
+ lastRect = rect
531
+ if (++totalFrames < 36) requestAnimationFrame(tick)
532
+ }
533
+ requestAnimationFrame(tick)
534
+ }
535
+
536
+ // Resolve the anchor rect to position against. For most triggers
537
+ // (buttons, block-level links) this is just getBoundingClientRect().
538
+ // For a wrapped inline anchor — multiple line boxes — the bounding
539
+ // rect spans the union of every line, which is too wide to position
540
+ // against meaningfully. When we have the recorded click point, pick
541
+ // the line-box rect containing it; otherwise fall back to the first
542
+ // line box.
543
+ _anchorRect() {
544
+ const rects = typeof this.anchor.getClientRects === "function"
545
+ ? Array.from(this.anchor.getClientRects())
546
+ : []
547
+ if (rects.length <= 1) return this.anchor.getBoundingClientRect()
548
+
549
+ const click = this.anchor.__turboOverlayClickPoint
550
+ if (click) {
551
+ const hit = rects.find((r) =>
552
+ click.x >= r.left && click.x <= r.right &&
553
+ click.y >= r.top && click.y <= r.bottom
554
+ )
555
+ if (hit) return hit
556
+ }
557
+ return rects[0]
558
+ }
559
+
560
+ _positionPopover() {
561
+ if (!this.dialog) return
562
+
563
+ // No anchor (e.g. tests, page rehydration without trigger): fall
564
+ // back to centered fixed positioning so the dialog is still visible.
565
+ if (!this.anchor || typeof this.anchor.getBoundingClientRect !== "function") {
566
+ this.dialog.style.position = "fixed"
567
+ this.dialog.style.top = "50%"
568
+ this.dialog.style.left = "50%"
569
+ this.dialog.style.margin = "0"
570
+ this.dialog.style.transform = "translate(-50%, -50%)"
571
+ return
572
+ }
573
+
574
+ // Pin the dialog at viewport origin and carry placement on
575
+ // `transform`. Transforms run on the compositor thread, so the
576
+ // popover stays in lock-step with scroll-induced repaint instead
577
+ // of trailing by a frame. The CSS for popovers sets
578
+ // `animation-composition: add` so the open/close keyframes
579
+ // compose with our inline transform rather than overriding it.
580
+ //
581
+ // Normalize BEFORE measuring the dialog's natural size. The UA
582
+ // styles for `[popover]` and especially `dialog:modal` apply
583
+ // `inset: 0` with `width: auto`, which stretches the dialog to
584
+ // fill the viewport; measuring then yields a width far larger
585
+ // than the content and auto-flip goes wrong. After
586
+ // `normalizePopoverDialogStyles` the dialog shrinks to content.
587
+ normalizePopoverDialogStyles(this.dialog)
588
+
589
+ const anchorRect = this._anchorRect()
590
+ // `offsetWidth/offsetHeight` ignore the current transform and
591
+ // return the laid-out box, which is what auto-flip math needs.
592
+ // `getBoundingClientRect()` here would include our prior
593
+ // positioning transform and bias the size measurement.
594
+ const dialogWidth = this.dialog.offsetWidth
595
+ const dialogHeight = this.dialog.offsetHeight
596
+ const dialogRect = {
597
+ top: 0, left: 0,
598
+ right: dialogWidth, bottom: dialogHeight,
599
+ width: dialogWidth, height: dialogHeight,
600
+ }
601
+ const viewport = {
602
+ width: document.documentElement.clientWidth,
603
+ height: document.documentElement.clientHeight
604
+ }
605
+
606
+ const { top, left, resolvedPosition } = computePopoverPosition({
607
+ anchor: anchorRect,
608
+ dialog: dialogRect,
609
+ viewport,
610
+ position: this.positionValue || "bottom",
611
+ align: this.alignValue || "start",
612
+ offset: this.offsetValue,
613
+ autoFlip: true
614
+ })
615
+
616
+ this.dialog.style.transform = `translate(${left}px, ${top}px)`
617
+ this.dialog.dataset.resolvedPosition = resolvedPosition
618
+ }
619
+
620
+ // data-action="click->turbo-overlay#close"
621
+ // Returns a Promise that resolves when the close animation has
622
+ // finished and `_finalizeClose` has run. Callers that need to act
623
+ // after the overlay is fully closed (e.g., the submit-end handler
624
+ // gating `Turbo.visit` on animation completion) can `await` it.
625
+ close(event) {
626
+ if (event) event.preventDefault()
627
+ const snapshot = this.stack && this.stack.entries ? this.stack.entries.slice() : []
628
+ if (this.stack) this.stack.unregister(this.idValue)
629
+ this._syncHistoryOnClose(snapshot)
630
+ return this._animatedClose()
631
+ }
632
+
633
+ // data-action="cancel->turbo-overlay#cancel" — native dialog ESC.
634
+ // Prevent the immediate close so we can animate; stop propagation
635
+ // so ESC doesn't bubble to the dialog beneath in the stack.
636
+ // Returns the same Promise as `close`.
637
+ cancel(event) {
638
+ if (event) {
639
+ event.preventDefault()
640
+ event.stopPropagation()
641
+ }
642
+ const snapshot = this.stack && this.stack.entries ? this.stack.entries.slice() : []
643
+ if (this.stack) this.stack.unregister(this.idValue)
644
+ this._syncHistoryOnClose(snapshot)
645
+ return this._animatedClose()
646
+ }
647
+
648
+ // data-action="click->turbo-overlay#backdropClick" — clicks on the
649
+ // dialog's ::backdrop register with the dialog as event.target.
650
+ // Children that bubble up have a different target and are ignored.
651
+ // Themes whose chrome wraps the dialog in an element that fills the
652
+ // dialog (e.g. Bootstrap5's `<div class="modal">`, which exists to
653
+ // scope `--bs-modal-*`) mark that wrapper with
654
+ // `data-turbo-overlay-backdrop-zone` so clicks on its uncovered area
655
+ // are also treated as backdrop clicks. The `-zone` suffix is
656
+ // intentional: a plain `data-turbo-overlay-backdrop` collides with
657
+ // the same-named attribute the link helper writes on triggers to
658
+ // signal `backdrop: false` to the fetch hook — a bubbled link click
659
+ // would otherwise dismiss the parent overlay.
660
+ // Opt out per-overlay with data-turbo-overlay-backdrop-dismiss-value="false".
661
+ backdropClick(event) {
662
+ if (!this.backdropDismissValue) return
663
+ if (this._shouldSuppressDismiss(event.target)) return
664
+ const target = event.target
665
+ if (target === this.dialog) {
666
+ this.cancel(event)
667
+ return
668
+ }
669
+ if (target && target.hasAttribute && target.hasAttribute("data-turbo-overlay-backdrop-zone")) {
670
+ this.cancel(event)
671
+ }
672
+ }
673
+
674
+ // Returns true when an apparent outside/backdrop click should NOT
675
+ // dismiss the overlay. Two cases:
676
+ //
677
+ // 1. Drag-out: the user mousedown'd inside the dialog content and
678
+ // released on the backdrop. The W3C click target is the dialog
679
+ // itself (LCA of mousedown/mouseup), so `backdropClick` would
680
+ // otherwise treat it as a dismissal — but the user was selecting
681
+ // text, not dismissing.
682
+ //
683
+ // 2. Allowlist match: the click landed on an element matching a
684
+ // configured CSS selector (e.g. `.flatpickr-calendar`,
685
+ // `.select2-container`). These widgets portal their UI to
686
+ // `<body>` and read as outside-dialog clicks even when the user
687
+ // is interacting with a widget rendered from inside the overlay.
688
+ _shouldSuppressDismiss(clickTarget) {
689
+ const mousedownTarget = this._lastMousedownTarget
690
+ if (mousedownTarget && this.dialog &&
691
+ this.dialog.contains(mousedownTarget) &&
692
+ mousedownTarget !== this.dialog) {
693
+ return true
694
+ }
695
+ if (this._isAllowlisted(mousedownTarget)) return true
696
+ if (this._isAllowlisted(clickTarget)) return true
697
+ return false
698
+ }
699
+
700
+ _isAllowlisted(target) {
701
+ if (!target || !target.closest) return false
702
+ const selectors = this._resolveAllowedSelectors()
703
+ if (!selectors.length) return false
704
+ for (const selector of selectors) {
705
+ try {
706
+ if (target.closest(selector)) return true
707
+ } catch (_) {
708
+ // Malformed selector. Skip it; a single bad entry must not
709
+ // break dismissal for everything else. Warn once per dialog.
710
+ if (!this._warnedSelectors) this._warnedSelectors = new Set()
711
+ if (!this._warnedSelectors.has(selector)) {
712
+ this._warnedSelectors.add(selector)
713
+ // eslint-disable-next-line no-console
714
+ console.warn(`[turbo_overlay] ignoring invalid allowed_click_outside_selectors entry: ${selector}`)
715
+ }
716
+ }
717
+ }
718
+ return false
719
+ }
720
+
721
+ _resolveAllowedSelectors() {
722
+ if (this._allowedSelectors) return this._allowedSelectors
723
+ const override = this.dialog && this.dialog.dataset
724
+ ? this.dialog.dataset.turboOverlayAllowClickOutside
725
+ : null
726
+ if (override != null) {
727
+ this._allowedSelectors = override
728
+ .split(",")
729
+ .map((s) => s.trim())
730
+ .filter((s) => s.length > 0)
731
+ return this._allowedSelectors
732
+ }
733
+ if (this.stack && Array.isArray(this.stack.allowedClickOutsideSelectorsValue)) {
734
+ this._allowedSelectors = this.stack.allowedClickOutsideSelectorsValue
735
+ return this._allowedSelectors
736
+ }
737
+ this._allowedSelectors = []
738
+ return this._allowedSelectors
739
+ }
740
+
741
+ // Returns a Promise that resolves after `_finalizeClose` runs. The
742
+ // promise never rejects — even the no-dialog and reduced-motion
743
+ // shortcuts resolve through `_finalizeClose` synchronously, so
744
+ // awaiting callers can rely on "after this, the overlay is gone."
745
+ _animatedClose() {
746
+ this._dispatch("before-close")
747
+
748
+ if (!this.dialog) {
749
+ this._removeFrame()
750
+ return Promise.resolve()
751
+ }
752
+
753
+ const reduced = typeof window !== "undefined" &&
754
+ window.matchMedia &&
755
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
756
+
757
+ if (reduced || !this.dialog.open) {
758
+ this._finalizeClose()
759
+ return Promise.resolve()
760
+ }
761
+
762
+ const target = this.element
763
+ target.classList.add(CLOSING_CLASS)
764
+
765
+ return new Promise((resolve) => {
766
+ let done = false
767
+ const finish = () => {
768
+ if (done) return
769
+ done = true
770
+ target.removeEventListener("animationend", onEnd)
771
+ this._finalizeClose()
772
+ resolve()
773
+ }
774
+ const onEnd = (event) => {
775
+ // Animations on inner elements may also fire; only finalize
776
+ // when the dialog (or its ::backdrop) finishes.
777
+ if (event.target !== target) return
778
+ finish()
779
+ }
780
+ target.addEventListener("animationend", onEnd)
781
+ setTimeout(finish, CLOSE_ANIMATION_TIMEOUT_MS)
782
+ })
783
+ }
784
+
785
+ _finalizeClose() {
786
+ if (this.dialog) {
787
+ if (this.typeValue === "popover") {
788
+ safelyHidePopover(this.dialog)
789
+ if (this.dialog.open) safelyCloseDialog(this.dialog)
790
+ } else if (this.dialog.open) {
791
+ safelyCloseDialog(this.dialog)
792
+ }
793
+ }
794
+ // Dispatch :closed before _removeFrame so the dialog is still in
795
+ // the DOM and the bubbled event reaches document-level listeners.
796
+ this._dispatch("closed")
797
+ this._removeFrame()
798
+ }
799
+
800
+ _removeFrame() {
801
+ const frame = this.element.closest("turbo-frame.turbo-overlay-frame")
802
+ if (frame && frame.parentNode) frame.remove()
803
+ else if (this.element.parentNode) this.element.remove()
804
+ }
805
+
806
+ // `showPopover()` doesn't set the `[open]` attribute, so check `:popover-open` for popovers too.
807
+ _isShown() {
808
+ if (!this.dialog) return false
809
+ if (this.dialog.open) return true
810
+ if (this.typeValue === "popover" && this.dialog.matches) {
811
+ try { return this.dialog.matches(":popover-open") } catch (_) { /* unsupported */ }
812
+ }
813
+ return false
814
+ }
815
+
816
+ _findStack() {
817
+ if (typeof document === "undefined") return null
818
+ const stackEl = document.querySelector("[data-controller~='turbo-overlay-stack']")
819
+ if (!stackEl || !this.application) return null
820
+ return this.application.getControllerForElementAndIdentifier(stackEl, "turbo-overlay-stack")
821
+ }
822
+
823
+ // URL advance: push the link's target (or a custom URL) into the
824
+ // history bar when a modal or drawer first opens. Popovers and
825
+ // hints never advance — they're ephemeral. The pushed entry is
826
+ // tracked by overlay id in a module-level Map so the bookkeeping
827
+ // survives idiomorph re-renders and any Stimulus reconnects.
828
+ _maybeAdvanceHistory() {
829
+ if (this.typeValue !== "modal" && this.typeValue !== "drawer") return
830
+ const url = getAdvanceUrl(this.idValue)
831
+ if (!url) return
832
+ try {
833
+ pushOverlayState(this.idValue, this.typeValue, url)
834
+ markPushed(this.idValue, url, this.typeValue)
835
+ } catch (_) {
836
+ // pushState can throw on cross-origin URLs; treat as a no-op.
837
+ }
838
+ clearAdvanceUrl(this.idValue)
839
+ }
840
+
841
+ // Reverse the history entry we pushed on open when this close
842
+ // actually removes the top-most pushed overlay from the stack.
843
+ // - _closedByBack: this close was triggered by a popstate; the
844
+ // browser already moved the history pointer, so we must not
845
+ // also history.back() (that would skip a real prior entry).
846
+ // - livePushedCount comparison: handles mid-stack closes
847
+ // correctly. The mid-stack overlay's `pushed` record was below
848
+ // the top's in history, so going back wouldn't recover its
849
+ // URL; only the count drop matters.
850
+ _syncHistoryOnClose(stackBefore) {
851
+ const id = this.idValue
852
+ if (!isPushed(id)) return
853
+ if (this._closedByBack) {
854
+ clearPushed(id)
855
+ return
856
+ }
857
+ const before = livePushedCount(stackBefore)
858
+ clearPushed(id)
859
+ const after = livePushedCount(this.stack && this.stack.entries ? this.stack.entries : [])
860
+ if (after < before) reverseHistoryForClose()
861
+ }
862
+
863
+ // Dispatch a lifecycle event on the dialog so listeners can attach
864
+ // per-overlay; the event bubbles so document-level listeners
865
+ // (analytics, autofocus controllers) catch it too. Detail always
866
+ // carries `{ id, type }`. Events:
867
+ //
868
+ // turbo-overlay:shown — controller is wired and the dialog
869
+ // is open + interactive.
870
+ // turbo-overlay:before-close — close just started, dialog still
871
+ // visible (not cancellable).
872
+ // turbo-overlay:closed — close animation done, dialog has
873
+ // closed; frame is about to be removed.
874
+ _dispatch(name) {
875
+ const target = this.dialog || this.element
876
+ if (!target) return
877
+ target.dispatchEvent(new CustomEvent(`turbo-overlay:${name}`, {
878
+ bubbles: true,
879
+ detail: { id: this.idValue, type: this.typeValue }
880
+ }))
881
+ }
882
+ }