phlex-reactive 0.13.3 → 0.14.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1097 -630
  3. data/README.md +638 -16
  4. data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
  5. data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
  6. data/app/javascript/phlex/reactive/core.js +181 -0
  7. data/app/javascript/phlex/reactive/core.min.js +4 -0
  8. data/app/javascript/phlex/reactive/core.min.js.map +11 -0
  9. data/app/javascript/phlex/reactive/early.js +175 -0
  10. data/app/javascript/phlex/reactive/early.min.js +4 -0
  11. data/app/javascript/phlex/reactive/early.min.js.map +10 -0
  12. data/app/javascript/phlex/reactive/features/bindings.js +1511 -0
  13. data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
  14. data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
  15. data/app/javascript/phlex/reactive/features/compute.js +541 -0
  16. data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
  17. data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
  18. data/app/javascript/phlex/reactive/features/defer.js +726 -0
  19. data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
  20. data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
  21. data/app/javascript/phlex/reactive/features/devtools.js +242 -0
  22. data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
  23. data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
  24. data/app/javascript/phlex/reactive/features/effects.js +362 -0
  25. data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
  26. data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
  27. data/app/javascript/phlex/reactive/features/form.js +216 -0
  28. data/app/javascript/phlex/reactive/features/form.min.js +4 -0
  29. data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
  30. data/app/javascript/phlex/reactive/features/hints.js +193 -0
  31. data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
  32. data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
  33. data/app/javascript/phlex/reactive/features/persist.js +663 -0
  34. data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
  35. data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
  36. data/app/javascript/phlex/reactive/inspect.js +21 -9
  37. data/app/javascript/phlex/reactive/inspect.min.js +2 -2
  38. data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
  39. data/app/javascript/phlex/reactive/reactive_controller.js +85 -5580
  40. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  41. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
  42. data/app/javascript/phlex/reactive/runtime.js +3690 -0
  43. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
  44. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
  45. data/lib/phlex/reactive/component/dsl.rb +74 -2
  46. data/lib/phlex/reactive/component/helpers.rb +104 -28
  47. data/lib/phlex/reactive/component/lazy.rb +138 -2
  48. data/lib/phlex/reactive/component/registry.rb +2 -0
  49. data/lib/phlex/reactive/component/selection.rb +71 -0
  50. data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
  51. data/lib/phlex/reactive/doctor.rb +166 -8
  52. data/lib/phlex/reactive/dormant.rb +66 -0
  53. data/lib/phlex/reactive/engine.rb +64 -3
  54. data/lib/phlex/reactive/fragment.rb +233 -0
  55. data/lib/phlex/reactive/inspector/report.rb +24 -3
  56. data/lib/phlex/reactive/inspector.rb +23 -2
  57. data/lib/phlex/reactive/js.rb +61 -12
  58. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
  59. data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
  60. data/lib/phlex/reactive/param_schema.rb +68 -54
  61. data/lib/phlex/reactive/pending/markup.rb +128 -0
  62. data/lib/phlex/reactive/pending.rb +25 -21
  63. data/lib/phlex/reactive/show_conditions.rb +84 -56
  64. data/lib/phlex/reactive/streamable.rb +20 -2
  65. data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
  66. data/lib/phlex/reactive/test_helpers/system.rb +62 -0
  67. data/lib/phlex/reactive/version.rb +1 -1
  68. data/lib/phlex/reactive.rb +84 -0
  69. data/lib/tasks/phlex_reactive.rake +6 -2
  70. metadata +39 -1
@@ -0,0 +1,362 @@
1
+ // phlex/reactive/features/effects — stream effects (issue #215) and
2
+ // self-dismissing flashes (issue #100): everything the client does AROUND a
3
+ // <turbo-stream> render. One of the feature modules the core imports on
4
+ // demand (issue #275); in the default client it is part of the one file.
5
+ //
6
+ // Both are document-level, not per root: the core owns the ONE
7
+ // turbo:before-stream-render listener and hands every event to wrap() below,
8
+ // which wraps event.detail.render. With phlex/reactive/core alone the module
9
+ // is imported when a root declares an effect, or when the first stream that
10
+ // needs it arrives — the core then holds that stream's render (and any stream
11
+ // behind it) until the module is here, so the effect applies to THAT stream.
12
+ //
13
+ // This module never imports the core.
14
+
15
+ // The core's stream-render listener calls this for every <turbo-stream>.
16
+ // Dismiss wraps first, effects around it — the order the two listeners were
17
+ // registered in before they shared one.
18
+ export function wrap(event) {
19
+ wrapStreamRenderForDismiss(event)
20
+ wrapStreamRenderForEffects(event)
21
+ }
22
+
23
+ // Schedule every dismissing flash already in the document: what the core runs
24
+ // once when the module arrives late (a flash rendered while it was loading).
25
+ export function sweep() {
26
+ scheduleReactiveDismissals()
27
+ }
28
+
29
+ // Document-level self-dismissing flashes (issue #100). A flash rendered with
30
+ // dismiss_after: carries data-reactive-dismiss-after="<ms>"; after the timeout
31
+ // it removes itself. This is deliberately NOT a Stimulus controller — the flash
32
+ // container is a plain host-app div (Response#flash appends into it) with no
33
+ // controller attached, so nothing would honor the attr. A document-level scan
34
+ // on turbo:before-stream-render (which fires for EVERY <turbo-stream> render —
35
+ // a reply AND a broadcast) schedules removal for any newly-arrived dismissing
36
+ // flash. Each is marked data-reactive-dismiss-scheduled so re-scans (a later
37
+ // stream render) never double-schedule the same node.
38
+ //
39
+ // turbo:before-stream-render fires BEFORE the stream is applied — and Turbo
40
+ // then does `await nextRepaint(); await event.detail.render(this)`, so a bare
41
+ // setTimeout(0) can run BEFORE the node is inserted (observed under Falcon).
42
+ // WRAP event.detail.render instead: run Turbo's own render, then scan once it
43
+ // has resolved — timing-independent and correct on every server. detail.render
44
+ // may be absent on exotic streams — guard it.
45
+
46
+ // Chain the dismissing-flash scan after Turbo's own stream render resolves, so
47
+ // the scan sees the freshly-inserted node. Idempotent per event (marks
48
+ // detail.render as already-wrapped) and defensive if detail/render is missing.
49
+ function wrapStreamRenderForDismiss(event) {
50
+ const detail = event.detail
51
+ const original = detail?.render
52
+ if (typeof original !== "function" || original.__reactiveDismissWrapped) {
53
+ // No render to wrap (or already wrapped) — fall back to a post-repaint scan.
54
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(scheduleReactiveDismissals)
55
+ else setTimeout(scheduleReactiveDismissals, 0)
56
+ return
57
+ }
58
+ const wrapped = async (streamElement) => {
59
+ await original(streamElement)
60
+ scheduleReactiveDismissals()
61
+ }
62
+ wrapped.__reactiveDismissWrapped = true
63
+ detail.render = wrapped
64
+ }
65
+
66
+ // Scan for un-scheduled dismissing flashes and schedule each one's removal.
67
+ // Kept a module function so the scan logic is testable and re-run on every
68
+ // stream render.
69
+ function scheduleReactiveDismissals() {
70
+ const flashes = document.querySelectorAll("[data-reactive-dismiss-after]")
71
+ for (const el of flashes) {
72
+ if (el.hasAttribute("data-reactive-dismiss-scheduled")) continue
73
+ const ms = Number(el.getAttribute("data-reactive-dismiss-after"))
74
+ if (!Number.isFinite(ms) || ms <= 0) continue
75
+ el.setAttribute("data-reactive-dismiss-scheduled", "")
76
+ setTimeout(() => el.remove(), ms)
77
+ }
78
+ }
79
+
80
+
81
+ // Reactive effects (issue #215): animate ENTER (append/prepend), EXIT (remove)
82
+ // and UPDATE (replace/update, plain or morph) when a <turbo-stream> renders.
83
+ // Document-level and render-wrapping like the dismiss hook above, so ONE
84
+ // interceptor covers both delivery paths (a reply and a broadcast). Strictly
85
+ // data-driven and default-deny: the per-call data-reactive-effect on the
86
+ // stream element wins ("off" suppresses), else the carrier element's
87
+ // data-reactive-effect-<hook> — the DOM target for exit/update, the INCOMING
88
+ // template root for enter. No attribute → no work; unknown names and
89
+ // malformed legs warn + skip (a newer or forged attr must never break the
90
+ // page). prefers-reduced-motion disables everything (the shipped CSS is also
91
+ // media-wrapped — defense in depth).
92
+ //
93
+ // Timing:
94
+ // * exit — the animation runs BEFORE Turbo's render (the removal), awaited
95
+ // via animationend/transitionend with a timeout fallback; a ZERO computed
96
+ // duration (no effects CSS loaded, reduced-motion CSS gate) skips the wait
97
+ // entirely, so a missing stylesheet can never freeze a removal.
98
+ // * enter/update — Turbo renders first, then the effect class is applied to
99
+ // the inserted/updated element(s) and removed on settle (fire-and-forget).
100
+ // Re-applying an update effect restarts it (class off → reflow → on).
101
+ //
102
+ // A named effect maps to the shipped CSS class reactive-fx--<name>-<hook>
103
+ // (app/assets/stylesheets/phlex/reactive/effects.css); "random" picks a
104
+ // built-in per application; a "["-prefixed value is a custom
105
+ // [during, from, to] class-legs triple (the #96/#186 vocabulary), run with
106
+ // runTransition's add → frame → swap → settle choreography.
107
+ const EFFECT_HOOKS = Object.freeze({
108
+ append: "enter",
109
+ prepend: "enter",
110
+ replace: "update",
111
+ update: "update",
112
+ remove: "exit",
113
+ })
114
+ const EFFECT_BUILT_INS = Object.freeze(["fade", "slide", "scale", "highlight", "shake"])
115
+ // Marks an incoming template root so the post-render scan finds the inserted
116
+ // CLONE (Turbo clones template content on render — attrs ride the clone).
117
+ const EFFECT_PENDING_ATTR = "data-reactive-fx-pending"
118
+ // The hard ceiling on any effect wait — an exit's removal is delayed at most
119
+ // this long even if animationend/transitionend never fire.
120
+ const EFFECT_SETTLE_FALLBACK_MS = 1000
121
+
122
+
123
+ // Wrap event.detail.render (the dismiss-hook pattern) when this stream both
124
+ // maps to a hook AND resolves to an effect. Resolution happens HERE, before
125
+ // the render, because exit must read the target while it is still in the DOM
126
+ // and enter must read (and mark) the template content before Turbo clones it.
127
+ function wrapStreamRenderForEffects(event) {
128
+ const detail = event.detail
129
+ const original = detail?.render
130
+ if (typeof original !== "function" || original.__reactiveEffectsWrapped) return
131
+ const streamEl = detail?.newStream ?? event.target
132
+ const hook = EFFECT_HOOKS[streamEl?.getAttribute?.("action")]
133
+ if (!hook || effectsReducedMotion()) return
134
+ const effect = resolveStreamEffect(streamEl, hook)
135
+ if (!effect) return
136
+
137
+ const wrapped =
138
+ hook === "exit"
139
+ ? async (el) => {
140
+ await runExitEffect(effectTarget(streamEl), effect)
141
+ await original(el)
142
+ }
143
+ : async (el) => {
144
+ const container = hook === "enter" ? markIncomingRoots(streamEl) : null
145
+ await original(el)
146
+ if (hook === "enter") animateMarkedRoots(container, effect)
147
+ else runEnterOrUpdateEffect(effectTarget(streamEl), effect)
148
+ }
149
+ wrapped.__reactiveEffectsWrapped = true
150
+ detail.render = wrapped
151
+ }
152
+
153
+ // The effect for this stream: per-call data-reactive-effect first ("off" →
154
+ // none), else the carrier's declared data-reactive-effect-<hook>.
155
+ function resolveStreamEffect(streamEl, hook) {
156
+ const perCall = streamEl.getAttribute?.("data-reactive-effect")
157
+ if (perCall === "off") return null
158
+ if (perCall) return parseEffect(perCall, hook)
159
+ const carrier = hook === "enter" ? incomingEffectRoot(streamEl) : effectTarget(streamEl)
160
+ const declared = carrier?.getAttribute?.(`data-reactive-effect-${hook}`)
161
+ return declared ? parseEffect(declared, hook) : null
162
+ }
163
+
164
+ // The stream's CURRENT DOM target (re-queried at use, so a post-replace call
165
+ // sees the freshly-swapped element). Our builders always emit `target` —
166
+ // multi-`targets` streams are not ours and pass through unanimated.
167
+ function effectTarget(streamEl) {
168
+ const target = streamEl.getAttribute?.("target")
169
+ return target ? (document.getElementById?.(target) ?? null) : null
170
+ }
171
+
172
+ // The incoming content's root element (an append/prepend's arriving
173
+ // component) — the carrier of a declared enter effect.
174
+ function incomingEffectRoot(streamEl) {
175
+ return streamEl.querySelector?.("template")?.content?.firstElementChild ?? null
176
+ }
177
+
178
+ // A wire value → an executable effect: { className } for a shipped built-in
179
+ // ("random" picks one per application), { legs } for a custom triple. null +
180
+ // console.warn for anything else (default-deny).
181
+ function parseEffect(value, hook) {
182
+ if (value.startsWith("[")) {
183
+ let legs = null
184
+ try {
185
+ const parsed = JSON.parse(value)
186
+ if (Array.isArray(parsed) && parsed.length === 3) legs = parsed.map(String)
187
+ } catch {
188
+ // malformed JSON → the shared warn below
189
+ }
190
+ if (legs) return { legs }
191
+ console.warn(`[phlex-reactive] malformed effect legs ${JSON.stringify(value)} — skipped`)
192
+ return null
193
+ }
194
+ const name =
195
+ value === "random" ? EFFECT_BUILT_INS[Math.floor(Math.random() * EFFECT_BUILT_INS.length)] : value
196
+ if (!EFFECT_BUILT_INS.includes(name)) {
197
+ console.warn(`[phlex-reactive] unknown effect ${JSON.stringify(value)} — skipped`)
198
+ return null
199
+ }
200
+ return { className: `reactive-fx--${name}-${hook}` }
201
+ }
202
+
203
+ function effectsReducedMotion() {
204
+ try {
205
+ return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches
206
+ } catch {
207
+ return false
208
+ }
209
+ }
210
+
211
+ // Stamp each incoming template root with the pending marker (Turbo's render
212
+ // clones the content, so the marker rides the inserted clone) and return the
213
+ // container the post-render scan searches. Pre-render on purpose.
214
+ function markIncomingRoots(streamEl) {
215
+ const content = streamEl.querySelector?.("template")?.content
216
+ if (!content) return null
217
+ for (const child of Array.from(content.children ?? [])) child.setAttribute?.(EFFECT_PENDING_ATTR, "")
218
+ return effectTarget(streamEl)
219
+ }
220
+
221
+ // Post-render: find the just-inserted clones by their marker, unmark, animate.
222
+ function animateMarkedRoots(container, effect) {
223
+ if (typeof container?.querySelectorAll !== "function") return
224
+ for (const el of Array.from(container.querySelectorAll(`[${EFFECT_PENDING_ATTR}]`))) {
225
+ el.removeAttribute(EFFECT_PENDING_ATTR)
226
+ runEnterOrUpdateEffect(el, effect)
227
+ }
228
+ }
229
+
230
+ // EXIT: animate on the still-present element, resolve when settled, and only
231
+ // then does the wrapper run Turbo's removal. Zero computed duration (no
232
+ // effects CSS) resolves immediately — never a dead 1s freeze.
233
+ async function runExitEffect(el, effect) {
234
+ if (!el?.classList) return
235
+ if (effect.legs) {
236
+ await runLegsEffect(el, effect.legs)
237
+ return
238
+ }
239
+ el.classList.add(effect.className)
240
+ const duration = effectDurationMs(el)
241
+ if (duration <= 0) {
242
+ el.classList.remove(effect.className)
243
+ return
244
+ }
245
+ await effectSettled(el, duration)
246
+ el.classList.remove(effect.className)
247
+ }
248
+
249
+ // ENTER/UPDATE: fire-and-forget after the render. A re-applied class is
250
+ // removed + reflowed first so rapid successive updates restart the flash; the
251
+ // per-element token keeps an older settle from clearing a newer application.
252
+ function runEnterOrUpdateEffect(el, effect) {
253
+ if (!el?.classList) return
254
+ if (effect.legs) {
255
+ runLegsEffect(el, effect.legs)
256
+ return
257
+ }
258
+ if (el.classList.contains(effect.className)) {
259
+ el.classList.remove(effect.className)
260
+ void el.offsetWidth // force a reflow so re-adding restarts the animation
261
+ }
262
+ el.classList.add(effect.className)
263
+ const duration = effectDurationMs(el)
264
+ if (duration <= 0) {
265
+ el.classList.remove(effect.className)
266
+ return
267
+ }
268
+ const token = (el.__reactiveFxToken = (el.__reactiveFxToken ?? 0) + 1)
269
+ effectSettled(el, duration).then(() => {
270
+ if (el.__reactiveFxToken === token) el.classList.remove(effect.className)
271
+ })
272
+ }
273
+
274
+ // Custom class legs — runTransition's choreography (add during+from, swap
275
+ // from→to on the next frame, settle, clean up), promise-shaped so an exit can
276
+ // await it. Class lists are space-separated (the #96/#186 wire).
277
+ //
278
+ // Rapid re-application on the same element RESTARTS, mirroring the named
279
+ // path's token guard: each run takes the per-element token, clears any
280
+ // earlier run's leg classes, and a superseded run stops touching the element
281
+ // the moment a newer run owns it — so a stale settle can never strip classes
282
+ // mid-animation or double-swap the legs. A superseded EXIT run resolves
283
+ // early, which only lets Turbo's removal proceed sooner (never later).
284
+ async function runLegsEffect(el, legs) {
285
+ const [during, from, to] = legs.map(splitEffectClasses)
286
+ const token = (el.__reactiveFxToken = (el.__reactiveFxToken ?? 0) + 1)
287
+ el.classList.remove(...during, ...from, ...to)
288
+ el.classList.add(...during, ...from)
289
+ await effectNextFrame()
290
+ if (el.__reactiveFxToken !== token) return
291
+ el.classList.remove(...from)
292
+ el.classList.add(...to)
293
+ const duration = effectDurationMs(el)
294
+ if (duration > 0) await effectSettled(el, duration)
295
+ if (el.__reactiveFxToken !== token) return
296
+ el.classList.remove(...during, ...to)
297
+ }
298
+
299
+ function splitEffectClasses(list) {
300
+ return String(list ?? "")
301
+ .split(/\s+/)
302
+ .filter(Boolean)
303
+ }
304
+
305
+ // The longest computed animation/transition (duration + delay, comma lists
306
+ // included) in ms, capped at the hard fallback. 0 when getComputedStyle is
307
+ // unavailable or nothing animates — callers skip the wait entirely.
308
+ function effectDurationMs(el) {
309
+ if (typeof getComputedStyle !== "function") return 0
310
+ try {
311
+ const style = getComputedStyle(el)
312
+ const longest = (value) =>
313
+ String(value ?? "")
314
+ .split(",")
315
+ .reduce((max, part) => Math.max(max, parseFloat(part) || 0), 0)
316
+ const animation = longest(style.animationDuration) + longest(style.animationDelay)
317
+ const transition = longest(style.transitionDuration) + longest(style.transitionDelay)
318
+ return Math.min(Math.max(animation, transition) * 1000, EFFECT_SETTLE_FALLBACK_MS)
319
+ } catch {
320
+ return 0
321
+ }
322
+ }
323
+
324
+ // Resolve on animationend/transitionend — whichever fires first — with a
325
+ // timeout slightly past the computed duration, so a canceled animation (a
326
+ // display:none ancestor, an interrupted transition) can't hang an exit.
327
+ // Only the element's OWN end event counts — both bubble, and a descendant's
328
+ // would settle the effect early (issue #296) — and settling removes both
329
+ // listeners (`once` would only drop the one that fired).
330
+ function effectSettled(el, durationMs) {
331
+ return new Promise((resolve) => {
332
+ const settle = (event) => {
333
+ if (event && event.target !== el) return
334
+ el.removeEventListener?.("animationend", settle)
335
+ el.removeEventListener?.("transitionend", settle)
336
+ resolve()
337
+ }
338
+ el.addEventListener?.("animationend", settle)
339
+ el.addEventListener?.("transitionend", settle)
340
+ setTimeout(settle, Math.min(durationMs + 50, EFFECT_SETTLE_FALLBACK_MS))
341
+ })
342
+ }
343
+
344
+ // One frame, raced against the settle fallback (issue #295): a background tab
345
+ // never runs rAF, and an exit must not hold its removal (and every stream
346
+ // behind it) until the tab is visible again. The fallback is the full settle
347
+ // ceiling, never a short timer — one could beat rAF in a visible tab and swap
348
+ // from→to before the `from` leg paints. Whichever wins cancels the other, so a
349
+ // visible tab leaves no stray 1s timer behind.
350
+ function effectNextFrame() {
351
+ return new Promise((resolve) => {
352
+ if (typeof requestAnimationFrame !== "function") return setTimeout(resolve, 16)
353
+ const timer = setTimeout(() => {
354
+ globalThis.cancelAnimationFrame?.(frame)
355
+ resolve()
356
+ }, EFFECT_SETTLE_FALLBACK_MS)
357
+ const frame = requestAnimationFrame(() => {
358
+ clearTimeout(timer)
359
+ resolve()
360
+ })
361
+ })
362
+ }
@@ -0,0 +1,4 @@
1
+ function k(t){_(t),F(t)}function M(){c()}function _(t){let e=t.detail,n=e?.render;if(typeof n!=="function"||n.__reactiveDismissWrapped){if(typeof requestAnimationFrame==="function")requestAnimationFrame(c);else setTimeout(c,0);return}let r=async(i)=>{await n(i),c()};r.__reactiveDismissWrapped=!0,e.render=r}function c(){let t=document.querySelectorAll("[data-reactive-dismiss-after]");for(let e of t){if(e.hasAttribute("data-reactive-dismiss-scheduled"))continue;let n=Number(e.getAttribute("data-reactive-dismiss-after"));if(!Number.isFinite(n)||n<=0)continue;e.setAttribute("data-reactive-dismiss-scheduled",""),setTimeout(()=>e.remove(),n)}}var E=Object.freeze({append:"enter",prepend:"enter",replace:"update",update:"update",remove:"exit"}),f=Object.freeze(["fade","slide","scale","highlight","shake"]),m="data-reactive-fx-pending",d=1000;function F(t){let e=t.detail,n=e?.render;if(typeof n!=="function"||n.__reactiveEffectsWrapped)return;let r=e?.newStream??t.target,i=E[r?.getAttribute?.("action")];if(!i||T())return;let a=A(r,i);if(!a)return;let s=i==="exit"?async(o)=>{await x(u(r),a),await n(o)}:async(o)=>{let h=i==="enter"?S(r):null;if(await n(o),i==="enter")w(h,a);else y(u(r),a)};s.__reactiveEffectsWrapped=!0,e.render=s}function A(t,e){let n=t.getAttribute?.("data-reactive-effect");if(n==="off")return null;if(n)return g(n,e);let i=(e==="enter"?L(t):u(t))?.getAttribute?.(`data-reactive-effect-${e}`);return i?g(i,e):null}function u(t){let e=t.getAttribute?.("target");return e?document.getElementById?.(e)??null:null}function L(t){return t.querySelector?.("template")?.content?.firstElementChild??null}function g(t,e){if(t.startsWith("[")){let r=null;try{let i=JSON.parse(t);if(Array.isArray(i)&&i.length===3)r=i.map(String)}catch{}if(r)return{legs:r};return console.warn(`[phlex-reactive] malformed effect legs ${JSON.stringify(t)} — skipped`),null}let n=t==="random"?f[Math.floor(Math.random()*f.length)]:t;if(!f.includes(n))return console.warn(`[phlex-reactive] unknown effect ${JSON.stringify(t)} — skipped`),null;return{className:`reactive-fx--${n}-${e}`}}function T(){try{return typeof matchMedia==="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches}catch{return!1}}function S(t){let e=t.querySelector?.("template")?.content;if(!e)return null;for(let n of Array.from(e.children??[]))n.setAttribute?.(m,"");return u(t)}function w(t,e){if(typeof t?.querySelectorAll!=="function")return;for(let n of Array.from(t.querySelectorAll(`[${m}]`)))n.removeAttribute(m),y(n,e)}async function x(t,e){if(!t?.classList)return;if(e.legs){await v(t,e.legs);return}t.classList.add(e.className);let n=l(t);if(n<=0){t.classList.remove(e.className);return}await p(t,n),t.classList.remove(e.className)}function y(t,e){if(!t?.classList)return;if(e.legs){v(t,e.legs);return}if(t.classList.contains(e.className))t.classList.remove(e.className),t.offsetWidth;t.classList.add(e.className);let n=l(t);if(n<=0){t.classList.remove(e.className);return}let r=t.__reactiveFxToken=(t.__reactiveFxToken??0)+1;p(t,n).then(()=>{if(t.__reactiveFxToken===r)t.classList.remove(e.className)})}async function v(t,e){let[n,r,i]=e.map(N),a=t.__reactiveFxToken=(t.__reactiveFxToken??0)+1;if(t.classList.remove(...n,...r,...i),t.classList.add(...n,...r),await b(),t.__reactiveFxToken!==a)return;t.classList.remove(...r),t.classList.add(...i);let s=l(t);if(s>0)await p(t,s);if(t.__reactiveFxToken!==a)return;t.classList.remove(...n,...i)}function N(t){return String(t??"").split(/\s+/).filter(Boolean)}function l(t){if(typeof getComputedStyle!=="function")return 0;try{let e=getComputedStyle(t),n=(a)=>String(a??"").split(",").reduce((s,o)=>Math.max(s,parseFloat(o)||0),0),r=n(e.animationDuration)+n(e.animationDelay),i=n(e.transitionDuration)+n(e.transitionDelay);return Math.min(Math.max(r,i)*1000,d)}catch{return 0}}function p(t,e){return new Promise((n)=>{let r=(i)=>{if(i&&i.target!==t)return;t.removeEventListener?.("animationend",r),t.removeEventListener?.("transitionend",r),n()};t.addEventListener?.("animationend",r),t.addEventListener?.("transitionend",r),setTimeout(r,Math.min(e+50,d))})}function b(){return new Promise((t)=>{if(typeof requestAnimationFrame!=="function")return setTimeout(t,16);let e=setTimeout(()=>{globalThis.cancelAnimationFrame?.(n),t()},d),n=requestAnimationFrame(()=>{clearTimeout(e),t()})})}export{M as sweep,k as wrap};
2
+
3
+ //# debugId=5B84C2BEAC9F25E664756E2164756E21
4
+ //# sourceMappingURL=effects.min.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["features/effects.js"],
4
+ "sourcesContent": [
5
+ "// phlex/reactive/features/effects — stream effects (issue #215) and\n// self-dismissing flashes (issue #100): everything the client does AROUND a\n// <turbo-stream> render. One of the feature modules the core imports on\n// demand (issue #275); in the default client it is part of the one file.\n//\n// Both are document-level, not per root: the core owns the ONE\n// turbo:before-stream-render listener and hands every event to wrap() below,\n// which wraps event.detail.render. With phlex/reactive/core alone the module\n// is imported when a root declares an effect, or when the first stream that\n// needs it arrives — the core then holds that stream's render (and any stream\n// behind it) until the module is here, so the effect applies to THAT stream.\n//\n// This module never imports the core.\n\n// The core's stream-render listener calls this for every <turbo-stream>.\n// Dismiss wraps first, effects around it — the order the two listeners were\n// registered in before they shared one.\nexport function wrap(event) {\n wrapStreamRenderForDismiss(event)\n wrapStreamRenderForEffects(event)\n}\n\n// Schedule every dismissing flash already in the document: what the core runs\n// once when the module arrives late (a flash rendered while it was loading).\nexport function sweep() {\n scheduleReactiveDismissals()\n}\n\n// Document-level self-dismissing flashes (issue #100). A flash rendered with\n// dismiss_after: carries data-reactive-dismiss-after=\"<ms>\"; after the timeout\n// it removes itself. This is deliberately NOT a Stimulus controller — the flash\n// container is a plain host-app div (Response#flash appends into it) with no\n// controller attached, so nothing would honor the attr. A document-level scan\n// on turbo:before-stream-render (which fires for EVERY <turbo-stream> render —\n// a reply AND a broadcast) schedules removal for any newly-arrived dismissing\n// flash. Each is marked data-reactive-dismiss-scheduled so re-scans (a later\n// stream render) never double-schedule the same node.\n//\n// turbo:before-stream-render fires BEFORE the stream is applied — and Turbo\n// then does `await nextRepaint(); await event.detail.render(this)`, so a bare\n// setTimeout(0) can run BEFORE the node is inserted (observed under Falcon).\n// WRAP event.detail.render instead: run Turbo's own render, then scan once it\n// has resolved — timing-independent and correct on every server. detail.render\n// may be absent on exotic streams — guard it.\n\n// Chain the dismissing-flash scan after Turbo's own stream render resolves, so\n// the scan sees the freshly-inserted node. Idempotent per event (marks\n// detail.render as already-wrapped) and defensive if detail/render is missing.\nfunction wrapStreamRenderForDismiss(event) {\n const detail = event.detail\n const original = detail?.render\n if (typeof original !== \"function\" || original.__reactiveDismissWrapped) {\n // No render to wrap (or already wrapped) — fall back to a post-repaint scan.\n if (typeof requestAnimationFrame === \"function\") requestAnimationFrame(scheduleReactiveDismissals)\n else setTimeout(scheduleReactiveDismissals, 0)\n return\n }\n const wrapped = async (streamElement) => {\n await original(streamElement)\n scheduleReactiveDismissals()\n }\n wrapped.__reactiveDismissWrapped = true\n detail.render = wrapped\n}\n\n// Scan for un-scheduled dismissing flashes and schedule each one's removal.\n// Kept a module function so the scan logic is testable and re-run on every\n// stream render.\nfunction scheduleReactiveDismissals() {\n const flashes = document.querySelectorAll(\"[data-reactive-dismiss-after]\")\n for (const el of flashes) {\n if (el.hasAttribute(\"data-reactive-dismiss-scheduled\")) continue\n const ms = Number(el.getAttribute(\"data-reactive-dismiss-after\"))\n if (!Number.isFinite(ms) || ms <= 0) continue\n el.setAttribute(\"data-reactive-dismiss-scheduled\", \"\")\n setTimeout(() => el.remove(), ms)\n }\n}\n\n\n// Reactive effects (issue #215): animate ENTER (append/prepend), EXIT (remove)\n// and UPDATE (replace/update, plain or morph) when a <turbo-stream> renders.\n// Document-level and render-wrapping like the dismiss hook above, so ONE\n// interceptor covers both delivery paths (a reply and a broadcast). Strictly\n// data-driven and default-deny: the per-call data-reactive-effect on the\n// stream element wins (\"off\" suppresses), else the carrier element's\n// data-reactive-effect-<hook> — the DOM target for exit/update, the INCOMING\n// template root for enter. No attribute → no work; unknown names and\n// malformed legs warn + skip (a newer or forged attr must never break the\n// page). prefers-reduced-motion disables everything (the shipped CSS is also\n// media-wrapped — defense in depth).\n//\n// Timing:\n// * exit — the animation runs BEFORE Turbo's render (the removal), awaited\n// via animationend/transitionend with a timeout fallback; a ZERO computed\n// duration (no effects CSS loaded, reduced-motion CSS gate) skips the wait\n// entirely, so a missing stylesheet can never freeze a removal.\n// * enter/update — Turbo renders first, then the effect class is applied to\n// the inserted/updated element(s) and removed on settle (fire-and-forget).\n// Re-applying an update effect restarts it (class off → reflow → on).\n//\n// A named effect maps to the shipped CSS class reactive-fx--<name>-<hook>\n// (app/assets/stylesheets/phlex/reactive/effects.css); \"random\" picks a\n// built-in per application; a \"[\"-prefixed value is a custom\n// [during, from, to] class-legs triple (the #96/#186 vocabulary), run with\n// runTransition's add → frame → swap → settle choreography.\nconst EFFECT_HOOKS = Object.freeze({\n append: \"enter\",\n prepend: \"enter\",\n replace: \"update\",\n update: \"update\",\n remove: \"exit\",\n})\nconst EFFECT_BUILT_INS = Object.freeze([\"fade\", \"slide\", \"scale\", \"highlight\", \"shake\"])\n// Marks an incoming template root so the post-render scan finds the inserted\n// CLONE (Turbo clones template content on render — attrs ride the clone).\nconst EFFECT_PENDING_ATTR = \"data-reactive-fx-pending\"\n// The hard ceiling on any effect wait — an exit's removal is delayed at most\n// this long even if animationend/transitionend never fire.\nconst EFFECT_SETTLE_FALLBACK_MS = 1000\n\n\n// Wrap event.detail.render (the dismiss-hook pattern) when this stream both\n// maps to a hook AND resolves to an effect. Resolution happens HERE, before\n// the render, because exit must read the target while it is still in the DOM\n// and enter must read (and mark) the template content before Turbo clones it.\nfunction wrapStreamRenderForEffects(event) {\n const detail = event.detail\n const original = detail?.render\n if (typeof original !== \"function\" || original.__reactiveEffectsWrapped) return\n const streamEl = detail?.newStream ?? event.target\n const hook = EFFECT_HOOKS[streamEl?.getAttribute?.(\"action\")]\n if (!hook || effectsReducedMotion()) return\n const effect = resolveStreamEffect(streamEl, hook)\n if (!effect) return\n\n const wrapped =\n hook === \"exit\"\n ? async (el) => {\n await runExitEffect(effectTarget(streamEl), effect)\n await original(el)\n }\n : async (el) => {\n const container = hook === \"enter\" ? markIncomingRoots(streamEl) : null\n await original(el)\n if (hook === \"enter\") animateMarkedRoots(container, effect)\n else runEnterOrUpdateEffect(effectTarget(streamEl), effect)\n }\n wrapped.__reactiveEffectsWrapped = true\n detail.render = wrapped\n}\n\n// The effect for this stream: per-call data-reactive-effect first (\"off\" →\n// none), else the carrier's declared data-reactive-effect-<hook>.\nfunction resolveStreamEffect(streamEl, hook) {\n const perCall = streamEl.getAttribute?.(\"data-reactive-effect\")\n if (perCall === \"off\") return null\n if (perCall) return parseEffect(perCall, hook)\n const carrier = hook === \"enter\" ? incomingEffectRoot(streamEl) : effectTarget(streamEl)\n const declared = carrier?.getAttribute?.(`data-reactive-effect-${hook}`)\n return declared ? parseEffect(declared, hook) : null\n}\n\n// The stream's CURRENT DOM target (re-queried at use, so a post-replace call\n// sees the freshly-swapped element). Our builders always emit `target` —\n// multi-`targets` streams are not ours and pass through unanimated.\nfunction effectTarget(streamEl) {\n const target = streamEl.getAttribute?.(\"target\")\n return target ? (document.getElementById?.(target) ?? null) : null\n}\n\n// The incoming content's root element (an append/prepend's arriving\n// component) — the carrier of a declared enter effect.\nfunction incomingEffectRoot(streamEl) {\n return streamEl.querySelector?.(\"template\")?.content?.firstElementChild ?? null\n}\n\n// A wire value → an executable effect: { className } for a shipped built-in\n// (\"random\" picks one per application), { legs } for a custom triple. null +\n// console.warn for anything else (default-deny).\nfunction parseEffect(value, hook) {\n if (value.startsWith(\"[\")) {\n let legs = null\n try {\n const parsed = JSON.parse(value)\n if (Array.isArray(parsed) && parsed.length === 3) legs = parsed.map(String)\n } catch {\n // malformed JSON → the shared warn below\n }\n if (legs) return { legs }\n console.warn(`[phlex-reactive] malformed effect legs ${JSON.stringify(value)} — skipped`)\n return null\n }\n const name =\n value === \"random\" ? EFFECT_BUILT_INS[Math.floor(Math.random() * EFFECT_BUILT_INS.length)] : value\n if (!EFFECT_BUILT_INS.includes(name)) {\n console.warn(`[phlex-reactive] unknown effect ${JSON.stringify(value)} — skipped`)\n return null\n }\n return { className: `reactive-fx--${name}-${hook}` }\n}\n\nfunction effectsReducedMotion() {\n try {\n return typeof matchMedia === \"function\" && matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n } catch {\n return false\n }\n}\n\n// Stamp each incoming template root with the pending marker (Turbo's render\n// clones the content, so the marker rides the inserted clone) and return the\n// container the post-render scan searches. Pre-render on purpose.\nfunction markIncomingRoots(streamEl) {\n const content = streamEl.querySelector?.(\"template\")?.content\n if (!content) return null\n for (const child of Array.from(content.children ?? [])) child.setAttribute?.(EFFECT_PENDING_ATTR, \"\")\n return effectTarget(streamEl)\n}\n\n// Post-render: find the just-inserted clones by their marker, unmark, animate.\nfunction animateMarkedRoots(container, effect) {\n if (typeof container?.querySelectorAll !== \"function\") return\n for (const el of Array.from(container.querySelectorAll(`[${EFFECT_PENDING_ATTR}]`))) {\n el.removeAttribute(EFFECT_PENDING_ATTR)\n runEnterOrUpdateEffect(el, effect)\n }\n}\n\n// EXIT: animate on the still-present element, resolve when settled, and only\n// then does the wrapper run Turbo's removal. Zero computed duration (no\n// effects CSS) resolves immediately — never a dead 1s freeze.\nasync function runExitEffect(el, effect) {\n if (!el?.classList) return\n if (effect.legs) {\n await runLegsEffect(el, effect.legs)\n return\n }\n el.classList.add(effect.className)\n const duration = effectDurationMs(el)\n if (duration <= 0) {\n el.classList.remove(effect.className)\n return\n }\n await effectSettled(el, duration)\n el.classList.remove(effect.className)\n}\n\n// ENTER/UPDATE: fire-and-forget after the render. A re-applied class is\n// removed + reflowed first so rapid successive updates restart the flash; the\n// per-element token keeps an older settle from clearing a newer application.\nfunction runEnterOrUpdateEffect(el, effect) {\n if (!el?.classList) return\n if (effect.legs) {\n runLegsEffect(el, effect.legs)\n return\n }\n if (el.classList.contains(effect.className)) {\n el.classList.remove(effect.className)\n void el.offsetWidth // force a reflow so re-adding restarts the animation\n }\n el.classList.add(effect.className)\n const duration = effectDurationMs(el)\n if (duration <= 0) {\n el.classList.remove(effect.className)\n return\n }\n const token = (el.__reactiveFxToken = (el.__reactiveFxToken ?? 0) + 1)\n effectSettled(el, duration).then(() => {\n if (el.__reactiveFxToken === token) el.classList.remove(effect.className)\n })\n}\n\n// Custom class legs — runTransition's choreography (add during+from, swap\n// from→to on the next frame, settle, clean up), promise-shaped so an exit can\n// await it. Class lists are space-separated (the #96/#186 wire).\n//\n// Rapid re-application on the same element RESTARTS, mirroring the named\n// path's token guard: each run takes the per-element token, clears any\n// earlier run's leg classes, and a superseded run stops touching the element\n// the moment a newer run owns it — so a stale settle can never strip classes\n// mid-animation or double-swap the legs. A superseded EXIT run resolves\n// early, which only lets Turbo's removal proceed sooner (never later).\nasync function runLegsEffect(el, legs) {\n const [during, from, to] = legs.map(splitEffectClasses)\n const token = (el.__reactiveFxToken = (el.__reactiveFxToken ?? 0) + 1)\n el.classList.remove(...during, ...from, ...to)\n el.classList.add(...during, ...from)\n await effectNextFrame()\n if (el.__reactiveFxToken !== token) return\n el.classList.remove(...from)\n el.classList.add(...to)\n const duration = effectDurationMs(el)\n if (duration > 0) await effectSettled(el, duration)\n if (el.__reactiveFxToken !== token) return\n el.classList.remove(...during, ...to)\n}\n\nfunction splitEffectClasses(list) {\n return String(list ?? \"\")\n .split(/\\s+/)\n .filter(Boolean)\n}\n\n// The longest computed animation/transition (duration + delay, comma lists\n// included) in ms, capped at the hard fallback. 0 when getComputedStyle is\n// unavailable or nothing animates — callers skip the wait entirely.\nfunction effectDurationMs(el) {\n if (typeof getComputedStyle !== \"function\") return 0\n try {\n const style = getComputedStyle(el)\n const longest = (value) =>\n String(value ?? \"\")\n .split(\",\")\n .reduce((max, part) => Math.max(max, parseFloat(part) || 0), 0)\n const animation = longest(style.animationDuration) + longest(style.animationDelay)\n const transition = longest(style.transitionDuration) + longest(style.transitionDelay)\n return Math.min(Math.max(animation, transition) * 1000, EFFECT_SETTLE_FALLBACK_MS)\n } catch {\n return 0\n }\n}\n\n// Resolve on animationend/transitionend — whichever fires first — with a\n// timeout slightly past the computed duration, so a canceled animation (a\n// display:none ancestor, an interrupted transition) can't hang an exit.\n// Only the element's OWN end event counts — both bubble, and a descendant's\n// would settle the effect early (issue #296) — and settling removes both\n// listeners (`once` would only drop the one that fired).\nfunction effectSettled(el, durationMs) {\n return new Promise((resolve) => {\n const settle = (event) => {\n if (event && event.target !== el) return\n el.removeEventListener?.(\"animationend\", settle)\n el.removeEventListener?.(\"transitionend\", settle)\n resolve()\n }\n el.addEventListener?.(\"animationend\", settle)\n el.addEventListener?.(\"transitionend\", settle)\n setTimeout(settle, Math.min(durationMs + 50, EFFECT_SETTLE_FALLBACK_MS))\n })\n}\n\n// One frame, raced against the settle fallback (issue #295): a background tab\n// never runs rAF, and an exit must not hold its removal (and every stream\n// behind it) until the tab is visible again. The fallback is the full settle\n// ceiling, never a short timer — one could beat rAF in a visible tab and swap\n// from→to before the `from` leg paints. Whichever wins cancels the other, so a\n// visible tab leaves no stray 1s timer behind.\nfunction effectNextFrame() {\n return new Promise((resolve) => {\n if (typeof requestAnimationFrame !== \"function\") return setTimeout(resolve, 16)\n const timer = setTimeout(() => {\n globalThis.cancelAnimationFrame?.(frame)\n resolve()\n }, EFFECT_SETTLE_FALLBACK_MS)\n const frame = requestAnimationFrame(() => {\n clearTimeout(timer)\n resolve()\n })\n })\n}\n"
6
+ ],
7
+ "mappings": "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",
8
+ "debugId": "5B84C2BEAC9F25E664756E2164756E21",
9
+ "names": []
10
+ }
@@ -0,0 +1,216 @@
1
+ // phlex/reactive/features/form — what a form root sets up when it connects
2
+ // and re-syncs after a morph, with no round trip: dirty tracking and the
3
+ // navigate-away guard (issue #103), and the paste-trigger availability gate
4
+ // (issue #228). One of the feature modules the core imports on demand (issue
5
+ // #275); in the default client it is part of the one file.
6
+ //
7
+ // The two share a module because they share a shape, not a page: a marker
8
+ // check at connect, one pass over the root's own controls, the same pass
9
+ // again on turbo:morph-element. The gate alone is smaller than what a module
10
+ // of its own costs the core.
11
+ //
12
+ // This module never imports the core: it reaches a controller through the
13
+ // `core` handle (core.owns — is this control this root's own, issue #15).
14
+
15
+ // controller -> the removals of what connect() added, for disconnect() to run.
16
+ const wired = new WeakMap()
17
+ // The turbo:before-visit events a warn_unsaved root already prompted for: one
18
+ // prompt per visit, however many dirty roots guard the page (issue #298).
19
+ const askedVisits = new WeakSet()
20
+
21
+ export function connect(controller, core) {
22
+ const root = controller.element
23
+ const offs = []
24
+ wired.set(controller, offs)
25
+ // Add a listener and keep its removal. Closures, not a record of named
26
+ // handlers: the minifier renames a local, never a property (issue #310).
27
+ const listen = (target, type, handler) => {
28
+ target.addEventListener?.(type, handler)
29
+ offs.push(() => target.removeEventListener?.(type, handler))
30
+ }
31
+
32
+ // Dirty tracking (issue #103) — ONLY when this root opts in (track_dirty: or a
33
+ // reactive_field(dirty:)), so a component that never uses it pays nothing (no
34
+ // baseline scan, no morph listener on every broadcast). A plain (outerHTML)
35
+ // replace re-connects the controller, so seed the baseline scan here — the root
36
+ // reflects current-vs-default WITHOUT waiting for the first input. An in-place
37
+ // morph / broadcast morph keeps the element CONNECTED and fires no Stimulus
38
+ // lifecycle, so ALSO listen for turbo:morph-element on the root to re-scan
39
+ // after the morph writes fresh default* attributes (reactive:applied is NOT a
40
+ // valid hook — it fires when streams are handed to Turbo, BEFORE the DOM
41
+ // mutation). Both listeners are torn down in disconnect().
42
+ if (dirtyTrackingEnabled(root, core)) {
43
+ const rescan = () => scan(controller, core)
44
+ listen(root, "turbo:morph-element", rescan)
45
+ rescan()
46
+
47
+ // warn_unsaved: arm a navigate-away guard gated on a LIVE dirty-count read
48
+ // (never a cached snapshot — the count is re-derived from the DOM each time).
49
+ // beforeunload covers a real browser unload; turbo:before-visit covers a
50
+ // Turbo in-app navigation (it does NOT fire on restoration visits — the
51
+ // documented gap). Registered on window only when the marker is present.
52
+ if (root.getAttribute?.("data-reactive-warn-unsaved") === "true") armUnsavedGuard(root, listen)
53
+ }
54
+
55
+ // Clipboard-trigger availability gate (issue #228) — ONLY when this root
56
+ // owns a paste trigger (on_client marks one with data-reactive-clipboard).
57
+ // The Async Clipboard API is absent in insecure contexts and some webviews;
58
+ // a paste button that can never work must not show. The gate OWNS a marked
59
+ // trigger's `hidden` flag: author the trigger `hidden` and this pass reveals
60
+ // it where the API exists (a dead button never paints); turbo:morph-element
61
+ // re-syncs because a morph rewrites the trigger back to its authored hidden
62
+ // state. The decision is made ONCE at connect — render the paste trigger
63
+ // unconditionally: a trigger first INTRODUCED by a later morph of a root
64
+ // that already tracks dirtiness stays ungated (hidden) until a full replace
65
+ // re-connects the controller.
66
+ if (clipboardGateEnabled(root, core)) {
67
+ const sync = () => syncClipboardTriggers(root, core)
68
+ listen(root, "turbo:morph-element", sync)
69
+ sync()
70
+ }
71
+ }
72
+
73
+ // Remove the listeners on disconnect (Turbo morph/navigation) so a morph
74
+ // re-scan or a navigate-away guard never runs against a detached root.
75
+ export function disconnect(controller) {
76
+ for (const off of wired.get(controller) ?? []) off()
77
+ wired.delete(controller)
78
+ }
79
+
80
+ // Whether this root opts into dirty tracking (issue #103): track_dirty: puts the
81
+ // trackDirty descriptor on the ROOT's data-action; a per-field reactive_field(
82
+ // dirty:) puts it on a descendant field. Either turns tracking on. A quick
83
+ // attribute read + one scoped query, evaluated once per connect (a cold path).
84
+ function dirtyTrackingEnabled(root, core) {
85
+ if ((root.getAttribute?.("data-action") ?? "").includes("reactive#trackDirty")) return true
86
+ const nodes = root.querySelectorAll?.('[data-action*="reactive#trackDirty"]') ?? []
87
+ for (const el of nodes) if (core.owns(el)) return true
88
+ return false
89
+ }
90
+
91
+ // Dirty tracking (issue #103). Wired by reactive_field(dirty: true) /
92
+ // reactive_root(track_dirty: true): an `input` on an owned field runs a FULL
93
+ // re-scan of every field this root owns (the controller's trackDirty action
94
+ // calls this). NO round trip, NO shipped state.
95
+ //
96
+ // Re-compute the dirty flag for EVERY field this root owns in one pass, then
97
+ // reflect the total onto the root. Called on an owned field's input, on
98
+ // connect (baseline seed), and after a turbo:morph-element re-render (fresh
99
+ // default* attrs). dirty = current ≠ the DOM's own default:
100
+ // checkbox/radio → checked !== defaultChecked
101
+ // select → some option.selected !== its reset state (issue #297)
102
+ // else → value !== defaultValue
103
+ // A full pass (not per-target) is REQUIRED: a radio group's previously-checked
104
+ // radio flips to checked=false with NO input event, so per-target toggling
105
+ // would leave its flag stale. File inputs are skipped — a file has no server
106
+ // default baseline. Per-dirty-field data-reactive-dirty="true" ("true" STRING,
107
+ // not a valueless boolean attr — mirrors the on() flag convention); the root
108
+ // carries data-reactive-dirty="<count>" and DROPS the attr at zero, so
109
+ // `[data-reactive-dirty]` styles the whole form and `[data-reactive-dirty]`
110
+ // on a field styles just the changed control — both pure CSS, zero JS.
111
+ export function scan(controller, core) {
112
+ const root = controller.element
113
+ // Runs at bootstrap (the connect baseline seed) as well as on input/morph, so
114
+ // it must never throw — degrade to a no-op if the root can't be queried (a real
115
+ // reactive root always can; this guards minimal/test element stubs).
116
+ if (typeof root?.querySelectorAll !== "function") return
117
+
118
+ let count = 0
119
+ root.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
120
+ if (!core.owns(field)) return // skip a nested reactive root's fields (issue #15)
121
+ if (field.type === "file") return // no server default baseline to diff against
122
+
123
+ if (fieldDirty(field)) {
124
+ field.setAttribute("data-reactive-dirty", "true")
125
+ count++
126
+ } else {
127
+ field.removeAttribute("data-reactive-dirty")
128
+ }
129
+ })
130
+
131
+ if (count > 0) root.setAttribute("data-reactive-dirty", String(count))
132
+ else root.removeAttribute("data-reactive-dirty")
133
+ }
134
+
135
+ // Whether a single owned control differs from its server-rendered default.
136
+ function fieldDirty(field) {
137
+ if (field.type === "checkbox" || field.type === "radio") {
138
+ return field.checked !== field.defaultChecked
139
+ }
140
+ if (field.tag === "select" || field.options) {
141
+ // Any option whose selected state diverges from the select's RESET state.
142
+ // Guard for a stub/absent options list (degrade to clean). A one-row single
143
+ // select resets to its last defaultSelected option or, with none, to its
144
+ // first enabled one — the browser selects it while its defaultSelected stays
145
+ // false, so a pristine form must not read as dirty (issue #297). A multiple
146
+ // (or size > 1) select resets to exactly its defaultSelected options. An
147
+ // option inside a disabled <optgroup> is disabled too (the browser skips it).
148
+ const options = Array.from(field.options ?? [])
149
+ const single = !field.multiple && !(field.size > 1)
150
+ const reset = options.filter((o) => o.defaultSelected).pop() ?? options.find((o) => !o.closest?.("optgroup")?.disabled && !o.disabled)
151
+ return options.some((o) => o.selected !== (single ? o === reset : o.defaultSelected))
152
+ }
153
+ return field.value !== field.defaultValue
154
+ }
155
+
156
+ // The live dirty-field count, re-derived from the DOM (never a cached snapshot)
157
+ // — the source of truth for the warn_unsaved guard's gate.
158
+ function dirtyCount(root) {
159
+ const raw = root.getAttribute?.("data-reactive-dirty")
160
+ const n = Number(raw)
161
+ return Number.isFinite(n) && n > 0 ? n : 0
162
+ }
163
+
164
+ // Arm the navigate-away guard (warn_unsaved: true, issue #103). beforeunload
165
+ // blocks a real browser unload; turbo:before-visit blocks a Turbo in-app
166
+ // navigation (it does NOT fire on restoration visits — the documented gap).
167
+ // Both read the LIVE dirty count, so a clean form never blocks. Added through
168
+ // connect()'s `listen`, so disconnect() removes exactly them.
169
+ function armUnsavedGuard(root, listen) {
170
+ if (typeof window === "undefined" || typeof window.addEventListener !== "function") return
171
+
172
+ listen(window, "beforeunload", (event) => {
173
+ if (dirtyCount(root) === 0) return undefined
174
+ // The spec dance: preventDefault + a truthy returnValue triggers the native
175
+ // "leave site?" prompt. The string is legacy (modern browsers show their own
176
+ // copy) but must be non-empty/truthy to arm the dialog.
177
+ event.preventDefault()
178
+ event.returnValue = "You have unsaved changes."
179
+ return event.returnValue
180
+ })
181
+ // Every warn_unsaved root arms its own turbo:before-visit handler, and one
182
+ // visit is one event dispatched to all of them: the first DIRTY root asks,
183
+ // and the rest see the event already asked (issue #298). A clean root never
184
+ // claims the event, so any dirty root on the page can still veto the visit.
185
+ listen(window, "turbo:before-visit", (event) => {
186
+ if (dirtyCount(root) === 0 || askedVisits.has(event)) return
187
+ askedVisits.add(event)
188
+ const ok = typeof window.confirm === "function" ? window.confirm("You have unsaved changes. Leave anyway?") : true
189
+ if (!ok) event.preventDefault?.()
190
+ })
191
+ }
192
+
193
+ // Whether this root owns a clipboard-marked paste trigger (issue #228). The
194
+ // ROOT itself counts (a button-only component that mixes on_client(paste_into)
195
+ // onto reactive_root), then one scoped query; a NESTED root's triggers are
196
+ // its own controller's to gate (issue #15 ownership).
197
+ function clipboardGateEnabled(root, core) {
198
+ if (root.getAttribute?.("data-reactive-clipboard")) return true
199
+ const nodes = root.querySelectorAll?.("[data-reactive-clipboard]") ?? []
200
+ for (const el of nodes) if (core.owns(el)) return true
201
+ return false
202
+ }
203
+
204
+ // Set every owned paste trigger's `hidden` from clipboard availability
205
+ // (issue #228): available → revealed (the authored `hidden` was only the
206
+ // no-dead-button first paint), missing → hidden (insecure context /
207
+ // webview). The gate owns the flag on MARKED elements only — nothing else
208
+ // is ever touched. A marked ROOT is gated too: when the component IS the
209
+ // paste button, hiding the root is exactly "the dead button never shows".
210
+ function syncClipboardTriggers(root, core) {
211
+ const available = typeof globalThis.navigator?.clipboard?.readText === "function"
212
+ if (root.getAttribute?.("data-reactive-clipboard")) root.hidden = !available
213
+ for (const el of root.querySelectorAll?.("[data-reactive-clipboard]") ?? []) {
214
+ if (core.owns(el)) el.hidden = !available
215
+ }
216
+ }