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,670 @@
|
|
|
1
|
+
import { computePopoverPosition } from "turbo_overlay/popover_position"
|
|
2
|
+
import { safelyHidePopover, normalizePopoverDialogStyles } from "turbo_overlay/dialog_utils"
|
|
3
|
+
import { randomIdSuffix } from "turbo_overlay/setup"
|
|
4
|
+
|
|
5
|
+
// Hover-triggered hint previews for turbo_overlay.
|
|
6
|
+
//
|
|
7
|
+
// Self-bootstraps on import. Uses delegated document listeners so it
|
|
8
|
+
// doesn't need to attach to individual links. Reads showDelay /
|
|
9
|
+
// hideDelay lazily from the stack element (`overlay_stack_tag`) — no
|
|
10
|
+
// Stimulus values, no controller.
|
|
11
|
+
//
|
|
12
|
+
// Lifecycle:
|
|
13
|
+
// - Hover a hint-marked link → after `show_delay_ms`, paint a
|
|
14
|
+
// pending placeholder (cloned from `turbo_overlay_loading_hint_template`).
|
|
15
|
+
// - Real content lands → swap in place (cache hit, prefetch
|
|
16
|
+
// response, or explicit `hint_url:` fetch).
|
|
17
|
+
// - Response carries no `<template id="…">` → dismiss silently.
|
|
18
|
+
// - User hovers away → dismiss (with `hide_delay_ms` grace window).
|
|
19
|
+
// - Navigation / click → drop everything.
|
|
20
|
+
//
|
|
21
|
+
// Update semantics: when a refreshed prefetch arrives for a URL that's
|
|
22
|
+
// currently displayed as a live hint (rare — Turbo dedupes prefetches),
|
|
23
|
+
// the cache is updated but the visible bubble stays as-is. The next
|
|
24
|
+
// hover shows the refreshed content. This avoids mid-read flicker;
|
|
25
|
+
// it also means hints can be slightly stale relative to the latest
|
|
26
|
+
// prefetch.
|
|
27
|
+
//
|
|
28
|
+
// Negative caching: when a hint-marked URL is fetched and returns a
|
|
29
|
+
// response with no `<template id>` (or the fetch errors out), the
|
|
30
|
+
// cache stores a `NO_HINT` sentinel for that URL. Subsequent hovers
|
|
31
|
+
// short-circuit at the show_delay tick without painting a pending
|
|
32
|
+
// placeholder. The negative cache clears on `turbo:visit`, so a page
|
|
33
|
+
// navigation gives the gem a fresh chance to discover a hint.
|
|
34
|
+
//
|
|
35
|
+
// Safety-net cap: the pending placeholder auto-dismisses after
|
|
36
|
+
// MAX_PENDING_MS (10s) if neither a `hint-ready` nor a
|
|
37
|
+
// `fetch-request-error` event arrives. Turbo doesn't dispatch on
|
|
38
|
+
// silently-cancelled prefetches (e.g. queue eviction) and the browser
|
|
39
|
+
// won't time out a hung server fetch, so this is the backstop. NO_HINT
|
|
40
|
+
// is cached when it fires so retries wait for the next page visit.
|
|
41
|
+
//
|
|
42
|
+
// Three content sources, one extraction path:
|
|
43
|
+
//
|
|
44
|
+
// 1. Turbo's hover prefetch (plain links, prefetch enabled).
|
|
45
|
+
// FetchRequest dispatches `turbo:before-fetch-response` for every
|
|
46
|
+
// fetch including prefetch; we listen, extract a `<template id="…">`
|
|
47
|
+
// from the response, and cache the fragment by URL.
|
|
48
|
+
//
|
|
49
|
+
// 2. Manual prefetch-style fetch (plain links, prefetch disabled).
|
|
50
|
+
// When the site sets `<meta name="turbo-prefetch" content="false">`
|
|
51
|
+
// or the link/ancestor carries `data-turbo-prefetch="false"`,
|
|
52
|
+
// Turbo won't fire a prefetch for us. The module falls back
|
|
53
|
+
// to a plain `fetch()` of the link's href so hints still work.
|
|
54
|
+
//
|
|
55
|
+
// 3. Explicit `hint_url:` (for overlay links Turbo refuses to
|
|
56
|
+
// prefetch — modal/drawer/popover_link_to set data-turbo-stream
|
|
57
|
+
// which excludes them from hover prefetch). The module
|
|
58
|
+
// fetches the hint URL with the `:hint` request variant on
|
|
59
|
+
// hover and extracts the same `<template id="…">` shape.
|
|
60
|
+
//
|
|
61
|
+
// All three producers emit:
|
|
62
|
+
//
|
|
63
|
+
// <template id="turbo-overlay-hint">
|
|
64
|
+
// <div class="turbo-overlay-hint" role="tooltip">...body...</div>
|
|
65
|
+
// </template>
|
|
66
|
+
//
|
|
67
|
+
// so the extractor doesn't branch on source.
|
|
68
|
+
//
|
|
69
|
+
// Detection is best-effort: we mirror Turbo's two common opt-out
|
|
70
|
+
// switches (meta and dataset). Other reasons Turbo might decline
|
|
71
|
+
// (cross-origin, non-GET, etc.) aren't relevant for `hint_link_to`,
|
|
72
|
+
// which always emits a same-origin GET.
|
|
73
|
+
|
|
74
|
+
const CACHE_LIMIT = 50
|
|
75
|
+
|
|
76
|
+
// Safety-net cap on how long the pending placeholder can spin before
|
|
77
|
+
// we give up. The happy path resolves via `hint-ready` (fetch success)
|
|
78
|
+
// or `fetch-request-error` (network failure). This catches the cases
|
|
79
|
+
// where neither fires: Turbo silently cancelling a queued prefetch,
|
|
80
|
+
// an indefinitely-hung server, or a browser that swallows the fetch
|
|
81
|
+
// event lifecycle. Cached as NO_HINT so a retry only happens after
|
|
82
|
+
// the next page visit.
|
|
83
|
+
const MAX_PENDING_MS = 10000
|
|
84
|
+
|
|
85
|
+
// Sentinel cached against a URL that we know has no hint template (or
|
|
86
|
+
// whose prefetch failed). Distinguishes "fetched and confirmed empty"
|
|
87
|
+
// from "never fetched", so we (a) don't paint a pending placeholder
|
|
88
|
+
// for a link we've already confirmed has nothing to show and (b)
|
|
89
|
+
// dismiss any in-flight pending immediately if the response already
|
|
90
|
+
// arrived during the show_delay window.
|
|
91
|
+
const NO_HINT = Symbol("turbo-overlay-no-hint")
|
|
92
|
+
|
|
93
|
+
const TEMPLATE_ID = "turbo-overlay-hint"
|
|
94
|
+
|
|
95
|
+
// Guard timers for CSS state-driven enter/leave animations. The CSS
|
|
96
|
+
// rules pivot on `data-state="entering"` / `data-state="leaving"`;
|
|
97
|
+
// these timeouts strip the attribute / remove the node if the
|
|
98
|
+
// `animationend` event never fires (e.g. prefers-reduced-motion or
|
|
99
|
+
// the bubble was removed/morphed before its animation finished).
|
|
100
|
+
// Must stay ≥ the longest matching CSS animation duration.
|
|
101
|
+
const HINT_ENTER_ANIMATION_TIMEOUT_MS = 200
|
|
102
|
+
const HINT_LEAVE_ANIMATION_TIMEOUT_MS = 250
|
|
103
|
+
|
|
104
|
+
const DEFAULTS = {
|
|
105
|
+
showDelay: 250,
|
|
106
|
+
hideDelay: 120
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const hintCache = new Map()
|
|
110
|
+
let current = null // { link, url, element }
|
|
111
|
+
let pending = null // { link, url, showTimer, fetchController, awaitTimer, hintReadyHandler }
|
|
112
|
+
let previousAriaDescribedBy = null
|
|
113
|
+
|
|
114
|
+
// Read show/hide delays. Per-link `data-turbo-overlay-hint-show-delay`
|
|
115
|
+
// / `-hide-delay` win over the stack tag's defaults
|
|
116
|
+
// (`overlay_stack_tag`), which in turn win over the module's hardcoded
|
|
117
|
+
// fallbacks. Re-read on every event so the values track late mounts
|
|
118
|
+
// and back/forward restores. `link` is optional — when omitted (or
|
|
119
|
+
// when the link has no per-link overrides) the stack-level config
|
|
120
|
+
// applies.
|
|
121
|
+
function readConfig(link) {
|
|
122
|
+
const stack = typeof document !== "undefined"
|
|
123
|
+
? document.querySelector("[data-controller~='turbo-overlay-stack']")
|
|
124
|
+
: null
|
|
125
|
+
|
|
126
|
+
const stackShow = stack && stack.dataset.turboOverlayHintShowDelay
|
|
127
|
+
const stackHide = stack && stack.dataset.turboOverlayHintHideDelay
|
|
128
|
+
const linkShow = link && link.dataset && link.dataset.turboOverlayHintShowDelay
|
|
129
|
+
const linkHide = link && link.dataset && link.dataset.turboOverlayHintHideDelay
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
showDelay: parseDelay(linkShow, parseDelay(stackShow, DEFAULTS.showDelay)),
|
|
133
|
+
hideDelay: parseDelay(linkHide, parseDelay(stackHide, DEFAULTS.hideDelay))
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseDelay(raw, fallback) {
|
|
138
|
+
if (raw == null || raw === "") return fallback
|
|
139
|
+
const n = parseInt(raw, 10)
|
|
140
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hintsActive() {
|
|
144
|
+
if (typeof window === "undefined") return false
|
|
145
|
+
if (window.matchMedia && window.matchMedia("(hover: none)").matches) return false
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ----- event handlers -----
|
|
150
|
+
|
|
151
|
+
function onMouseOver(event) {
|
|
152
|
+
if (!hintsActive()) return
|
|
153
|
+
const link = closestHintLink(event.target)
|
|
154
|
+
if (!link) return
|
|
155
|
+
hoverEnter(link)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function onMouseOut(event) {
|
|
159
|
+
const link = closestHintLink(event.target)
|
|
160
|
+
if (!link) return
|
|
161
|
+
const moveTo = event.relatedTarget
|
|
162
|
+
if (moveTo && link.contains(moveTo)) return
|
|
163
|
+
const active = activeHintElement()
|
|
164
|
+
if (active && moveTo && active.contains(moveTo)) return
|
|
165
|
+
hoverLeave(link)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function activeHintElement() {
|
|
169
|
+
if (current && current.element) return current.element
|
|
170
|
+
if (pending && pending.element) return pending.element
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function onFocusIn(event) {
|
|
175
|
+
if (!hintsActive()) return
|
|
176
|
+
const link = closestHintLink(event.target)
|
|
177
|
+
if (!link) return
|
|
178
|
+
hoverEnter(link)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function onFocusOut(event) {
|
|
182
|
+
const link = closestHintLink(event.target)
|
|
183
|
+
if (!link) return
|
|
184
|
+
hoverLeave(link)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function onKeyDown(event) {
|
|
188
|
+
if (event.key === "Escape" && current) {
|
|
189
|
+
dismissCurrent()
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function onTurboVisit() {
|
|
194
|
+
hintCache.clear()
|
|
195
|
+
cancelPending()
|
|
196
|
+
dismissCurrent({ animate: false })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function onTurboClick() {
|
|
200
|
+
cancelPending()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// FetchRequest dispatches before-fetch-response for every fetch
|
|
204
|
+
// (navigation, prefetch, preload, form). For any response whose
|
|
205
|
+
// request URL matches a hint-marked link in the DOM, parse the body
|
|
206
|
+
// for a hint template and cache the fragment.
|
|
207
|
+
async function onTurboFetchResp(event) {
|
|
208
|
+
const detail = event.detail || {}
|
|
209
|
+
const response = detail.fetchResponse && detail.fetchResponse.response
|
|
210
|
+
if (!response) return
|
|
211
|
+
|
|
212
|
+
const requestUrl = response.url || (detail.url && detail.url.toString())
|
|
213
|
+
if (!requestUrl) return
|
|
214
|
+
|
|
215
|
+
if (!anyHintLinkMatches(requestUrl)) return
|
|
216
|
+
|
|
217
|
+
let fragment = null
|
|
218
|
+
if (response.ok) {
|
|
219
|
+
try {
|
|
220
|
+
fragment = await extractHintFragment(response)
|
|
221
|
+
} catch (_) {
|
|
222
|
+
fragment = null
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const value = fragment || NO_HINT
|
|
227
|
+
cacheFragment(requestUrl, value)
|
|
228
|
+
if (response.url && response.url !== requestUrl) cacheFragment(response.url, value)
|
|
229
|
+
|
|
230
|
+
document.dispatchEvent(new CustomEvent("turbo-overlay:hint-ready", {
|
|
231
|
+
detail: { url: requestUrl, fragment: fragment }
|
|
232
|
+
}))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function onTurboFetchError(event) {
|
|
236
|
+
const detail = event.detail || {}
|
|
237
|
+
const request = detail.request || detail.fetchRequest
|
|
238
|
+
if (!request) return
|
|
239
|
+
const requestUrl = request.url && (typeof request.url.toString === "function" ? request.url.toString() : String(request.url))
|
|
240
|
+
if (!requestUrl) return
|
|
241
|
+
if (!anyHintLinkMatches(requestUrl)) return
|
|
242
|
+
|
|
243
|
+
// Network failure for a hint-marked URL. Treat as "no hint" so we
|
|
244
|
+
// don't strand a pending spinner and don't retry on the next hover
|
|
245
|
+
// until the next page visit clears the cache.
|
|
246
|
+
cacheFragment(requestUrl, NO_HINT)
|
|
247
|
+
|
|
248
|
+
document.dispatchEvent(new CustomEvent("turbo-overlay:hint-ready", {
|
|
249
|
+
detail: { url: requestUrl, fragment: null }
|
|
250
|
+
}))
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ----- hover state machine -----
|
|
254
|
+
|
|
255
|
+
function hoverEnter(link) {
|
|
256
|
+
if (current && current.link === link) {
|
|
257
|
+
cancelHideTimer()
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (pending && pending.link !== link) cancelPending()
|
|
262
|
+
if (pending && pending.link === link) {
|
|
263
|
+
cancelHideTimer()
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const url = link.dataset.turboOverlayHintUrl || link.href
|
|
268
|
+
if (!url) return
|
|
269
|
+
|
|
270
|
+
const delay = readConfig(link).showDelay
|
|
271
|
+
const showTimer = setTimeout(() => onShowTimerFire(link, url), delay)
|
|
272
|
+
pending = { link, url, showTimer, fetchController: null, hintReadyHandler: null, hideTimer: null, element: null, maxPendingTimer: null }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function hoverLeave(link) {
|
|
276
|
+
if (pending && pending.link === link) {
|
|
277
|
+
cancelPending()
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
if (current && current.link === link) {
|
|
281
|
+
scheduleHide()
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function onShowTimerFire(link, url) {
|
|
286
|
+
if (!pending || pending.link !== link) return
|
|
287
|
+
|
|
288
|
+
const cached = hintCache.get(url)
|
|
289
|
+
if (cached === NO_HINT) {
|
|
290
|
+
cancelPending()
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
if (cached) {
|
|
294
|
+
showHint(link, url, cached)
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const isOverlayLink = !!(link.dataset.turboOverlay || link.dataset.turboStream === "true")
|
|
299
|
+
const hasHintUrl = !!link.dataset.turboOverlayHintUrl
|
|
300
|
+
|
|
301
|
+
if (isOverlayLink && !hasHintUrl) {
|
|
302
|
+
cancelPending()
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
renderPendingHint(link)
|
|
307
|
+
|
|
308
|
+
if (hasHintUrl) {
|
|
309
|
+
fetchAndShow(link, url)
|
|
310
|
+
} else if (turboWillPrefetch(link)) {
|
|
311
|
+
awaitPrefetchAndShow(link, url)
|
|
312
|
+
} else {
|
|
313
|
+
fetchAndShow(link, url, { hintVariant: false })
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Mirror Turbo's link-prefetch opt-out predicates so we know when to
|
|
318
|
+
// fall back to a manual fetch. Best-effort — Turbo may decline to
|
|
319
|
+
// prefetch for other reasons (cross-origin, non-GET, etc.) but
|
|
320
|
+
// hint_link_to already emits a same-origin GET, so the common opt-outs
|
|
321
|
+
// we care about are the meta and dataset switches.
|
|
322
|
+
function turboWillPrefetch(link) {
|
|
323
|
+
const meta = document.querySelector('meta[name="turbo-prefetch"]')
|
|
324
|
+
if (meta && meta.getAttribute("content") === "false") return false
|
|
325
|
+
if (link.closest('[data-turbo-prefetch="false"]')) return false
|
|
326
|
+
return true
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Clone the host app's `_loading.html+hint.erb` template into the
|
|
330
|
+
// body, position it like a real hint, and stash the node on `pending`
|
|
331
|
+
// so it tracks the in-flight request. The real hint (or silent
|
|
332
|
+
// dismissal) takes over via `showHint` / `cancelPending`.
|
|
333
|
+
function renderPendingHint(link) {
|
|
334
|
+
if (!pending || pending.link !== link) return
|
|
335
|
+
if (pending.element) return
|
|
336
|
+
|
|
337
|
+
const template = document.getElementById("turbo_overlay_loading_hint_template")
|
|
338
|
+
if (!template || !template.content) return
|
|
339
|
+
|
|
340
|
+
const node = template.content.cloneNode(true).firstElementChild
|
|
341
|
+
if (!node) return
|
|
342
|
+
|
|
343
|
+
node.dataset.state = "entering"
|
|
344
|
+
node.dataset.turboOverlayHintPending = "true"
|
|
345
|
+
document.body.appendChild(node)
|
|
346
|
+
showHintPopover(node)
|
|
347
|
+
|
|
348
|
+
positionFloatingHint(node, link)
|
|
349
|
+
|
|
350
|
+
node.addEventListener("mouseenter", () => cancelHideTimer())
|
|
351
|
+
node.addEventListener("mouseleave", () => scheduleHide())
|
|
352
|
+
|
|
353
|
+
pending.element = node
|
|
354
|
+
|
|
355
|
+
pending.maxPendingTimer = setTimeout(() => {
|
|
356
|
+
if (!pending || pending.link !== link) return
|
|
357
|
+
cacheFragment(pending.url, NO_HINT)
|
|
358
|
+
cancelPending()
|
|
359
|
+
}, MAX_PENDING_MS)
|
|
360
|
+
|
|
361
|
+
setTimeout(() => { if (node.dataset.state === "entering") delete node.dataset.state }, HINT_ENTER_ANIMATION_TIMEOUT_MS)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function positionFloatingHint(node, link) {
|
|
365
|
+
// Normalize positioning BEFORE measuring. UA `[popover]` rules
|
|
366
|
+
// (which apply since hints render as `<div popover="manual">` and
|
|
367
|
+
// enter the top layer via showPopover) set `inset: 0; margin: auto`
|
|
368
|
+
// — without explicit `right: auto; bottom: auto; margin: 0` on our
|
|
369
|
+
// side, the leftover space gets distributed via the auto margins
|
|
370
|
+
// and the hint ends up centered in the gap rather than anchored to
|
|
371
|
+
// our computed left.
|
|
372
|
+
normalizePopoverDialogStyles(node)
|
|
373
|
+
|
|
374
|
+
const dialogRect = node.getBoundingClientRect()
|
|
375
|
+
const anchorRect = link.getBoundingClientRect()
|
|
376
|
+
const viewport = {
|
|
377
|
+
width: document.documentElement.clientWidth,
|
|
378
|
+
height: document.documentElement.clientHeight
|
|
379
|
+
}
|
|
380
|
+
const { top, left } = computePopoverPosition({
|
|
381
|
+
anchor: anchorRect,
|
|
382
|
+
dialog: dialogRect,
|
|
383
|
+
viewport,
|
|
384
|
+
position: "bottom",
|
|
385
|
+
align: "start",
|
|
386
|
+
offset: 6,
|
|
387
|
+
autoFlip: true
|
|
388
|
+
})
|
|
389
|
+
node.style.top = `${top}px`
|
|
390
|
+
node.style.left = `${left}px`
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function awaitPrefetchAndShow(link, url) {
|
|
394
|
+
if (!pending || pending.link !== link) return
|
|
395
|
+
|
|
396
|
+
const ready = (event) => {
|
|
397
|
+
if (!pending || pending.link !== link) return
|
|
398
|
+
if (event.detail.url !== url && normalize(event.detail.url) !== normalize(url)) return
|
|
399
|
+
cleanup()
|
|
400
|
+
const cached = hintCache.get(url) || hintCache.get(normalize(url))
|
|
401
|
+
if (cached && cached !== NO_HINT) {
|
|
402
|
+
showHint(link, url, cached)
|
|
403
|
+
} else {
|
|
404
|
+
// Response carried no hint template (or fetch errored). Dismiss
|
|
405
|
+
// the pending placeholder silently. The NO_HINT cache entry
|
|
406
|
+
// stops the next hover from painting a placeholder.
|
|
407
|
+
cancelPending()
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const cleanup = () => {
|
|
411
|
+
document.removeEventListener("turbo-overlay:hint-ready", ready)
|
|
412
|
+
if (pending) {
|
|
413
|
+
pending.hintReadyHandler = null
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
pending.hintReadyHandler = ready
|
|
418
|
+
document.addEventListener("turbo-overlay:hint-ready", ready)
|
|
419
|
+
// No fixed timeout: the pending placeholder keeps spinning until the
|
|
420
|
+
// prefetch response arrives (success → swap; no-template → dismiss),
|
|
421
|
+
// the user hovers away (`hoverLeave` → `cancelPending`), or the page
|
|
422
|
+
// navigates / clicks (`onTurboVisit` / `onTurboClick`). A slow
|
|
423
|
+
// controller (eg. heavy DB query) reliably wins this race;
|
|
424
|
+
// previously a 750ms budget killed the placeholder before the
|
|
425
|
+
// response landed.
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function fetchAndShow(link, url, { hintVariant = true } = {}) {
|
|
429
|
+
if (!pending || pending.link !== link) return
|
|
430
|
+
|
|
431
|
+
const controller = new AbortController()
|
|
432
|
+
pending.fetchController = controller
|
|
433
|
+
|
|
434
|
+
// Two callers:
|
|
435
|
+
// - hint_url: links (hintVariant=true) — send X-Turbo-Overlay: hint
|
|
436
|
+
// so the server can render `show.html+hint.erb` or similar.
|
|
437
|
+
// - prefetch-disabled fallback (hintVariant=false) — fetch the
|
|
438
|
+
// plain page and extract the `<template id="turbo-overlay-hint">`
|
|
439
|
+
// that `overlay_stack_tag` emitted for the action's `+hint`
|
|
440
|
+
// variant, matching what Turbo prefetch would have delivered.
|
|
441
|
+
const headers = { "Accept": "text/html" }
|
|
442
|
+
if (hintVariant) headers["X-Turbo-Overlay"] = "hint"
|
|
443
|
+
|
|
444
|
+
let response
|
|
445
|
+
try {
|
|
446
|
+
response = await fetch(url, {
|
|
447
|
+
signal: controller.signal,
|
|
448
|
+
cache: "default",
|
|
449
|
+
headers: headers,
|
|
450
|
+
credentials: "same-origin"
|
|
451
|
+
})
|
|
452
|
+
} catch (e) {
|
|
453
|
+
// AbortError lands here when `cancelPending` aborted us — pending
|
|
454
|
+
// is already null. Don't cache abort as NO_HINT; it might succeed
|
|
455
|
+
// next time.
|
|
456
|
+
if (e && e.name !== "AbortError") cacheFragment(url, NO_HINT)
|
|
457
|
+
if (pending && pending.link === link) cancelPending()
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (!pending || pending.link !== link) return
|
|
462
|
+
if (!response || !response.ok) {
|
|
463
|
+
cacheFragment(url, NO_HINT)
|
|
464
|
+
cancelPending()
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
let fragment
|
|
469
|
+
try {
|
|
470
|
+
fragment = await extractHintFragment(response, readConfig().templateId)
|
|
471
|
+
} catch (_) {
|
|
472
|
+
cacheFragment(url, NO_HINT)
|
|
473
|
+
cancelPending()
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
if (!fragment) {
|
|
477
|
+
cacheFragment(url, NO_HINT)
|
|
478
|
+
cancelPending()
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
cacheFragment(url, fragment)
|
|
483
|
+
if (response.url && response.url !== url) cacheFragment(response.url, fragment)
|
|
484
|
+
|
|
485
|
+
if (pending && pending.link === link) {
|
|
486
|
+
showHint(link, url, fragment)
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ----- show / hide -----
|
|
491
|
+
|
|
492
|
+
function showHint(link, url, fragment) {
|
|
493
|
+
// If a pending placeholder for the same link is on screen, transfer
|
|
494
|
+
// it through `current` so the standard dismissal removes it
|
|
495
|
+
// synchronously (no fade) right before the real hint mounts —
|
|
496
|
+
// avoids a visible out/in flicker.
|
|
497
|
+
if (pending && pending.element && pending.link === link) {
|
|
498
|
+
current = { link, url, element: pending.element, hideTimer: null }
|
|
499
|
+
pending.element = null
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
dismissCurrent({ animate: false })
|
|
503
|
+
cancelPending()
|
|
504
|
+
|
|
505
|
+
const node = fragment.cloneNode(true).firstElementChild
|
|
506
|
+
if (!node) return
|
|
507
|
+
|
|
508
|
+
node.dataset.state = "entering"
|
|
509
|
+
document.body.appendChild(node)
|
|
510
|
+
showHintPopover(node)
|
|
511
|
+
|
|
512
|
+
positionFloatingHint(node, link)
|
|
513
|
+
|
|
514
|
+
const hintId = node.id || `turbo-overlay-hint-${randomIdSuffix()}`
|
|
515
|
+
node.id = hintId
|
|
516
|
+
previousAriaDescribedBy = link.getAttribute("aria-describedby")
|
|
517
|
+
link.setAttribute("aria-describedby", hintId)
|
|
518
|
+
|
|
519
|
+
node.addEventListener("mouseenter", () => cancelHideTimer())
|
|
520
|
+
node.addEventListener("mouseleave", () => scheduleHide())
|
|
521
|
+
|
|
522
|
+
current = { link, url, element: node, hideTimer: null }
|
|
523
|
+
|
|
524
|
+
setTimeout(() => { if (node.dataset.state === "entering") delete node.dataset.state }, HINT_ENTER_ANIMATION_TIMEOUT_MS)
|
|
525
|
+
|
|
526
|
+
document.dispatchEvent(new CustomEvent("turbo-overlay:hint-shown", {
|
|
527
|
+
detail: { url }
|
|
528
|
+
}))
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function scheduleHide() {
|
|
532
|
+
cancelHideTimer()
|
|
533
|
+
const link = (current && current.link) || (pending && pending.link) || null
|
|
534
|
+
const delay = readConfig(link).hideDelay
|
|
535
|
+
if (current) {
|
|
536
|
+
current.hideTimer = setTimeout(() => dismissCurrent(), delay)
|
|
537
|
+
} else if (pending && pending.element) {
|
|
538
|
+
// Pending placeholder visible but the user has hovered away —
|
|
539
|
+
// dismiss after the same grace window so brief cursor excursions
|
|
540
|
+
// don't kill in-flight requests.
|
|
541
|
+
pending.hideTimer = setTimeout(() => cancelPending(), delay)
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function cancelHideTimer() {
|
|
546
|
+
if (current && current.hideTimer) {
|
|
547
|
+
clearTimeout(current.hideTimer)
|
|
548
|
+
current.hideTimer = null
|
|
549
|
+
}
|
|
550
|
+
if (pending && pending.hideTimer) {
|
|
551
|
+
clearTimeout(pending.hideTimer)
|
|
552
|
+
pending.hideTimer = null
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function dismissCurrent({ animate = true } = {}) {
|
|
557
|
+
if (!current) return
|
|
558
|
+
const { link, element } = current
|
|
559
|
+
cancelHideTimer()
|
|
560
|
+
if (link && previousAriaDescribedBy != null) {
|
|
561
|
+
link.setAttribute("aria-describedby", previousAriaDescribedBy)
|
|
562
|
+
} else if (link) {
|
|
563
|
+
link.removeAttribute("aria-describedby")
|
|
564
|
+
}
|
|
565
|
+
previousAriaDescribedBy = null
|
|
566
|
+
current = null
|
|
567
|
+
|
|
568
|
+
if (!element || !element.parentNode) return
|
|
569
|
+
if (!animate) { removeHint(element); return }
|
|
570
|
+
|
|
571
|
+
element.dataset.state = "leaving"
|
|
572
|
+
const onEnd = () => { element.removeEventListener("animationend", onEnd); removeHint(element) }
|
|
573
|
+
element.addEventListener("animationend", onEnd)
|
|
574
|
+
setTimeout(() => { if (element.parentNode) removeHint(element) }, HINT_LEAVE_ANIMATION_TIMEOUT_MS)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function cancelPending() {
|
|
578
|
+
if (!pending) return
|
|
579
|
+
if (pending.showTimer) clearTimeout(pending.showTimer)
|
|
580
|
+
if (pending.hideTimer) clearTimeout(pending.hideTimer)
|
|
581
|
+
if (pending.maxPendingTimer) clearTimeout(pending.maxPendingTimer)
|
|
582
|
+
if (pending.hintReadyHandler) {
|
|
583
|
+
document.removeEventListener("turbo-overlay:hint-ready", pending.hintReadyHandler)
|
|
584
|
+
}
|
|
585
|
+
if (pending.fetchController) {
|
|
586
|
+
try { pending.fetchController.abort() } catch (_) { /* ignore */ }
|
|
587
|
+
}
|
|
588
|
+
if (pending.element && pending.element.parentNode) {
|
|
589
|
+
const el = pending.element
|
|
590
|
+
el.dataset.state = "leaving"
|
|
591
|
+
const onEnd = () => { el.removeEventListener("animationend", onEnd); removeHint(el) }
|
|
592
|
+
el.addEventListener("animationend", onEnd)
|
|
593
|
+
setTimeout(() => { if (el.parentNode) removeHint(el) }, HINT_LEAVE_ANIMATION_TIMEOUT_MS)
|
|
594
|
+
}
|
|
595
|
+
pending = null
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Hints join the top layer via the Popover API so they render above any open modal/drawer.
|
|
599
|
+
function showHintPopover(node) {
|
|
600
|
+
if (typeof node.showPopover !== "function") return
|
|
601
|
+
try { node.showPopover() } catch (_) { /* already open or unsupported */ }
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function removeHint(node) {
|
|
605
|
+
safelyHidePopover(node)
|
|
606
|
+
node.remove()
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ----- cache -----
|
|
610
|
+
|
|
611
|
+
function cacheFragment(url, fragment) {
|
|
612
|
+
if (!hintCache.has(url) && hintCache.size >= CACHE_LIMIT) {
|
|
613
|
+
const oldestKey = hintCache.keys().next().value
|
|
614
|
+
hintCache.delete(oldestKey)
|
|
615
|
+
}
|
|
616
|
+
hintCache.set(url, fragment)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function normalize(url) {
|
|
620
|
+
try { return new URL(url, document.baseURI).toString() } catch (_) { return url }
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ----- helpers -----
|
|
624
|
+
|
|
625
|
+
function closestHintLink(target) {
|
|
626
|
+
if (!target || !target.closest) return null
|
|
627
|
+
return target.closest("a[data-turbo-overlay-hint]")
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function anyHintLinkMatches(url) {
|
|
631
|
+
const normalized = normalize(url)
|
|
632
|
+
const links = document.querySelectorAll("a[data-turbo-overlay-hint]")
|
|
633
|
+
for (const link of links) {
|
|
634
|
+
const linkHref = normalize(link.dataset.turboOverlayHintUrl || link.href || "")
|
|
635
|
+
if (linkHref === normalized) return true
|
|
636
|
+
}
|
|
637
|
+
return false
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Shared extractor — feeds from a single code path regardless of
|
|
641
|
+
// whether the response was a Turbo prefetch or our own hint_url fetch.
|
|
642
|
+
async function extractHintFragment(response) {
|
|
643
|
+
const html = await response.clone().text()
|
|
644
|
+
const doc = new DOMParser().parseFromString(html, "text/html")
|
|
645
|
+
const tpl = doc.querySelector(`template#${cssEscape(TEMPLATE_ID)}`)
|
|
646
|
+
return tpl ? tpl.content.cloneNode(true) : null
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function cssEscape(value) {
|
|
650
|
+
if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(value)
|
|
651
|
+
return String(value).replace(/[^a-zA-Z0-9_-]/g, "\\$&")
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function setup() {
|
|
655
|
+
if (typeof document === "undefined") return
|
|
656
|
+
if (window._turboOverlayHintRegistered) return
|
|
657
|
+
window._turboOverlayHintRegistered = true
|
|
658
|
+
|
|
659
|
+
document.addEventListener("mouseover", onMouseOver)
|
|
660
|
+
document.addEventListener("mouseout", onMouseOut)
|
|
661
|
+
document.addEventListener("focusin", onFocusIn)
|
|
662
|
+
document.addEventListener("focusout", onFocusOut)
|
|
663
|
+
document.addEventListener("keydown", onKeyDown)
|
|
664
|
+
document.addEventListener("turbo:before-fetch-response", onTurboFetchResp)
|
|
665
|
+
document.addEventListener("turbo:fetch-request-error", onTurboFetchError)
|
|
666
|
+
document.addEventListener("turbo:visit", onTurboVisit)
|
|
667
|
+
document.addEventListener("turbo:click", onTurboClick)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
setup()
|