phlex-reactive 0.13.4 → 0.14.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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1079 -630
  3. data/README.md +612 -17
  4. data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
  5. data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
  6. data/app/javascript/phlex/reactive/core.js +181 -0
  7. data/app/javascript/phlex/reactive/core.min.js +4 -0
  8. data/app/javascript/phlex/reactive/core.min.js.map +11 -0
  9. data/app/javascript/phlex/reactive/early.js +175 -0
  10. data/app/javascript/phlex/reactive/early.min.js +4 -0
  11. data/app/javascript/phlex/reactive/early.min.js.map +10 -0
  12. data/app/javascript/phlex/reactive/features/bindings.js +1582 -0
  13. data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
  14. data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
  15. data/app/javascript/phlex/reactive/features/compute.js +541 -0
  16. data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
  17. data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
  18. data/app/javascript/phlex/reactive/features/defer.js +726 -0
  19. data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
  20. data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
  21. data/app/javascript/phlex/reactive/features/devtools.js +242 -0
  22. data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
  23. data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
  24. data/app/javascript/phlex/reactive/features/effects.js +362 -0
  25. data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
  26. data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
  27. data/app/javascript/phlex/reactive/features/form.js +216 -0
  28. data/app/javascript/phlex/reactive/features/form.min.js +4 -0
  29. data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
  30. data/app/javascript/phlex/reactive/features/hints.js +193 -0
  31. data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
  32. data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
  33. data/app/javascript/phlex/reactive/features/persist.js +663 -0
  34. data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
  35. data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
  36. data/app/javascript/phlex/reactive/reactive_controller.js +86 -5821
  37. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  38. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
  39. data/app/javascript/phlex/reactive/runtime.js +3706 -0
  40. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
  41. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
  42. data/lib/phlex/reactive/component/dsl.rb +74 -2
  43. data/lib/phlex/reactive/component/helpers.rb +39 -9
  44. data/lib/phlex/reactive/component/lazy.rb +138 -2
  45. data/lib/phlex/reactive/component/registry.rb +2 -0
  46. data/lib/phlex/reactive/component/selection.rb +188 -0
  47. data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
  48. data/lib/phlex/reactive/doctor.rb +166 -8
  49. data/lib/phlex/reactive/dormant.rb +66 -0
  50. data/lib/phlex/reactive/engine.rb +63 -3
  51. data/lib/phlex/reactive/fragment.rb +233 -0
  52. data/lib/phlex/reactive/inspector/report.rb +24 -3
  53. data/lib/phlex/reactive/inspector.rb +23 -2
  54. data/lib/phlex/reactive/js.rb +47 -2
  55. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
  56. data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
  57. data/lib/phlex/reactive/param_schema.rb +68 -54
  58. data/lib/phlex/reactive/show_conditions.rb +84 -56
  59. data/lib/phlex/reactive/streamable.rb +27 -7
  60. data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
  61. data/lib/phlex/reactive/test_helpers/system.rb +62 -0
  62. data/lib/phlex/reactive/version.rb +1 -1
  63. data/lib/phlex/reactive.rb +84 -0
  64. data/lib/tasks/phlex_reactive.rake +6 -2
  65. metadata +38 -1
@@ -0,0 +1,1582 @@
1
+ // phlex/reactive/features/bindings — what a form root wires at connect and
2
+ // re-syncs after a morph, all of it client-only and all of it reading the
3
+ // root's own controls: show bindings and cross-root show targets (issues
4
+ // #161, #164, #176, #180, #209), completion bindings (#226), option filtering
5
+ // (#163), the tag-chip input (#203) and draft nested-attribute rows with their
6
+ // JSON mode (#208). One of the feature modules the opt-in client imports on
7
+ // demand (issue #275); in the default client it is part of the one file.
8
+ //
9
+ // Grouped because they share a shape — a marker check at connect, one
10
+ // delegated listener pair on the root, a seed, the same pass again on
11
+ // turbo:morph-element — and a page: a form with a show binding is the form
12
+ // that filters options and adds rows. The seeds here write visible state
13
+ // (and the JSON mode writes a hidden field a request collects), so with the
14
+ // opt-in client the feature `gates`: a root's requests wait for it.
15
+ //
16
+ // The conditional confirm (issue #179) lives here too: its condition is the
17
+ // same DNF fold a show binding uses, and its `predicate:` form reads the
18
+ // confirm_predicate seam — imported here by its bare name, so the runtime no
19
+ // longer imports it. A plain string confirm: never comes here; a conditional
20
+ // one on the opt-in client waits for this module before its dialog (a root
21
+ // with one preloads it).
22
+ //
23
+ // This module never imports the runtime: it reaches a controller through the
24
+ // `core` handle.
25
+ import { confirmPredicate } from "phlex/reactive/confirm_predicate"
26
+
27
+ // controller -> its context: the root, the core handle, and what used to be
28
+ // the controller's own fields for these features. Every field it does not
29
+ // set here starts undefined (falsy); a property name is one thing the
30
+ // minifier never renames, so the record carries no more of them than it
31
+ // must (issue #310).
32
+ const contexts = new WeakMap()
33
+ // root element -> the same context, for the check_group op (issue #342),
34
+ // which names a root, not a controller.
35
+ const rootContexts = new WeakMap()
36
+
37
+ function ctx(controller, core) {
38
+ let c = contexts.get(controller)
39
+ if (!c) {
40
+ contexts.set(controller, (c = { root: controller.element, core, nestedIndex: 0 }))
41
+ if (c.root) rootContexts.set(c.root, c)
42
+ }
43
+ return c
44
+ }
45
+
46
+ // Add a delegated listener on the root for each of the space-separated
47
+ // `types`, and keep its removal for disconnect().
48
+ function listen(c, types, handler) {
49
+ for (const type of types.split(" ")) {
50
+ c.root.addEventListener?.(type, handler)
51
+ c.offs.push(() => c.root.removeEventListener?.(type, handler))
52
+ }
53
+ }
54
+
55
+ // What connect() wires is one seed per binding: it runs now, again on
56
+ // turbo:morph-element, and again from reseed().
57
+ function seed(c, run) {
58
+ listen(c, "turbo:morph-element", run)
59
+ c.seeds.push(run)
60
+ run()
61
+ }
62
+
63
+ export function connect(controller, core) {
64
+ const c = ctx(controller, core)
65
+ c.offs = []
66
+ c.seeds = []
67
+
68
+ // Show bindings (issue #161) — ONLY when this root owns one, so a component
69
+ // without any pays a single probe (the dirty-tracking gate precedent). ONE
70
+ // delegated listener pair on the root (input + change bubble from every
71
+ // owned field — no per-field wiring, and a reactive_compute output write
72
+ // dispatches a real input event, so computed values drive visibility too).
73
+ // The connect sync seeds the initial state — a plain replace re-connects —
74
+ // and turbo:morph-element re-syncs after an in-place morph (which keeps the
75
+ // element connected, fires no Stimulus lifecycle, and may preserve a
76
+ // user-edited field value the server's hidden attrs don't reflect).
77
+ if (showSyncEnabled(c)) {
78
+ // A select-all header's edit flips its group first (issue #319) — on
79
+ // `input`, which a click fires BEFORE `change`, or the pass below would
80
+ // reset the header to its group's state first (the flip is idempotent, so
81
+ // the `change` that follows flips nothing). The change events it
82
+ // dispatches re-enter here and are skipped while it flips, so the pass
83
+ // after the loop runs once, not once per box.
84
+ c.show = (event) => {
85
+ if (c.flipping) return
86
+ // Only a user edit pushes the header onto its group; a morph (whose
87
+ // turbo:morph-element bubbles from the header too) derives it instead.
88
+ if (event?.type === "input" || event?.type === "change") flipGroup(c, event.target)
89
+ // Whether this root has group bindings is read at connect and again on
90
+ // a morph (which may add one, and so need the observer) — never per
91
+ // keystroke.
92
+ else {
93
+ c.groups = hasGroups(c)
94
+ observeGroups(c)
95
+ }
96
+ syncShow(c)
97
+ }
98
+ listen(c, "input change", c.show)
99
+ seed(c, c.show)
100
+ }
101
+
102
+ // Completion bindings (issue #226) — ONLY when the root declares
103
+ // data-reactive-on-complete (the show/filter gate precedent). ONE
104
+ // delegated input+change listener evaluates every binding's DNF over the
105
+ // owned fields and runs its ops on the RISING EDGE — the event-driven
106
+ // flip to true. The connect pass ARMS without firing (a fresh render with
107
+ // already-satisfied conditions must never self-fire — the $ops seed
108
+ // precedent), and turbo:morph-element re-arms the same way after an
109
+ // in-place morph. Listeners added HERE run after the Stimulus-wired
110
+ // recompute delegation for the same event, so the evaluation reads
111
+ // compute-NORMALIZED values (and a compute output write dispatches a real
112
+ // input event that re-evaluates anyway).
113
+ if (onCompleteEnabled(c)) {
114
+ listen(c, "input change", (event) => syncOnComplete(c, event))
115
+ seed(
116
+ c,
117
+ (c.arm = () => {
118
+ syncOnComplete(c, null)
119
+ observeGroups(c)
120
+ }),
121
+ )
122
+ }
123
+
124
+ // A form reset (issue #342) restores its fields AFTER the `reset` event
125
+ // dispatches (and only if nothing cancelled it), so this waits a task, then
126
+ // re-syncs the bindings and re-arms the completion latches without firing
127
+ // (a reset fires no input/change, so a checked-count latch would stay high).
128
+ if (c.show || c.arm) {
129
+ listen(c, "reset", () =>
130
+ setTimeout(() => {
131
+ if (contexts.get(controller) !== c) return
132
+ c.show?.()
133
+ c.arm?.()
134
+ }, 0),
135
+ )
136
+ }
137
+
138
+ // Option filtering (issue #163) — ONLY when the root declares the binding
139
+ // (reactive_filter emits both attrs together), so a component without one
140
+ // pays two attribute reads. ONE delegated input listener on the root — the
141
+ // handler re-filters only for events from the NAMED input, so keystrokes in
142
+ // unrelated fields on a wide form never pay a filter pass. The connect sync
143
+ // seeds from the input's current value (a plain replace re-connects; back
144
+ // navigation may restore typed text), and turbo:morph-element re-applies
145
+ // after an in-place morph (which keeps the element connected, fires no
146
+ // Stimulus lifecycle, and may preserve the user's typed query while the
147
+ // server re-rendered every option visible).
148
+ if (filterEnabled(c)) {
149
+ listen(c, "input", (event) => filterInputEvent(c, event) && syncFilter(c))
150
+ seed(c, () => syncFilter(c))
151
+ }
152
+
153
+ // Tag-chip input (issue #203) — ONLY when the root names the hidden value
154
+ // field (reactive_tags), so a component without one pays one attribute
155
+ // read. The chip list is a CLIENT PROJECTION of the hidden field's
156
+ // comma-joined value: connect seeds it (a plain replace re-connects with
157
+ // the server-rendered value), and turbo:morph-element re-projects after an
158
+ // in-place morph (the morph wrote server truth into the hidden field while
159
+ // the chips DOM kept the pre-morph projection). Registered AFTER the
160
+ // filter's listeners so a morph re-filters first and the tags pass then
161
+ // re-marks selected options on the fresh visibility state.
162
+ if (tagsEnabled(c)) seed(c, () => syncTags(c))
163
+
164
+ // JSON-mode nested rows (issue #208) — ONLY when the root owns a list with
165
+ // `as: :json`, so a form without one pays a single probe (the show/filter/
166
+ // tags gate precedent). ONE delegated input + change listener re-serializes
167
+ // the rows into the hidden field on every owned edit (nestedAdd/Remove call
168
+ // the sync directly; this covers typing into a row's fields). The connect
169
+ // seed writes the initial array (a plain replace re-connects), and
170
+ // turbo:morph-element re-seeds after an in-place morph (which keeps the
171
+ // element connected, fires no Stimulus lifecycle, and may have rewritten
172
+ // the rows to server truth while the hidden field kept its pre-morph value).
173
+ if (nestedJsonEnabled(c)) {
174
+ listen(c, "input change", (event) => syncNestedJson(c, event))
175
+ seed(c, () => syncAllNestedJson(c))
176
+ }
177
+ }
178
+
179
+ // Remove every listener connect() added, so a stray event after a Turbo
180
+ // morph/navigation never re-evaluates against a detached root.
181
+ export function disconnect(controller) {
182
+ const c = contexts.get(controller)
183
+ if (!c) return
184
+ contexts.delete(controller)
185
+ rootContexts.delete(c.root)
186
+ for (const off of c.offs ?? []) off()
187
+ c.groupObserver?.disconnect()
188
+ }
189
+
190
+ // Re-run the seeds that read field values, in connect() order — for a
191
+ // feature that changed those values after connect() ran (the draft restore).
192
+ // Each is the same re-sync a morph runs; on-complete re-ARMS without firing.
193
+ export function reseed(controller) {
194
+ for (const run of contexts.get(controller)?.seeds ?? []) run()
195
+ }
196
+
197
+ // A value this module wrote is announced the way a user's edit is: a bubbling
198
+ // `input` (the set-value + dispatch contract, issue #183), so dirty tracking,
199
+ // reactive_show and compute all see it.
200
+ function fireInput(el) {
201
+ if (typeof el.dispatchEvent === "function") el.dispatchEvent(new Event("input", { bubbles: true }))
202
+ }
203
+
204
+ // The Stimulus actions this module answers (the controller's own methods of
205
+ // the same names hand the event here). tagsAdd, tagsPick, tagsRemove,
206
+ // nestedAdd and nestedRemove take the controller themselves; the JSON sync is
207
+ // also the root's own listener, so it keeps its context form inside.
208
+ function publicSyncNestedJson(controller, core, event) {
209
+ return syncNestedJson(ctx(controller, core), event)
210
+ }
211
+ export { publicSyncNestedJson as syncNestedJson }
212
+
213
+ // The runtime's call-in for a conditional confirm: the message when its
214
+ // condition fires over this root's fields, else null.
215
+ export function confirmMessage(controller, core, confirmWhen) {
216
+ return conditionalConfirmMessage(ctx(controller, core), confirmWhen)
217
+ }
218
+
219
+ // An id-only selector (the issue #159/#164 allowlist for anything that
220
+ // escapes the root).
221
+ const ID_SELECTOR = /^#[A-Za-z_][\w-]*$/
222
+
223
+ // The selector matching every OWNED-element show binding: single-field
224
+ // (data-reactive-show-field, issue #161) OR compound all:/any:
225
+ // (data-reactive-show, issue #176). Both the connect() gate and the sync walk
226
+ // use it so a compound-only root still enables the sync.
227
+ const SHOW_BINDING_SELECTOR = "[data-reactive-show-field], [data-reactive-show]"
228
+
229
+ // Evaluate a show binding's declared literal predicate (issue #161) against
230
+ // the controlling field's current value. Exactly one of the three predicate
231
+ // attrs decides: equals (value === literal), not (value !== literal), in
232
+ // (value ∈ a JSON string list). The vocabulary is fixed and literal-only —
233
+ // never an expression, so there is no eval surface (the reactive_show helper
234
+ // enforces the same shape loudly at render; this is the client half of the
235
+ // two-sided posture). Returns true/false for a decidable binding, or null for
236
+ // a malformed/missing predicate — the caller SKIPS a null so a hand-built or
237
+ // stale binding never flips visibility it doesn't understand (default-deny,
238
+ // like the op whitelist).
239
+ function showBindingMatches(el, value) {
240
+ const equals = el.getAttribute("data-reactive-show-equals")
241
+ if (equals !== null) return value === equals
242
+ const not = el.getAttribute("data-reactive-show-not")
243
+ if (not !== null) return value !== not
244
+ const inRaw = el.getAttribute("data-reactive-show-in")
245
+ if (inRaw !== null) {
246
+ try {
247
+ const list = JSON.parse(inRaw)
248
+ if (Array.isArray(list)) return list.includes(value)
249
+ } catch {
250
+ // fall through to the warn below — malformed JSON and a non-array both skip
251
+ }
252
+ console.warn(`[phlex-reactive] malformed reactive_show in: list ${JSON.stringify(inRaw)} — skipped`)
253
+ return null
254
+ }
255
+ // Numeric threshold predicates (issue #176 part B): gte/gt/lte/lt read the
256
+ // literal off its own flat attr and compare Number(value) against it. Any
257
+ // present numeric attr decides the binding — a non-numeric field value (NaN)
258
+ // is false (hidden), and a non-numeric LITERAL warn-skips (null).
259
+ for (const key of SHOW_NUMERIC_KEYS) {
260
+ const raw = el.getAttribute(`data-reactive-show-${key}`)
261
+ if (raw !== null) return numericPredicateMatches(key, raw, value)
262
+ }
263
+ console.warn("[phlex-reactive] a reactive_show binding declares no predicate — skipped")
264
+ return null
265
+ }
266
+
267
+ // Parse a compound show binding's JSON payload (issue #176 part A). Malformed
268
+ // JSON degrades to null WITH a warn — a bad binding must never throw or blank
269
+ // the page (client-side default-deny), but a collision (two bindings' JSON
270
+ // mix-joined) is worth surfacing.
271
+ function parseShowCompound(raw) {
272
+ try {
273
+ const parsed = JSON.parse(raw)
274
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed
275
+ } catch {
276
+ // fall through to the warn
277
+ }
278
+ console.warn(`[phlex-reactive] malformed compound reactive_show payload ${JSON.stringify(raw)} — skipped`)
279
+ return null
280
+ }
281
+
282
+ // Parse a data-reactive-on-complete payload (issue #226): a JSON array of
283
+ // { any: [[term, …], …], ops: [[op, args], …] } bindings. Malformed JSON, a
284
+ // non-array, or a binding missing either half degrades to [] WITH a warn — a
285
+ // bad payload must never throw or fire an op (client-side default-deny, the
286
+ // parseShowCompound posture).
287
+ function parseOnComplete(raw) {
288
+ try {
289
+ const list = JSON.parse(raw)
290
+ if (
291
+ Array.isArray(list) &&
292
+ list.every((b) => b && typeof b === "object" && Array.isArray(b.any) && Array.isArray(b.ops))
293
+ ) {
294
+ return list
295
+ }
296
+ } catch {
297
+ // fall through to the warn
298
+ }
299
+ console.warn(`[phlex-reactive] malformed reactive_on_complete payload ${JSON.stringify(raw)} — skipped`)
300
+ return []
301
+ }
302
+
303
+ // Route a parsed data-reactive-show payload to the right evaluator. The 0.10
304
+ // wire is { any: [ [term,…], … ] } (DNF — groups are ARRAYS). For a stale tab
305
+ // still serving pre-0.10 HTML (deploy overlap), fall back to the 0.9.5 compound
306
+ // shape { all: [term,…] } / { any: [term,…] } where the values are flat TERM
307
+ // OBJECTS, not arrays. The nesting distinguishes them: DNF's any[0] is an Array.
308
+ // DELETE the legacy arm in 0.11.
309
+ function showPayloadMatches(payload, fieldValue) {
310
+ if (!payload || typeof payload !== "object") return null
311
+ const any = payload.any
312
+ if (Array.isArray(any) && (any.length === 0 || Array.isArray(any[0]))) {
313
+ return anyOfAllsMatches(any, fieldValue)
314
+ }
315
+ return legacyCompoundShowMatches(payload, fieldValue)
316
+ }
317
+
318
+ // LEGACY (0.9.5, deploy-overlap only — DELETE in 0.11): the flat all:/any:
319
+ // compound fold, where terms are objects (not groups). Preserved so a morph of
320
+ // stale pre-0.10 HTML doesn't go dead.
321
+ function legacyCompoundShowMatches(payload, fieldValue) {
322
+ const connective = Array.isArray(payload.all) ? "all" : Array.isArray(payload.any) ? "any" : null
323
+ if (!connective) return null
324
+ const terms = payload[connective]
325
+ if (terms.length === 0) return null
326
+ const results = terms.map((term) => dnfTermMatches(term, fieldValue))
327
+ return connective === "all" ? results.every(Boolean) : results.some(Boolean)
328
+ }
329
+
330
+ // A cross-root show target must be a single ID selector (issue #164) — the
331
+ // SAME shape the #159 mirror enforces (one shared regex), with its own warn so
332
+ // a refused show target is distinguishable in the console. The client half of
333
+ // the two-sided default-deny: reactive_show_targets raises at declare time; a
334
+ // hand-built wire attr must not widen the escape to class/compound selectors.
335
+ // A refused selector warns + skips — its siblings still apply. The group
336
+ // targets (issue #343) share it, naming their own kind in the warn.
337
+ function guardShowTargetSelector(selector, kind = "show") {
338
+ if (typeof selector === "string" && ID_SELECTOR.test(selector)) return true
339
+ console.warn(`[phlex-reactive] refused cross-root ${kind} target ${JSON.stringify(selector)} — skipped`)
340
+ return false
341
+ }
342
+
343
+
344
+ // Whether this root owns a show binding (issue #161) or declares cross-root
345
+ // show targets (issue #164) — the connect() gate, so a component with
346
+ // neither pays only this probe (the #dirtyTrackingEnabled precedent). A
347
+ // NESTED root's bindings don't count: its own controller instance syncs them
348
+ // (issue #15 ownership). The targets attr is checked FIRST — one
349
+ // getAttribute, cheaper than the binding walk.
350
+ function showSyncEnabled(c) {
351
+ if (c.root.getAttribute?.("data-reactive-show-targets")) return true
352
+ if (c.root.getAttribute?.("data-reactive-group-targets")) return true
353
+ // Single-field bindings carry -field; compound all:/any: bindings (issue
354
+ // #176) carry data-reactive-show and have NO single controlling field, so
355
+ // both selectors gate the sync.
356
+ const nodes = c.root.querySelectorAll?.(SHOW_BINDING_SELECTOR) ?? []
357
+ for (const el of nodes) if (c.core.owns(el)) return true
358
+ for (const el of c.root.querySelectorAll?.(GROUP_BINDING_SELECTOR) ?? []) if (c.core.owns(el)) return true
359
+ return false
360
+ }
361
+
362
+ // The connect() gate for completion bindings (issue #226) — one attribute read.
363
+ function onCompleteEnabled(c) {
364
+ return !!c.root.getAttribute?.("data-reactive-on-complete")
365
+ }
366
+
367
+ // Parse-and-memoize the completion bindings, keyed on the RAW attr string:
368
+ // a morph that rewrote the payload re-parses and RESETS the latches (the
369
+ // morph listener's own arm pass then re-arms without firing). A removed
370
+ // attr yields [] silently; malformed JSON warns (in parseOnComplete).
371
+ function onCompleteBindings(c) {
372
+ const raw = c.root.getAttribute?.("data-reactive-on-complete") ?? null
373
+ if (raw !== c.onCompleteRaw) {
374
+ c.onCompleteRaw = raw
375
+ c.onCompleteParsed = raw == null ? [] : parseOnComplete(raw)
376
+ c.onCompleteStates = c.onCompleteParsed.map(() => false)
377
+ }
378
+ return c.onCompleteParsed
379
+ }
380
+
381
+ // Evaluate every completion binding (issue #226) over the owned fields —
382
+ // the SAME memoized, scope-aware field resolver and DNF fold the show sync
383
+ // uses — and run each binding's ops on ITS OWN rising edge. `event` null
384
+ // (the connect/morph arm pass) updates the latches WITHOUT firing, so ops
385
+ // only ever run from a real user gesture. An undecidable payload (no
386
+ // groups) leaves its latch alone — default-deny, like every malformed-wire
387
+ // arm. Ops resolve through runOps's root-scoped targets; a missing to:
388
+ // defaults to "@root" (the $ops convention).
389
+ function syncOnComplete(c, event) {
390
+ const bindings = onCompleteBindings(c)
391
+ if (!bindings.length) return
392
+
393
+ const fieldValue = fieldResolver(c, c.core.ownership())
394
+ bindings.forEach((binding, i) => {
395
+ const matches = anyOfAllsMatches(binding.any, fieldValue)
396
+ if (matches === null) return
397
+ const fire = matches && !c.onCompleteStates[i] && Boolean(event)
398
+ c.onCompleteStates[i] = matches
399
+ if (fire) {
400
+ c.core.applyOps(binding.ops, "@root")
401
+ }
402
+ })
403
+ }
404
+
405
+ // Re-evaluate every OWNED show binding in one pass (issue #161): read the
406
+ // controlling field's current value, evaluate the declared literal predicate,
407
+ // toggle `hidden`. A full pass (not per-target) for the same reason as
408
+ // #scanDirty — a radio group's deselected radio fires no event — and because
409
+ // several bindings can hang off one field (the value read is memoized per
410
+ // pass). A binding whose field can't be resolved, or whose predicate is
411
+ // malformed, leaves visibility ALONE — a bad binding must never break or
412
+ // blank the page (client-side default-deny).
413
+ function syncShow(c) {
414
+ if (typeof c.root?.querySelectorAll !== "function") return
415
+
416
+ const owns = c.core.ownership()
417
+ const scope = c.root.getAttribute?.("data-reactive-scope") || null
418
+ // A memoized resolver shared by every binding in this pass — a field driving
419
+ // several bindings (and several DNF terms) reads exactly once. Scope-aware:
420
+ // a bare field `director` resolves as `[name="scope[director]"]` (issue #180).
421
+ const fieldValue = fieldResolver(c, owns)
422
+ for (const el of c.root.querySelectorAll(SHOW_BINDING_SELECTOR)) {
423
+ if (!owns(el)) continue // a nested root's binding is its own controller's job
424
+
425
+ // The 0.10 DNF payload (issue #180): data-reactive-show carries
426
+ // { any: [ [term,…], … ] }. The legacy flat-attr and 0.9.5-compound read
427
+ // arms live in showPayloadMatches/showBindingMatches for deploy overlap.
428
+ const payloadRaw = el.getAttribute("data-reactive-show")
429
+ if (payloadRaw !== null) {
430
+ const match = showPayloadMatches(parseShowCompound(payloadRaw), fieldValue)
431
+ if (match !== null) applyShowVisibility(el, match, owns, scope)
432
+ continue
433
+ }
434
+
435
+ // LEGACY flat-attr binding (pre-0.10, deploy overlap — removed in 0.11).
436
+ const name = el.getAttribute("data-reactive-show-field")
437
+ if (!name) continue
438
+ const value = fieldValue(name)
439
+ if (value === null) continue // no owned field with that name — leave it be
440
+ const match = showBindingMatches(el, value)
441
+ if (match === null) continue // malformed predicate — warned + skipped
442
+ applyShowVisibility(el, match, owns, scope)
443
+ }
444
+
445
+ // The group bindings (issue #319) and the cross-root pass (issue #164)
446
+ // share the same owned-field memo, so a field driving several reads once.
447
+ if (c.groups) syncGroups(c, fieldValue, owns, scope)
448
+ syncShowTargets(c, fieldValue)
449
+ syncGroupTargets(c, fieldValue)
450
+ }
451
+
452
+ // Toggle `hidden` (and, when the binding declares data-reactive-show-disable,
453
+ // the `disabled` of every owned named control inside it) from a match result
454
+ // (issue #180). Disabling a hidden section's controls stops them submitting —
455
+ // the stale-value fix. A visible section re-enables them. Controls a nested
456
+ // reactive root owns are left alone (#15 ownership).
457
+ function applyShowVisibility(el, match, owns, scope) {
458
+ el.hidden = !match
459
+ if (el.getAttribute("data-reactive-show-disable") !== "true") return
460
+ if (typeof el.querySelectorAll !== "function") return
461
+ for (const control of el.querySelectorAll("input[name], select[name], textarea[name]")) {
462
+ if (owns(control)) control.disabled = !match
463
+ }
464
+ // The element itself may be a named control (a bare field with a binding).
465
+ if (el.name && owns(el)) el.disabled = !match
466
+ }
467
+
468
+ // Apply the declared cross-root show targets (issue #164) — the visibility
469
+ // parallel of #applyComputeMirrors. For each declared field: read the OWNED
470
+ // field's current value (never a nested root's — you can only drive outside
471
+ // visibility from a field this root owns), then for each "#id" → predicate
472
+ // entry: guard the selector id-only (warn-and-skip; the Ruby helper raised
473
+ // at declare time — two-sided default-deny), resolve it DOCUMENT-WIDE, and
474
+ // toggle `hidden`. A target id not on the page is silently skipped (an
475
+ // unrendered tab pane is normal); a malformed predicate warn-skips its one
476
+ // target while siblings still apply. With no map declared this is one
477
+ // getAttribute and out.
478
+ //
479
+ // A "#id" KEY (issue #209) is a TARGET-KEYED entry instead: its value is the
480
+ // same DNF payload data-reactive-show holds, folded with per-term owned-field
481
+ // reads — the multi-field cross-root case. The "#" prefix routes unambiguously
482
+ // (a field name may never start with "#"; the Ruby helper raises).
483
+ function syncShowTargets(c, fieldValue) {
484
+ const map = parseShowTargets(c)
485
+ for (const [name, targets] of Object.entries(map)) {
486
+ if (name.startsWith("#")) {
487
+ applyConditionsTarget(name, targets, fieldValue)
488
+ continue
489
+ }
490
+ if (!targets || typeof targets !== "object" || Array.isArray(targets)) continue
491
+ const value = fieldValue(name)
492
+ if (value === null) continue // no owned field with that name — leave them be
493
+ // Every target's terms share this one field, so a constant resolver folds
494
+ // the group (issue #180): a target's value is a DNF GROUP (terms ANDed).
495
+ // (A checked_* term asks for the group's count instead, issue #319.)
496
+ const resolve = (_, count) => (count ? fieldValue(name, true) : value)
497
+ for (const [selector, group] of Object.entries(targets)) {
498
+ if (!guardShowTargetSelector(selector)) continue
499
+ // 0.10 wire: the value is a DNF GROUP (an array of terms, ANDed).
500
+ // LEGACY (0.9.5, deploy overlap — DELETE in 0.11): a flat predicate
501
+ // OBJECT ({ equals/not/in/gte… }) routed through showPredicateMatches.
502
+ let match
503
+ if (Array.isArray(group)) {
504
+ if (group.length === 0) {
505
+ console.warn(`[phlex-reactive] malformed reactive_show_targets group for ${selector} — skipped`)
506
+ continue
507
+ }
508
+ match = group.every((term) => dnfTermMatches(term, resolve))
509
+ } else {
510
+ const legacy = showPredicateMatches(group, value)
511
+ if (legacy === null) {
512
+ console.warn(`[phlex-reactive] malformed reactive_show_targets predicate for ${selector} — skipped`)
513
+ continue
514
+ }
515
+ match = legacy
516
+ }
517
+ for (const node of document.querySelectorAll(selector)) node.hidden = !match
518
+ }
519
+ }
520
+ }
521
+
522
+ // Apply ONE target-keyed conditions entry (issue #209): "#id" → the DNF
523
+ // payload { any: [[term,…],…] }, folded by the SAME anyOfAllsMatches as an
524
+ // in-root reactive_show — each term reads its OWN owned field, a missing
525
+ // owned field reads as blank (fail-closed, the shared-fixture contract). A
526
+ // target whose referenced fields are ALL unowned is left alone — the
527
+ // single-field skip generalized (this root has nothing to evaluate with). A
528
+ // malformed payload warn-skips its one target while siblings still apply;
529
+ // the selector guard is the same id-only allowlist as every cross-root arm.
530
+ function applyConditionsTarget(selector, payload, fieldValue) {
531
+ if (!guardShowTargetSelector(selector)) return
532
+ const groups = payload && typeof payload === "object" && !Array.isArray(payload) ? payload.any : null
533
+ const fields = dnfGroupFields(groups)
534
+ if (fields === null) {
535
+ console.warn(`[phlex-reactive] malformed reactive_show_targets conditions for ${selector} — skipped`)
536
+ return
537
+ }
538
+ if (fields.every((name) => fieldValue(name) === null)) return // no owned field — leave it be
539
+ const match = anyOfAllsMatches(groups, fieldValue)
540
+ if (match === null) return // unreachable after dnfGroupFields, kept fail-closed
541
+ for (const node of document.querySelectorAll(selector)) node.hidden = !match
542
+ }
543
+
544
+ // The declared cross-root show-target map (issue #164): a JSON object of
545
+ // { field: { "#id": predicate } } from data-reactive-show-targets (emitted
546
+ // by reactive_show_targets on the root). Absent degrades to {}; malformed
547
+ // degrades to {} WITH a warn — never a throw (the #parseComputeMirror
548
+ // contract), but never silent either: the likeliest cause is TWO
549
+ // reactive_show_targets calls on one root, whose JSON strings Phlex `mix`
550
+ // space-joined into an unparseable attr. The warn names the fix.
551
+ function parseShowTargets(c, kind = "show", fix = "mode: { ... }, kind: { ... }") {
552
+ const raw = c.root.getAttribute?.(`data-reactive-${kind}-targets`)
553
+ if (!raw) return {}
554
+ try {
555
+ const parsed = JSON.parse(raw)
556
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed
557
+ } catch {
558
+ // fall through to the shared warn below
559
+ }
560
+ console.warn(
561
+ `[phlex-reactive] malformed data-reactive-${kind}-targets — ignored. ` +
562
+ `Did two reactive_${kind}_targets calls collide on one root? Declare every entry in ONE call: ` +
563
+ `reactive_${kind}_targets(${fix})`
564
+ )
565
+ return {}
566
+ }
567
+
568
+ // The current value of the OWNED field controlling a show binding, as the
569
+ // string the literal predicate compares against. Mirrors #collectFields'
570
+ // per-kind reads: a checkbox reports its checked state ("true"/"false" — its
571
+ // .value is the constant "on", and the checkbox wins over the hidden input
572
+ // Rails pairs with it); a radio group reports the CHECKED radio's value (""
573
+ // when none is); anything else reports .value first-wins. Returns null when
574
+ // no owned field carries the name — the caller then leaves visibility alone.
575
+ function showFieldValue(c, name, owns, scope) {
576
+ let sawRadio = false
577
+ let first = null
578
+ for (const el of c.root.querySelectorAll(namedSelector(name, scope))) {
579
+ if (!owns(el)) continue
580
+ if (el.type === "checkbox") return el.checked ? "true" : "false"
581
+ if (el.type === "radio") {
582
+ if (el.checked) return el.value ?? ""
583
+ sawRadio = true
584
+ continue
585
+ }
586
+ first ??= el
587
+ }
588
+ if (first) return first.value ?? ""
589
+ return sawRadio ? "" : null
590
+ }
591
+
592
+ // Scope (issue #180): a bare field `director` under `data-reactive-scope=
593
+ // "form"` resolves as `[name="form[director]"]`. A name already carrying a
594
+ // bracket (a raw wire name the author passed, like "ids[]") is used verbatim.
595
+ function namedSelector(name, scope) {
596
+ return `[name="${scope && !name.includes("[") ? `${scope}[${name}]` : name}"]`
597
+ }
598
+
599
+ // The per-pass field resolver every DNF fold reads (show, enable, on-complete).
600
+ // fieldValue(name) is the field's string value; fieldValue(name, true) is how
601
+ // many owned boxes of that checkbox group are ticked (the checked_* terms,
602
+ // issue #319). Memoized, so a field driving several bindings reads once.
603
+ function fieldResolver(c, owns) {
604
+ const scope = c.root.getAttribute?.("data-reactive-scope") || null
605
+ const values = new Map()
606
+ return (name, count) => {
607
+ const key = count ? `#${name}` : name
608
+ if (!values.has(key)) {
609
+ values.set(key, count ? groupBoxes(c, name, owns, scope).filter((box) => box.checked).length : showFieldValue(c, name, owns, scope))
610
+ }
611
+ return values.get(key)
612
+ }
613
+ }
614
+
615
+ // --- Bulk selection (issue #319) ---------------------------------------------
616
+ // A checkbox GROUP is the owned boxes sharing one name. Three bindings read it:
617
+ // the select-all header (data-reactive-select-all), the ticked count
618
+ // (data-reactive-count), and the checked_* terms any condition can use —
619
+ // reactive_enable (data-reactive-enable) flips the element's own `disabled`
620
+ // from one. They re-sync with the show pass: on every input/change, on a
621
+ // morph, and when boxes are added or removed (observeGroups).
622
+ const GROUP_BINDING_SELECTOR = "[data-reactive-enable], [data-reactive-select-all], [data-reactive-count]"
623
+
624
+ // Whether the root holds any group binding (owned or not — a stray nested
625
+ // one only costs the walk, which still skips it).
626
+ function hasGroups(c) {
627
+ return !!c.root.querySelector?.(GROUP_BINDING_SELECTOR)
628
+ }
629
+
630
+ // The owned checkboxes of a group (a nested root's boxes are its own, #15).
631
+ function groupBoxes(c, name, owns, scope) {
632
+ return [...c.root.querySelectorAll(namedSelector(name, scope))].filter((el) => el.type === "checkbox" && owns(el))
633
+ }
634
+
635
+ // A header's edit ticks or unticks every owned box of its group (the header
636
+ // itself is never a member). The show listener syncs once afterwards.
637
+ function flipGroup(c, header) {
638
+ const group = header?.getAttribute?.("data-reactive-select-all")
639
+ if (group && c.core.owns(header)) setGroup(c, group, header.checked, header)
640
+ }
641
+
642
+ // The check_group op (issue #342): set a root's group the way its header
643
+ // would, then sync its bindings once. A root this module never connected (no
644
+ // group binding, only the boxes) still flips, with the #15 ownership check.
645
+ export function checkGroup(root, group, checked) {
646
+ if (!group) return
647
+ const c = rootContexts.get(root) ?? {
648
+ root,
649
+ core: { ownership: () => (el) => el.closest('[data-controller~="reactive"]') === root },
650
+ }
651
+ setGroup(c, group, checked !== false)
652
+ c.show?.()
653
+ }
654
+
655
+ // Tick or untick every owned box of a group, and dispatch `input` + `change`
656
+ // on each one it flips so computes, shows and on-complete bindings see a real
657
+ // edit. The re-entrant events are skipped by the show listener while this
658
+ // runs (c.flipping); the caller syncs once afterwards.
659
+ function setGroup(c, group, checked, except) {
660
+ const scope = c.root.getAttribute?.("data-reactive-scope") || null
661
+ const flipped = groupBoxes(c, group, c.core.ownership(), scope).filter((box) => box !== except && box.checked !== checked)
662
+ // Every box first, then the events: a listener (a checked-count
663
+ // on_complete) must see the group's final count, never a half-flipped one.
664
+ for (const box of flipped) box.checked = checked
665
+ c.flipping = true
666
+ try {
667
+ for (const box of flipped) {
668
+ // input then change, as a click does: computes listen on input.
669
+ for (const type of ["input", "change"]) box.dispatchEvent?.(new Event(type, { bubbles: true }))
670
+ }
671
+ } finally {
672
+ c.flipping = false
673
+ }
674
+ }
675
+
676
+ // One pass over the owned group bindings: enable from its conditions, the
677
+ // count as text (change-guarded, like #mirrorText), and each header's
678
+ // checked/indeterminate from its group (the header itself never counts).
679
+ function syncGroups(c, fieldValue, owns, scope) {
680
+ for (const el of c.root.querySelectorAll(GROUP_BINDING_SELECTOR)) {
681
+ if (!owns(el)) continue
682
+ const enable = el.getAttribute("data-reactive-enable")
683
+ if (enable !== null) {
684
+ const match = showPayloadMatches(parseShowCompound(enable), fieldValue)
685
+ if (match !== null) el.disabled = !match
686
+ }
687
+ const counted = el.getAttribute("data-reactive-count")
688
+ if (counted) {
689
+ const text = String(fieldValue(counted, true))
690
+ if (el.textContent !== text) el.textContent = text
691
+ }
692
+ const group = el.getAttribute("data-reactive-select-all")
693
+ if (group) {
694
+ const boxes = groupBoxes(c, group, owns, scope).filter((box) => box !== el)
695
+ const ticked = boxes.filter((box) => box.checked).length
696
+ el.checked = ticked > 0 && ticked === boxes.length
697
+ el.indeterminate = ticked > 0 && ticked < boxes.length
698
+ }
699
+ }
700
+ }
701
+
702
+ // The declared cross-root group targets (issue #343) — the count and enable
703
+ // parallel of syncShowTargets, declared on the root that OWNS the group:
704
+ // { group: { count: ["#id", …], enable: { "#id": { any: … } } } }. The count
705
+ // is this root's owned ticked boxes (a nested root's are its own, #15), the
706
+ // enable folds its DNF payload with the same owned-field memo as every other
707
+ // binding. Targets are id-only (warn-skip — the Ruby helper raised at
708
+ // declare time) and resolved document-wide; a missing one is skipped.
709
+ function syncGroupTargets(c, fieldValue) {
710
+ for (const [group, targets] of Object.entries(parseShowTargets(c, "group", '"ids[]" => { ... }, "tags[]" => { ... }'))) {
711
+ if (!targets || typeof targets !== "object" || Array.isArray(targets)) continue
712
+ const text = String(fieldValue(group, true))
713
+ for (const selector of Array.isArray(targets.count) ? targets.count : []) {
714
+ if (!guardShowTargetSelector(selector, "group")) continue
715
+ for (const node of document.querySelectorAll(selector)) if (node.textContent !== text) node.textContent = text
716
+ }
717
+ const enable = targets.enable && typeof targets.enable === "object" ? targets.enable : {}
718
+ for (const [selector, payload] of Object.entries(enable)) {
719
+ if (!guardShowTargetSelector(selector, "group")) continue
720
+ const match = anyOfAllsMatches(payload?.any, fieldValue)
721
+ if (match === null) {
722
+ console.warn(`[phlex-reactive] malformed reactive_group_targets enable for ${selector} — skipped`)
723
+ continue
724
+ }
725
+ for (const node of document.querySelectorAll(selector)) node.disabled = !match
726
+ }
727
+ }
728
+ }
729
+
730
+ // Boxes added or removed later — a stream append, a removal — fire no event,
731
+ // so a root with a group binding (a header, a count, an enable, or a show,
732
+ // show target or on_complete with a checked_* term) watches its subtree and
733
+ // re-syncs when a mutation adds or removes a checkbox: the show pass, and the
734
+ // on_complete latches re-armed without firing. Text writes (the count itself)
735
+ // never qualify, so it cannot loop. Installed once — at connect, or by the
736
+ // first morph that adds such a binding.
737
+ function observeGroups(c) {
738
+ if (c.groupObserver || typeof MutationObserver !== "function") return
739
+ const onRoot = (name) => c.root.getAttribute?.(`data-reactive-${name}`)?.includes("checked_")
740
+ const targeted = !!c.root.getAttribute?.("data-reactive-group-targets")
741
+ if (!targeted && !c.root.querySelector?.(`${GROUP_BINDING_SELECTOR}, [data-reactive-show*=checked_]`) && !onRoot("show-targets") && !onRoot("on-complete")) return
742
+ const boxIn = (node) => node.nodeType === 1 && (node.matches('input[type="checkbox"]') || !!node.querySelector('input[type="checkbox"]'))
743
+ c.groupObserver = new MutationObserver((records) => {
744
+ if (!records.some((r) => [...r.addedNodes, ...r.removedNodes].some(boxIn))) return
745
+ c.show?.()
746
+ c.arm?.()
747
+ })
748
+ c.groupObserver.observe(c.root, { childList: true, subtree: true })
749
+ }
750
+
751
+ // Whether this root declares an option filter (issue #163) — the connect()
752
+ // gate. reactive_filter always emits input + option together, so requiring
753
+ // BOTH also default-denies a half-built hand-authored binding.
754
+ function filterEnabled(c) {
755
+ return !!(
756
+ c.root.getAttribute?.("data-reactive-filter-input") &&
757
+ c.root.getAttribute?.("data-reactive-filter-option")
758
+ )
759
+ }
760
+
761
+ // Whether a delegated input event came from the NAMED filter input (issue
762
+ // #163). Anything else — another field's keystroke, a target without
763
+ // matches() — skips the filter pass (the morph re-sync path bypasses this).
764
+ function filterInputEvent(c, event) {
765
+ const selector = c.root.getAttribute("data-reactive-filter-input")
766
+ return !!selector && typeof event.target?.matches === "function" && event.target.matches(selector)
767
+ }
768
+
769
+ // Re-apply the filter in one pass (issue #163): lowercase the named input's
770
+ // current value, toggle `hidden` on every OWNED option by a substring match
771
+ // against its haystack (data-reactive-filter-text, falling back to the
772
+ // option's own text), collapse any group whose every contained option is
773
+ // hidden, and reveal the empty target at 0 visible. A filtered-out option
774
+ // also loses its listnav highlight so Enter can never pick an invisible row.
775
+ // No owned input → leave visibility ALONE — a binding that can't resolve
776
+ // must never break or blank the page (client-side default-deny). All
777
+ // selectors resolve within this root, skipping nested reactive roots'
778
+ // elements (issue #15 ownership; the predicate is hoisted once per pass).
779
+ function syncFilter(c) {
780
+ if (typeof c.root?.querySelectorAll !== "function") return
781
+ const inputSelector = c.root.getAttribute("data-reactive-filter-input")
782
+ const optionSelector = c.root.getAttribute("data-reactive-filter-option")
783
+ if (!inputSelector || !optionSelector) return
784
+
785
+ const owns = c.core.ownership()
786
+ const input = [...c.root.querySelectorAll(inputSelector)].find(owns)
787
+ if (!input) return
788
+
789
+ const query = (input.value ?? "").trim().toLowerCase()
790
+ let visible = 0
791
+ for (const el of c.root.querySelectorAll(optionSelector)) {
792
+ if (!owns(el)) continue // a nested root's option is its own controller's job
793
+ const haystack = (el.getAttribute("data-reactive-filter-text") ?? el.textContent ?? "").toLowerCase()
794
+ // An option whose tag is already selected (reactive_tags, issue #203)
795
+ // stays hidden through every re-filter — clearing the query must not
796
+ // resurface an already-added tag.
797
+ const hidden =
798
+ el.hasAttribute?.("data-reactive-tags-selected") || (query !== "" && !haystack.includes(query))
799
+ el.hidden = hidden
800
+ if (hidden) el.removeAttribute("data-reactive-highlighted")
801
+ else visible++
802
+ }
803
+
804
+ const groupSelector = c.root.getAttribute("data-reactive-filter-group")
805
+ if (groupSelector) {
806
+ for (const group of c.root.querySelectorAll(groupSelector)) {
807
+ if (!owns(group)) continue
808
+ const contained = [...group.querySelectorAll(optionSelector)].filter(owns)
809
+ // A group with no options isn't this filter's to decide — server state
810
+ // stands (it may be a header the app toggles by other means).
811
+ if (contained.length === 0) continue
812
+ group.hidden = contained.every((el) => el.hidden)
813
+ }
814
+ }
815
+
816
+ const emptySelector = c.root.getAttribute("data-reactive-filter-empty")
817
+ if (emptySelector) {
818
+ for (const el of c.root.querySelectorAll(emptySelector)) {
819
+ if (owns(el)) el.hidden = visible > 0
820
+ }
821
+ }
822
+ }
823
+
824
+ // Whether this root declares a tag-chip binding (issue #203) — the connect()
825
+ // gate and every tags action's first check (an action bound without the root
826
+ // binding is default-denied, the filter posture).
827
+ function tagsEnabled(c) {
828
+ return !!c.root.getAttribute?.("data-reactive-tags-field")
829
+ }
830
+
831
+ // Whether this root owns at least one JSON-mode nested list (issue #208) —
832
+ // the connect() gate. A cheap descendant probe: a form without one wires no
833
+ // input/change listeners. Ownership is re-checked per sync, so a stray match
834
+ // in a nested reactive root here is harmless (it just arms the listeners).
835
+ function nestedJsonEnabled(c) {
836
+ if (typeof c.root?.querySelector !== "function") return false
837
+ return !!c.root.querySelector("[data-reactive-nested-json]")
838
+ }
839
+
840
+ // The hidden input storing the comma-joined value, resolved fresh per use
841
+ // (a morph replaces nodes — never cache it) and OWNED by this root (issue
842
+ // #15). null when the selector resolves nothing — every caller then no-ops:
843
+ // a binding that can't resolve must never break the page.
844
+ function tagsField(c) {
845
+ if (typeof c.root?.querySelectorAll !== "function") return null
846
+ const selector = c.root.getAttribute("data-reactive-tags-field")
847
+ if (!selector) return null
848
+ const owns = c.core.ownership()
849
+ return [...c.root.querySelectorAll(selector)].find(owns) ?? null
850
+ }
851
+
852
+ // Parse the field's comma-joined value into the canonical tag list: split,
853
+ // trim, drop blanks, dedupe case-insensitively KEEPING the first casing (the
854
+ // server may have stored a ragged value — the projection normalizes without
855
+ // rewriting the field, so we never fight server truth).
856
+ function tagsRead(field) {
857
+ const seen = new Set()
858
+ const tags = []
859
+ for (const part of String(field.value ?? "").split(",")) {
860
+ const tag = part.trim()
861
+ if (tag === "" || seen.has(tag.toLowerCase())) continue
862
+ seen.add(tag.toLowerCase())
863
+ tags.push(tag)
864
+ }
865
+ return tags
866
+ }
867
+
868
+ // Append any NEW tags (trimmed, non-blank, not already present under the
869
+ // case-insensitive dedupe) and write the field once. Returns whether
870
+ // anything was actually added — callers only clear the query input then.
871
+ function tagsAddValues(c, values) {
872
+ const field = tagsField(c)
873
+ if (!field) return false
874
+
875
+ const tags = tagsRead(field)
876
+ const seen = new Set(tags.map((tag) => tag.toLowerCase()))
877
+ let added = false
878
+ for (const value of values) {
879
+ const tag = String(value ?? "").trim()
880
+ if (tag === "" || seen.has(tag.toLowerCase())) continue
881
+ seen.add(tag.toLowerCase())
882
+ tags.push(tag)
883
+ added = true
884
+ }
885
+ if (added) tagsWrite(c, field, tags)
886
+ return added
887
+ }
888
+
889
+ // The ONE writer: join, store, dispatch a real bubbling `input` on the field
890
+ // (the set-value + dispatch contract, issue #183 — dirty tracking,
891
+ // reactive_show, and compute all see the change), then re-project.
892
+ function tagsWrite(c, field, tags) {
893
+ field.value = tags.join(",")
894
+ fireInput(field)
895
+ syncTags(c)
896
+ }
897
+
898
+ // The query input the tags widget resets after a pick — the SAME input that
899
+ // drives reactive_filter (a tags widget without filtering has none; the
900
+ // caller then skips the reset).
901
+ function tagsQueryInput(c) {
902
+ if (typeof c.root?.querySelectorAll !== "function") return null
903
+ const selector = c.root.getAttribute("data-reactive-filter-input")
904
+ if (!selector) return null
905
+ const owns = c.core.ownership()
906
+ return [...c.root.querySelectorAll(selector)].find(owns) ?? null
907
+ }
908
+
909
+ // Re-project the hidden field into the DOM (issue #203): rebuild the chip
910
+ // list from the <template> and mark/hide the options whose tag is already
911
+ // selected. The field is the single source of truth — this never writes it.
912
+ function syncTags(c) {
913
+ const field = tagsField(c)
914
+ if (!field) return
915
+ const tags = tagsRead(field)
916
+ const owns = c.core.ownership()
917
+ tagsRenderChips(c, tags, owns)
918
+ tagsMarkOptions(c, tags, owns)
919
+ }
920
+
921
+ // Rebuild the chip list: clear the container and clone one chip per tag from
922
+ // the server-owned template. The tag lands in the clone's
923
+ // [data-reactive-tag-text] node via textContent (XSS-safe by construction —
924
+ // never innerHTML, the reactive_text posture), and every tagsRemove trigger
925
+ // in the clone gets the tag as its param. A missing list is a chip-less
926
+ // widget (fine — the value still maintains); a missing/empty template warns
927
+ // ONCE (a half-built binding should be loud, but never per-keystroke).
928
+ function tagsRenderChips(c, tags, owns) {
929
+ const list = [...c.root.querySelectorAll("[data-reactive-tags-list]")].find(owns)
930
+ if (!list) return
931
+
932
+ const template = [...c.root.querySelectorAll("[data-reactive-tags-template]")].find(owns)
933
+ const chipProto = template?.content?.firstElementChild
934
+ if (!chipProto) {
935
+ if (!c.tagsWarnedTemplate) {
936
+ console.warn(
937
+ "[phlex-reactive] reactive_tags: no chip <template data-reactive-tags-template> found in this root — " +
938
+ "chips will not render (the hidden field still updates). Add a template with a " +
939
+ "[data-reactive-tag-text] node and a reactive_tags_remove button."
940
+ )
941
+ c.tagsWarnedTemplate = true
942
+ }
943
+ return
944
+ }
945
+
946
+ while (list.firstChild) list.removeChild(list.firstChild)
947
+ for (const tag of tags) {
948
+ const chip = chipProto.cloneNode(true)
949
+ chip.setAttribute?.("data-reactive-tag", tag)
950
+ const sink = chip.matches?.("[data-reactive-tag-text]")
951
+ ? chip
952
+ : (chip.querySelectorAll?.("[data-reactive-tag-text]") ?? [])[0]
953
+ if (sink) sink.textContent = tag
954
+ const removers = [...(chip.querySelectorAll?.('[data-action*="reactive#tagsRemove"]') ?? [])]
955
+ if (chip.matches?.('[data-action*="reactive#tagsRemove"]')) removers.push(chip)
956
+ for (const remover of removers) remover.setAttribute?.("data-reactive-tag-param", tag)
957
+ list.appendChild(chip)
958
+ }
959
+ }
960
+
961
+ // Hide + mark every owned option whose DECLARED tag is already selected
962
+ // (data-reactive-tags-selected — #syncFilter keeps it hidden through
963
+ // re-filters), and resurface an option WE hid when its tag is removed. Only
964
+ // marker-carrying options are un-hidden — an option hidden by the filter or
965
+ // the server stays as-is. With a filter bound, one final #syncFilter re-folds
966
+ // groups/empty against the new selected set.
967
+ function tagsMarkOptions(c, tags, owns) {
968
+ const selected = new Set(tags.map((tag) => tag.toLowerCase()))
969
+ for (const el of c.root.querySelectorAll("[role=option]")) {
970
+ if (!owns(el)) continue
971
+ const tag = el.getAttribute?.("data-reactive-tag-param")
972
+ if (!tag) continue
973
+ if (selected.has(tag.toLowerCase())) {
974
+ el.setAttribute("data-reactive-tags-selected", "true")
975
+ el.hidden = true
976
+ el.removeAttribute?.("data-reactive-highlighted")
977
+ } else if (el.hasAttribute?.("data-reactive-tags-selected")) {
978
+ el.removeAttribute("data-reactive-tags-selected")
979
+ if (!filterEnabled(c)) el.hidden = false
980
+ }
981
+ }
982
+ if (filterEnabled(c)) syncFilter(c)
983
+ }
984
+
985
+ // A fresh index per nested-row add (issue #208) — strictly monotonic and
986
+ // clock-seeded, so it can never collide with server-rendered integer indexes
987
+ // (0..n) NOR with a rapid same-millisecond double add.
988
+ function nextNestedIndex(c) {
989
+ c.nestedIndex = Math.max(c.nestedIndex + 1, Date.now())
990
+ return c.nestedIndex
991
+ }
992
+
993
+ // Swap every NEW_ROW in the clone's name/id/for for the fresh index, so the
994
+ // row posts as its own `…_attributes[<index>][field]` group and labels keep
995
+ // pointing at their (renumbered) inputs.
996
+ function renumberNestedRow(row, index) {
997
+ const nodes = [row, ...(row.querySelectorAll?.("*") ?? [])]
998
+ for (const el of nodes) {
999
+ for (const attr of ["name", "id", "for"]) {
1000
+ const value = el.getAttribute?.(attr)
1001
+ if (value && value.includes("NEW_ROW")) el.setAttribute?.(attr, value.replaceAll("NEW_ROW", String(index)))
1002
+ }
1003
+ }
1004
+ }
1005
+
1006
+ // A half-built nested-rows binding should be loud, but never per-click.
1007
+ function warnNestedOnce(c, assoc) {
1008
+ if (c.nestedWarned) return
1009
+ console.warn(
1010
+ `[phlex-reactive] nested rows: no owned [data-reactive-nested-list="${assoc}"] container + ` +
1011
+ `<template data-reactive-nested-template="${assoc}"> pair found in this root — the add ` +
1012
+ "trigger did nothing. Render both inside the same reactive root (reactive_nested_list / " +
1013
+ "reactive_nested_template)."
1014
+ )
1015
+ c.nestedWarned = true
1016
+ }
1017
+
1018
+ // Fill-then-add (issue #208): copy each source control's value into the
1019
+ // matching cloned-row field, keyed by the trailing bracket segment of the
1020
+ // field's name (#nestedJsonKey — the SAME inference JSON mode uses, so the
1021
+ // two features can't drift). Sources resolve root-scoped and owned (#15); an
1022
+ // unresolved source or an unmatched key is silently skipped (the row still
1023
+ // adds — a half-mapped binding must never throw on click). Returns the FIRST
1024
+ // source control read (for focus), or null. With `clear`, resets every source
1025
+ // it read via the set-value + dispatch contract (#183) so dirty/show/compute
1026
+ // observe the reset.
1027
+ function seedNestedRow(c, row, fromJson, clear) {
1028
+ if (!fromJson) return null
1029
+ let map
1030
+ try {
1031
+ map = JSON.parse(fromJson)
1032
+ } catch {
1033
+ return null
1034
+ }
1035
+ if (!map || typeof map !== "object") return null
1036
+
1037
+ const owns = c.core.ownership()
1038
+ const rowFields = [...(row.querySelectorAll?.("input, select, textarea") ?? [])]
1039
+ const sources = []
1040
+ for (const [key, selector] of Object.entries(map)) {
1041
+ const source = [...(c.root.querySelectorAll?.(selector) ?? [])].find(owns)
1042
+ if (!source) continue
1043
+ const target = rowFields.find((field) => nestedJsonKey(field.getAttribute?.("name")) === key)
1044
+ if (!target) continue
1045
+ seedNestedField(target, source)
1046
+ sources.push(source)
1047
+ }
1048
+ if (clear) for (const source of sources) clearNestedSource(source)
1049
+ return sources[0] ?? null
1050
+ }
1051
+
1052
+ // Copy a source control's value into a cloned-row field, then dispatch a
1053
+ // bubbling `input` (the set-value + dispatch contract, #183). Checkbox ↔
1054
+ // checkbox copies the checked state; every other target takes the source's
1055
+ // submit-shaped value (#nestedFieldValue), so a checkbox source feeding a
1056
+ // text field lands "on"/"" exactly as a submit would.
1057
+ function seedNestedField(target, source) {
1058
+ if (target.type === "checkbox") {
1059
+ target.checked = source.type === "checkbox" ? !!source.checked : nestedFieldValue(source) !== ""
1060
+ } else {
1061
+ target.value = nestedFieldValue(source)
1062
+ }
1063
+ fireInput(target)
1064
+ }
1065
+
1066
+ // Reset a source control after a fill-then-add (issue #208), dispatching a
1067
+ // bubbling `input` so dirty tracking / reactive_show / compute see the reset.
1068
+ function clearNestedSource(source) {
1069
+ if (source.type === "checkbox") source.checked = false
1070
+ else source.value = ""
1071
+ fireInput(source)
1072
+ }
1073
+
1074
+ // Resolve %{field} placeholders in a confirm message from a row's field map
1075
+ // (issue #222). Ruby-style %{name} tokens; an unresolved key is left verbatim
1076
+ // (a visible, debuggable placeholder — never an empty hole or a throw). A
1077
+ // message with no placeholders returns unchanged, so this is inert for every
1078
+ // server-rendered (already-interpolated) confirm string.
1079
+ function interpolateConfirm(message, fields) {
1080
+ if (!message.includes("%{")) return message
1081
+ return message.replace(/%\{(\w+)\}/g, (whole, key) =>
1082
+ Object.prototype.hasOwnProperty.call(fields, key) ? fields[key] : whole,
1083
+ )
1084
+ }
1085
+
1086
+ // The remove itself, shared by the confirmed and no-confirm paths. Draft rows
1087
+ // leave the DOM; a persisted row (a hidden [_destroy] input present) is marked
1088
+ // "1" + hidden instead (set-value + dispatch contract, #183), so Rails destroys
1089
+ // it on save. Then re-sync every owned JSON-mode list (#208) — an absent row
1090
+ // IS the removal; a form without a JSON list iterates an empty set and exits.
1091
+ function removeNestedRow(c, row) {
1092
+ const destroy = [...(row.querySelectorAll?.('input[name$="[_destroy]"]') ?? [])][0]
1093
+ if (destroy) {
1094
+ destroy.value = "1"
1095
+ fireInput(destroy)
1096
+ row.hidden = true
1097
+ } else {
1098
+ row.parentNode?.removeChild?.(row)
1099
+ }
1100
+
1101
+ syncAllNestedJson(c)
1102
+ }
1103
+
1104
+ // Serialize every owned JSON-mode list into its hidden field. The connect
1105
+ // seed and the input/remove re-syncs funnel through here so one place owns
1106
+ // the "DOM rows → JSON field" projection.
1107
+ function syncAllNestedJson(c) {
1108
+ if (typeof c.root?.querySelectorAll !== "function") return
1109
+ const owns = c.core.ownership()
1110
+ for (const list of [...c.root.querySelectorAll("[data-reactive-nested-json]")].filter(owns)) {
1111
+ syncNestedJsonList(c, list.getAttribute("data-reactive-nested-json"))
1112
+ }
1113
+ }
1114
+
1115
+ // The hidden field a JSON-mode list mirrors into — resolved fresh (a morph
1116
+ // replaces nodes, never cache it) and OWNED by this root (#15). null when
1117
+ // the selector resolves nothing, so the caller no-ops (a half-built binding
1118
+ // must never break the page).
1119
+ function nestedJsonField(c, list) {
1120
+ const selector = list.getAttribute?.("data-reactive-nested-json-field")
1121
+ if (!selector) return null
1122
+ const owns = c.core.ownership()
1123
+ return [...c.root.querySelectorAll(selector)].find(owns) ?? null
1124
+ }
1125
+
1126
+ // One row → { key: value } over its named form controls. The JSON key is the
1127
+ // trailing bracket segment of each control's name (order[todos_attributes]
1128
+ // [3][title] → "title"; a bare `title` → "title"), the "infer from input
1129
+ // names" contract. The [_destroy] control is dropped (JSON has no destroy
1130
+ // marker). Later inputs with the same key win (last-wins, the DOM order).
1131
+ // Like a real submit (#299): an unchecked radio, a disabled control (its own
1132
+ // flag or a <fieldset disabled> ancestor: :disabled) and a nested reactive
1133
+ // root's control are skipped.
1134
+ function nestedRowObject(c, row) {
1135
+ const obj = {}
1136
+ const owns = c.core.ownership()
1137
+ for (const el of [...(row.querySelectorAll?.("input, select, textarea") ?? [])]) {
1138
+ if (el.matches(":disabled") || (el.type === "radio" && !el.checked) || !owns(el)) continue
1139
+ const key = nestedJsonKey(el.getAttribute?.("name"))
1140
+ if (key === null || key === "_destroy") continue
1141
+ obj[key] = nestedFieldValue(el)
1142
+ }
1143
+ return obj
1144
+ }
1145
+
1146
+ // The trailing bracket segment of a field name (the inferred JSON key), or
1147
+ // the bare name when it carries no brackets. null for a nameless control
1148
+ // (a bare button, an unnamed helper input) — skipped by the caller.
1149
+ function nestedJsonKey(name) {
1150
+ if (!name) return null
1151
+ const match = name.match(/\[([^\][]+)\]$/)
1152
+ return match ? match[1] : name
1153
+ }
1154
+
1155
+ // A form control's submitted value: an unchecked checkbox contributes "" (it
1156
+ // wouldn't post at all), a checked one its value (default "on"); everything
1157
+ // else its .value. Keeps the JSON shape close to what a real form submit
1158
+ // would carry for the same control.
1159
+ function nestedFieldValue(el) {
1160
+ if (el.type === "checkbox") return el.checked ? (el.value || "on") : ""
1161
+ return el.value ?? ""
1162
+ }
1163
+
1164
+ // Project ONE JSON-mode list's surviving rows into its hidden field. Each
1165
+ // row becomes an object keyed by the trailing bracket segment of its inputs'
1166
+ // names (…[title] → "title"); a hidden/_destroy-marked row is skipped (an
1167
+ // absent row IS the removal — JSON carries no destroy marker). The write
1168
+ // uses the set-value + dispatch contract (issue #183) so dirty tracking,
1169
+ // reactive_show, and compute see the change — but only when the value
1170
+ // actually changed, so a connect seed on an already-correct field is silent.
1171
+ function syncNestedJsonList(c, assoc) {
1172
+ const owns = c.core.ownership()
1173
+ const list = [...c.root.querySelectorAll(`[data-reactive-nested-list="${assoc}"]`)].find(owns)
1174
+ if (!list) return
1175
+ const field = nestedJsonField(c, list)
1176
+ if (!field) return
1177
+
1178
+ const rows = []
1179
+ for (const row of [...(list.querySelectorAll?.("[data-reactive-nested-row]") ?? [])]) {
1180
+ if (!owns(row) || row.hidden) continue
1181
+ rows.push(nestedRowObject(c, row))
1182
+ }
1183
+
1184
+ const next = JSON.stringify(rows)
1185
+ if (field.value === next) return
1186
+ field.value = next
1187
+ fireInput(field)
1188
+ }
1189
+
1190
+ // Tag-chip input (issue #203) — the composed combobox/tags primitive. The
1191
+ // root's data-reactive-tags-field names the hidden input that stores the
1192
+ // COMMA-JOINED value; these three actions are its only writers. All of it is
1193
+ // FORM state (like text in an input) — no token, no POST: the surrounding
1194
+ // form submit carries the joined value. The chip list is re-projected from
1195
+ // the field on every write (#syncTags), so the field stays the single source
1196
+ // of truth.
1197
+ //
1198
+ // Enter on the query input: add the TYPED text — unless this Enter belongs
1199
+ // to listnav (reactive_tags_add composes after reactive_listnav on the same
1200
+ // keydown.enter). Two guards make the composition order-independent:
1201
+ // defaultPrevented means listnavPick ALREADY picked the highlighted option
1202
+ // (adding the typed text too would double-add); a still-visible highlighted
1203
+ // option means listnavPick is ABOUT to pick it (when tagsAdd is bound
1204
+ // first). Past the guards, Enter is OURS — preventDefault unconditionally so
1205
+ // it can never submit the enclosing form (blank input included). A
1206
+ // comma-separated paste splits into individual tags (the value is
1207
+ // comma-joined, so a comma can never be part of one tag). The input clears
1208
+ // only when something was actually added — a duplicate keeps the typed text
1209
+ // for correction.
1210
+ export function tagsAdd(controller, core, event) {
1211
+ const c = ctx(controller, core)
1212
+ if (!tagsEnabled(c)) return
1213
+ if (event?.defaultPrevented) return
1214
+ if (c.core.listnavOptions(event).some((el) => el.hasAttribute?.("data-reactive-highlighted"))) return
1215
+ event?.preventDefault?.()
1216
+
1217
+ const input = event?.currentTarget ?? event?.target
1218
+ if (!input) return
1219
+ const added = tagsAddValues(c, String(input.value ?? "").split(","))
1220
+ if (!added) return
1221
+ input.value = ""
1222
+ if (filterEnabled(c)) syncFilter(c)
1223
+ }
1224
+
1225
+ // Click (or listnav Enter, which CLICKS the highlighted option) on a
1226
+ // preloaded option: add its DECLARED tag (data-reactive-tag-param — set by
1227
+ // reactive_tags_option, never free text). After a successful add, reset the
1228
+ // query so the next tag starts from the full list: clear the filter input,
1229
+ // re-narrow, and hand focus back for continued typing.
1230
+ export function tagsPick(controller, core, event) {
1231
+ const c = ctx(controller, core)
1232
+ if (!tagsEnabled(c)) return
1233
+ event?.preventDefault?.()
1234
+
1235
+ const trigger = event?.currentTarget ?? event?.target
1236
+ const tag = trigger?.getAttribute?.("data-reactive-tag-param")
1237
+ if (!tag) return
1238
+ if (!tagsAddValues(c, [tag])) return
1239
+
1240
+ const input = tagsQueryInput(c)
1241
+ if (!input) return
1242
+ input.value = ""
1243
+ syncFilter(c)
1244
+ input.focus?.()
1245
+ }
1246
+
1247
+ // Click on a chip's remove button: drop its tag (case-insensitive match, the
1248
+ // dedupe convention) from the hidden value. The re-projection removes the
1249
+ // chip and resurfaces the option. Removing an absent tag is a no-op.
1250
+ export function tagsRemove(controller, core, event) {
1251
+ const c = ctx(controller, core)
1252
+ if (!tagsEnabled(c)) return
1253
+ event?.preventDefault?.()
1254
+
1255
+ const trigger = event?.currentTarget ?? event?.target
1256
+ const tag = trigger?.getAttribute?.("data-reactive-tag-param")
1257
+ if (!tag) return
1258
+ const field = tagsField(c)
1259
+ if (!field) return
1260
+
1261
+ const tags = tagsRead(field)
1262
+ const next = tags.filter((t) => t.toLowerCase() !== tag.toLowerCase())
1263
+ if (next.length === tags.length) return
1264
+ tagsWrite(c, field, next)
1265
+ }
1266
+
1267
+ // Draft nested-attribute rows (issue #208) — the "new parent + child rows"
1268
+ // window. The rows are FORM state (the reactive_tags posture): no token, no
1269
+ // POST, ever — the surrounding REAL form submit carries Rails'
1270
+ // accepts_nested_attributes_for names and the server reconciles parent +
1271
+ // rows in ONE create. Add clones the association's server-owned
1272
+ // <template data-reactive-nested-template="assoc"> row, swaps every NEW_ROW
1273
+ // in the clone's name/id/for for a fresh unique index (each row posts as its
1274
+ // own `…_attributes[<index>][field]` group), appends it to the owned
1275
+ // [data-reactive-nested-list="assoc"] container, and focuses the new row's
1276
+ // first field. Several collections can share one root — everything is keyed
1277
+ // by the association name the trigger carries.
1278
+ export function nestedAdd(controller, core, event) {
1279
+ const c = ctx(controller, core)
1280
+ event?.preventDefault?.()
1281
+ const trigger = event?.currentTarget ?? event?.target
1282
+ const assoc = trigger?.getAttribute?.("data-reactive-association-param")
1283
+ if (!assoc) return
1284
+ if (typeof c.root?.querySelectorAll !== "function") return
1285
+
1286
+ const owns = c.core.ownership()
1287
+ const list = [...c.root.querySelectorAll(`[data-reactive-nested-list="${assoc}"]`)].find(owns)
1288
+ const template = [...c.root.querySelectorAll(`[data-reactive-nested-template="${assoc}"]`)].find(owns)
1289
+ const proto = template?.content?.firstElementChild
1290
+ if (!list || !proto) {
1291
+ warnNestedOnce(c, assoc)
1292
+ return
1293
+ }
1294
+
1295
+ const row = proto.cloneNode(true)
1296
+ renumberNestedRow(row, nextNestedIndex(c))
1297
+ list.appendChild(row)
1298
+
1299
+ // Fill-then-add (issue #208 Scenario A): seed the cloned row from named
1300
+ // source controls OUTSIDE the row, then (optionally) clear the sources.
1301
+ // Runs AFTER renumber+append so a seeded field's name already carries its
1302
+ // final `[<index>][field]` form — the key match agrees with what JSON mode
1303
+ // reads. `seeded` is the FIRST source we cleared/read, so fill-then-add can
1304
+ // return focus to the sources instead of stealing it into the new row.
1305
+ const fromJson = trigger?.getAttribute?.("data-reactive-nested-from-param")
1306
+ const clear = trigger?.getAttribute?.("data-reactive-nested-clear-param") === "true"
1307
+ const firstSource = seedNestedRow(c, row, fromJson, clear)
1308
+
1309
+ // Focus: inline-edit (no from:) focuses the new row's first field so you
1310
+ // type INTO it; fill-then-add keeps focus on the sources (the first one) so
1311
+ // you keep entering the next item — stealing focus would break that loop.
1312
+ if (fromJson) firstSource?.focus?.()
1313
+ else [...(row.querySelectorAll?.("input, select, textarea") ?? [])][0]?.focus?.()
1314
+
1315
+ // JSON mode (issue #208): a freshly-added row must land in the hidden field
1316
+ // immediately (seeded values included), so the serialized array reflects the
1317
+ // DOM even before the first keystroke. A no-op when not `as: :json`.
1318
+ if (list.getAttribute?.("data-reactive-nested-json") === assoc) syncNestedJsonList(c, assoc)
1319
+ }
1320
+
1321
+ // Remove the trigger's closest row wrapper. A DRAFT row (no [_destroy]
1322
+ // input) leaves the DOM — it was never persisted, so removing its fields IS
1323
+ // the removal. A PERSISTED row (an edit form rendered a hidden [_destroy]
1324
+ // input via nested_field_name) is marked "1" and hidden instead — Rails
1325
+ // destroys it on save. The mark dispatches a real bubbling `input` (the
1326
+ // set-value + dispatch contract, issue #183) so dirty tracking/compute see it.
1327
+ export function nestedRemove(controller, core, event) {
1328
+ const c = ctx(controller, core)
1329
+ event?.preventDefault?.()
1330
+ const trigger = event?.currentTarget ?? event?.target
1331
+ const row = trigger?.closest?.("[data-reactive-nested-row]")
1332
+ if (!row) return
1333
+ // The closest() walk must not escape this root — a root can itself sit
1334
+ // inside ANOTHER collection's row (the issue #15 closest-form posture).
1335
+ if (row.closest?.('[data-controller~="reactive"]') !== c.root) return
1336
+
1337
+ // Confirm gate (issue #218): reactive_nested_remove(confirm:) emits the SAME
1338
+ // data-reactive-confirm[-when]-param the other triggers do (nestedRemove reads
1339
+ // params via getAttribute, not event.params, so pull them off the trigger),
1340
+ // routed through the SAME #effectiveConfirmMessage + confirmResolver seam. A
1341
+ // static string always shows; a conditional Hash fires only when it matches,
1342
+ // else null. No confirm attr → null → the immediate-remove fast path.
1343
+ const confirm = trigger?.getAttribute?.("data-reactive-confirm-param")
1344
+ const confirmWhen = trigger?.getAttribute?.("data-reactive-confirm-when-param")
1345
+ const rawMessage = confirm || (confirmWhen ? conditionalConfirmMessage(c, confirmWhen) : null)
1346
+ if (!rawMessage) return removeNestedRow(c, row)
1347
+
1348
+ // Per-row confirm interpolation (issue #222). A row added client-side is a
1349
+ // cloneNode of the <template>, and the clone carries the TEMPLATE's confirm
1350
+ // string verbatim — the renumber/seed steps never rewrite the confirm attr.
1351
+ // So resolve %{field} placeholders here, from THIS row's live field values
1352
+ // (read now, not at clone time, so a later edit is reflected). An unresolved
1353
+ // key is left as its literal %{key} (debuggable, never throws). Server-
1354
+ // rendered rows already interpolate server-side, so their finished strings
1355
+ // carry no %{}; this is a no-op for them.
1356
+ const fields = nestedRowObject(c, row)
1357
+ const message = interpolateConfirm(rawMessage, fields)
1358
+
1359
+ // Gate through the overridable confirmResolver (issues #52/#55/#178) — a
1360
+ // themed dialog set with setConfirmResolver covers this trigger too. Pass the
1361
+ // row context (issue #222, superset of proposal 3) as an optional 2nd arg so
1362
+ // a power-user override can build the string itself; the message is already
1363
+ // interpolated for the default window.confirm path. Call the resolver INSIDE
1364
+ // the chain so even a SYNCHRONOUS override throw is a cancel (like a dismissed
1365
+ // dialog), and remove ONLY on a truthy resolution.
1366
+ return c.core.confirm(message, { el: trigger, row, fields }).then((ok) => {
1367
+ if (ok) removeNestedRow(c, row)
1368
+ })
1369
+ }
1370
+
1371
+ // JSON-mode nested rows (issue #208) — the delegated input/change handler.
1372
+ // An app whose controller parses a serialized JSON param instead of Rails'
1373
+ // accepts_nested_attributes_for opts a list into `as: :json`; the client
1374
+ // then mirrors that list's rows into ONE hidden field as a JSON array on
1375
+ // every owned edit. Public so Stimulus can bind it; a no-op unless the
1376
+ // edited field belongs to a JSON-mode list this root owns.
1377
+ function syncNestedJson(c, event) {
1378
+ const target = event?.target
1379
+ if (!target || !c.core.owns(target)) return
1380
+ // Re-serialize every JSON-mode list (an edit could touch any of them; the
1381
+ // per-list owned-row scan is cheap and keeps this handler association-free).
1382
+ syncAllNestedJson(c)
1383
+ }
1384
+
1385
+ // The numeric threshold keys (issue #176 part B) — the client half of the Ruby
1386
+ // SHOW_NUMERIC_KEYS. Order-independent; the evaluator reads the one that's
1387
+ // present. Each coerces BOTH sides to Number and compares.
1388
+ const SHOW_NUMERIC_KEYS = ["gte", "gt", "lte", "lt"]
1389
+
1390
+ // The length predicate keys (issue #226) — the client half of Ruby's
1391
+ // ShowConditions::LENGTH_KEYS. Length is counted in CODEPOINTS
1392
+ // ([...str].length), NOT UTF-16 code units (str.length), so Ruby's
1393
+ // String#length and this evaluator agree on multibyte values — the shared
1394
+ // fixture's emoji vector proves it.
1395
+ const SHOW_LENGTH_KEYS = ["len_eq", "len_gte", "len_gt", "len_lte", "len_lt"]
1396
+
1397
+ // The checked-count keys (issue #319) — the client half of Ruby's
1398
+ // ShowConditions::CHECKED_KEYS: how many owned boxes of a group are ticked.
1399
+ const SHOW_CHECKED_KEYS = ["checked_eq", "checked_gte", "checked_gt", "checked_lte", "checked_lt"]
1400
+
1401
+ // Evaluate one count predicate (len_* or checked_*). A count is a TOTAL
1402
+ // function (blank/absent → 0), so every field value is decidable — no
1403
+ // fail-closed special case like the numeric thresholds ({ length: 0 }
1404
+ // legitimately matches a blank field). A non-Integer LITERAL is a malformed
1405
+ // binding — warn-skip (null), default-deny.
1406
+ function countPredicateMatches(key, literal, count) {
1407
+ if (!Number.isInteger(literal)) {
1408
+ console.warn(`[phlex-reactive] reactive_show ${key}: needs an integer literal, got ${JSON.stringify(literal)} — skipped`)
1409
+ return null
1410
+ }
1411
+ switch (key.slice(key.indexOf("_") + 1)) {
1412
+ case "eq":
1413
+ return count === literal
1414
+ case "gte":
1415
+ return count >= literal
1416
+ case "gt":
1417
+ return count > literal
1418
+ case "lte":
1419
+ return count <= literal
1420
+ case "lt":
1421
+ return count < literal
1422
+ default:
1423
+ return null
1424
+ }
1425
+ }
1426
+
1427
+ // Evaluate one numeric threshold predicate against a field value. Returns
1428
+ // true/false for a decidable comparison, or null when the LITERAL itself is
1429
+ // non-numeric (a malformed binding — warn-skip, default-deny). A non-numeric
1430
+ // FIELD value (empty/blank/garbage) is treated as NaN → false: the
1431
+ // reveal-on-threshold notice stays hidden, the safe default. Shared by the
1432
+ // owned-binding evaluator (raw string literal off an attr) and the
1433
+ // cross-root/compound evaluator (a literal that arrived as a JSON number or
1434
+ // string).
1435
+ function numericPredicateMatches(key, literal, value) {
1436
+ const rhs = Number(literal)
1437
+ if (Number.isNaN(rhs)) {
1438
+ console.warn(`[phlex-reactive] reactive_show ${key}: needs a numeric literal, got ${JSON.stringify(literal)} — skipped`)
1439
+ return null
1440
+ }
1441
+ // A blank/whitespace field value must fail closed. Number("") and
1442
+ // Number(" ") are 0 (NOT NaN), so a bare Number()+isNaN check would wrongly
1443
+ // reveal a `lte:`/`lt:`/`gte: 0` binding on an EMPTY field. Force the
1444
+ // empty/blank case to NaN so the "blank → hidden" contract holds for every
1445
+ // operator, not just the ones where 0 happens to fail the comparison.
1446
+ const trimmed = value == null ? "" : String(value).trim()
1447
+ const n = trimmed === "" ? NaN : Number(trimmed)
1448
+ if (Number.isNaN(n)) return false
1449
+ switch (key) {
1450
+ case "gte":
1451
+ return n >= rhs
1452
+ case "gt":
1453
+ return n > rhs
1454
+ case "lte":
1455
+ return n <= rhs
1456
+ case "lt":
1457
+ return n < rhs
1458
+ default:
1459
+ return null
1460
+ }
1461
+ }
1462
+
1463
+ // Evaluate an ALREADY-PARSED show predicate object (issue #164) — the
1464
+ // reactive_show_targets map embeds { equals/not/in } directly in its JSON, so
1465
+ // unlike showBindingMatches there are no attrs to read or re-parse. The same
1466
+ // literal-only vocabulary; anything else (empty, unknown keys, a non-array
1467
+ // in:) returns null and the caller warn-skips that target (default-deny — a
1468
+ // hand-built map entry must never flip visibility it doesn't declare).
1469
+ function showPredicateMatches(pred, value) {
1470
+ if (!pred || typeof pred !== "object") return null
1471
+ if (typeof pred.equals === "string") return value === pred.equals
1472
+ if (typeof pred.not === "string") return value !== pred.not
1473
+ if (Array.isArray(pred.in)) return pred.in.includes(value)
1474
+ // Numeric threshold predicates (issue #176 part B): the literal arrives as a
1475
+ // JSON number (or a numeric string) embedded in the predicate object — one
1476
+ // shared numericPredicateMatches with the owned-binding evaluator.
1477
+ for (const key of SHOW_NUMERIC_KEYS) {
1478
+ if (key in pred) return numericPredicateMatches(key, pred[key], value)
1479
+ }
1480
+ // Length predicates (issue #226): codepoint count vs an Integer literal.
1481
+ for (const key of SHOW_LENGTH_KEYS) {
1482
+ if (key in pred) return countPredicateMatches(key, pred[key], [...String(value ?? "")].length)
1483
+ }
1484
+ return null
1485
+ }
1486
+
1487
+ // Evaluate one DNF TERM against a resolved field value (issue #180). A missing
1488
+ // field (null) or a malformed/unknown predicate folds to FALSE — fail-closed
1489
+ // (default-deny): a broken AND term can't pass, a broken OR term can't reveal.
1490
+ function dnfTermMatches(term, fieldValue) {
1491
+ if (!term || typeof term !== "object" || typeof term.field !== "string") return false
1492
+ // A checked_* term (issue #319) counts the group's ticked boxes: the
1493
+ // resolver's count form, or — from collected fields (a conditional confirm)
1494
+ // — the group's array of checked values (a lone box's true/false is 1/0).
1495
+ const checked = SHOW_CHECKED_KEYS.find((key) => key in term)
1496
+ if (checked) {
1497
+ const n = fieldValue(term.field, true)
1498
+ return countPredicateMatches(checked, term[checked], Array.isArray(n) ? n.length : Number(n) || 0) === true
1499
+ }
1500
+ // An absent owned field reads as "" — identical to the server evaluator
1501
+ // (ShowConditions.match? treats a missing field as blank). This keeps the
1502
+ // Ruby first-paint and the client live-toggle in exact agreement (the shared
1503
+ // fixture proves it). A malformed predicate still folds to false.
1504
+ const value = fieldValue(term.field) ?? ""
1505
+ return showPredicateMatches(term, value) === true
1506
+ }
1507
+
1508
+ // Evaluate a DNF show payload (issue #180): { any: [group, …] } where each
1509
+ // GROUP is an array of terms (terms AND within a group, groups OR). Returns
1510
+ // true/false for a decidable payload, or null for a malformed one (no groups)
1511
+ // so the caller warn-skips and leaves visibility alone. This is the ONE shape
1512
+ // the 0.10 wire emits; showPayloadMatches routes the legacy shapes here or to
1513
+ // the compatibility arm below.
1514
+ function anyOfAllsMatches(groups, fieldValue) {
1515
+ if (!Array.isArray(groups) || groups.length === 0) return null
1516
+ // groups OR; within a group, terms AND (an empty group can't decide → false).
1517
+ return groups.some((group) => Array.isArray(group) && group.length > 0 &&
1518
+ group.every((term) => dnfTermMatches(term, fieldValue)))
1519
+ }
1520
+
1521
+ // Every field a DNF payload's groups reference (issue #209) — drives the
1522
+ // "leave the target alone when NO referenced field is owned" skip, the
1523
+ // single-field-target skip generalized. Returns null for a malformed payload
1524
+ // (no groups, or no term names a field) so the caller warn-skips instead of
1525
+ // toggling on garbage (default-deny, like every other malformed-wire arm).
1526
+ function dnfGroupFields(groups) {
1527
+ if (!Array.isArray(groups) || groups.length === 0) return null
1528
+ const fields = new Set()
1529
+ for (const group of groups) {
1530
+ if (!Array.isArray(group)) continue
1531
+ for (const term of group) {
1532
+ if (term && typeof term === "object" && typeof term.field === "string") fields.add(term.field)
1533
+ }
1534
+ }
1535
+ return fields.size > 0 ? [...fields] : null
1536
+ }
1537
+
1538
+ // Resolve the effective confirm message (issue #179). A plain string is the
1539
+ // static #52 form (always shown). A confirmWhen JSON payload is the CONDITIONAL
1540
+ // form — evaluated over the SAME collected fields reactive_compute reads — and
1541
+ // returns the message ONLY when it fires, else null (proceed, no dialog):
1542
+ // { groups, message } — the reactive_show conditions fold (anyOfAllsMatches)
1543
+ // { predicate, message } — a registered fn (setConfirmPredicate) over the fields
1544
+ // A missing predicate warns and returns null (PROCEED without a dialog) — the
1545
+ // compute unknown-reducer posture. This is soft-validation UX; the endpoint's
1546
+ // authorize/default-deny is the real gate, so failing OPEN here never grants
1547
+ // anything the server wouldn't already allow.
1548
+ function conditionalConfirmMessage(c, confirmWhen) {
1549
+
1550
+ // Stimulus auto-parses a JSON-object -param value, so confirmWhen usually
1551
+ // arrives ALREADY parsed. Accept an object as-is; parse a string defensively
1552
+ // (a hand-built attr, or a non-Stimulus caller). A malformed string warns and
1553
+ // proceeds without a dialog (default-deny UX — the server is the real gate).
1554
+ let payload = confirmWhen
1555
+ if (typeof confirmWhen === "string") {
1556
+ try {
1557
+ payload = JSON.parse(confirmWhen)
1558
+ } catch {
1559
+ console.warn(`[phlex-reactive] malformed conditional confirm payload ${JSON.stringify(confirmWhen)} — skipped`)
1560
+ return null
1561
+ }
1562
+ }
1563
+ if (!payload || typeof payload !== "object") return null
1564
+
1565
+ const { fields } = c.core.collectFields()
1566
+ const fieldValue = (name) => fields[name]
1567
+
1568
+ let fires
1569
+ if (typeof payload.predicate === "string") {
1570
+ const fn = confirmPredicate(payload.predicate)
1571
+ if (!fn) {
1572
+ console.warn(`[phlex-reactive] confirm predicate "${payload.predicate}" is not registered — proceeding without a dialog (register it with setConfirmPredicate)`)
1573
+ return null
1574
+ }
1575
+ fires = !!fn(fields)
1576
+ } else {
1577
+ // Declarative: the DNF groups fold, identical to reactive_show — matches → fire.
1578
+ fires = anyOfAllsMatches(payload.groups?.any, fieldValue) === true
1579
+ }
1580
+
1581
+ return fires ? payload.message : null
1582
+ }