phlex-reactive 0.11.5 → 0.11.6

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: 5c5d8ca344fdd9583bd79ad6e9b162c7a1888d48055dfb36474a3cbc6caf10fe
4
+ data.tar.gz: c903ed3e437da570c06924055d0e1ad1c9c611c0086431ae8c7d38fc000f8337
5
5
  SHA512:
6
- metadata.gz: 311c87cf131ec0db83082089133b32dcc0583aa49203950ff59495929a2b512d72bedff7ced52747b2074bbce16a6b32eac99249e2b985cb19acfb9afce3a1ab
7
- data.tar.gz: 5856533fe3e2207246082a56f6709d732f32db50b8a8c6f1e2f3c8bd74b0f5b2c2330440e0c2b4c9bc078d785d33b85e95ae22f7edc91f746df959f86819fa37
6
+ metadata.gz: 749fdfd67d453f51c527eb8e058e5b062d04431d3f099aa35a8b9135a38bd2fdc7baa7251eb0ed7d25775bcb6be7f3370958301a0838d89d7893a37f6dd1306b
7
+ data.tar.gz: 6322de1f369d12c6aff0f187749d4a2f6f090939cce2fb4f4389c9b1a6b7ff1cce3de7d90b4b969706c0997c5d481d8816f45d728dded107445a7455a337f640
data/CHANGELOG.md CHANGED
@@ -8,6 +8,38 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **Multi-field cross-root targets — `reactive_show_targets` speaks the full
12
+ conditions language (#209).** A `"#id"` KEY now takes an `if:`/`if_any:`/
13
+ `unless:` conditions Hash, so an OUTSIDE element can show/hide from a
14
+ COMBINATION of owned fields — `reactive_show_targets("#trade-warning" =>
15
+ { if: { type: "trade", price: ..0 } })` — the last case that forced a
16
+ bespoke two-field JS listener on otherwise-declarative forms. The client
17
+ folds the target's DNF payload with the SAME per-term owned-field reads as
18
+ an in-root `reactive_show` (missing owned field reads blank, fail-closed);
19
+ a target whose referenced fields are ALL unowned is left alone (the
20
+ single-field skip, generalized). Target-keyed and field-keyed entries mix
21
+ in the ONE call per root (a field name may never start with `#`, so the
22
+ key routes unambiguously); existing field-keyed calls emit a byte-identical
23
+ wire. Declare-time validation stays loud: a bare value, unknown keys, or an
24
+ empty conditions Hash under a `"#id"` key raises a guided `ArgumentError`.
25
+
26
+ - **JSON mode for draft nested rows — `reactive_nested_list(:assoc, as: :json)` (#208).**
27
+ The draft-rows primitive (#208) posts standard Rails
28
+ `accepts_nested_attributes_for` names. But many apps persist a "new parent +
29
+ child rows" form by serializing the rows into ONE hidden field and
30
+ `JSON.parse`-ing it in the controller — adopting the primitive meant rewiring
31
+ that persistence path. `as: :json` removes the choice: the list keeps the same
32
+ `<template>` row, `nested_field_name`, and add/remove triggers, but the generic
33
+ controller now mirrors the surviving rows into a single hidden field as a JSON
34
+ array on every add / remove / keystroke (the set-value + dispatch contract, so
35
+ dirty tracking and compute still see it), inferring each JSON key from the
36
+ trailing bracket segment of a row input's name
37
+ (`order[line_items_attributes][3][quantity]` → `"quantity"`). A removed row
38
+ leaves the array (an absent row IS the removal — JSON has no `_destroy`
39
+ marker). The default (`:attributes`) is unchanged, so existing draft forms are
40
+ byte-identical. See the "Draft rows for a new parent" README section and the
41
+ `/docs/example-draft-rows` page.
42
+
11
43
  - **Turnkey APM adapters — `Phlex::Reactive.apm = :appsignal` (#207).** The gem
12
44
  already emits `*.phlex_reactive` `ActiveSupport::Notifications` events (#107), but
13
45
  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,7 +396,7 @@ 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_). |
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
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). |
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). |
@@ -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,36 @@ 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
+
1337
1384
  Relatedly, a **draft parent can now run real server actions too** (issue #208):
1338
1385
  an unsaved record signs a gid-less `{c, state}` token, and the endpoint rebuilds
1339
1386
  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)
@@ -1830,6 +1871,11 @@ export default class extends Controller {
1830
1871
  list.appendChild(row)
1831
1872
  const first = [...(row.querySelectorAll?.("input, select, textarea") ?? [])][0]
1832
1873
  first?.focus?.()
1874
+
1875
+ // JSON mode (issue #208): a freshly-added empty row must land in the hidden
1876
+ // field immediately, so the serialized array reflects the DOM even before
1877
+ // the first keystroke. A no-op when this list isn't `as: :json`.
1878
+ if (list.getAttribute?.("data-reactive-nested-json") === assoc) this.#syncNestedJson(assoc)
1833
1879
  }
1834
1880
 
1835
1881
  // Remove the trigger's closest row wrapper. A DRAFT row (no [_destroy]
@@ -1857,6 +1903,108 @@ export default class extends Controller {
1857
1903
  } else {
1858
1904
  row.parentNode?.removeChild?.(row)
1859
1905
  }
1906
+
1907
+ // JSON mode (issue #208): the removed (or _destroy-hidden) row must leave
1908
+ // the serialized array too — an absent row IS the removal. Re-sync every
1909
+ // owned JSON-mode list; a form without one iterates an empty set and exits.
1910
+ this.#syncAllNestedJson()
1911
+ }
1912
+
1913
+ // JSON-mode nested rows (issue #208) — the delegated input/change handler.
1914
+ // An app whose controller parses a serialized JSON param instead of Rails'
1915
+ // accepts_nested_attributes_for opts a list into `as: :json`; the client
1916
+ // then mirrors that list's rows into ONE hidden field as a JSON array on
1917
+ // every owned edit. Public so Stimulus can bind it; a no-op unless the
1918
+ // edited field belongs to a JSON-mode list this root owns.
1919
+ syncNestedJson(event) {
1920
+ const target = event?.target
1921
+ if (!target || !this.#ownsField(target)) return
1922
+ // Re-serialize every JSON-mode list (an edit could touch any of them; the
1923
+ // per-list owned-row scan is cheap and keeps this handler association-free).
1924
+ this.#syncAllNestedJson()
1925
+ }
1926
+
1927
+ // Serialize every owned JSON-mode list into its hidden field. The connect
1928
+ // seed and the input/remove re-syncs funnel through here so one place owns
1929
+ // the "DOM rows → JSON field" projection.
1930
+ #syncAllNestedJson() {
1931
+ if (typeof this.element?.querySelectorAll !== "function") return
1932
+ const owns = this.#ownershipFilter()
1933
+ for (const list of [...this.element.querySelectorAll("[data-reactive-nested-json]")].filter(owns)) {
1934
+ this.#syncNestedJson(list.getAttribute("data-reactive-nested-json"))
1935
+ }
1936
+ }
1937
+
1938
+ // Project ONE JSON-mode list's surviving rows into its hidden field. Each
1939
+ // row becomes an object keyed by the trailing bracket segment of its inputs'
1940
+ // names (…[title] → "title"); a hidden/_destroy-marked row is skipped (an
1941
+ // absent row IS the removal — JSON carries no destroy marker). The write
1942
+ // uses the set-value + dispatch contract (issue #183) so dirty tracking,
1943
+ // reactive_show, and compute see the change — but only when the value
1944
+ // actually changed, so a connect seed on an already-correct field is silent.
1945
+ #syncNestedJson(assoc) {
1946
+ const owns = this.#ownershipFilter()
1947
+ const list = [...this.element.querySelectorAll(`[data-reactive-nested-list="${assoc}"]`)].find(owns)
1948
+ if (!list) return
1949
+ const field = this.#nestedJsonField(list)
1950
+ if (!field) return
1951
+
1952
+ const rows = []
1953
+ for (const row of [...(list.querySelectorAll?.("[data-reactive-nested-row]") ?? [])]) {
1954
+ if (!owns(row) || row.hidden) continue
1955
+ rows.push(this.#nestedRowObject(row))
1956
+ }
1957
+
1958
+ const next = JSON.stringify(rows)
1959
+ if (field.value === next) return
1960
+ field.value = next
1961
+ if (typeof field.dispatchEvent === "function") {
1962
+ field.dispatchEvent(new Event("input", { bubbles: true }))
1963
+ }
1964
+ }
1965
+
1966
+ // The hidden field a JSON-mode list mirrors into — resolved fresh (a morph
1967
+ // replaces nodes, never cache it) and OWNED by this root (#15). null when
1968
+ // the selector resolves nothing, so the caller no-ops (a half-built binding
1969
+ // must never break the page).
1970
+ #nestedJsonField(list) {
1971
+ const selector = list.getAttribute?.("data-reactive-nested-json-field")
1972
+ if (!selector) return null
1973
+ const owns = this.#ownershipFilter()
1974
+ return [...this.element.querySelectorAll(selector)].find(owns) ?? null
1975
+ }
1976
+
1977
+ // One row → { key: value } over its named form controls. The JSON key is the
1978
+ // trailing bracket segment of each control's name (order[todos_attributes]
1979
+ // [3][title] → "title"; a bare `title` → "title"), the "infer from input
1980
+ // names" contract. The [_destroy] control is dropped (JSON has no destroy
1981
+ // marker). Later inputs with the same key win (last-wins, the DOM order).
1982
+ #nestedRowObject(row) {
1983
+ const obj = {}
1984
+ for (const el of [...(row.querySelectorAll?.("input, select, textarea") ?? [])]) {
1985
+ const key = this.#nestedJsonKey(el.getAttribute?.("name"))
1986
+ if (key === null || key === "_destroy") continue
1987
+ obj[key] = this.#nestedFieldValue(el)
1988
+ }
1989
+ return obj
1990
+ }
1991
+
1992
+ // The trailing bracket segment of a field name (the inferred JSON key), or
1993
+ // the bare name when it carries no brackets. null for a nameless control
1994
+ // (a bare button, an unnamed helper input) — skipped by the caller.
1995
+ #nestedJsonKey(name) {
1996
+ if (!name) return null
1997
+ const match = name.match(/\[([^\][]+)\]$/)
1998
+ return match ? match[1] : name
1999
+ }
2000
+
2001
+ // A form control's submitted value: an unchecked checkbox contributes "" (it
2002
+ // wouldn't post at all), a checked one its value (default "on"); everything
2003
+ // else its .value. Keeps the JSON shape close to what a real form submit
2004
+ // would carry for the same control.
2005
+ #nestedFieldValue(el) {
2006
+ if (el.type === "checkbox") return el.checked ? (el.value || "on") : ""
2007
+ return el.value ?? ""
1860
2008
  }
1861
2009
 
1862
2010
  // Parse a JSON string list from a root data attr; [] on absence/parse error so
@@ -2876,7 +3024,7 @@ export default class extends Controller {
2876
3024
 
2877
3025
  // The cross-root pass (issue #164) shares the same owned-field memo, so a
2878
3026
  // field driving both an owned binding and an outside target reads once.
2879
- this.#syncShowTargets(owns, values, scope)
3027
+ this.#syncShowTargets(fieldValue)
2880
3028
  }
2881
3029
 
2882
3030
  // Toggle `hidden` (and, when the binding declares data-reactive-show-disable,
@@ -2905,12 +3053,20 @@ export default class extends Controller {
2905
3053
  // unrendered tab pane is normal); a malformed predicate warn-skips its one
2906
3054
  // target while siblings still apply. With no map declared this is one
2907
3055
  // getAttribute and out.
2908
- #syncShowTargets(owns, values, scope) {
3056
+ //
3057
+ // A "#id" KEY (issue #209) is a TARGET-KEYED entry instead: its value is the
3058
+ // same DNF payload data-reactive-show holds, folded with per-term owned-field
3059
+ // reads — the multi-field cross-root case. The "#" prefix routes unambiguously
3060
+ // (a field name may never start with "#"; the Ruby helper raises).
3061
+ #syncShowTargets(fieldValue) {
2909
3062
  const map = this.#parseShowTargets()
2910
3063
  for (const [name, targets] of Object.entries(map)) {
3064
+ if (name.startsWith("#")) {
3065
+ this.#applyConditionsTarget(name, targets, fieldValue)
3066
+ continue
3067
+ }
2911
3068
  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)
3069
+ const value = fieldValue(name)
2914
3070
  if (value === null) continue // no owned field with that name — leave them be
2915
3071
  // Every target's terms share this one field, so a constant resolver folds
2916
3072
  // the group (issue #180): a target's value is a DNF GROUP (terms ANDed).
@@ -2940,6 +3096,28 @@ export default class extends Controller {
2940
3096
  }
2941
3097
  }
2942
3098
 
3099
+ // Apply ONE target-keyed conditions entry (issue #209): "#id" → the DNF
3100
+ // payload { any: [[term,…],…] }, folded by the SAME anyOfAllsMatches as an
3101
+ // in-root reactive_show — each term reads its OWN owned field, a missing
3102
+ // owned field reads as blank (fail-closed, the shared-fixture contract). A
3103
+ // target whose referenced fields are ALL unowned is left alone — the
3104
+ // single-field skip generalized (this root has nothing to evaluate with). A
3105
+ // malformed payload warn-skips its one target while siblings still apply;
3106
+ // the selector guard is the same id-only allowlist as every cross-root arm.
3107
+ #applyConditionsTarget(selector, payload, fieldValue) {
3108
+ if (!guardShowTargetSelector(selector)) return
3109
+ const groups = payload && typeof payload === "object" && !Array.isArray(payload) ? payload.any : null
3110
+ const fields = dnfGroupFields(groups)
3111
+ if (fields === null) {
3112
+ console.warn(`[phlex-reactive] malformed reactive_show_targets conditions for ${selector} — skipped`)
3113
+ return
3114
+ }
3115
+ if (fields.every((name) => fieldValue(name) === null)) return // no owned field — leave it be
3116
+ const match = anyOfAllsMatches(groups, fieldValue)
3117
+ if (match === null) return // unreachable after dnfGroupFields, kept fail-closed
3118
+ for (const node of document.querySelectorAll(selector)) node.hidden = !match
3119
+ }
3120
+
2943
3121
  // The declared cross-root show-target map (issue #164): a JSON object of
2944
3122
  // { field: { "#id": predicate } } from data-reactive-show-targets (emitted
2945
3123
  // by reactive_show_targets on the root). Absent degrades to {}; malformed
@@ -3099,6 +3277,15 @@ export default class extends Controller {
3099
3277
  return !!this.element.getAttribute?.("data-reactive-tags-field")
3100
3278
  }
3101
3279
 
3280
+ // Whether this root owns at least one JSON-mode nested list (issue #208) —
3281
+ // the connect() gate. A cheap descendant probe: a form without one wires no
3282
+ // input/change listeners. Ownership is re-checked per sync, so a stray match
3283
+ // in a nested reactive root here is harmless (it just arms the listeners).
3284
+ #nestedJsonEnabled() {
3285
+ if (typeof this.element?.querySelector !== "function") return false
3286
+ return !!this.element.querySelector("[data-reactive-nested-json]")
3287
+ }
3288
+
3102
3289
  // The hidden input storing the comma-joined value, resolved fresh per use
3103
3290
  // (a morph replaces nodes — never cache it) and OWNED by this root (issue
3104
3291
  // #15). null when the selector resolves nothing — every caller then no-ops:
@@ -3287,6 +3474,21 @@ export default class extends Controller {
3287
3474
  this.#boundSyncTags = undefined
3288
3475
  }
3289
3476
 
3477
+ // Remove the JSON-mode nested-rows listeners on disconnect (issue #208), so a
3478
+ // stray input/change/morph after the element leaves the DOM never re-syncs
3479
+ // against a detached root.
3480
+ #teardownNestedJsonSync() {
3481
+ if (this.#boundSyncNestedJson) {
3482
+ this.element.removeEventListener?.("input", this.#boundSyncNestedJson)
3483
+ this.element.removeEventListener?.("change", this.#boundSyncNestedJson)
3484
+ this.#boundSyncNestedJson = undefined
3485
+ }
3486
+ if (this.#boundSeedNestedJson) {
3487
+ this.element.removeEventListener?.("turbo:morph-element", this.#boundSeedNestedJson)
3488
+ this.#boundSeedNestedJson = undefined
3489
+ }
3490
+ }
3491
+
3290
3492
  // Remove the compute seed morph listener on disconnect (issue #199), so a
3291
3493
  // stray turbo:morph-element after the element leaves the DOM never re-seeds
3292
3494
  // against a detached root.