phlex-reactive 0.11.6 → 0.12.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 +74 -0
- data/README.md +98 -3
- data/app/assets/stylesheets/phlex/reactive/effects.css +106 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +387 -8
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/dsl.rb +66 -0
- data/lib/phlex/reactive/component/helpers.rb +75 -9
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/effects.rb +169 -0
- data/lib/phlex/reactive/engine.rb +5 -0
- data/lib/phlex/reactive/reply.rb +20 -15
- data/lib/phlex/reactive/response.rb +28 -19
- data/lib/phlex/reactive/streamable.rb +69 -32
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +30 -2
- metadata +3 -1
|
@@ -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
|
}
|
|
@@ -1869,15 +2148,89 @@ export default class extends Controller {
|
|
|
1869
2148
|
const row = proto.cloneNode(true)
|
|
1870
2149
|
this.#renumberNestedRow(row, this.#nextNestedIndex())
|
|
1871
2150
|
list.appendChild(row)
|
|
1872
|
-
const first = [...(row.querySelectorAll?.("input, select, textarea") ?? [])][0]
|
|
1873
|
-
first?.focus?.()
|
|
1874
2151
|
|
|
1875
|
-
//
|
|
1876
|
-
//
|
|
1877
|
-
//
|
|
2152
|
+
// Fill-then-add (issue #208 Scenario A): seed the cloned row from named
|
|
2153
|
+
// source controls OUTSIDE the row, then (optionally) clear the sources.
|
|
2154
|
+
// Runs AFTER renumber+append so a seeded field's name already carries its
|
|
2155
|
+
// final `[<index>][field]` form — the key match agrees with what JSON mode
|
|
2156
|
+
// reads. `seeded` is the FIRST source we cleared/read, so fill-then-add can
|
|
2157
|
+
// return focus to the sources instead of stealing it into the new row.
|
|
2158
|
+
const fromJson = trigger?.getAttribute?.("data-reactive-nested-from-param")
|
|
2159
|
+
const clear = trigger?.getAttribute?.("data-reactive-nested-clear-param") === "true"
|
|
2160
|
+
const firstSource = this.#seedNestedRow(row, fromJson, clear)
|
|
2161
|
+
|
|
2162
|
+
// Focus: inline-edit (no from:) focuses the new row's first field so you
|
|
2163
|
+
// type INTO it; fill-then-add keeps focus on the sources (the first one) so
|
|
2164
|
+
// you keep entering the next item — stealing focus would break that loop.
|
|
2165
|
+
if (fromJson) firstSource?.focus?.()
|
|
2166
|
+
else [...(row.querySelectorAll?.("input, select, textarea") ?? [])][0]?.focus?.()
|
|
2167
|
+
|
|
2168
|
+
// JSON mode (issue #208): a freshly-added row must land in the hidden field
|
|
2169
|
+
// immediately (seeded values included), so the serialized array reflects the
|
|
2170
|
+
// DOM even before the first keystroke. A no-op when not `as: :json`.
|
|
1878
2171
|
if (list.getAttribute?.("data-reactive-nested-json") === assoc) this.#syncNestedJson(assoc)
|
|
1879
2172
|
}
|
|
1880
2173
|
|
|
2174
|
+
// Fill-then-add (issue #208): copy each source control's value into the
|
|
2175
|
+
// matching cloned-row field, keyed by the trailing bracket segment of the
|
|
2176
|
+
// field's name (#nestedJsonKey — the SAME inference JSON mode uses, so the
|
|
2177
|
+
// two features can't drift). Sources resolve root-scoped and owned (#15); an
|
|
2178
|
+
// unresolved source or an unmatched key is silently skipped (the row still
|
|
2179
|
+
// adds — a half-mapped binding must never throw on click). Returns the FIRST
|
|
2180
|
+
// source control read (for focus), or null. With `clear`, resets every source
|
|
2181
|
+
// it read via the set-value + dispatch contract (#183) so dirty/show/compute
|
|
2182
|
+
// observe the reset.
|
|
2183
|
+
#seedNestedRow(row, fromJson, clear) {
|
|
2184
|
+
if (!fromJson) return null
|
|
2185
|
+
let map
|
|
2186
|
+
try {
|
|
2187
|
+
map = JSON.parse(fromJson)
|
|
2188
|
+
} catch {
|
|
2189
|
+
return null
|
|
2190
|
+
}
|
|
2191
|
+
if (!map || typeof map !== "object") return null
|
|
2192
|
+
|
|
2193
|
+
const owns = this.#ownershipFilter()
|
|
2194
|
+
const rowFields = [...(row.querySelectorAll?.("input, select, textarea") ?? [])]
|
|
2195
|
+
const sources = []
|
|
2196
|
+
for (const [key, selector] of Object.entries(map)) {
|
|
2197
|
+
const source = [...(this.element.querySelectorAll?.(selector) ?? [])].find(owns)
|
|
2198
|
+
if (!source) continue
|
|
2199
|
+
const target = rowFields.find((field) => this.#nestedJsonKey(field.getAttribute?.("name")) === key)
|
|
2200
|
+
if (!target) continue
|
|
2201
|
+
this.#seedNestedField(target, source)
|
|
2202
|
+
sources.push(source)
|
|
2203
|
+
}
|
|
2204
|
+
if (clear) for (const source of sources) this.#clearNestedSource(source)
|
|
2205
|
+
return sources[0] ?? null
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
// Copy a source control's value into a cloned-row field, then dispatch a
|
|
2209
|
+
// bubbling `input` (the set-value + dispatch contract, #183). Checkbox ↔
|
|
2210
|
+
// checkbox copies the checked state; every other target takes the source's
|
|
2211
|
+
// submit-shaped value (#nestedFieldValue), so a checkbox source feeding a
|
|
2212
|
+
// text field lands "on"/"" exactly as a submit would.
|
|
2213
|
+
#seedNestedField(target, source) {
|
|
2214
|
+
if (target.type === "checkbox") {
|
|
2215
|
+
target.checked = source.type === "checkbox" ? !!source.checked : this.#nestedFieldValue(source) !== ""
|
|
2216
|
+
} else {
|
|
2217
|
+
target.value = this.#nestedFieldValue(source)
|
|
2218
|
+
}
|
|
2219
|
+
if (typeof target.dispatchEvent === "function") {
|
|
2220
|
+
target.dispatchEvent(new Event("input", { bubbles: true }))
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
// Reset a source control after a fill-then-add (issue #208), dispatching a
|
|
2225
|
+
// bubbling `input` so dirty tracking / reactive_show / compute see the reset.
|
|
2226
|
+
#clearNestedSource(source) {
|
|
2227
|
+
if (source.type === "checkbox") source.checked = false
|
|
2228
|
+
else source.value = ""
|
|
2229
|
+
if (typeof source.dispatchEvent === "function") {
|
|
2230
|
+
source.dispatchEvent(new Event("input", { bubbles: true }))
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
|
|
1881
2234
|
// Remove the trigger's closest row wrapper. A DRAFT row (no [_destroy]
|
|
1882
2235
|
// input) leaves the DOM — it was never persisted, so removing its fields IS
|
|
1883
2236
|
// the removal. A PERSISTED row (an edit form rendered a hidden [_destroy]
|
|
@@ -1893,6 +2246,35 @@ export default class extends Controller {
|
|
|
1893
2246
|
// inside ANOTHER collection's row (the issue #15 closest-form posture).
|
|
1894
2247
|
if (row.closest?.('[data-controller~="reactive"]') !== this.element) return
|
|
1895
2248
|
|
|
2249
|
+
// Confirm gate (issue #218): reactive_nested_remove(confirm:) emits the SAME
|
|
2250
|
+
// data-reactive-confirm[-when]-param the other triggers do (nestedRemove reads
|
|
2251
|
+
// params via getAttribute, not event.params, so pull them off the trigger),
|
|
2252
|
+
// routed through the SAME #effectiveConfirmMessage + confirmResolver seam. A
|
|
2253
|
+
// static string always shows; a conditional Hash fires only when it matches,
|
|
2254
|
+
// else null. No confirm attr → null → the immediate-remove fast path.
|
|
2255
|
+
const confirm = trigger?.getAttribute?.("data-reactive-confirm-param")
|
|
2256
|
+
const confirmWhen = trigger?.getAttribute?.("data-reactive-confirm-when-param")
|
|
2257
|
+
const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
|
|
2258
|
+
if (!message) return this.#removeNestedRow(row)
|
|
2259
|
+
|
|
2260
|
+
// Gate through the overridable confirmResolver (issues #52/#55/#178) — a
|
|
2261
|
+
// themed dialog set with setConfirmResolver covers this trigger too. Call the
|
|
2262
|
+
// resolver INSIDE the chain so even a SYNCHRONOUS override throw is a cancel
|
|
2263
|
+
// (like a dismissed dialog), and remove ONLY on a truthy resolution.
|
|
2264
|
+
return Promise.resolve()
|
|
2265
|
+
.then(() => confirmResolver(message))
|
|
2266
|
+
.catch(() => false)
|
|
2267
|
+
.then((ok) => {
|
|
2268
|
+
if (ok) this.#removeNestedRow(row)
|
|
2269
|
+
})
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
// The remove itself, shared by the confirmed and no-confirm paths. Draft rows
|
|
2273
|
+
// leave the DOM; a persisted row (a hidden [_destroy] input present) is marked
|
|
2274
|
+
// "1" + hidden instead (set-value + dispatch contract, #183), so Rails destroys
|
|
2275
|
+
// it on save. Then re-sync every owned JSON-mode list (#208) — an absent row
|
|
2276
|
+
// IS the removal; a form without a JSON list iterates an empty set and exits.
|
|
2277
|
+
#removeNestedRow(row) {
|
|
1896
2278
|
const destroy = [...(row.querySelectorAll?.('input[name$="[_destroy]"]') ?? [])][0]
|
|
1897
2279
|
if (destroy) {
|
|
1898
2280
|
destroy.value = "1"
|
|
@@ -1904,9 +2286,6 @@ export default class extends Controller {
|
|
|
1904
2286
|
row.parentNode?.removeChild?.(row)
|
|
1905
2287
|
}
|
|
1906
2288
|
|
|
1907
|
-
// JSON mode (issue #208): the removed (or _destroy-hidden) row must leave
|
|
1908
|
-
// the serialized array too — an absent row IS the removal. Re-sync every
|
|
1909
|
-
// owned JSON-mode list; a form without one iterates an empty set and exits.
|
|
1910
2289
|
this.#syncAllNestedJson()
|
|
1911
2290
|
}
|
|
1912
2291
|
|