phlex-reactive 0.11.5 → 0.11.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a7758f95f9e1a35e71bf870416d6e042367b7ca19bb2a1c3b618939e72aa5775
4
- data.tar.gz: 2203a04fcf550a95e828945e56d06ed1b335fc430a1fdb8669f566893aa17c7d
3
+ metadata.gz: ce274b76a614b8510a9fc365e717d0b5391b815743a9cd0e9f3ffa716428c0ec
4
+ data.tar.gz: 9be84dd96ad24ef556935b8b68a60ecdbf48fabe902e1541584d6af6f22e17cf
5
5
  SHA512:
6
- metadata.gz: 311c87cf131ec0db83082089133b32dcc0583aa49203950ff59495929a2b512d72bedff7ced52747b2074bbce16a6b32eac99249e2b985cb19acfb9afce3a1ab
7
- data.tar.gz: 5856533fe3e2207246082a56f6709d732f32db50b8a8c6f1e2f3c8bd74b0f5b2c2330440e0c2b4c9bc078d785d33b85e95ae22f7edc91f746df959f86819fa37
6
+ metadata.gz: 5e87ae21d988550c315b9ac27a721d5e65b7fdf354ddbaa487cd0e2db7e062ad854198f0a6893ab8e9d0531b6ff698829f7421bd1d8a4fbd3c533992a4485a51
7
+ data.tar.gz: 107d841025bc3d6d2fecb4b30027e417607773edd12c45503a5615ecee40d9cd5994d42c5ddd40a3ec53c354fa49ef482953844b2ee2ee01311e27c0bbf987c2
data/CHANGELOG.md CHANGED
@@ -8,6 +8,57 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **Fill-then-add for draft nested rows — `reactive_nested_add(:assoc, from:, clear:)` (#208).**
12
+ The draft-rows primitive's add is *inline-edit* — it clones the template and
13
+ focuses the new row's first field, so you type INTO the row. A common
14
+ alternative puts the add controls OUTSIDE the row (a preset `<select>`, a
15
+ typeahead, plain inputs) and "Add" **snapshots** those values into a new row,
16
+ then clears them for the next entry. `from: { row_field => "#source-selector" }`
17
+ seeds each cloned-row field from its source control's current value (matching
18
+ the field by the same trailing-bracket-segment key inference JSON mode uses),
19
+ keeps focus on the sources rather than stealing it into the row, and `clear:
20
+ true` resets the sources (each via the set-value + dispatch contract, so dirty
21
+ tracking and compute observe the reset). `from:` values are raw CSS selectors
22
+ resolved within the reactive root (#15 ownership); an unresolved source or an
23
+ unmatched row-field key is silently skipped (the row still adds). It composes
24
+ with BOTH wire modes: the seeded values ride the renumbered `_attributes` names
25
+ on submit (`:attributes`) and the end-of-add JSON sync serializes them
26
+ (`as: :json`), with no extra wiring. The no-`from:` inline-edit default is
27
+ unchanged. See the "Draft rows for a new parent" README section and the
28
+ `/docs/example-draft-rows` page.
29
+
30
+ - **Multi-field cross-root targets — `reactive_show_targets` speaks the full
31
+ conditions language (#209).** A `"#id"` KEY now takes an `if:`/`if_any:`/
32
+ `unless:` conditions Hash, so an OUTSIDE element can show/hide from a
33
+ COMBINATION of owned fields — `reactive_show_targets("#trade-warning" =>
34
+ { if: { type: "trade", price: ..0 } })` — the last case that forced a
35
+ bespoke two-field JS listener on otherwise-declarative forms. The client
36
+ folds the target's DNF payload with the SAME per-term owned-field reads as
37
+ an in-root `reactive_show` (missing owned field reads blank, fail-closed);
38
+ a target whose referenced fields are ALL unowned is left alone (the
39
+ single-field skip, generalized). Target-keyed and field-keyed entries mix
40
+ in the ONE call per root (a field name may never start with `#`, so the
41
+ key routes unambiguously); existing field-keyed calls emit a byte-identical
42
+ wire. Declare-time validation stays loud: a bare value, unknown keys, or an
43
+ empty conditions Hash under a `"#id"` key raises a guided `ArgumentError`.
44
+
45
+ - **JSON mode for draft nested rows — `reactive_nested_list(:assoc, as: :json)` (#208).**
46
+ The draft-rows primitive (#208) posts standard Rails
47
+ `accepts_nested_attributes_for` names. But many apps persist a "new parent +
48
+ child rows" form by serializing the rows into ONE hidden field and
49
+ `JSON.parse`-ing it in the controller — adopting the primitive meant rewiring
50
+ that persistence path. `as: :json` removes the choice: the list keeps the same
51
+ `<template>` row, `nested_field_name`, and add/remove triggers, but the generic
52
+ controller now mirrors the surviving rows into a single hidden field as a JSON
53
+ array on every add / remove / keystroke (the set-value + dispatch contract, so
54
+ dirty tracking and compute still see it), inferring each JSON key from the
55
+ trailing bracket segment of a row input's name
56
+ (`order[line_items_attributes][3][quantity]` → `"quantity"`). A removed row
57
+ leaves the array (an absent row IS the removal — JSON has no `_destroy`
58
+ marker). The default (`:attributes`) is unchanged, so existing draft forms are
59
+ byte-identical. See the "Draft rows for a new parent" README section and the
60
+ `/docs/example-draft-rows` page.
61
+
11
62
  - **Turnkey APM adapters — `Phlex::Reactive.apm = :appsignal` (#207).** The gem
12
63
  already emits `*.phlex_reactive` `ActiveSupport::Notifications` events (#107), but
13
64
  every reactive action still POSTs to the same endpoint, so an APM rolled all
data/README.md CHANGED
@@ -387,7 +387,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
387
387
  | `reactive_field(:param, **attrs)` | The attribute hash that binds a control to an action param (no magic `name:`) — spread onto any control: `input(**reactive_field(:value, value: @record.name))`, `select(**reactive_field(:status)) { … }`. |
388
388
  | `reactive_text(:name, initial)` | Mirror a compute output (or a declared input) into a **text node** — a live preview heading, a character counter, `"Hello, {name}"` — via `textContent` (XSS-safe). The text sibling of `reactive_field`; carries no `name`, so it's never POSTed. See [Client-side computes](#client-side-computes-reactive_compute--reactive_text). |
389
389
  | `reactive_show(if:/if_any:/unless:)` | **Value-conditional visibility** (the `x-show`/`data-show` case): spread onto the element to show/hide — it toggles `hidden` from the fields' **current values**, client-only, zero round trip. One conditions language: a **Hash is an AND**, an **Array is membership**, a **Range is a threshold**, `if_any:` is OR-of-AND, `unless:` negates. `reactive_values` computes first paint; `disable:` disables a hidden section's controls. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
390
- | `reactive_show_targets(:field, "#id" => value)` | **Cross-root visibility**: the component that owns the field declares which **outside**, id-allowlisted elements it governs (a nav tab, a panel in another pane) — the visibility parallel of `mirror:`. Spread on the **root** via `mix(reactive_root, …)`, **once per root** — several fields go in one call via the hash form. The value uses the same `where`-style vocabulary (`"advanced"`, `%w[a b]`, `10..`). Id selectors only (raise at render + client warn-skip); toggles `hidden` only. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
390
+ | `reactive_show_targets(:field, "#id" => value)` | **Cross-root visibility**: the component that owns the field declares which **outside**, id-allowlisted elements it governs (a nav tab, a panel in another pane) — the visibility parallel of `mirror:`. Spread on the **root** via `mix(reactive_root, …)`, **once per root** — several fields go in one call via the hash form. The value uses the same `where`-style vocabulary (`"advanced"`, `%w[a b]`, `10..`); a `"#id"` **key** takes a full conditions Hash for a **multi-field** predicate (`"#warn" => { if: { type: "trade", price: ..0 } }`). Id selectors only (raise at render + client warn-skip); toggles `hidden` only. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
391
391
  | `reactive_filter(:field, option: nil, group: nil, empty: nil)` | **Client-side option filtering** for a preloaded combobox: spread onto the root and name the **field** that drives it — `reactive_filter(:q)` compiles `:q` to `[name="q"]` (scope-aware) and typing shows/hides the options by their `data-reactive-filter-text` haystack, **zero round trips**. `option:` defaults to `[role=option]`; optional `group:` collapses an all-hidden group header; `empty:` reveals a no-matches node. See [Client-side option filtering](#client-side-option-filtering-reactive_filter). |
392
392
  | `reactive_listnav("[role=option]")` | The **standalone** combobox keyboard wiring (Arrow/Enter/Escape) for an input that fires **no action** — the preload-and-filter case. Same behavior as `on(…, listnav:)`, minus the POST. |
393
393
  | `reactive_tags(:tags)` | **Tag-chip input** (the combobox/tags widget): spread onto the root and name the hidden field that stores the **comma-joined** value — the client maintains that field + the chip list entirely client-side (form state, zero round trips), rebuilding chips from your server-owned `<template>`. Composes with `reactive_filter` (type to narrow) and `reactive_listnav` (Enter picks the highlighted option). See [Tag-chip input](#tag-chip-input-reactive_tags). |
@@ -396,8 +396,8 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
396
396
  | `reactive_compute :name, ..., mirror: { sum: "#summary-sum" }` | **Cross-root text mirrors**: paint a compute value into declared, id-allowlisted nodes **outside** the reactive root (a recap in another tab pane) via `textContent` — no bespoke listener. See [Cross-root mirrors](#cross-root-mirrors-mirror--painting-a-recap-outside-the-root). |
397
397
  | `reactive_dirty` / `reactive_dirty warn_unsaved: true` / `reactive_dirty only: %i[...]` | **Dirty tracking**, declared once at the class level, against the DOM's own `defaultValue`/`defaultChecked`/`defaultSelected` — no client state. Marks changed fields + the root `data-reactive-dirty`; `warn_unsaved:` arms a `beforeunload`/`turbo:before-visit` guard; `only:` scopes tracking to named fields. Style with `[data-reactive-dirty]`. See [Dirty-field tracking](#dirty-field-tracking-reactive_dirty). |
398
398
  | `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
399
- | `reactive_nested_list(:assoc)` / `reactive_nested_template(:assoc)` / `reactive_nested_row` | **Draft nested-attribute rows** (the "new parent + child rows" form): the container rows land in, the `<template>` holding ONE row's markup, and the row wrapper marker — all **client-only** form state, keyed by association (several collections per root). See [Draft rows for a new parent](#draft-rows-for-a-new-parent-reactive_nested_). |
400
- | `reactive_nested_add(:assoc)` / `reactive_nested_remove` | The row triggers, client-only (zero round trips): add clones the template and renumbers its placeholder index; remove deletes a draft row from the DOM, or `_destroy`-marks + hides a persisted row (a hidden `[_destroy]` input present). |
399
+ | `reactive_nested_list(:assoc, as: :attributes \| :json)` / `reactive_nested_template(:assoc)` / `reactive_nested_row` | **Draft nested-attribute rows** (the "new parent + child rows" form): the container rows land in, the `<template>` holding ONE row's markup, and the row wrapper marker — all **client-only** form state, keyed by association (several collections per root). `as: :json` (default `:attributes`) serializes the rows into ONE hidden JSON field instead of posting `accepts_nested_attributes_for` names — for an app whose controller `JSON.parse`s a serialized param. See [Draft rows for a new parent](#draft-rows-for-a-new-parent-reactive_nested_). |
400
+ | `reactive_nested_add(:assoc, from:, clear:)` / `reactive_nested_remove` | The row triggers, client-only (zero round trips): add clones the template and renumbers its placeholder index; remove deletes a draft row from the DOM, or `_destroy`-marks + hides a persisted row (a hidden `[_destroy]` input present). **Fill-then-add**: `from: { row_field => "#source-selector" }` seeds the new row from add controls that live OUTSIDE it (a preset select, a typeahead), and `clear: true` resets them — composes with `as: :attributes` AND `as: :json`. See [Draft rows for a new parent](#draft-rows-for-a-new-parent-reactive_nested_). |
401
401
  | `nested_field_name(:assoc, :field, index: nil)` | The Rails `accepts_nested_attributes_for` wire name for one row field — `order[line_items_attributes][NEW_ROW][quantity]` (the template placeholder) by default, a real index when given. Scope-aware under `reactive_scope`. |
402
402
  | `reactive_collection :name, item:, container:, count:, empty:, size:` | Declare an add/remove-row list once; actions call `reply.append`/`prepend`/`remove`. See [Reactive collections](#reactive-collections-addremove-rows--count--empty-state). |
403
403
  | `reply.replace` / `.morph` / `.update` / `.remove` / `.redirect(url)` / `.with(*)` / `.js(ops)` | Return from an action to control the reply (flash, remove, redirect, multi-stream, server-pushed client ops). See [Controlling the action's reply](#reply--controlling-the-actions-reply). |
@@ -1019,6 +1019,23 @@ reactive_show_targets(mode: { "#advanced-tab" => "advanced" },
1019
1019
  kind: { "#premium-note" => %w[gold platinum] })
1020
1020
  ```
1021
1021
 
1022
+ **Multi-field targets.** A `"#id"` *key* takes a full `if:`/`if_any:`/`unless:`
1023
+ conditions Hash — the same language `reactive_show` speaks — so a cross-root
1024
+ target can read a **combination** of owned fields (the case that used to force
1025
+ a bespoke two-field JS listener):
1026
+
1027
+ ```ruby
1028
+ reactive_show_targets(
1029
+ "#trade-warning" => { if: { type: "trade", price: ..0 } }, # type == "trade" AND price <= 0
1030
+ mode: { "#advanced-tab" => "advanced" } # field-keyed entries mix in the ONE call
1031
+ )
1032
+ ```
1033
+
1034
+ The fold is identical to an in-root `reactive_show` (each term reads its own
1035
+ field; a missing owned field reads as blank — fail-closed). Every referenced
1036
+ field must be owned by the declaring root; a target whose fields are all
1037
+ unowned is left alone, like the single-field skip.
1038
+
1022
1039
  ### Client-side computes (`reactive_compute` + `reactive_text`)
1023
1040
 
1024
1041
  Some math should feel instant with **no round trip** — a NEW, unsaved record's
@@ -1334,6 +1351,65 @@ is saved, the persisted flow takes over: the same row markup renders with real
1334
1351
  indexes, or the list graduates to a [reactive collection](#reactive-collections-addremove-rows--count--empty-state)
1335
1352
  (`reactive_collection` + `reply.append`/`reply.remove`).
1336
1353
 
1354
+ **JSON mode — one hidden field instead of `accepts_nested_attributes_for`.**
1355
+ If your controller already parses a **serialized JSON param** (the app-rolled
1356
+ "stuff the rows into a hidden field, `JSON.parse` on submit" pattern) rather
1357
+ than nested attributes, opt the list into `as: :json` and keep your persistence
1358
+ path exactly as it is:
1359
+
1360
+ ```ruby
1361
+ div(**reactive_nested_list(:line_items, as: :json)) { } # + a hidden field to sync
1362
+ # the hidden field the client keeps in sync (seed "[]" so an empty submit posts one):
1363
+ input(type: "hidden", **reactive_field(:line_items), value: "[]")
1364
+ ```
1365
+
1366
+ ```ruby
1367
+ # The controller stays hand-rolled — NO accepts_nested_attributes_for:
1368
+ rows = JSON.parse(params.require(:order).permit(:line_items)[:line_items].presence || "[]")
1369
+ order = Order.create!(total: params[:order][:total])
1370
+ rows.each { order.line_items.create!(quantity: it["quantity"], price: it["price"]) }
1371
+ ```
1372
+
1373
+ Everything else is identical — the same `<template>` row, the same
1374
+ `nested_field_name`, the same add/remove triggers. In JSON mode the client
1375
+ mirrors the surviving rows into that one hidden field as a JSON array on every
1376
+ add / remove / keystroke (the set-value + dispatch contract, so dirty tracking
1377
+ and compute still see it), inferring each JSON key from the **trailing bracket
1378
+ segment** of a row input's name (`order[line_items_attributes][3][quantity]` →
1379
+ `"quantity"`). A removed row simply leaves the array (JSON carries no `_destroy`
1380
+ marker — an absent row *is* the removal). The per-row `_attributes` names still
1381
+ render but are ignored by a controller that doesn't permit them; the JSON field
1382
+ is the single source of truth.
1383
+
1384
+ **Fill-then-add — snapshot add controls that live OUTSIDE the row.** The default
1385
+ `reactive_nested_add` is *inline-edit*: it clones the template and focuses the
1386
+ new row's first field, so you type INTO the row. A common alternative puts the
1387
+ add controls outside the row — a preset `<select>`, a typeahead, plain inputs —
1388
+ and "Add" **snapshots** those values into a new row, then clears them for the
1389
+ next entry. Pass `from:` (a map of row-field → source-control selector) and
1390
+ optionally `clear:`:
1391
+
1392
+ ```ruby
1393
+ input(id: "item-name", type: "text") # the add controls, OUTSIDE the row
1394
+ input(id: "item-qty", type: "number")
1395
+
1396
+ button(**reactive_nested_add(:line_items,
1397
+ from: { name: "#item-name", quantity: "#item-qty" }, # row field => source selector
1398
+ clear: true)) { "Add item" }
1399
+ ```
1400
+
1401
+ On click the client clones the template, fills each cloned-row field from its
1402
+ source control's current value (matching the field by the **same** trailing
1403
+ bracket-segment key inference JSON mode uses), keeps focus on the sources (so
1404
+ you keep entering the next item — it does *not* steal focus into the row), and,
1405
+ with `clear: true`, resets the sources (each via the set-value + dispatch
1406
+ contract, so dirty tracking and compute see it). The `from:` values are raw CSS
1407
+ selectors resolved within this root (`#15` ownership); a selector that resolves
1408
+ nothing, or a row-field key with no matching cloned field, is skipped (the row
1409
+ still adds — never a throw). It composes with **both** wire modes: the seeded
1410
+ values ride the renumbered `_attributes` names on submit (`:attributes`) and the
1411
+ end-of-add JSON sync serializes them (`as: :json`), with no extra wiring.
1412
+
1337
1413
  Relatedly, a **draft parent can now run real server actions too** (issue #208):
1338
1414
  an unsaved record signs a gid-less `{c, state}` token, and the endpoint rebuilds
1339
1415
  the component through the record kwarg's **initialize default** —
@@ -989,6 +989,23 @@ function anyOfAllsMatches(groups, fieldValue) {
989
989
  group.every((term) => dnfTermMatches(term, fieldValue)))
990
990
  }
991
991
 
992
+ // Every field a DNF payload's groups reference (issue #209) — drives the
993
+ // "leave the target alone when NO referenced field is owned" skip, the
994
+ // single-field-target skip generalized. Returns null for a malformed payload
995
+ // (no groups, or no term names a field) so the caller warn-skips instead of
996
+ // toggling on garbage (default-deny, like every other malformed-wire arm).
997
+ function dnfGroupFields(groups) {
998
+ if (!Array.isArray(groups) || groups.length === 0) return null
999
+ const fields = new Set()
1000
+ for (const group of groups) {
1001
+ if (!Array.isArray(group)) continue
1002
+ for (const term of group) {
1003
+ if (term && typeof term === "object" && typeof term.field === "string") fields.add(term.field)
1004
+ }
1005
+ }
1006
+ return fields.size > 0 ? [...fields] : null
1007
+ }
1008
+
992
1009
  // Route a parsed data-reactive-show payload to the right evaluator. The 0.10
993
1010
  // wire is { any: [ [term,…], … ] } (DNF — groups are ARRAYS). For a stale tab
994
1011
  // still serving pre-0.10 HTML (deploy overlap), fall back to the 0.9.5 compound
@@ -1142,6 +1159,11 @@ export default class extends Controller {
1142
1159
  // indexes) plus the once-only missing-list/template warning latch.
1143
1160
  #nestedIndex = 0
1144
1161
  #nestedWarned = false
1162
+ // JSON-mode nested rows (issue #208): the bound delegated input/change
1163
+ // handler and the bound morph re-seed, held so disconnect() removes exactly
1164
+ // them.
1165
+ #boundSyncNestedJson
1166
+ #boundSeedNestedJson
1145
1167
  // Connect-time compute seed (issue #199): the bound re-seed attached to
1146
1168
  // turbo:morph-element so an in-place morph re-runs the compute, held for teardown.
1147
1169
  #boundSeedCompute
@@ -1266,6 +1288,24 @@ export default class extends Controller {
1266
1288
  this.#syncTags()
1267
1289
  }
1268
1290
 
1291
+ // JSON-mode nested rows (issue #208) — ONLY when the root owns a list with
1292
+ // `as: :json`, so a form without one pays a single probe (the show/filter/
1293
+ // tags gate precedent). ONE delegated input + change listener re-serializes
1294
+ // the rows into the hidden field on every owned edit (nestedAdd/Remove call
1295
+ // the sync directly; this covers typing into a row's fields). The connect
1296
+ // seed writes the initial array (a plain replace re-connects), and
1297
+ // turbo:morph-element re-seeds after an in-place morph (which keeps the
1298
+ // element connected, fires no Stimulus lifecycle, and may have rewritten
1299
+ // the rows to server truth while the hidden field kept its pre-morph value).
1300
+ if (this.#nestedJsonEnabled()) {
1301
+ this.#boundSyncNestedJson = (event) => this.syncNestedJson(event)
1302
+ this.#boundSeedNestedJson = () => this.#syncAllNestedJson()
1303
+ this.element.addEventListener?.("input", this.#boundSyncNestedJson)
1304
+ this.element.addEventListener?.("change", this.#boundSyncNestedJson)
1305
+ this.element.addEventListener?.("turbo:morph-element", this.#boundSeedNestedJson)
1306
+ this.#syncAllNestedJson()
1307
+ }
1308
+
1269
1309
  // Connect-time compute seed (issue #199) — ONLY when the root carries a
1270
1310
  // reactive_compute binding that opts in (data-reactive-compute-seed). A
1271
1311
  // freshly-rendered compute root (a first paint, or a server validation-error
@@ -1318,6 +1358,7 @@ export default class extends Controller {
1318
1358
  this.#teardownShowSync()
1319
1359
  this.#teardownFilterSync()
1320
1360
  this.#teardownTagsSync()
1361
+ this.#teardownNestedJsonSync()
1321
1362
  this.#teardownComputeSeed()
1322
1363
  if (this.#boundProbeLazyDefer) {
1323
1364
  this.element.removeEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
@@ -1828,8 +1869,87 @@ export default class extends Controller {
1828
1869
  const row = proto.cloneNode(true)
1829
1870
  this.#renumberNestedRow(row, this.#nextNestedIndex())
1830
1871
  list.appendChild(row)
1831
- const first = [...(row.querySelectorAll?.("input, select, textarea") ?? [])][0]
1832
- first?.focus?.()
1872
+
1873
+ // Fill-then-add (issue #208 Scenario A): seed the cloned row from named
1874
+ // source controls OUTSIDE the row, then (optionally) clear the sources.
1875
+ // Runs AFTER renumber+append so a seeded field's name already carries its
1876
+ // final `[<index>][field]` form — the key match agrees with what JSON mode
1877
+ // reads. `seeded` is the FIRST source we cleared/read, so fill-then-add can
1878
+ // return focus to the sources instead of stealing it into the new row.
1879
+ const fromJson = trigger?.getAttribute?.("data-reactive-nested-from-param")
1880
+ const clear = trigger?.getAttribute?.("data-reactive-nested-clear-param") === "true"
1881
+ const firstSource = this.#seedNestedRow(row, fromJson, clear)
1882
+
1883
+ // Focus: inline-edit (no from:) focuses the new row's first field so you
1884
+ // type INTO it; fill-then-add keeps focus on the sources (the first one) so
1885
+ // you keep entering the next item — stealing focus would break that loop.
1886
+ if (fromJson) firstSource?.focus?.()
1887
+ else [...(row.querySelectorAll?.("input, select, textarea") ?? [])][0]?.focus?.()
1888
+
1889
+ // JSON mode (issue #208): a freshly-added row must land in the hidden field
1890
+ // immediately (seeded values included), so the serialized array reflects the
1891
+ // DOM even before the first keystroke. A no-op when not `as: :json`.
1892
+ if (list.getAttribute?.("data-reactive-nested-json") === assoc) this.#syncNestedJson(assoc)
1893
+ }
1894
+
1895
+ // Fill-then-add (issue #208): copy each source control's value into the
1896
+ // matching cloned-row field, keyed by the trailing bracket segment of the
1897
+ // field's name (#nestedJsonKey — the SAME inference JSON mode uses, so the
1898
+ // two features can't drift). Sources resolve root-scoped and owned (#15); an
1899
+ // unresolved source or an unmatched key is silently skipped (the row still
1900
+ // adds — a half-mapped binding must never throw on click). Returns the FIRST
1901
+ // source control read (for focus), or null. With `clear`, resets every source
1902
+ // it read via the set-value + dispatch contract (#183) so dirty/show/compute
1903
+ // observe the reset.
1904
+ #seedNestedRow(row, fromJson, clear) {
1905
+ if (!fromJson) return null
1906
+ let map
1907
+ try {
1908
+ map = JSON.parse(fromJson)
1909
+ } catch {
1910
+ return null
1911
+ }
1912
+ if (!map || typeof map !== "object") return null
1913
+
1914
+ const owns = this.#ownershipFilter()
1915
+ const rowFields = [...(row.querySelectorAll?.("input, select, textarea") ?? [])]
1916
+ const sources = []
1917
+ for (const [key, selector] of Object.entries(map)) {
1918
+ const source = [...(this.element.querySelectorAll?.(selector) ?? [])].find(owns)
1919
+ if (!source) continue
1920
+ const target = rowFields.find((field) => this.#nestedJsonKey(field.getAttribute?.("name")) === key)
1921
+ if (!target) continue
1922
+ this.#seedNestedField(target, source)
1923
+ sources.push(source)
1924
+ }
1925
+ if (clear) for (const source of sources) this.#clearNestedSource(source)
1926
+ return sources[0] ?? null
1927
+ }
1928
+
1929
+ // Copy a source control's value into a cloned-row field, then dispatch a
1930
+ // bubbling `input` (the set-value + dispatch contract, #183). Checkbox ↔
1931
+ // checkbox copies the checked state; every other target takes the source's
1932
+ // submit-shaped value (#nestedFieldValue), so a checkbox source feeding a
1933
+ // text field lands "on"/"" exactly as a submit would.
1934
+ #seedNestedField(target, source) {
1935
+ if (target.type === "checkbox") {
1936
+ target.checked = source.type === "checkbox" ? !!source.checked : this.#nestedFieldValue(source) !== ""
1937
+ } else {
1938
+ target.value = this.#nestedFieldValue(source)
1939
+ }
1940
+ if (typeof target.dispatchEvent === "function") {
1941
+ target.dispatchEvent(new Event("input", { bubbles: true }))
1942
+ }
1943
+ }
1944
+
1945
+ // Reset a source control after a fill-then-add (issue #208), dispatching a
1946
+ // bubbling `input` so dirty tracking / reactive_show / compute see the reset.
1947
+ #clearNestedSource(source) {
1948
+ if (source.type === "checkbox") source.checked = false
1949
+ else source.value = ""
1950
+ if (typeof source.dispatchEvent === "function") {
1951
+ source.dispatchEvent(new Event("input", { bubbles: true }))
1952
+ }
1833
1953
  }
1834
1954
 
1835
1955
  // Remove the trigger's closest row wrapper. A DRAFT row (no [_destroy]
@@ -1857,6 +1977,108 @@ export default class extends Controller {
1857
1977
  } else {
1858
1978
  row.parentNode?.removeChild?.(row)
1859
1979
  }
1980
+
1981
+ // JSON mode (issue #208): the removed (or _destroy-hidden) row must leave
1982
+ // the serialized array too — an absent row IS the removal. Re-sync every
1983
+ // owned JSON-mode list; a form without one iterates an empty set and exits.
1984
+ this.#syncAllNestedJson()
1985
+ }
1986
+
1987
+ // JSON-mode nested rows (issue #208) — the delegated input/change handler.
1988
+ // An app whose controller parses a serialized JSON param instead of Rails'
1989
+ // accepts_nested_attributes_for opts a list into `as: :json`; the client
1990
+ // then mirrors that list's rows into ONE hidden field as a JSON array on
1991
+ // every owned edit. Public so Stimulus can bind it; a no-op unless the
1992
+ // edited field belongs to a JSON-mode list this root owns.
1993
+ syncNestedJson(event) {
1994
+ const target = event?.target
1995
+ if (!target || !this.#ownsField(target)) return
1996
+ // Re-serialize every JSON-mode list (an edit could touch any of them; the
1997
+ // per-list owned-row scan is cheap and keeps this handler association-free).
1998
+ this.#syncAllNestedJson()
1999
+ }
2000
+
2001
+ // Serialize every owned JSON-mode list into its hidden field. The connect
2002
+ // seed and the input/remove re-syncs funnel through here so one place owns
2003
+ // the "DOM rows → JSON field" projection.
2004
+ #syncAllNestedJson() {
2005
+ if (typeof this.element?.querySelectorAll !== "function") return
2006
+ const owns = this.#ownershipFilter()
2007
+ for (const list of [...this.element.querySelectorAll("[data-reactive-nested-json]")].filter(owns)) {
2008
+ this.#syncNestedJson(list.getAttribute("data-reactive-nested-json"))
2009
+ }
2010
+ }
2011
+
2012
+ // Project ONE JSON-mode list's surviving rows into its hidden field. Each
2013
+ // row becomes an object keyed by the trailing bracket segment of its inputs'
2014
+ // names (…[title] → "title"); a hidden/_destroy-marked row is skipped (an
2015
+ // absent row IS the removal — JSON carries no destroy marker). The write
2016
+ // uses the set-value + dispatch contract (issue #183) so dirty tracking,
2017
+ // reactive_show, and compute see the change — but only when the value
2018
+ // actually changed, so a connect seed on an already-correct field is silent.
2019
+ #syncNestedJson(assoc) {
2020
+ const owns = this.#ownershipFilter()
2021
+ const list = [...this.element.querySelectorAll(`[data-reactive-nested-list="${assoc}"]`)].find(owns)
2022
+ if (!list) return
2023
+ const field = this.#nestedJsonField(list)
2024
+ if (!field) return
2025
+
2026
+ const rows = []
2027
+ for (const row of [...(list.querySelectorAll?.("[data-reactive-nested-row]") ?? [])]) {
2028
+ if (!owns(row) || row.hidden) continue
2029
+ rows.push(this.#nestedRowObject(row))
2030
+ }
2031
+
2032
+ const next = JSON.stringify(rows)
2033
+ if (field.value === next) return
2034
+ field.value = next
2035
+ if (typeof field.dispatchEvent === "function") {
2036
+ field.dispatchEvent(new Event("input", { bubbles: true }))
2037
+ }
2038
+ }
2039
+
2040
+ // The hidden field a JSON-mode list mirrors into — resolved fresh (a morph
2041
+ // replaces nodes, never cache it) and OWNED by this root (#15). null when
2042
+ // the selector resolves nothing, so the caller no-ops (a half-built binding
2043
+ // must never break the page).
2044
+ #nestedJsonField(list) {
2045
+ const selector = list.getAttribute?.("data-reactive-nested-json-field")
2046
+ if (!selector) return null
2047
+ const owns = this.#ownershipFilter()
2048
+ return [...this.element.querySelectorAll(selector)].find(owns) ?? null
2049
+ }
2050
+
2051
+ // One row → { key: value } over its named form controls. The JSON key is the
2052
+ // trailing bracket segment of each control's name (order[todos_attributes]
2053
+ // [3][title] → "title"; a bare `title` → "title"), the "infer from input
2054
+ // names" contract. The [_destroy] control is dropped (JSON has no destroy
2055
+ // marker). Later inputs with the same key win (last-wins, the DOM order).
2056
+ #nestedRowObject(row) {
2057
+ const obj = {}
2058
+ for (const el of [...(row.querySelectorAll?.("input, select, textarea") ?? [])]) {
2059
+ const key = this.#nestedJsonKey(el.getAttribute?.("name"))
2060
+ if (key === null || key === "_destroy") continue
2061
+ obj[key] = this.#nestedFieldValue(el)
2062
+ }
2063
+ return obj
2064
+ }
2065
+
2066
+ // The trailing bracket segment of a field name (the inferred JSON key), or
2067
+ // the bare name when it carries no brackets. null for a nameless control
2068
+ // (a bare button, an unnamed helper input) — skipped by the caller.
2069
+ #nestedJsonKey(name) {
2070
+ if (!name) return null
2071
+ const match = name.match(/\[([^\][]+)\]$/)
2072
+ return match ? match[1] : name
2073
+ }
2074
+
2075
+ // A form control's submitted value: an unchecked checkbox contributes "" (it
2076
+ // wouldn't post at all), a checked one its value (default "on"); everything
2077
+ // else its .value. Keeps the JSON shape close to what a real form submit
2078
+ // would carry for the same control.
2079
+ #nestedFieldValue(el) {
2080
+ if (el.type === "checkbox") return el.checked ? (el.value || "on") : ""
2081
+ return el.value ?? ""
1860
2082
  }
1861
2083
 
1862
2084
  // Parse a JSON string list from a root data attr; [] on absence/parse error so
@@ -2876,7 +3098,7 @@ export default class extends Controller {
2876
3098
 
2877
3099
  // The cross-root pass (issue #164) shares the same owned-field memo, so a
2878
3100
  // field driving both an owned binding and an outside target reads once.
2879
- this.#syncShowTargets(owns, values, scope)
3101
+ this.#syncShowTargets(fieldValue)
2880
3102
  }
2881
3103
 
2882
3104
  // Toggle `hidden` (and, when the binding declares data-reactive-show-disable,
@@ -2905,12 +3127,20 @@ export default class extends Controller {
2905
3127
  // unrendered tab pane is normal); a malformed predicate warn-skips its one
2906
3128
  // target while siblings still apply. With no map declared this is one
2907
3129
  // getAttribute and out.
2908
- #syncShowTargets(owns, values, scope) {
3130
+ //
3131
+ // A "#id" KEY (issue #209) is a TARGET-KEYED entry instead: its value is the
3132
+ // same DNF payload data-reactive-show holds, folded with per-term owned-field
3133
+ // reads — the multi-field cross-root case. The "#" prefix routes unambiguously
3134
+ // (a field name may never start with "#"; the Ruby helper raises).
3135
+ #syncShowTargets(fieldValue) {
2909
3136
  const map = this.#parseShowTargets()
2910
3137
  for (const [name, targets] of Object.entries(map)) {
3138
+ if (name.startsWith("#")) {
3139
+ this.#applyConditionsTarget(name, targets, fieldValue)
3140
+ continue
3141
+ }
2911
3142
  if (!targets || typeof targets !== "object" || Array.isArray(targets)) continue
2912
- if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns, scope))
2913
- const value = values.get(name)
3143
+ const value = fieldValue(name)
2914
3144
  if (value === null) continue // no owned field with that name — leave them be
2915
3145
  // Every target's terms share this one field, so a constant resolver folds
2916
3146
  // the group (issue #180): a target's value is a DNF GROUP (terms ANDed).
@@ -2940,6 +3170,28 @@ export default class extends Controller {
2940
3170
  }
2941
3171
  }
2942
3172
 
3173
+ // Apply ONE target-keyed conditions entry (issue #209): "#id" → the DNF
3174
+ // payload { any: [[term,…],…] }, folded by the SAME anyOfAllsMatches as an
3175
+ // in-root reactive_show — each term reads its OWN owned field, a missing
3176
+ // owned field reads as blank (fail-closed, the shared-fixture contract). A
3177
+ // target whose referenced fields are ALL unowned is left alone — the
3178
+ // single-field skip generalized (this root has nothing to evaluate with). A
3179
+ // malformed payload warn-skips its one target while siblings still apply;
3180
+ // the selector guard is the same id-only allowlist as every cross-root arm.
3181
+ #applyConditionsTarget(selector, payload, fieldValue) {
3182
+ if (!guardShowTargetSelector(selector)) return
3183
+ const groups = payload && typeof payload === "object" && !Array.isArray(payload) ? payload.any : null
3184
+ const fields = dnfGroupFields(groups)
3185
+ if (fields === null) {
3186
+ console.warn(`[phlex-reactive] malformed reactive_show_targets conditions for ${selector} — skipped`)
3187
+ return
3188
+ }
3189
+ if (fields.every((name) => fieldValue(name) === null)) return // no owned field — leave it be
3190
+ const match = anyOfAllsMatches(groups, fieldValue)
3191
+ if (match === null) return // unreachable after dnfGroupFields, kept fail-closed
3192
+ for (const node of document.querySelectorAll(selector)) node.hidden = !match
3193
+ }
3194
+
2943
3195
  // The declared cross-root show-target map (issue #164): a JSON object of
2944
3196
  // { field: { "#id": predicate } } from data-reactive-show-targets (emitted
2945
3197
  // by reactive_show_targets on the root). Absent degrades to {}; malformed
@@ -3099,6 +3351,15 @@ export default class extends Controller {
3099
3351
  return !!this.element.getAttribute?.("data-reactive-tags-field")
3100
3352
  }
3101
3353
 
3354
+ // Whether this root owns at least one JSON-mode nested list (issue #208) —
3355
+ // the connect() gate. A cheap descendant probe: a form without one wires no
3356
+ // input/change listeners. Ownership is re-checked per sync, so a stray match
3357
+ // in a nested reactive root here is harmless (it just arms the listeners).
3358
+ #nestedJsonEnabled() {
3359
+ if (typeof this.element?.querySelector !== "function") return false
3360
+ return !!this.element.querySelector("[data-reactive-nested-json]")
3361
+ }
3362
+
3102
3363
  // The hidden input storing the comma-joined value, resolved fresh per use
3103
3364
  // (a morph replaces nodes — never cache it) and OWNED by this root (issue
3104
3365
  // #15). null when the selector resolves nothing — every caller then no-ops:
@@ -3287,6 +3548,21 @@ export default class extends Controller {
3287
3548
  this.#boundSyncTags = undefined
3288
3549
  }
3289
3550
 
3551
+ // Remove the JSON-mode nested-rows listeners on disconnect (issue #208), so a
3552
+ // stray input/change/morph after the element leaves the DOM never re-syncs
3553
+ // against a detached root.
3554
+ #teardownNestedJsonSync() {
3555
+ if (this.#boundSyncNestedJson) {
3556
+ this.element.removeEventListener?.("input", this.#boundSyncNestedJson)
3557
+ this.element.removeEventListener?.("change", this.#boundSyncNestedJson)
3558
+ this.#boundSyncNestedJson = undefined
3559
+ }
3560
+ if (this.#boundSeedNestedJson) {
3561
+ this.element.removeEventListener?.("turbo:morph-element", this.#boundSeedNestedJson)
3562
+ this.#boundSeedNestedJson = undefined
3563
+ }
3564
+ }
3565
+
3290
3566
  // Remove the compute seed morph listener on disconnect (issue #199), so a
3291
3567
  // stray turbo:morph-element after the element leaves the DOM never re-seeds
3292
3568
  // against a detached root.