phlex-reactive 0.12.2 → 0.12.4

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.
@@ -1047,9 +1047,11 @@ function runTransition(el, transition, flip) {
1047
1047
  // Phlex::Reactive::JS's vocabulary; an op name not in this map is
1048
1048
  // warn-and-skipped by #applyOps (client-side default-deny — a stale or newer
1049
1049
  // ops attr must never break the page). Each op is a pure, local DOM mutation:
1050
- // nothing is read back, nothing is sent anywhere. Frozen so nothing can be
1051
- // registered into it at runtime extending the vocabulary is a gem change,
1052
- // not an app hook.
1050
+ // nothing is sent anywhere, and nothing is read back with ONE deliberate
1051
+ // exception, paste_into (issue #228), which reads the clipboard behind the
1052
+ // browser's own gesture + permission gates and still only writes locally.
1053
+ // Frozen so nothing can be registered into it at runtime — extending the
1054
+ // vocabulary is a gem change, not an app hook.
1053
1055
  const CLIENT_OPS = Object.freeze({
1054
1056
  show: (el, args) => setHidden(el, false, args),
1055
1057
  hide: (el, args) => setHidden(el, true, args),
@@ -1092,8 +1094,69 @@ const CLIENT_OPS = Object.freeze({
1092
1094
  dispatch: (el, args) => {
1093
1095
  el.dispatchEvent(new CustomEvent(args.name, { bubbles: true, composed: true, detail: args.detail ?? {} }))
1094
1096
  },
1097
+
1098
+ // Submit the target's OWN form (issue #226) via requestSubmit() — constraint
1099
+ // validation runs and a REAL cancelable `submit` event fires, so an
1100
+ // on(:action, event: "submit") interception or a native/Turbo form handles it
1101
+ // exactly like a user submit. No form → no-op. ACTOR-ONLY like focus: the
1102
+ // broadcast builder refuses it server-side (BROADCAST_REFUSED_OPS).
1103
+ submit: (el) => submitFormFor(el)?.requestSubmit?.(),
1104
+
1105
+ // Clipboard-source paste (issue #228): on a user gesture, read
1106
+ // navigator.clipboard.readText() and feed the text into the target field
1107
+ // through the normal input pipeline — exactly what a native Cmd/Ctrl+V does.
1108
+ // The ONE op that reads a browser API and is async: fire-and-forget, so
1109
+ // applyOps stays sync and chain siblings never wait (the runTransition
1110
+ // posture). A rejected/dismissed read, empty text, or a missing API is a
1111
+ // SILENT no-op — page state must not change. ACTOR-ONLY like focus/submit:
1112
+ // the broadcast builder refuses it server-side (BROADCAST_REFUSED_OPS) —
1113
+ // and that server gate is the REAL one. The browser only partially backs it
1114
+ // up: Safari gates every read on a fresh gesture and Firefox shows its
1115
+ // paste picker per read, but Chromium's clipboard-read is a PERSISTENT
1116
+ // per-origin permission — once granted (the legit paste button itself
1117
+ // induces that), readText() succeeds with no gesture. No client-side
1118
+ // refusal is possible here: the reactive:js interpreter cannot distinguish
1119
+ // an actor reply's stream from a broadcast's, and reply.js legitimately
1120
+ // carries this op.
1121
+ paste_into: (el) => pasteClipboardInto(el),
1095
1122
  })
1096
1123
 
1124
+ // The form a submit op commits (issue #226), in order: the target itself when
1125
+ // it IS a form (tagName, not instanceof — fake-node/test friendly), its form
1126
+ // owner for a control (input.form — honors a form= attribute), else the nearest
1127
+ // ancestor form. closest() may cross the component root by design — the
1128
+ // FIELD'S OWN form is the submit scope, not the reactive boundary.
1129
+ function submitFormFor(el) {
1130
+ if (el?.tagName === "FORM") return el
1131
+ return el?.form ?? el?.closest?.("form") ?? null
1132
+ }
1133
+
1134
+ // Read the clipboard into a field (issue #228) — the body of the paste_into
1135
+ // op. The write mirrors a native paste: set .value, dispatch a bubbling
1136
+ // `input` event (the set-value + dispatch contract, issue #183 — compute
1137
+ // reducers, show bindings, and on_complete all run exactly as if the user
1138
+ // had typed), then focus (the caret lands where the user continues typing on
1139
+ // a partial paste). Availability-guarded: insecure contexts and some
1140
+ // webviews have no navigator.clipboard — the connect()-time gate hides
1141
+ // marked triggers there, so this guard is belt-and-braces. Empty text is a
1142
+ // no-op: "paste nothing" must not clear a half-typed field.
1143
+ function pasteClipboardInto(field) {
1144
+ const clipboard = globalThis.navigator?.clipboard
1145
+ if (typeof clipboard?.readText !== "function") return
1146
+ clipboard
1147
+ .readText()
1148
+ .then((text) => {
1149
+ if (!text) return
1150
+ field.value = text
1151
+ if (typeof field.dispatchEvent === "function") field.dispatchEvent(new Event("input", { bubbles: true }))
1152
+ field.focus?.()
1153
+ })
1154
+ .catch(() => {
1155
+ // Permission denied or the prompt dismissed — the browser's own UX said
1156
+ // no. The issue-#228 contract: a silent no-op, never an error.
1157
+ })
1158
+ }
1159
+
1097
1160
  // Apply a hidden-flag change, optionally animated by a [during, from, to]
1098
1161
  // transition (issue #96). Split out so show/hide/toggle share it.
1099
1162
  function setHidden(el, hidden, args) {
@@ -1165,6 +1228,39 @@ function showBindingMatches(el, value) {
1165
1228
  // present. Each coerces BOTH sides to Number and compares.
1166
1229
  const SHOW_NUMERIC_KEYS = ["gte", "gt", "lte", "lt"]
1167
1230
 
1231
+ // The length predicate keys (issue #226) — the client half of Ruby's
1232
+ // ShowConditions::LENGTH_KEYS. Length is counted in CODEPOINTS
1233
+ // ([...str].length), NOT UTF-16 code units (str.length), so Ruby's
1234
+ // String#length and this evaluator agree on multibyte values — the shared
1235
+ // fixture's emoji vector proves it.
1236
+ const SHOW_LENGTH_KEYS = ["len_eq", "len_gte", "len_gt", "len_lte", "len_lt"]
1237
+
1238
+ // Evaluate one length predicate. Length is a TOTAL function (blank/absent →
1239
+ // 0), so every field value is decidable — no fail-closed special case like the
1240
+ // numeric thresholds ({ length: 0 } legitimately matches a blank field). A
1241
+ // non-Integer LITERAL is a malformed binding — warn-skip (null), default-deny.
1242
+ function lengthPredicateMatches(key, literal, value) {
1243
+ if (!Number.isInteger(literal)) {
1244
+ console.warn(`[phlex-reactive] reactive_show ${key}: needs an integer literal, got ${JSON.stringify(literal)} — skipped`)
1245
+ return null
1246
+ }
1247
+ const length = [...String(value ?? "")].length
1248
+ switch (key) {
1249
+ case "len_eq":
1250
+ return length === literal
1251
+ case "len_gte":
1252
+ return length >= literal
1253
+ case "len_gt":
1254
+ return length > literal
1255
+ case "len_lte":
1256
+ return length <= literal
1257
+ case "len_lt":
1258
+ return length < literal
1259
+ default:
1260
+ return null
1261
+ }
1262
+ }
1263
+
1168
1264
  // Evaluate one numeric threshold predicate against a field value. Returns
1169
1265
  // true/false for a decidable comparison, or null when the LITERAL itself is
1170
1266
  // non-numeric (a malformed binding — warn-skip, default-deny). A non-numeric
@@ -1218,6 +1314,10 @@ function showPredicateMatches(pred, value) {
1218
1314
  for (const key of SHOW_NUMERIC_KEYS) {
1219
1315
  if (key in pred) return numericPredicateMatches(key, pred[key], value)
1220
1316
  }
1317
+ // Length predicates (issue #226): codepoint count vs an Integer literal.
1318
+ for (const key of SHOW_LENGTH_KEYS) {
1319
+ if (key in pred) return lengthPredicateMatches(key, pred[key], value)
1320
+ }
1221
1321
  return null
1222
1322
  }
1223
1323
 
@@ -1242,6 +1342,27 @@ function parseShowCompound(raw) {
1242
1342
  return null
1243
1343
  }
1244
1344
 
1345
+ // Parse a data-reactive-on-complete payload (issue #226): a JSON array of
1346
+ // { any: [[term, …], …], ops: [[op, args], …] } bindings. Malformed JSON, a
1347
+ // non-array, or a binding missing either half degrades to [] WITH a warn — a
1348
+ // bad payload must never throw or fire an op (client-side default-deny, the
1349
+ // parseShowCompound posture).
1350
+ function parseOnComplete(raw) {
1351
+ try {
1352
+ const list = JSON.parse(raw)
1353
+ if (
1354
+ Array.isArray(list) &&
1355
+ list.every((b) => b && typeof b === "object" && Array.isArray(b.any) && Array.isArray(b.ops))
1356
+ ) {
1357
+ return list
1358
+ }
1359
+ } catch {
1360
+ // fall through to the warn
1361
+ }
1362
+ console.warn(`[phlex-reactive] malformed reactive_on_complete payload ${JSON.stringify(raw)} — skipped`)
1363
+ return []
1364
+ }
1365
+
1245
1366
  // Evaluate one DNF TERM against a resolved field value (issue #180). A missing
1246
1367
  // field (null) or a malformed/unknown predicate folds to FALSE — fail-closed
1247
1368
  // (default-deny): a broken AND term can't pass, a broken OR term can't reveal.
@@ -1348,6 +1469,20 @@ function parseOps(raw) {
1348
1469
  }
1349
1470
  }
1350
1471
 
1472
+ // Normalize a reducer's reserved $ops output (issue #226): the compute `ops`
1473
+ // builder (its .ops list), a raw [[name, args], ...] array, or null/undefined
1474
+ // (no effect this pass). An EMPTY list is the same as null — "nothing to run"
1475
+ // must not latch the rising edge. Anything else warns + is dropped
1476
+ // (default-deny, like every other malformed op source). Reducers are trusted
1477
+ // app code, but their ops still run through the frozen CLIENT_OPS whitelist.
1478
+ function computeOpsList(raw) {
1479
+ if (raw == null) return null
1480
+ const list = Array.isArray(raw) ? raw : raw.ops
1481
+ if (Array.isArray(list)) return list.length > 0 ? list : null
1482
+ console.warn("[phlex-reactive] $ops must be an ops chain or a [[op, args], ...] list — skipped")
1483
+ return null
1484
+ }
1485
+
1351
1486
  // Interpret a [[name, args], ...] op list against the frozen CLIENT_OPS
1352
1487
  // whitelist (issues #95/#96/#97). `resolveTargets(args)` returns the element(s)
1353
1488
  // an op applies to — the controller scopes to its root (excluding nested
@@ -1416,6 +1551,13 @@ export default class extends Controller {
1416
1551
  // (single-pass write set). Per-instance, so another root's events are never
1417
1552
  // swallowed. WeakSet: entries drop when the short-lived Event is GC'd.
1418
1553
  #computeSelfDispatched = new WeakSet()
1554
+ // Issue #226: the serialized $ops chain of the LAST reducer pass (null when
1555
+ // absent) — the rising-edge latch, keyed on CONTENT: an identical chain
1556
+ // never re-fires (a capped extra keystroke can't re-submit), while a chain
1557
+ // that CHANGED fires again (a multi-box reducer advancing focus box-by-box
1558
+ // emits a different focus target per digit). The seed pass arms this
1559
+ // without firing.
1560
+ #computeOpsSignature = null
1419
1561
  // Dirty tracking (issue #103): the bound re-scan (turbo:morph-element) and the
1420
1562
  // navigate-away guard handlers, held so disconnect() can remove exactly them.
1421
1563
  #boundScanDirty
@@ -1424,6 +1566,15 @@ export default class extends Controller {
1424
1566
  // Show bindings (issue #161): the ONE delegated sync handler shared by the
1425
1567
  // root's input/change/turbo:morph-element listeners, held for teardown.
1426
1568
  #boundSyncShow
1569
+ // Completion bindings (issue #226): the delegated gesture handler + the
1570
+ // no-gesture morph arm, held for teardown; the per-binding rising-edge
1571
+ // latches; and the raw-attr memo that re-parses (and resets the latches)
1572
+ // when a morph rewrites the payload.
1573
+ #boundSyncOnComplete
1574
+ #boundArmOnComplete
1575
+ #onCompleteRaw
1576
+ #onCompleteParsed
1577
+ #onCompleteStates
1427
1578
  // Option filtering (issue #163): the ONE delegated sync handler shared by the
1428
1579
  // root's input/turbo:morph-element listeners, held for teardown.
1429
1580
  #boundSyncFilter
@@ -1449,6 +1600,9 @@ export default class extends Controller {
1449
1600
  // Lazy initial mount (issue #165): the bound re-probe attached to
1450
1601
  // turbo:morph-element so a Turbo page-refresh morph re-fires the defer fetch.
1451
1602
  #boundProbeLazyDefer
1603
+ // Clipboard-trigger availability gate (issue #228): the bound morph re-sync,
1604
+ // held for teardown.
1605
+ #boundSyncClipboard
1452
1606
 
1453
1607
  // Mark that a reactive controller actually connected, so the registration
1454
1608
  // guard above knows the controller was registered (issue #26 part 2).
@@ -1532,6 +1686,26 @@ export default class extends Controller {
1532
1686
  this.#syncShow()
1533
1687
  }
1534
1688
 
1689
+ // Completion bindings (issue #226) — ONLY when the root declares
1690
+ // data-reactive-on-complete (the show/filter gate precedent). ONE
1691
+ // delegated input+change listener evaluates every binding's DNF over the
1692
+ // owned fields and runs its ops on the RISING EDGE — the event-driven
1693
+ // flip to true. The connect pass ARMS without firing (a fresh render with
1694
+ // already-satisfied conditions must never self-fire — the $ops seed
1695
+ // precedent), and turbo:morph-element re-arms the same way after an
1696
+ // in-place morph. Listeners added HERE run after the Stimulus-wired
1697
+ // recompute delegation for the same event, so the evaluation reads
1698
+ // compute-NORMALIZED values (and a compute output write dispatches a real
1699
+ // input event that re-evaluates anyway).
1700
+ if (this.#onCompleteEnabled()) {
1701
+ this.#boundSyncOnComplete = (event) => this.#syncOnComplete(event)
1702
+ this.#boundArmOnComplete = () => this.#syncOnComplete(null)
1703
+ this.element.addEventListener?.("input", this.#boundSyncOnComplete)
1704
+ this.element.addEventListener?.("change", this.#boundSyncOnComplete)
1705
+ this.element.addEventListener?.("turbo:morph-element", this.#boundArmOnComplete)
1706
+ this.#syncOnComplete(null)
1707
+ }
1708
+
1535
1709
  // Option filtering (issue #163) — ONLY when the root declares the binding
1536
1710
  // (reactive_filter emits both attrs together), so a component without one
1537
1711
  // pays two attribute reads. ONE delegated input listener on the root — the
@@ -1610,6 +1784,24 @@ export default class extends Controller {
1610
1784
  this.element.addEventListener?.("turbo:morph-element", this.#boundSeedCompute)
1611
1785
  this.recompute()
1612
1786
  }
1787
+
1788
+ // Clipboard-trigger availability gate (issue #228) — ONLY when this root
1789
+ // owns a paste trigger (on_client marks one with data-reactive-clipboard),
1790
+ // so every other component pays a single probe (the show/filter/tags gate
1791
+ // precedent). The Async Clipboard API is absent in insecure contexts and
1792
+ // some webviews; a paste button that can never work must not show. The
1793
+ // gate OWNS a marked trigger's `hidden` flag: author the trigger `hidden`
1794
+ // and this pass reveals it where the API exists (a dead button never
1795
+ // paints); turbo:morph-element re-syncs because a morph rewrites the
1796
+ // trigger back to its authored hidden state. Like every sibling gate the
1797
+ // decision is made ONCE at connect — render the paste trigger
1798
+ // unconditionally: a trigger first INTRODUCED by a later morph stays
1799
+ // ungated (hidden) until a full replace re-connects the controller.
1800
+ if (this.#clipboardGateEnabled()) {
1801
+ this.#boundSyncClipboard = () => this.#syncClipboardTriggers()
1802
+ this.element.addEventListener?.("turbo:morph-element", this.#boundSyncClipboard)
1803
+ this.#syncClipboardTriggers()
1804
+ }
1613
1805
  }
1614
1806
 
1615
1807
  // Whether this root opts into dirty tracking (issue #103): track_dirty: puts the
@@ -1635,10 +1827,12 @@ export default class extends Controller {
1635
1827
  this.#clearAllThrottles()
1636
1828
  this.#teardownDirtyTracking()
1637
1829
  this.#teardownShowSync()
1830
+ this.#teardownOnCompleteSync()
1638
1831
  this.#teardownFilterSync()
1639
1832
  this.#teardownTagsSync()
1640
1833
  this.#teardownNestedJsonSync()
1641
1834
  this.#teardownComputeSeed()
1835
+ this.#teardownClipboardGate()
1642
1836
  if (this.#boundProbeLazyDefer) {
1643
1837
  this.element.removeEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
1644
1838
  }
@@ -1928,7 +2122,12 @@ export default class extends Controller {
1928
2122
  // snapshot above (issue #183): its result drives the whole single-pass write.
1929
2123
  const result = reduce(values, { changed: this.#changedComputeField(event, inputs, scope) }) || {}
1930
2124
 
1931
- // Issue #183 SINGLE-PASS WRITE SET. Three ordered phases, so declared output
2125
+ // The reserved $ops output (issue #226) is CONSUMED here normalized once,
2126
+ // then excluded from every write phase below (it is an op chain, never a
2127
+ // field value, text sink, or mirror), and applied as phase 4.
2128
+ const reducerOps = computeOpsList(result.$ops)
2129
+
2130
+ // Issue #183 — SINGLE-PASS WRITE SET. Ordered phases, so declared output
1932
2131
  // order stops being semantics and a wrong order can no longer corrupt values:
1933
2132
  //
1934
2133
  // 1. BATCH the field writes from the ONE result. Each output name in the
@@ -1942,9 +2141,12 @@ export default class extends Controller {
1942
2141
  // per-instance WeakSet) makes recompute skip re-running the reducer for our
1943
2142
  // own write, while the event still fires every OTHER listener (chained
1944
2143
  // repaint, dirty tracking, show bindings, sibling roots).
2144
+ // 4. RUN the reducer's $ops chain (issue #226) — rising-edge, event-gated —
2145
+ // so its ops (dispatch a completion event, submit the form) always see
2146
+ // the fully settled DOM and every chained listener has already run.
1945
2147
  const changedFields = []
1946
2148
  for (const name of outputs) {
1947
- if (!(name in result)) continue
2149
+ if (name === "$ops" || !(name in result)) continue
1948
2150
  const field = ownedField(name)
1949
2151
  if (!field) continue // a non-field output paints as a text sink in phase 2
1950
2152
  if (String(result[name]) === field.value) continue // change-guard — unchanged, skip
@@ -1958,6 +2160,7 @@ export default class extends Controller {
1958
2160
  // undefined result value is SKIPPED (never stringified to "null"/"undefined") —
1959
2161
  // the same "no value this pass, don't paint" filter #applyComputeMirrors uses.
1960
2162
  for (const name of Object.keys(result)) {
2163
+ if (name === "$ops") continue
1961
2164
  const value = result[name]
1962
2165
  if (value === undefined || value === null) continue
1963
2166
  this.#mirrorText(name, value)
@@ -1979,6 +2182,29 @@ export default class extends Controller {
1979
2182
  this.#computeSelfDispatched.add(inputEvent)
1980
2183
  field.dispatchEvent(inputEvent)
1981
2184
  }
2185
+
2186
+ // Phase 4 — the reducer's $ops chain (issue #226), after everything settled.
2187
+ // Event-gated: a seed/direct recompute() pass (no event) arms the latch but
2188
+ // never fires, so a restored/re-rendered complete value can't auto-fire.
2189
+ this.#applyComputeOps(reducerOps, Boolean(event))
2190
+ }
2191
+
2192
+ // Apply a reducer-emitted $ops chain (issue #226) on its RISING EDGE, keyed
2193
+ // on CONTENT: fire only when THIS pass's chain differs from the LAST pass's
2194
+ // (including from "absent"), and only on an event-driven pass. An unchanged
2195
+ // chain never re-fires — "still complete, same submit" is settled, exactly
2196
+ // like the change-guarded field writes. A pass with no $ops re-arms; a pass
2197
+ // whose chain CHANGED fires again (per-keystroke focus advance across OTP
2198
+ // boxes is a different focus target each time — a deliberately new intent).
2199
+ // Ops run through the shared CLIENT_OPS interpreter with runOps's
2200
+ // root-scoped target resolution; a missing to: defaults to "@root"
2201
+ // (hand-written reducer ergonomics).
2202
+ #applyComputeOps(list, eventDriven) {
2203
+ const signature = list === null ? null : JSON.stringify(list)
2204
+ const fire = signature !== null && signature !== this.#computeOpsSignature && eventDriven
2205
+ this.#computeOpsSignature = signature
2206
+ if (!fire) return
2207
+ applyOps(list, (args) => this.#opTargets(args.to == null ? { ...args, to: "@root" } : args))
1982
2208
  }
1983
2209
 
1984
2210
  // Client-side list navigation (combobox keyboard nav, issue #72). Wired by
@@ -3388,6 +3614,94 @@ export default class extends Controller {
3388
3614
  return false
3389
3615
  }
3390
3616
 
3617
+ // The connect() gate for completion bindings (issue #226) — one attribute read.
3618
+ #onCompleteEnabled() {
3619
+ return !!this.element.getAttribute?.("data-reactive-on-complete")
3620
+ }
3621
+
3622
+ // Whether this root owns a clipboard-marked paste trigger (issue #228) —
3623
+ // the connect() gate. The ROOT itself counts (a button-only component that
3624
+ // mixes on_client(paste_into) onto reactive_root — the #dirtyTrackingEnabled
3625
+ // root-then-descendants precedent), then one scoped query; a NESTED root's
3626
+ // triggers are its own controller's to gate (issue #15 ownership).
3627
+ #clipboardGateEnabled() {
3628
+ if (this.element.getAttribute?.("data-reactive-clipboard")) return true
3629
+ const nodes = this.element.querySelectorAll?.("[data-reactive-clipboard]") ?? []
3630
+ for (const el of nodes) if (this.#ownsField(el)) return true
3631
+ return false
3632
+ }
3633
+
3634
+ // Set every owned paste trigger's `hidden` from clipboard availability
3635
+ // (issue #228): available → revealed (the authored `hidden` was only the
3636
+ // no-dead-button first paint), missing → hidden (insecure context /
3637
+ // webview). The gate owns the flag on MARKED elements only — nothing else
3638
+ // is ever touched. A marked ROOT is gated too: when the component IS the
3639
+ // paste button, hiding the root is exactly "the dead button never shows".
3640
+ #syncClipboardTriggers() {
3641
+ const available = typeof globalThis.navigator?.clipboard?.readText === "function"
3642
+ if (this.element.getAttribute?.("data-reactive-clipboard")) this.element.hidden = !available
3643
+ for (const el of this.element.querySelectorAll?.("[data-reactive-clipboard]") ?? []) {
3644
+ if (this.#ownsField(el)) el.hidden = !available
3645
+ }
3646
+ }
3647
+
3648
+ // Remove the clipboard gate's morph listener on disconnect, so a stray
3649
+ // morph event after a Turbo navigation never re-syncs a detached root.
3650
+ #teardownClipboardGate() {
3651
+ if (!this.#boundSyncClipboard) return
3652
+ this.element.removeEventListener?.("turbo:morph-element", this.#boundSyncClipboard)
3653
+ this.#boundSyncClipboard = undefined
3654
+ }
3655
+
3656
+ // Parse-and-memoize the completion bindings, keyed on the RAW attr string:
3657
+ // a morph that rewrote the payload re-parses and RESETS the latches (the
3658
+ // morph listener's own arm pass then re-arms without firing). A removed
3659
+ // attr yields [] silently; malformed JSON warns (in parseOnComplete).
3660
+ #onCompleteBindings() {
3661
+ const raw = this.element.getAttribute?.("data-reactive-on-complete") ?? null
3662
+ if (raw !== this.#onCompleteRaw) {
3663
+ this.#onCompleteRaw = raw
3664
+ this.#onCompleteParsed = raw == null ? [] : parseOnComplete(raw)
3665
+ this.#onCompleteStates = this.#onCompleteParsed.map(() => false)
3666
+ }
3667
+ return this.#onCompleteParsed
3668
+ }
3669
+
3670
+ // Evaluate every completion binding (issue #226) over the owned fields —
3671
+ // the SAME memoized, scope-aware field resolver and DNF fold the show sync
3672
+ // uses — and run each binding's ops on ITS OWN rising edge. `event` null
3673
+ // (the connect/morph arm pass) updates the latches WITHOUT firing, so ops
3674
+ // only ever run from a real user gesture. An undecidable payload (no
3675
+ // groups) leaves its latch alone — default-deny, like every malformed-wire
3676
+ // arm. Ops resolve through runOps's root-scoped targets; a missing to:
3677
+ // defaults to "@root" (the $ops convention).
3678
+ #syncOnComplete(event) {
3679
+ const bindings = this.#onCompleteBindings()
3680
+ if (!bindings.length) return
3681
+
3682
+ const owns = this.#ownershipFilter()
3683
+ const scope = this.element.getAttribute?.("data-reactive-scope") || null
3684
+ const values = new Map()
3685
+ const fieldValue = (name) => {
3686
+ if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns, scope))
3687
+ return values.get(name)
3688
+ }
3689
+ bindings.forEach((binding, i) => {
3690
+ const matches = anyOfAllsMatches(binding.any, fieldValue)
3691
+ if (matches === null) return
3692
+ const fire = matches && !this.#onCompleteStates[i] && Boolean(event)
3693
+ this.#onCompleteStates[i] = matches
3694
+ if (fire) applyOps(binding.ops, (args) => this.#opTargets(args.to == null ? { ...args, to: "@root" } : args))
3695
+ })
3696
+ }
3697
+
3698
+ #teardownOnCompleteSync() {
3699
+ if (!this.#boundSyncOnComplete) return
3700
+ this.element.removeEventListener?.("input", this.#boundSyncOnComplete)
3701
+ this.element.removeEventListener?.("change", this.#boundSyncOnComplete)
3702
+ this.element.removeEventListener?.("turbo:morph-element", this.#boundArmOnComplete)
3703
+ }
3704
+
3391
3705
  // Re-evaluate every OWNED show binding in one pass (issue #161): read the
3392
3706
  // controlling field's current value, evaluate the declared literal predicate,
3393
3707
  // toggle `hidden`. A full pass (not per-target) for the same reason as