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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -0
- data/README.md +163 -6
- data/app/javascript/phlex/reactive/compute.js +158 -0
- data/app/javascript/phlex/reactive/compute.min.js +2 -2
- data/app/javascript/phlex/reactive/compute.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +319 -5
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/dsl.rb +84 -0
- data/lib/phlex/reactive/component/helpers.rb +21 -0
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/component.rb +9 -0
- data/lib/phlex/reactive/js.rb +44 -0
- data/lib/phlex/reactive/show_conditions.rb +116 -0
- data/lib/phlex/reactive/streamable.rb +35 -23
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -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
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
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
|
-
//
|
|
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
|