phlex-reactive 0.13.3 → 0.14.0

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