phlex-reactive 0.11.7 → 0.12.1

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.
@@ -490,6 +490,284 @@ export function __resetReactiveDismissForTest() {
490
490
  dismissRegistered = false
491
491
  }
492
492
 
493
+ // Reactive effects (issue #215): animate ENTER (append/prepend), EXIT (remove)
494
+ // and UPDATE (replace/update, plain or morph) when a <turbo-stream> renders.
495
+ // Document-level and render-wrapping like the dismiss hook above, so ONE
496
+ // interceptor covers both delivery paths (a reply and a broadcast). Strictly
497
+ // data-driven and default-deny: the per-call data-reactive-effect on the
498
+ // stream element wins ("off" suppresses), else the carrier element's
499
+ // data-reactive-effect-<hook> — the DOM target for exit/update, the INCOMING
500
+ // template root for enter. No attribute → no work; unknown names and
501
+ // malformed legs warn + skip (a newer or forged attr must never break the
502
+ // page). prefers-reduced-motion disables everything (the shipped CSS is also
503
+ // media-wrapped — defense in depth).
504
+ //
505
+ // Timing:
506
+ // * exit — the animation runs BEFORE Turbo's render (the removal), awaited
507
+ // via animationend/transitionend with a timeout fallback; a ZERO computed
508
+ // duration (no effects CSS loaded, reduced-motion CSS gate) skips the wait
509
+ // entirely, so a missing stylesheet can never freeze a removal.
510
+ // * enter/update — Turbo renders first, then the effect class is applied to
511
+ // the inserted/updated element(s) and removed on settle (fire-and-forget).
512
+ // Re-applying an update effect restarts it (class off → reflow → on).
513
+ //
514
+ // A named effect maps to the shipped CSS class reactive-fx--<name>-<hook>
515
+ // (app/assets/stylesheets/phlex/reactive/effects.css); "random" picks a
516
+ // built-in per application; a "["-prefixed value is a custom
517
+ // [during, from, to] class-legs triple (the #96/#186 vocabulary), run with
518
+ // runTransition's add → frame → swap → settle choreography.
519
+ const EFFECT_HOOKS = Object.freeze({
520
+ append: "enter",
521
+ prepend: "enter",
522
+ replace: "update",
523
+ update: "update",
524
+ remove: "exit",
525
+ })
526
+ const EFFECT_BUILT_INS = Object.freeze(["fade", "slide", "scale", "highlight", "shake"])
527
+ // Marks an incoming template root so the post-render scan finds the inserted
528
+ // CLONE (Turbo clones template content on render — attrs ride the clone).
529
+ const EFFECT_PENDING_ATTR = "data-reactive-fx-pending"
530
+ // The hard ceiling on any effect wait — an exit's removal is delayed at most
531
+ // this long even if animationend/transitionend never fire.
532
+ const EFFECT_SETTLE_FALLBACK_MS = 1000
533
+
534
+ let effectsRegistered = false
535
+ export function registerReactiveEffects() {
536
+ if (effectsRegistered) return
537
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
538
+ effectsRegistered = true
539
+ document.addEventListener("turbo:before-stream-render", wrapStreamRenderForEffects)
540
+ }
541
+
542
+ export function __resetReactiveEffectsForTest() {
543
+ effectsRegistered = false
544
+ }
545
+
546
+ // Wrap event.detail.render (the dismiss-hook pattern) when this stream both
547
+ // maps to a hook AND resolves to an effect. Resolution happens HERE, before
548
+ // the render, because exit must read the target while it is still in the DOM
549
+ // and enter must read (and mark) the template content before Turbo clones it.
550
+ function wrapStreamRenderForEffects(event) {
551
+ const detail = event.detail
552
+ const original = detail?.render
553
+ if (typeof original !== "function" || original.__reactiveEffectsWrapped) return
554
+ const streamEl = detail?.newStream ?? event.target
555
+ const hook = EFFECT_HOOKS[streamEl?.getAttribute?.("action")]
556
+ if (!hook || effectsReducedMotion()) return
557
+ const effect = resolveStreamEffect(streamEl, hook)
558
+ if (!effect) return
559
+
560
+ const wrapped =
561
+ hook === "exit"
562
+ ? async (el) => {
563
+ await runExitEffect(effectTarget(streamEl), effect)
564
+ await original(el)
565
+ }
566
+ : async (el) => {
567
+ const container = hook === "enter" ? markIncomingRoots(streamEl) : null
568
+ await original(el)
569
+ if (hook === "enter") animateMarkedRoots(container, effect)
570
+ else runEnterOrUpdateEffect(effectTarget(streamEl), effect)
571
+ }
572
+ wrapped.__reactiveEffectsWrapped = true
573
+ detail.render = wrapped
574
+ }
575
+
576
+ // The effect for this stream: per-call data-reactive-effect first ("off" →
577
+ // none), else the carrier's declared data-reactive-effect-<hook>.
578
+ function resolveStreamEffect(streamEl, hook) {
579
+ const perCall = streamEl.getAttribute?.("data-reactive-effect")
580
+ if (perCall === "off") return null
581
+ if (perCall) return parseEffect(perCall, hook)
582
+ const carrier = hook === "enter" ? incomingEffectRoot(streamEl) : effectTarget(streamEl)
583
+ const declared = carrier?.getAttribute?.(`data-reactive-effect-${hook}`)
584
+ return declared ? parseEffect(declared, hook) : null
585
+ }
586
+
587
+ // The stream's CURRENT DOM target (re-queried at use, so a post-replace call
588
+ // sees the freshly-swapped element). Our builders always emit `target` —
589
+ // multi-`targets` streams are not ours and pass through unanimated.
590
+ function effectTarget(streamEl) {
591
+ const target = streamEl.getAttribute?.("target")
592
+ return target ? (document.getElementById?.(target) ?? null) : null
593
+ }
594
+
595
+ // The incoming content's root element (an append/prepend's arriving
596
+ // component) — the carrier of a declared enter effect.
597
+ function incomingEffectRoot(streamEl) {
598
+ return streamEl.querySelector?.("template")?.content?.firstElementChild ?? null
599
+ }
600
+
601
+ // A wire value → an executable effect: { className } for a shipped built-in
602
+ // ("random" picks one per application), { legs } for a custom triple. null +
603
+ // console.warn for anything else (default-deny).
604
+ function parseEffect(value, hook) {
605
+ if (value.startsWith("[")) {
606
+ let legs = null
607
+ try {
608
+ const parsed = JSON.parse(value)
609
+ if (Array.isArray(parsed) && parsed.length === 3) legs = parsed.map(String)
610
+ } catch {
611
+ // malformed JSON → the shared warn below
612
+ }
613
+ if (legs) return { legs }
614
+ console.warn(`[phlex-reactive] malformed effect legs ${JSON.stringify(value)} — skipped`)
615
+ return null
616
+ }
617
+ const name =
618
+ value === "random" ? EFFECT_BUILT_INS[Math.floor(Math.random() * EFFECT_BUILT_INS.length)] : value
619
+ if (!EFFECT_BUILT_INS.includes(name)) {
620
+ console.warn(`[phlex-reactive] unknown effect ${JSON.stringify(value)} — skipped`)
621
+ return null
622
+ }
623
+ return { className: `reactive-fx--${name}-${hook}` }
624
+ }
625
+
626
+ function effectsReducedMotion() {
627
+ try {
628
+ return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches
629
+ } catch {
630
+ return false
631
+ }
632
+ }
633
+
634
+ // Stamp each incoming template root with the pending marker (Turbo's render
635
+ // clones the content, so the marker rides the inserted clone) and return the
636
+ // container the post-render scan searches. Pre-render on purpose.
637
+ function markIncomingRoots(streamEl) {
638
+ const content = streamEl.querySelector?.("template")?.content
639
+ if (!content) return null
640
+ for (const child of Array.from(content.children ?? [])) child.setAttribute?.(EFFECT_PENDING_ATTR, "")
641
+ return effectTarget(streamEl)
642
+ }
643
+
644
+ // Post-render: find the just-inserted clones by their marker, unmark, animate.
645
+ function animateMarkedRoots(container, effect) {
646
+ if (typeof container?.querySelectorAll !== "function") return
647
+ for (const el of Array.from(container.querySelectorAll(`[${EFFECT_PENDING_ATTR}]`))) {
648
+ el.removeAttribute(EFFECT_PENDING_ATTR)
649
+ runEnterOrUpdateEffect(el, effect)
650
+ }
651
+ }
652
+
653
+ // EXIT: animate on the still-present element, resolve when settled, and only
654
+ // then does the wrapper run Turbo's removal. Zero computed duration (no
655
+ // effects CSS) resolves immediately — never a dead 1s freeze.
656
+ async function runExitEffect(el, effect) {
657
+ if (!el?.classList) return
658
+ if (effect.legs) {
659
+ await runLegsEffect(el, effect.legs)
660
+ return
661
+ }
662
+ el.classList.add(effect.className)
663
+ const duration = effectDurationMs(el)
664
+ if (duration <= 0) {
665
+ el.classList.remove(effect.className)
666
+ return
667
+ }
668
+ await effectSettled(el, duration)
669
+ el.classList.remove(effect.className)
670
+ }
671
+
672
+ // ENTER/UPDATE: fire-and-forget after the render. A re-applied class is
673
+ // removed + reflowed first so rapid successive updates restart the flash; the
674
+ // per-element token keeps an older settle from clearing a newer application.
675
+ function runEnterOrUpdateEffect(el, effect) {
676
+ if (!el?.classList) return
677
+ if (effect.legs) {
678
+ runLegsEffect(el, effect.legs)
679
+ return
680
+ }
681
+ if (el.classList.contains(effect.className)) {
682
+ el.classList.remove(effect.className)
683
+ void el.offsetWidth // force a reflow so re-adding restarts the animation
684
+ }
685
+ el.classList.add(effect.className)
686
+ const duration = effectDurationMs(el)
687
+ if (duration <= 0) {
688
+ el.classList.remove(effect.className)
689
+ return
690
+ }
691
+ const token = (el.__reactiveFxToken = (el.__reactiveFxToken ?? 0) + 1)
692
+ effectSettled(el, duration).then(() => {
693
+ if (el.__reactiveFxToken === token) el.classList.remove(effect.className)
694
+ })
695
+ }
696
+
697
+ // Custom class legs — runTransition's choreography (add during+from, swap
698
+ // from→to on the next frame, settle, clean up), promise-shaped so an exit can
699
+ // await it. Class lists are space-separated (the #96/#186 wire).
700
+ //
701
+ // Rapid re-application on the same element RESTARTS, mirroring the named
702
+ // path's token guard: each run takes the per-element token, clears any
703
+ // earlier run's leg classes, and a superseded run stops touching the element
704
+ // the moment a newer run owns it — so a stale settle can never strip classes
705
+ // mid-animation or double-swap the legs. A superseded EXIT run resolves
706
+ // early, which only lets Turbo's removal proceed sooner (never later).
707
+ async function runLegsEffect(el, legs) {
708
+ const [during, from, to] = legs.map(splitEffectClasses)
709
+ const token = (el.__reactiveFxToken = (el.__reactiveFxToken ?? 0) + 1)
710
+ el.classList.remove(...during, ...from, ...to)
711
+ el.classList.add(...during, ...from)
712
+ await effectNextFrame()
713
+ if (el.__reactiveFxToken !== token) return
714
+ el.classList.remove(...from)
715
+ el.classList.add(...to)
716
+ const duration = effectDurationMs(el)
717
+ if (duration > 0) await effectSettled(el, duration)
718
+ if (el.__reactiveFxToken !== token) return
719
+ el.classList.remove(...during, ...to)
720
+ }
721
+
722
+ function splitEffectClasses(list) {
723
+ return String(list ?? "")
724
+ .split(/\s+/)
725
+ .filter(Boolean)
726
+ }
727
+
728
+ // The longest computed animation/transition (duration + delay, comma lists
729
+ // included) in ms, capped at the hard fallback. 0 when getComputedStyle is
730
+ // unavailable or nothing animates — callers skip the wait entirely.
731
+ function effectDurationMs(el) {
732
+ if (typeof getComputedStyle !== "function") return 0
733
+ try {
734
+ const style = getComputedStyle(el)
735
+ const longest = (value) =>
736
+ String(value ?? "")
737
+ .split(",")
738
+ .reduce((max, part) => Math.max(max, parseFloat(part) || 0), 0)
739
+ const animation = longest(style.animationDuration) + longest(style.animationDelay)
740
+ const transition = longest(style.transitionDuration) + longest(style.transitionDelay)
741
+ return Math.min(Math.max(animation, transition) * 1000, EFFECT_SETTLE_FALLBACK_MS)
742
+ } catch {
743
+ return 0
744
+ }
745
+ }
746
+
747
+ // Resolve on animationend/transitionend — whichever fires first — with a
748
+ // timeout slightly past the computed duration, so a canceled animation (a
749
+ // display:none ancestor, an interrupted transition) can't hang an exit.
750
+ function effectSettled(el, durationMs) {
751
+ return new Promise((resolve) => {
752
+ let done = false
753
+ const settle = () => {
754
+ if (done) return
755
+ done = true
756
+ resolve()
757
+ }
758
+ el.addEventListener?.("animationend", settle, { once: true })
759
+ el.addEventListener?.("transitionend", settle, { once: true })
760
+ setTimeout(settle, Math.min(durationMs + 50, EFFECT_SETTLE_FALLBACK_MS))
761
+ })
762
+ }
763
+
764
+ function effectNextFrame() {
765
+ return new Promise((resolve) => {
766
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => resolve())
767
+ else setTimeout(resolve, 16)
768
+ })
769
+ }
770
+
493
771
  // Offline CSS hook (issue #101). Mirror data-reactive-offline on
494
772
  // document.documentElement from navigator.onLine, kept in sync by the window
495
773
  // online/offline events — so an app can dim a save button or show a banner with
@@ -666,6 +944,7 @@ export function registerReactiveActions() {
666
944
  registerReactiveJs()
667
945
  registerReactiveDefer()
668
946
  registerReactiveDismiss()
947
+ registerReactiveEffects()
669
948
  registerReactiveOffline()
670
949
  attachLatencyHandle()
671
950
  }
@@ -1463,8 +1742,10 @@ export default class extends Controller {
1463
1742
  // so a dismissed/erroring dialog never surfaces as an unhandled rejection AND a
1464
1743
  // genuine bug inside #proceed is NOT silently swallowed. Enqueue ONLY on a
1465
1744
  // truthy resolution — nothing is enqueued, no timer scheduled, otherwise.
1745
+ // The resolver's optional 2nd arg (issue #222) carries the trigger element,
1746
+ // so an override has the same ctx shape here as on nestedRemove ({ el, … }).
1466
1747
  Promise.resolve()
1467
- .then(() => confirmResolver(message))
1748
+ .then(() => confirmResolver(message, { el: target }))
1468
1749
  .catch(() => false)
1469
1750
  .then((ok) => {
1470
1751
  if (ok) this.#proceed(target, action, params, debounce, throttle, optimistic, busy)
@@ -1479,6 +1760,9 @@ export default class extends Controller {
1479
1760
  // owns state that must survive re-renders).
1480
1761
  runOps(event) {
1481
1762
  const { ops, confirm, confirmWhen, outside, window: windowBound } = event.params
1763
+ // The trigger element on_client was spread onto (issue #222 ctx: { el }),
1764
+ // captured now — currentTarget resets before the confirm resolver's microtask.
1765
+ const trigger = event.currentTarget ?? event.target
1482
1766
 
1483
1767
  // Outside guard FIRST — identical semantics to dispatch() (issue #80): an
1484
1768
  // outside: trigger is a COMPLETE no-op for events inside this root, before
@@ -1510,7 +1794,7 @@ export default class extends Controller {
1510
1794
  // here (the user gesture), NOT in #applyOps: that applier is shared with the
1511
1795
  // server-pushed reactive:js stream action, which must NEVER prompt.
1512
1796
  Promise.resolve()
1513
- .then(() => confirmResolver(message))
1797
+ .then(() => confirmResolver(message, { el: trigger }))
1514
1798
  .catch(() => false)
1515
1799
  .then((ok) => {
1516
1800
  if (ok) this.#applyOps(this.#parseOps(ops))
@@ -1967,6 +2251,61 @@ export default class extends Controller {
1967
2251
  // inside ANOTHER collection's row (the issue #15 closest-form posture).
1968
2252
  if (row.closest?.('[data-controller~="reactive"]') !== this.element) return
1969
2253
 
2254
+ // Confirm gate (issue #218): reactive_nested_remove(confirm:) emits the SAME
2255
+ // data-reactive-confirm[-when]-param the other triggers do (nestedRemove reads
2256
+ // params via getAttribute, not event.params, so pull them off the trigger),
2257
+ // routed through the SAME #effectiveConfirmMessage + confirmResolver seam. A
2258
+ // static string always shows; a conditional Hash fires only when it matches,
2259
+ // else null. No confirm attr → null → the immediate-remove fast path.
2260
+ const confirm = trigger?.getAttribute?.("data-reactive-confirm-param")
2261
+ const confirmWhen = trigger?.getAttribute?.("data-reactive-confirm-when-param")
2262
+ const rawMessage = this.#effectiveConfirmMessage(confirm, confirmWhen)
2263
+ if (!rawMessage) return this.#removeNestedRow(row)
2264
+
2265
+ // Per-row confirm interpolation (issue #222). A row added client-side is a
2266
+ // cloneNode of the <template>, and the clone carries the TEMPLATE's confirm
2267
+ // string verbatim — the renumber/seed steps never rewrite the confirm attr.
2268
+ // So resolve %{field} placeholders here, from THIS row's live field values
2269
+ // (read now, not at clone time, so a later edit is reflected). An unresolved
2270
+ // key is left as its literal %{key} (debuggable, never throws). Server-
2271
+ // rendered rows already interpolate server-side, so their finished strings
2272
+ // carry no %{}; this is a no-op for them.
2273
+ const fields = this.#nestedRowObject(row)
2274
+ const message = this.#interpolateConfirm(rawMessage, fields)
2275
+
2276
+ // Gate through the overridable confirmResolver (issues #52/#55/#178) — a
2277
+ // themed dialog set with setConfirmResolver covers this trigger too. Pass the
2278
+ // row context (issue #222, superset of proposal 3) as an optional 2nd arg so
2279
+ // a power-user override can build the string itself; the message is already
2280
+ // interpolated for the default window.confirm path. Call the resolver INSIDE
2281
+ // the chain so even a SYNCHRONOUS override throw is a cancel (like a dismissed
2282
+ // dialog), and remove ONLY on a truthy resolution.
2283
+ return Promise.resolve()
2284
+ .then(() => confirmResolver(message, { el: trigger, row, fields }))
2285
+ .catch(() => false)
2286
+ .then((ok) => {
2287
+ if (ok) this.#removeNestedRow(row)
2288
+ })
2289
+ }
2290
+
2291
+ // Resolve %{field} placeholders in a confirm message from a row's field map
2292
+ // (issue #222). Ruby-style %{name} tokens; an unresolved key is left verbatim
2293
+ // (a visible, debuggable placeholder — never an empty hole or a throw). A
2294
+ // message with no placeholders returns unchanged, so this is inert for every
2295
+ // server-rendered (already-interpolated) confirm string.
2296
+ #interpolateConfirm(message, fields) {
2297
+ if (!message.includes("%{")) return message
2298
+ return message.replace(/%\{(\w+)\}/g, (whole, key) =>
2299
+ Object.prototype.hasOwnProperty.call(fields, key) ? fields[key] : whole,
2300
+ )
2301
+ }
2302
+
2303
+ // The remove itself, shared by the confirmed and no-confirm paths. Draft rows
2304
+ // leave the DOM; a persisted row (a hidden [_destroy] input present) is marked
2305
+ // "1" + hidden instead (set-value + dispatch contract, #183), so Rails destroys
2306
+ // it on save. Then re-sync every owned JSON-mode list (#208) — an absent row
2307
+ // IS the removal; a form without a JSON list iterates an empty set and exits.
2308
+ #removeNestedRow(row) {
1970
2309
  const destroy = [...(row.querySelectorAll?.('input[name$="[_destroy]"]') ?? [])][0]
1971
2310
  if (destroy) {
1972
2311
  destroy.value = "1"
@@ -1978,9 +2317,6 @@ export default class extends Controller {
1978
2317
  row.parentNode?.removeChild?.(row)
1979
2318
  }
1980
2319
 
1981
- // JSON mode (issue #208): the removed (or _destroy-hidden) row must leave
1982
- // the serialized array too — an absent row IS the removal. Re-sync every
1983
- // owned JSON-mode list; a form without one iterates an empty set and exits.
1984
2320
  this.#syncAllNestedJson()
1985
2321
  }
1986
2322