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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1097 -630
- data/README.md +638 -16
- data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
- data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
- data/app/javascript/phlex/reactive/core.js +181 -0
- data/app/javascript/phlex/reactive/core.min.js +4 -0
- data/app/javascript/phlex/reactive/core.min.js.map +11 -0
- data/app/javascript/phlex/reactive/early.js +175 -0
- data/app/javascript/phlex/reactive/early.min.js +4 -0
- data/app/javascript/phlex/reactive/early.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/bindings.js +1511 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/compute.js +541 -0
- data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/defer.js +726 -0
- data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
- data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/devtools.js +242 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/effects.js +362 -0
- data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
- data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/form.js +216 -0
- data/app/javascript/phlex/reactive/features/form.min.js +4 -0
- data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/hints.js +193 -0
- data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
- data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/persist.js +663 -0
- data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
- data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
- data/app/javascript/phlex/reactive/inspect.js +21 -9
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +85 -5580
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
- data/app/javascript/phlex/reactive/runtime.js +3690 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
- data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
- data/lib/phlex/reactive/component/dsl.rb +74 -2
- data/lib/phlex/reactive/component/helpers.rb +104 -28
- data/lib/phlex/reactive/component/lazy.rb +138 -2
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/component/selection.rb +71 -0
- data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
- data/lib/phlex/reactive/doctor.rb +166 -8
- data/lib/phlex/reactive/dormant.rb +66 -0
- data/lib/phlex/reactive/engine.rb +64 -3
- data/lib/phlex/reactive/fragment.rb +233 -0
- data/lib/phlex/reactive/inspector/report.rb +24 -3
- data/lib/phlex/reactive/inspector.rb +23 -2
- data/lib/phlex/reactive/js.rb +61 -12
- data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
- data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
- data/lib/phlex/reactive/param_schema.rb +68 -54
- data/lib/phlex/reactive/pending/markup.rb +128 -0
- data/lib/phlex/reactive/pending.rb +25 -21
- data/lib/phlex/reactive/show_conditions.rb +84 -56
- data/lib/phlex/reactive/streamable.rb +20 -2
- data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
- data/lib/phlex/reactive/test_helpers/system.rb +62 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +84 -0
- data/lib/tasks/phlex_reactive.rake +6 -2
- 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
|
+
}
|