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,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()