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