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,184 @@
1
+ // URL advance bookkeeping for turbo_overlay.
2
+ //
3
+ // `advance: true` on a modal_link_to / drawer_link_to (or a per-type
4
+ // config default) pushes a history entry when the overlay opens, so
5
+ // the URL bar reflects what the user is looking at and browser-back
6
+ // closes the top overlay instead of navigating away from the page
7
+ // beneath. Popovers and hints never advance — they're ephemeral and
8
+ // shouldn't churn browser history.
9
+ //
10
+ // State lives at module scope so the bookkeeping survives Stimulus
11
+ // disconnect/reconnect and idiomorph re-renders (the dialog node may
12
+ // be morphed in place; the controller instance can be re-created;
13
+ // the overlay id is the stable key).
14
+
15
+ // Resolved advance URL set by the click handler before the fetch
16
+ // goes out; consumed by the overlay controller when `shown` fires.
17
+ const advanceUrls = new Map()
18
+
19
+ // Overlays we've called history.pushState for. Keyed by overlay id so
20
+ // close-path code can ask "did we push for this overlay?" without
21
+ // reaching into a possibly-discarded controller instance.
22
+ const pushedEntries = new Map()
23
+
24
+ // Counter of popstates we caused ourselves via `history.back()` and
25
+ // must swallow before the popstate handler treats one as user input.
26
+ let expectedPopstates = 0
27
+
28
+ // Read-only accessor for setup.js's `turbo:before-visit` guard.
29
+ // Exposed as a function so the value reflects the current count, not
30
+ // a snapshot at import time.
31
+ export function expectedPopstateCount() {
32
+ return expectedPopstates
33
+ }
34
+
35
+ // True if any currently-tracked overlay entry on the stack has a
36
+ // `pushed` record. Used by setup.js to decide whether to cancel a
37
+ // Turbo restoration visit that was triggered by popstate over an
38
+ // advance-pushed entry.
39
+ export function hasPushedOverlayOnStack() {
40
+ if (!stackController || !stackController.entries) return false
41
+ for (const entry of stackController.entries) {
42
+ if (entry && pushedEntries.has(entry.id)) return true
43
+ }
44
+ return false
45
+ }
46
+
47
+ // Live reference to the per-page stack controller. The stack
48
+ // Stimulus controller calls `setStackController(this)` on connect and
49
+ // `setStackController(null)` on disconnect. Used by the popstate
50
+ // handler to walk the stack without reaching into Stimulus internals
51
+ // from setup.js (which is not itself a controller).
52
+ let stackController = null
53
+
54
+ export function setStackController(controller) {
55
+ stackController = controller
56
+ }
57
+
58
+ export function getStackController() {
59
+ return stackController
60
+ }
61
+
62
+ export function setAdvanceUrl(id, url) {
63
+ if (!id) return
64
+ if (url) advanceUrls.set(id, url)
65
+ else advanceUrls.delete(id)
66
+ }
67
+
68
+ export function getAdvanceUrl(id) {
69
+ return id ? (advanceUrls.get(id) || null) : null
70
+ }
71
+
72
+ export function clearAdvanceUrl(id) {
73
+ if (id) advanceUrls.delete(id)
74
+ }
75
+
76
+ export function markPushed(id, url, type) {
77
+ if (!id) return
78
+ pushedEntries.set(id, { url, type })
79
+ }
80
+
81
+ export function isPushed(id) {
82
+ return !!(id && pushedEntries.has(id))
83
+ }
84
+
85
+ export function clearPushed(id) {
86
+ if (id) pushedEntries.delete(id)
87
+ }
88
+
89
+ // Count entries from a stack snapshot whose id has a `pushed` record.
90
+ // Used by close-path code to detect whether a given close actually
91
+ // reduced the live pushed count (and therefore should reverse history).
92
+ export function livePushedCount(stackEntries) {
93
+ if (!stackEntries || !stackEntries.length) return 0
94
+ let n = 0
95
+ for (const e of stackEntries) {
96
+ if (e && pushedEntries.has(e.id)) n += 1
97
+ }
98
+ return n
99
+ }
100
+
101
+ export function pushOverlayState(id, type, url) {
102
+ if (typeof window === "undefined" || !window.history) return
103
+ try {
104
+ window.history.pushState(
105
+ { turboOverlay: { id, type } },
106
+ "",
107
+ url
108
+ )
109
+ } catch (_) {
110
+ // pushState can throw on cross-origin URLs; treat as "didn't push"
111
+ // so the close path won't try to reverse a history entry that
112
+ // doesn't exist. The caller is expected to skip markPushed too if
113
+ // this throws — but we're defensive: the caller's surrounding
114
+ // try/catch will handle it.
115
+ throw _
116
+ }
117
+ }
118
+
119
+ export function reverseHistoryForClose() {
120
+ if (typeof window === "undefined" || !window.history) return
121
+ expectedPopstates += 1
122
+ try {
123
+ window.history.back()
124
+ } catch (_) {
125
+ // Same defensive note as pushOverlayState. If back() throws we'll
126
+ // have an over-counted expectedPopstates that one stray popstate
127
+ // (if it ever fires) would swallow. Acceptable.
128
+ }
129
+ }
130
+
131
+ // Wipe state at navigation boundaries. The pushed history entries the
132
+ // gem created remain in the session-history backing store — the user
133
+ // could still navigate back to them — but the gem no longer tracks
134
+ // them, so a popstate landing on one of those URLs after navigation
135
+ // is treated as a regular user-initiated history navigation (no
136
+ // overlay matches; we no-op).
137
+ export function resetHistoryState() {
138
+ advanceUrls.clear()
139
+ pushedEntries.clear()
140
+ expectedPopstates = 0
141
+ }
142
+
143
+ export function registerPopstateHandler() {
144
+ if (typeof window === "undefined") return
145
+ if (window._turboOverlayPopstateRegistered) return
146
+ window._turboOverlayPopstateRegistered = true
147
+
148
+ // Two responsibilities:
149
+ //
150
+ // 1. If we caused this popstate via `history.back()` (close path),
151
+ // just decrement the counter. The matching overlay was already
152
+ // cleared from `pushedEntries` before history.back().
153
+ //
154
+ // 2. If the user pressed browser-back over an advance overlay's
155
+ // pushed URL, close the topmost overlay whose id is still in
156
+ // `pushedEntries`. The overlay's close path sees `_closedByBack`
157
+ // and skips its own `history.back()` so we don't double-pop.
158
+ //
159
+ // Note: we can't stop Turbo Drive's own popstate handler from
160
+ // running (for window-only events, capture/bubble flags don't
161
+ // change at-target order; Turbo's handler is registered first via
162
+ // its own import order and runs first). The protection against
163
+ // Turbo's `historyPoppedWithEmptyState` → `before-cache` → teardown
164
+ // chain lives in setup.js, which gates `tearDownAllOverlays` on
165
+ // whether a real Turbo visit is actually in progress.
166
+ window.addEventListener("popstate", () => {
167
+ if (expectedPopstates > 0) {
168
+ expectedPopstates -= 1
169
+ return
170
+ }
171
+ const stack = stackController
172
+ if (!stack || !stack.entries) return
173
+ for (let i = stack.entries.length - 1; i >= 0; i--) {
174
+ const entry = stack.entries[i]
175
+ if (!entry || !entry.controller) continue
176
+ if (!pushedEntries.has(entry.id)) continue
177
+ entry.controller._closedByBack = true
178
+ if (typeof entry.controller.close === "function") {
179
+ entry.controller.close()
180
+ }
181
+ return
182
+ }
183
+ })
184
+ }
@@ -0,0 +1,53 @@
1
+ import "turbo_overlay/setup"
2
+ import "turbo_overlay/hint"
3
+ import { registerConfirm } from "turbo_overlay/setup"
4
+ import StackController from "turbo_overlay/stack_controller"
5
+ import OverlayController from "turbo_overlay/overlay_controller"
6
+ import { visit } from "turbo_overlay/visit"
7
+
8
+ // Single entry point for the gem's Stimulus controllers.
9
+ //
10
+ // import { register } from "turbo_overlay"
11
+ // register(application)
12
+ //
13
+ // Registers two controllers under their canonical identifiers:
14
+ // `turbo-overlay-stack` and `turbo-overlay`.
15
+ //
16
+ // Importing this module also runs `turbo_overlay/setup` (page-level
17
+ // wiring: request-header injection, loading placeholders, back/forward
18
+ // cache teardown, the `turbo_stream.overlay` custom stream action) and
19
+ // `turbo_overlay/hint` (hover-triggered preview module). Both
20
+ // self-bootstrap and are guarded by `window._turboOverlay*Registered`
21
+ // flags, so importing this entry point more than once is safe.
22
+ //
23
+ // Pass `{ confirm: true }` to also route `data-turbo-confirm` on
24
+ // links/forms through the gem's themed overlay instead of the
25
+ // browser-native `window.confirm`. Requires confirm chrome partials
26
+ // in `app/views/turbo_overlay/` (copied by `turbo_overlay:install`);
27
+ // falls back to native `confirm` if no variant partial is present.
28
+ //
29
+ // register(application, { confirm: true })
30
+ //
31
+ // The hint module is loaded unconditionally — it's inert until a link
32
+ // carrying `data-turbo-overlay-hint` is hovered.
33
+ export function register(application, options = {}) {
34
+ application.register("turbo-overlay-stack", StackController)
35
+ application.register("turbo-overlay", OverlayController)
36
+ if (options.confirm) registerConfirm()
37
+ }
38
+
39
+ export { StackController, OverlayController, registerConfirm, visit }
40
+
41
+ // Expose a window global so non-bundler callers — inline `onclick`,
42
+ // third-party callbacks (Google Maps markers, Leaflet popups), custom
43
+ // elements that don't import from this package — can call
44
+ // `TurboOverlay.visit(url, opts)` without a module import. Assigned at
45
+ // import time, not inside `register()`, so it's available the moment
46
+ // the gem's JS loads.
47
+ if (typeof window !== "undefined") {
48
+ window.TurboOverlay = Object.assign(window.TurboOverlay || {}, {
49
+ visit,
50
+ register,
51
+ registerConfirm,
52
+ })
53
+ }
@@ -0,0 +1,152 @@
1
+ // Single source of truth for the overlay trigger contract — option
2
+ // names, dataset attribute names, request header names, encoding rules,
3
+ // type restrictions, plus the validation + dataset-build helpers that
4
+ // the JS-facing `visit()` API relies on.
5
+ //
6
+ // Three places write or read these mappings:
7
+ // 1. Ruby view helpers (`view_helper.rb`) — write dataset attributes
8
+ // when rendering `modal_link_to` / `drawer_link_to` /
9
+ // `popover_link_to`. Authoritative on the server side; Ruby can't
10
+ // share this table.
11
+ // 2. `visit.js` — writes dataset attributes on a synthesized link
12
+ // when JS code calls `TurboOverlay.visit(url, opts)`. Uses
13
+ // `buildOverlayDataset` and `validateVisitArgs` from this file.
14
+ // 3. `setup.js` (turbo:before-fetch-request hook) — reads dataset
15
+ // attributes off the trigger and emits the X-Turbo-Overlay-*
16
+ // headers via `emitOverlayHeaders` from this file.
17
+ //
18
+ // (2) and (3) both consume this table. Adding a header-bearing option
19
+ // here is enough to make it round-trip through `visit()` and the fetch
20
+ // hook. The Ruby helper must still be updated in parallel.
21
+ //
22
+ // This module deliberately has no imports so it stays unit-testable
23
+ // under plain `node --test` (no DOM, no importmap).
24
+ //
25
+ // Encoding rules mirror `_overlay_normalize_link_args` at
26
+ // `lib/turbo_overlay/helpers/view_helper.rb:454-482`:
27
+ // - `backdrop` / `close` only emit on `false` (the non-default)
28
+ // - `keepOpenOnRedirect` only emits on `true` (the non-default)
29
+ // - `advance` is restricted to modal/drawer
30
+ // - everything else emits whenever a value is supplied
31
+
32
+ export const OVERLAY_OPTIONS = [
33
+ {
34
+ key: "id",
35
+ dataset: "turboOverlayId",
36
+ header: "X-Turbo-Overlay-Id",
37
+ },
38
+ {
39
+ key: "position",
40
+ dataset: "turboOverlayPosition",
41
+ header: "X-Turbo-Overlay-Position",
42
+ },
43
+ {
44
+ key: "align",
45
+ dataset: "turboOverlayAlign",
46
+ header: "X-Turbo-Overlay-Align",
47
+ },
48
+ {
49
+ key: "offset",
50
+ dataset: "turboOverlayOffset",
51
+ header: "X-Turbo-Overlay-Offset",
52
+ },
53
+ {
54
+ key: "backdrop",
55
+ dataset: "turboOverlayBackdrop",
56
+ header: "X-Turbo-Overlay-Backdrop",
57
+ onlyWhen: (v) => v === false,
58
+ encode: () => "false",
59
+ },
60
+ {
61
+ key: "close",
62
+ dataset: "turboOverlayClose",
63
+ header: "X-Turbo-Overlay-Close",
64
+ onlyWhen: (v) => v === false,
65
+ encode: () => "false",
66
+ },
67
+ {
68
+ key: "keepOpenOnRedirect",
69
+ dataset: "turboOverlayKeepOpenOnRedirect",
70
+ header: "X-Turbo-Overlay-Keep-Open",
71
+ onlyWhen: (v) => v === true,
72
+ encode: () => "true",
73
+ },
74
+ {
75
+ key: "advance",
76
+ dataset: "turboOverlayAdvance",
77
+ onlyTypes: ["modal", "drawer"],
78
+ encode: (v) => (v === true ? "true" : v === false ? "false" : String(v)),
79
+ },
80
+ ]
81
+
82
+ export const VALID_TYPES = ["modal", "drawer", "popover", "hint"]
83
+
84
+ // Writes dataset attributes on `el` for each option in `opts` whose
85
+ // entry passes the table's gating rules. `type` is the overlay type
86
+ // (modal/drawer/popover/hint) and is used to honor `onlyTypes`.
87
+ //
88
+ // Returns nothing; mutates `el.dataset` in place. `el` is duck-typed —
89
+ // any object with a `.dataset` property works.
90
+ export function applyOverlayOptions(el, type, opts) {
91
+ for (const entry of OVERLAY_OPTIONS) {
92
+ const value = opts[entry.key]
93
+ if (value === undefined || value === null) continue
94
+ if (entry.onlyTypes && !entry.onlyTypes.includes(type)) continue
95
+ if (entry.onlyWhen && !entry.onlyWhen(value)) continue
96
+ el.dataset[entry.dataset] = entry.encode ? entry.encode(value) : String(value)
97
+ }
98
+ }
99
+
100
+ // Reads dataset attributes from a trigger element and writes the
101
+ // corresponding X-Turbo-Overlay-* headers into `headers`. Only entries
102
+ // that declare a `header` participate (e.g. `advance` is dataset-only —
103
+ // the server reads it from the response context, not a request header).
104
+ export function emitOverlayHeaders(dataset, headers) {
105
+ for (const entry of OVERLAY_OPTIONS) {
106
+ if (!entry.header) continue
107
+ const value = dataset[entry.dataset]
108
+ if (value === undefined || value === null || value === "") continue
109
+ headers[entry.header] = value
110
+ }
111
+ }
112
+
113
+ // Returns the dataset object that a trigger element synthesized by
114
+ // `visit()` should carry. Includes the three always-present attributes
115
+ // (`turboStream`, `turboOverlay`, `turboFrame`) plus everything written
116
+ // by `applyOverlayOptions`.
117
+ export function buildOverlayDataset(type, options = {}) {
118
+ const target = { dataset: {} }
119
+ target.dataset.turboStream = "true"
120
+ target.dataset.turboOverlay = type
121
+ target.dataset.turboFrame = options.frame || "_top"
122
+ applyOverlayOptions(target, type, options)
123
+ return target.dataset
124
+ }
125
+
126
+ // Validates the (url, options) pair `visit()` receives. Throws
127
+ // TypeError on misuse. Returns the resolved overlay type so callers
128
+ // don't have to re-derive it.
129
+ //
130
+ // Popovers must carry an `anchor` element — the synthesized trigger
131
+ // link has no useful bounding rect, so `setup.js` reads the anchor off
132
+ // `link.__turboOverlayAnchor` to position the popover. We skip the
133
+ // `instanceof Element` check when `Element` is undefined (the node test
134
+ // environment) so the pure validation can be unit-tested with a duck.
135
+ export function validateVisitArgs(url, options) {
136
+ if (typeof url !== "string" || url.length === 0) {
137
+ throw new TypeError("TurboOverlay.visit: url must be a non-empty string")
138
+ }
139
+ const type = options.type || "modal"
140
+ if (!VALID_TYPES.includes(type)) {
141
+ throw new TypeError(
142
+ `TurboOverlay.visit: invalid type "${type}" (expected one of ${VALID_TYPES.join(", ")})`
143
+ )
144
+ }
145
+ if (type === "popover") {
146
+ const ElementCtor = typeof Element === "function" ? Element : null
147
+ if (!options.anchor || (ElementCtor && !(options.anchor instanceof ElementCtor))) {
148
+ throw new TypeError("TurboOverlay.visit: popover requires an `anchor` element")
149
+ }
150
+ }
151
+ return type
152
+ }