phlex-reactive 0.9.5 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +315 -0
- data/README.md +176 -122
- data/app/controllers/phlex/reactive/actions_controller.rb +41 -8
- data/app/javascript/phlex/reactive/confirm_predicate.js +52 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +475 -218
- 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/client_bindings.rb +57 -0
- data/lib/phlex/reactive/component/dsl.rb +158 -33
- data/lib/phlex/reactive/component/helpers.rb +478 -381
- data/lib/phlex/reactive/component/registry.rb +8 -1
- data/lib/phlex/reactive/component.rb +26 -12
- data/lib/phlex/reactive/defer.rb +1 -1
- data/lib/phlex/reactive/deferred_render_job.rb +2 -2
- data/lib/phlex/reactive/engine.rb +14 -0
- data/lib/phlex/reactive/js.rb +29 -15
- data/lib/phlex/reactive/reply.rb +105 -44
- data/lib/phlex/reactive/response.rb +139 -48
- data/lib/phlex/reactive/show_conditions.rb +249 -0
- data/lib/phlex/reactive/streamable.rb +244 -202
- data/lib/phlex/reactive/test_helpers.rb +10 -2
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +137 -12
- metadata +6 -1
|
@@ -13,6 +13,9 @@ import { confirmResolver } from "phlex/reactive/confirm"
|
|
|
13
13
|
// Client-side computes (data bindings): the reducer registry behind
|
|
14
14
|
// reactive_compute. Bare specifier for the same import-map reason as confirm.
|
|
15
15
|
import { computeReducer } from "phlex/reactive/compute"
|
|
16
|
+
// Conditional-confirm predicates (issue #179): the registry behind the
|
|
17
|
+
// confirm: { predicate: "name" } escape hatch. Same bare-specifier reason.
|
|
18
|
+
import { confirmPredicate } from "phlex/reactive/confirm_predicate"
|
|
16
19
|
|
|
17
20
|
// The ONE generic controller behind every reactive Phlex component. It
|
|
18
21
|
// replaces the per-feature Stimulus controllers you'd otherwise hand-write
|
|
@@ -860,30 +863,56 @@ function parseShowCompound(raw) {
|
|
|
860
863
|
return null
|
|
861
864
|
}
|
|
862
865
|
|
|
863
|
-
// Evaluate a
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
|
|
866
|
+
// Evaluate one DNF TERM against a resolved field value (issue #180). A missing
|
|
867
|
+
// field (null) or a malformed/unknown predicate folds to FALSE — fail-closed
|
|
868
|
+
// (default-deny): a broken AND term can't pass, a broken OR term can't reveal.
|
|
869
|
+
function dnfTermMatches(term, fieldValue) {
|
|
870
|
+
if (!term || typeof term !== "object" || typeof term.field !== "string") return false
|
|
871
|
+
// An absent owned field reads as "" — identical to the server evaluator
|
|
872
|
+
// (ShowConditions.match? treats a missing field as blank). This keeps the
|
|
873
|
+
// Ruby first-paint and the client live-toggle in exact agreement (the shared
|
|
874
|
+
// fixture proves it). A malformed predicate still folds to false.
|
|
875
|
+
const value = fieldValue(term.field) ?? ""
|
|
876
|
+
return showPredicateMatches(term, value) === true
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Evaluate a DNF show payload (issue #180): { any: [group, …] } where each
|
|
880
|
+
// GROUP is an array of terms (terms AND within a group, groups OR). Returns
|
|
881
|
+
// true/false for a decidable payload, or null for a malformed one (no groups)
|
|
882
|
+
// so the caller warn-skips and leaves visibility alone. This is the ONE shape
|
|
883
|
+
// the 0.10 wire emits; showPayloadMatches routes the legacy shapes here or to
|
|
884
|
+
// the compatibility arm below.
|
|
885
|
+
function anyOfAllsMatches(groups, fieldValue) {
|
|
886
|
+
if (!Array.isArray(groups) || groups.length === 0) return null
|
|
887
|
+
// groups OR; within a group, terms AND (an empty group can't decide → false).
|
|
888
|
+
return groups.some((group) => Array.isArray(group) && group.length > 0 &&
|
|
889
|
+
group.every((term) => dnfTermMatches(term, fieldValue)))
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Route a parsed data-reactive-show payload to the right evaluator. The 0.10
|
|
893
|
+
// wire is { any: [ [term,…], … ] } (DNF — groups are ARRAYS). For a stale tab
|
|
894
|
+
// still serving pre-0.10 HTML (deploy overlap), fall back to the 0.9.5 compound
|
|
895
|
+
// shape { all: [term,…] } / { any: [term,…] } where the values are flat TERM
|
|
896
|
+
// OBJECTS, not arrays. The nesting distinguishes them: DNF's any[0] is an Array.
|
|
897
|
+
// DELETE the legacy arm in 0.11.
|
|
898
|
+
function showPayloadMatches(payload, fieldValue) {
|
|
873
899
|
if (!payload || typeof payload !== "object") return null
|
|
900
|
+
const any = payload.any
|
|
901
|
+
if (Array.isArray(any) && (any.length === 0 || Array.isArray(any[0]))) {
|
|
902
|
+
return anyOfAllsMatches(any, fieldValue)
|
|
903
|
+
}
|
|
904
|
+
return legacyCompoundShowMatches(payload, fieldValue)
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// LEGACY (0.9.5, deploy-overlap only — DELETE in 0.11): the flat all:/any:
|
|
908
|
+
// compound fold, where terms are objects (not groups). Preserved so a morph of
|
|
909
|
+
// stale pre-0.10 HTML doesn't go dead.
|
|
910
|
+
function legacyCompoundShowMatches(payload, fieldValue) {
|
|
874
911
|
const connective = Array.isArray(payload.all) ? "all" : Array.isArray(payload.any) ? "any" : null
|
|
875
912
|
if (!connective) return null
|
|
876
913
|
const terms = payload[connective]
|
|
877
|
-
if (terms.length === 0) return null
|
|
878
|
-
|
|
879
|
-
const results = terms.map((term) => {
|
|
880
|
-
if (!term || typeof term !== "object" || typeof term.field !== "string") return false
|
|
881
|
-
const value = fieldValue(term.field)
|
|
882
|
-
if (value === null) return false // no owned field with that name → fail-closed
|
|
883
|
-
const match = showPredicateMatches(term, value)
|
|
884
|
-
return match === true // null (malformed term) or false both fold to false
|
|
885
|
-
})
|
|
886
|
-
|
|
914
|
+
if (terms.length === 0) return null
|
|
915
|
+
const results = terms.map((term) => dnfTermMatches(term, fieldValue))
|
|
887
916
|
return connective === "all" ? results.every(Boolean) : results.some(Boolean)
|
|
888
917
|
}
|
|
889
918
|
|
|
@@ -985,7 +1014,12 @@ export default class extends Controller {
|
|
|
985
1014
|
#busyPending = 0 // root aria-busy pending counter (remove only at zero)
|
|
986
1015
|
#busyActions = new Map() // action -> in-flight count (root's space-separated busy set + busy_on)
|
|
987
1016
|
#busyTokenCounts = new WeakMap() // element -> Map(action -> count): its data-reactive-busy token set
|
|
988
|
-
#
|
|
1017
|
+
#textDisableSnapshots = new Map() // trigger -> { count, disabled, html } refcounted text/disable snapshot (issue #181)
|
|
1018
|
+
// Issue #183: the `input` events recompute dispatches for its OWN output writes,
|
|
1019
|
+
// marked so a re-entrant recompute on THIS root skips re-running the reducer
|
|
1020
|
+
// (single-pass write set). Per-instance, so another root's events are never
|
|
1021
|
+
// swallowed. WeakSet: entries drop when the short-lived Event is GC'd.
|
|
1022
|
+
#computeSelfDispatched = new WeakSet()
|
|
989
1023
|
// Dirty tracking (issue #103): the bound re-scan (turbo:morph-element) and the
|
|
990
1024
|
// navigate-away guard handlers, held so disconnect() can remove exactly them.
|
|
991
1025
|
#boundScanDirty
|
|
@@ -1160,10 +1194,17 @@ export default class extends Controller {
|
|
|
1160
1194
|
// modifier params (issue #80). The client decides preventDefault behavior
|
|
1161
1195
|
// from event.params — set by the Ruby on() — never by sniffing the
|
|
1162
1196
|
// Stimulus descriptor.
|
|
1163
|
-
const { action, params, debounce, throttle, confirm, outside, window: windowBound, optimistic
|
|
1197
|
+
const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic } =
|
|
1164
1198
|
event.params
|
|
1165
1199
|
if (!action) return
|
|
1166
1200
|
|
|
1201
|
+
// The pending-state hint (issue #181): data-reactive-busy-param. During a
|
|
1202
|
+
// deploy overlap a page rendered by the PREVIOUS gem still emits the old
|
|
1203
|
+
// data-reactive-loading-param — read it as a fallback so an in-flight page
|
|
1204
|
+
// keeps its pending affordance until the next full render. The old `class:`
|
|
1205
|
+
// key is remapped to add_class: so it flows through the one hint applier.
|
|
1206
|
+
const busy = event.params.busy ?? this.#legacyLoadingHint(event.params.loading)
|
|
1207
|
+
|
|
1167
1208
|
// Outside guard FIRST (issue #80): an outside: trigger only fires for
|
|
1168
1209
|
// events whose target is OUTSIDE this component's ROOT (containment against
|
|
1169
1210
|
// this.element — .contains includes the root itself). An event inside the
|
|
@@ -1204,8 +1245,14 @@ export default class extends Controller {
|
|
|
1204
1245
|
// `change` isn't cancelable, so preventDefault was already a no-op there.
|
|
1205
1246
|
if (!windowBound && !this.#keepsNativeToggle(optimistic, target)) event.preventDefault()
|
|
1206
1247
|
|
|
1207
|
-
//
|
|
1208
|
-
|
|
1248
|
+
// Resolve the EFFECTIVE confirm message (issue #179): a plain string confirm:
|
|
1249
|
+
// is that string (static, #52); a Hash confirm: (confirmWhen) evaluates its
|
|
1250
|
+
// condition/predicate over the collected fields and returns the message ONLY
|
|
1251
|
+
// when it fires, else null → no dialog. No confirm at all → also null.
|
|
1252
|
+
const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
|
|
1253
|
+
|
|
1254
|
+
// No message → proceed straight away (unchanged fast path).
|
|
1255
|
+
if (!message) return this.#proceed(target, action, params, debounce, throttle, optimistic, busy)
|
|
1209
1256
|
|
|
1210
1257
|
// Confirmation gate (issue #52, made overridable + async in #55). A reactive
|
|
1211
1258
|
// trigger can't use Hotwire's data-turbo-confirm — this controller preempts
|
|
@@ -1219,10 +1266,10 @@ export default class extends Controller {
|
|
|
1219
1266
|
// genuine bug inside #proceed is NOT silently swallowed. Enqueue ONLY on a
|
|
1220
1267
|
// truthy resolution — nothing is enqueued, no timer scheduled, otherwise.
|
|
1221
1268
|
Promise.resolve()
|
|
1222
|
-
.then(() => confirmResolver(
|
|
1269
|
+
.then(() => confirmResolver(message))
|
|
1223
1270
|
.catch(() => false)
|
|
1224
1271
|
.then((ok) => {
|
|
1225
|
-
if (ok) this.#proceed(target, action, params, debounce, throttle, optimistic,
|
|
1272
|
+
if (ok) this.#proceed(target, action, params, debounce, throttle, optimistic, busy)
|
|
1226
1273
|
})
|
|
1227
1274
|
}
|
|
1228
1275
|
|
|
@@ -1233,7 +1280,7 @@ export default class extends Controller {
|
|
|
1233
1280
|
// the component resets whatever they toggled (by design — a signed action
|
|
1234
1281
|
// owns state that must survive re-renders).
|
|
1235
1282
|
runOps(event) {
|
|
1236
|
-
const { ops, outside, window: windowBound } = event.params
|
|
1283
|
+
const { ops, confirm, confirmWhen, outside, window: windowBound } = event.params
|
|
1237
1284
|
|
|
1238
1285
|
// Outside guard FIRST — identical semantics to dispatch() (issue #80): an
|
|
1239
1286
|
// outside: trigger is a COMPLETE no-op for events inside this root, before
|
|
@@ -1243,10 +1290,33 @@ export default class extends Controller {
|
|
|
1243
1290
|
// Element-bound triggers preventDefault (a bare button inside a <form>
|
|
1244
1291
|
// must not submit it); window-bound triggers (window:/outside:) never do —
|
|
1245
1292
|
// they hear every matching event on the page, and preventDefault-ing those
|
|
1246
|
-
// would kill native clicks site-wide (issue #80 rationale).
|
|
1293
|
+
// would kill native clicks site-wide (issue #80 rationale). Runs BEFORE the
|
|
1294
|
+
// (possibly async) confirm gate below — a native default can't wait for a
|
|
1295
|
+
// pending dialog (same ordering as dispatch()).
|
|
1247
1296
|
if (!windowBound) event.preventDefault()
|
|
1248
1297
|
|
|
1249
|
-
|
|
1298
|
+
// Resolve the effective confirm message — static string, or the conditional
|
|
1299
|
+
// Hash form (issue #179) evaluated over collected fields. Null → no dialog.
|
|
1300
|
+
const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
|
|
1301
|
+
|
|
1302
|
+
// No message → apply straight away (unchanged fast path, no prompt).
|
|
1303
|
+
if (!message) return this.#applyOps(this.#parseOps(ops))
|
|
1304
|
+
|
|
1305
|
+
// Confirmation gate for client ops (issue #178) — the SAME confirmResolver
|
|
1306
|
+
// gate on(:action, confirm:) uses (issues #52/#55), reused verbatim so a
|
|
1307
|
+
// themed dialog set with setConfirmResolver covers BOTH paths. A destructive
|
|
1308
|
+
// client op (clear a draft, reset a form) gets the one-line themed confirm
|
|
1309
|
+
// without a round trip. Call the resolver INSIDE the chain (leading .then)
|
|
1310
|
+
// so even a SYNCHRONOUS override throw rejects here instead of escaping
|
|
1311
|
+
// runOps — a throwing dialog is a cancel, like dismissing it. The gate is
|
|
1312
|
+
// here (the user gesture), NOT in #applyOps: that applier is shared with the
|
|
1313
|
+
// server-pushed reactive:js stream action, which must NEVER prompt.
|
|
1314
|
+
Promise.resolve()
|
|
1315
|
+
.then(() => confirmResolver(message))
|
|
1316
|
+
.catch(() => false)
|
|
1317
|
+
.then((ok) => {
|
|
1318
|
+
if (ok) this.#applyOps(this.#parseOps(ops))
|
|
1319
|
+
})
|
|
1250
1320
|
}
|
|
1251
1321
|
|
|
1252
1322
|
// Dirty tracking (issue #103). Wired by reactive_field(dirty: true) /
|
|
@@ -1279,6 +1349,16 @@ export default class extends Controller {
|
|
|
1279
1349
|
// changed = that output's name — the reducer must be convergent (see
|
|
1280
1350
|
// compute.js) so the change guard settles the chain.
|
|
1281
1351
|
recompute(event) {
|
|
1352
|
+
// Issue #183 — single-pass write set: an `input` event this method dispatched
|
|
1353
|
+
// for its OWN output writes is self-marked. Re-running the reducer on it would
|
|
1354
|
+
// re-enter from a partially-written DOM (the old mid-loop-dispatch corruption
|
|
1355
|
+
// class). Skip the reducer for our own event — but ONLY ours: the marker lives
|
|
1356
|
+
// in a per-instance WeakSet, so a genuinely different root's compute event (or
|
|
1357
|
+
// a real user edit) is never swallowed. The event still bubbled and fired every
|
|
1358
|
+
// OTHER listener (dirty tracking, show bindings, sibling roots) before reaching
|
|
1359
|
+
// here; we simply don't recompute a second time from our own write.
|
|
1360
|
+
if (event && this.#computeSelfDispatched.has(event)) return
|
|
1361
|
+
|
|
1282
1362
|
// Inputs may be a JSON ARRAY of names (array form — every input coerced
|
|
1283
1363
|
// through Number, the shipped behavior) or a JSON OBJECT of name→type (hash
|
|
1284
1364
|
// form, issue #104 — :number coerced, :string read raw). #parseComputeInputs
|
|
@@ -1304,12 +1384,19 @@ export default class extends Controller {
|
|
|
1304
1384
|
// The memo is per-CALL only: an output write dispatches `input` (issue #76),
|
|
1305
1385
|
// re-entering recompute, which correctly rebuilds a fresh map (a morph may
|
|
1306
1386
|
// have replaced the nodes) — it is NEVER stored on the instance.
|
|
1387
|
+
// Scope (issue #183, mirroring #showFieldValue): a bare compute name `cash`
|
|
1388
|
+
// under `data-reactive-scope="order"` resolves as `[name="order[cash]"]`. A
|
|
1389
|
+
// name already carrying a bracket (a raw wire name the author passed) is used
|
|
1390
|
+
// verbatim — so bracketed literals pass through unscoped.
|
|
1391
|
+
const scope = this.element.getAttribute?.("data-reactive-scope") || null
|
|
1392
|
+
const scoped = (name) => (scope && !name.includes("[") ? `${scope}[${name}]` : name)
|
|
1393
|
+
|
|
1307
1394
|
const owns = this.#ownershipFilter()
|
|
1308
1395
|
const byName = new Map()
|
|
1309
1396
|
const ownedField = (name) => {
|
|
1310
1397
|
if (byName.has(name)) return byName.get(name)
|
|
1311
1398
|
let found = null
|
|
1312
|
-
for (const el of this.element.querySelectorAll(`[name="${name}"]`)) {
|
|
1399
|
+
for (const el of this.element.querySelectorAll(`[name="${scoped(name)}"]`)) {
|
|
1313
1400
|
if (owns(el)) {
|
|
1314
1401
|
found = el // FIRST-WINS (radio groups, Rails hidden+checkbox name pairs)
|
|
1315
1402
|
break
|
|
@@ -1355,36 +1442,61 @@ export default class extends Controller {
|
|
|
1355
1442
|
|
|
1356
1443
|
// meta.changed stays on #changedComputeField (its own #ownsField check over
|
|
1357
1444
|
// the raw event target) — NOT this resolver. The issue-#15 nested-rejection
|
|
1358
|
-
// test depends on that path being unchanged.
|
|
1359
|
-
|
|
1445
|
+
// test depends on that path being unchanged. ONE run, from the ONE pre-write
|
|
1446
|
+
// snapshot above (issue #183): its result drives the whole single-pass write.
|
|
1447
|
+
const result = reduce(values, { changed: this.#changedComputeField(event, inputs, scope) }) || {}
|
|
1448
|
+
|
|
1449
|
+
// Issue #183 — SINGLE-PASS WRITE SET. Three ordered phases, so declared output
|
|
1450
|
+
// order stops being semantics and a wrong order can no longer corrupt values:
|
|
1451
|
+
//
|
|
1452
|
+
// 1. BATCH the field writes from the ONE result. Each output name in the
|
|
1453
|
+
// allowlist (outputs:) whose owned field's value actually changes is
|
|
1454
|
+
// written now (change-guarded) and remembered — but NO `input` event is
|
|
1455
|
+
// dispatched yet, so nothing re-enters mid-batch.
|
|
1456
|
+
// 2. PAINT the sinks from the SETTLED values: any owned reactive_text node by
|
|
1457
|
+
// presence (issue #183 change #4 — a text node no longer needs its name in
|
|
1458
|
+
// outputs:), then the cross-root mirror: ids (issue #159).
|
|
1459
|
+
// 3. DISPATCH a self-marked `input` on each changed field. The marker (a
|
|
1460
|
+
// per-instance WeakSet) makes recompute skip re-running the reducer for our
|
|
1461
|
+
// own write, while the event still fires every OTHER listener (chained
|
|
1462
|
+
// repaint, dirty tracking, show bindings, sibling roots).
|
|
1463
|
+
const changedFields = []
|
|
1360
1464
|
for (const name of outputs) {
|
|
1361
1465
|
if (!(name in result)) continue
|
|
1362
1466
|
const field = ownedField(name)
|
|
1363
|
-
//
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
// A text-node output: textContent, XSS-safe by construction. Change-
|
|
1379
|
-
// guarded too (compare before writing), but NO input dispatch — a text
|
|
1380
|
-
// node has no listener contract, so nothing chains off it.
|
|
1381
|
-
this.#mirrorText(name, result[name])
|
|
1382
|
-
}
|
|
1467
|
+
if (!field) continue // a non-field output paints as a text sink in phase 2
|
|
1468
|
+
if (String(result[name]) === field.value) continue // change-guard — unchanged, skip
|
|
1469
|
+
field.value = result[name]
|
|
1470
|
+
changedFields.push(field)
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Phase 2 — text sinks declare themselves (issue #183 change #4): every result
|
|
1474
|
+
// key paints into any owned [data-reactive-text="<name>"] node by PRESENCE,
|
|
1475
|
+
// regardless of outputs: membership. Runs from settled field values. A null/
|
|
1476
|
+
// undefined result value is SKIPPED (never stringified to "null"/"undefined") —
|
|
1477
|
+
// the same "no value this pass, don't paint" filter #applyComputeMirrors uses.
|
|
1478
|
+
for (const name of Object.keys(result)) {
|
|
1479
|
+
const value = result[name]
|
|
1480
|
+
if (value === undefined || value === null) continue
|
|
1481
|
+
this.#mirrorText(name, value)
|
|
1383
1482
|
}
|
|
1384
1483
|
|
|
1385
|
-
// Cross-root text mirrors (issue #159) — AFTER the
|
|
1386
|
-
//
|
|
1484
|
+
// Cross-root text mirrors (issue #159) — AFTER the batch + text sinks, so a
|
|
1485
|
+
// mirror keyed on a just-written output paints the settled value.
|
|
1387
1486
|
this.#applyComputeMirrors(result, ownedField)
|
|
1487
|
+
|
|
1488
|
+
// Phase 3 — dispatch the deferred `input` events (issue #183). Real browsers
|
|
1489
|
+
// do NOT fire `input` on a programmatic .value write (issue #76), so we do it
|
|
1490
|
+
// ourselves, matching the server's set_value + dispatch("input") contract.
|
|
1491
|
+
// Each event is SELF-MARKED so our own re-entry skips the reducer (guard at the
|
|
1492
|
+
// top of recompute) — but the event still bubbles and fires every other
|
|
1493
|
+
// listener. Dispatched AFTER all writes + paints, so a chained listener reads
|
|
1494
|
+
// SETTLED values, never a half-written DOM.
|
|
1495
|
+
for (const field of changedFields) {
|
|
1496
|
+
const inputEvent = new Event("input", { bubbles: true })
|
|
1497
|
+
this.#computeSelfDispatched.add(inputEvent)
|
|
1498
|
+
field.dispatchEvent(inputEvent)
|
|
1499
|
+
}
|
|
1388
1500
|
}
|
|
1389
1501
|
|
|
1390
1502
|
// Client-side list navigation (combobox keyboard nav, issue #72). Wired by
|
|
@@ -1491,11 +1603,26 @@ export default class extends Controller {
|
|
|
1491
1603
|
// named form control OWNED by this root (not a nested reactive root's, issue
|
|
1492
1604
|
// #15) AND its name is among the declared compute inputs; anything else
|
|
1493
1605
|
// (a direct call, an unowned/undeclared target) yields null.
|
|
1494
|
-
|
|
1606
|
+
//
|
|
1607
|
+
// Scope-aware (issue #184): under data-reactive-scope, the edited field's DOM
|
|
1608
|
+
// name is scoped (order[allowance]) while the declared inputs are BARE
|
|
1609
|
+
// (allowance). Strip the scope prefix off the DOM name before comparing, and
|
|
1610
|
+
// return the BARE name — so a reducer branching on `changed` sees the same
|
|
1611
|
+
// names it declared, scoped or not.
|
|
1612
|
+
#changedComputeField(event, inputs, scope) {
|
|
1495
1613
|
const target = event?.target
|
|
1496
1614
|
if (!target?.name || typeof target.closest !== "function") return null
|
|
1497
|
-
|
|
1498
|
-
|
|
1615
|
+
const bare = this.#unscopeName(target.name, scope)
|
|
1616
|
+
if (!inputs.includes(bare)) return null
|
|
1617
|
+
return this.#ownsField(target) ? bare : null
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// Strip a leading `scope[…]` wrapper off a DOM field name, returning the bare
|
|
1621
|
+
// inner name; a name that isn't wrapped in this scope passes through unchanged.
|
|
1622
|
+
#unscopeName(name, scope) {
|
|
1623
|
+
if (!scope) return name
|
|
1624
|
+
const prefix = `${scope}[`
|
|
1625
|
+
return name.startsWith(prefix) && name.endsWith("]") ? name.slice(prefix.length, -1) : name
|
|
1499
1626
|
}
|
|
1500
1627
|
|
|
1501
1628
|
// Write `value` into every owned [data-reactive-text="<name>"] node via
|
|
@@ -1564,7 +1691,7 @@ export default class extends Controller {
|
|
|
1564
1691
|
// Split out of dispatch so both the no-confirm fast path and the post-confirm
|
|
1565
1692
|
// microtask share one place (issue #55). `target` is captured up front because
|
|
1566
1693
|
// this can run in a later microtask, after event.target has been reset.
|
|
1567
|
-
#proceed(target, action, params, debounce, throttle, optimistic,
|
|
1694
|
+
#proceed(target, action, params, debounce, throttle, optimistic, busy) {
|
|
1568
1695
|
// Lifecycle veto point (issue #79): one cancelable reactive:before-dispatch
|
|
1569
1696
|
// per user gesture — post-preventDefault, post-confirm, PRE-debounce (and
|
|
1570
1697
|
// PRE-throttle, the same timing). event.preventDefault() skips the
|
|
@@ -1582,21 +1709,21 @@ export default class extends Controller {
|
|
|
1582
1709
|
// Debounced trigger (e.g. on(:update, event: "input", debounce: 300)):
|
|
1583
1710
|
// coalesce rapid events into ONE round trip after a quiet period, instead of
|
|
1584
1711
|
// one POST per keystroke (issue #17). A blur flushes a pending dispatch.
|
|
1585
|
-
// The optimistic hint (issue #98) and the
|
|
1712
|
+
// The optimistic hint (issue #98) and the busy state (issue #181) ride to
|
|
1586
1713
|
// the flush too, so they apply ONCE per enqueue — a debounced input must not
|
|
1587
1714
|
// flap toggle_class per keystroke, and its element must NOT be disabled
|
|
1588
1715
|
// during the quiet period (that would break typing). Both apply at ENQUEUE.
|
|
1589
1716
|
const ms = Number(debounce) || 0
|
|
1590
|
-
if (ms > 0) return this.#debounceDispatch(target, ms, action, params, optimistic,
|
|
1717
|
+
if (ms > 0) return this.#debounceDispatch(target, ms, action, params, optimistic, busy)
|
|
1591
1718
|
|
|
1592
1719
|
// Throttled trigger (e.g. on(:track, event: "scroll", window: true,
|
|
1593
1720
|
// throttle: 250), issue #80): LEADING-EDGE rate limit — fire the first
|
|
1594
1721
|
// event immediately, drop the rest until the window elapses. debounce and
|
|
1595
1722
|
// throttle are mutually exclusive (the Ruby on() raises on both).
|
|
1596
1723
|
const throttleMs = Number(throttle) || 0
|
|
1597
|
-
if (throttleMs > 0) return this.#throttleDispatch(target, throttleMs, action, params, optimistic,
|
|
1724
|
+
if (throttleMs > 0) return this.#throttleDispatch(target, throttleMs, action, params, optimistic, busy)
|
|
1598
1725
|
|
|
1599
|
-
return this.#enqueue(action, params, optimistic, target,
|
|
1726
|
+
return this.#enqueue(action, params, optimistic, target, busy)
|
|
1600
1727
|
}
|
|
1601
1728
|
|
|
1602
1729
|
// Apply the optimistic hint ONCE (recording its inverse) and chain the round
|
|
@@ -1605,29 +1732,55 @@ export default class extends Controller {
|
|
|
1605
1732
|
// #98). Applying here — the single flush/enqueue point every path funnels
|
|
1606
1733
|
// through — is what makes a hint apply once per enqueue, not per raw dispatch.
|
|
1607
1734
|
//
|
|
1608
|
-
// The
|
|
1735
|
+
// The busy state (issue #181) applies here too, for the same reason: enqueue
|
|
1609
1736
|
// is the moment the request is committed to the queue, so the always-on busy
|
|
1610
1737
|
// vocabulary (data-reactive-busy on the trigger + root, aria-busy via a pending
|
|
1611
|
-
// counter, busy_on scoping) and the
|
|
1738
|
+
// counter, busy_on scoping) and the busy hint (disable + class + text swap)
|
|
1612
1739
|
// cover the WHOLE pending window — queue wait included — not just the fetch. It
|
|
1613
1740
|
// returns a `settle` closure that #perform runs in its finally (success OR
|
|
1614
1741
|
// failure), guarded so a morph-replaced trigger is never clobbered.
|
|
1615
|
-
#enqueue(action, params, optimistic, target,
|
|
1742
|
+
#enqueue(action, params, optimistic, target, busy) {
|
|
1616
1743
|
const inverse = this.#applyOptimistic(optimistic, target)
|
|
1617
|
-
const settle = this.#
|
|
1618
|
-
|
|
1744
|
+
const settle = this.#applyBusy(action, target, busy)
|
|
1745
|
+
// Debug-only teaching aid (issue #181): if optimistic: { hide: true } is used
|
|
1746
|
+
// for instant-delete but the reply RE-RENDERS the element (bringing it back),
|
|
1747
|
+
// that hint was pointless — the developer likely wanted reply.remove. Capture
|
|
1748
|
+
// the hidden nodes now; the success path re-checks the OBSERVED DOM after the
|
|
1749
|
+
// morph (never inferred from the verb) and warns if any came back visible.
|
|
1750
|
+
const resurrect = this.#debugEnabled() ? this.#buildResurrectionCheck(optimistic, target) : null
|
|
1751
|
+
this.queue = (this.queue ?? Promise.resolve())
|
|
1752
|
+
.then(() => this.#perform(action, params, inverse, settle, resurrect))
|
|
1619
1753
|
return this.queue
|
|
1620
1754
|
}
|
|
1621
1755
|
|
|
1756
|
+
// Snapshot the elements an optimistic hide: targeted (the trigger, or the `to:`
|
|
1757
|
+
// selector) so the success path can detect a resurrection. Returns null unless
|
|
1758
|
+
// a hide: hint is present — nothing else can be "resurrected".
|
|
1759
|
+
#buildResurrectionCheck(optimistic, target) {
|
|
1760
|
+
if (!optimistic?.hide) return null
|
|
1761
|
+
const hidden = this.#hintTargets(optimistic, target)
|
|
1762
|
+
if (!hidden.length) return null
|
|
1763
|
+
return () => {
|
|
1764
|
+
const back = hidden.filter((el) => el.isConnected && !el.hidden)
|
|
1765
|
+
if (!back.length) return
|
|
1766
|
+
console.warn(
|
|
1767
|
+
"[phlex-reactive] optimistic: { hide: true } was undone by the reply's re-render — " +
|
|
1768
|
+
"the element is visible again. For an instant delete, return reply.remove so the " +
|
|
1769
|
+
"server removes it; otherwise the hide only flashes.",
|
|
1770
|
+
back,
|
|
1771
|
+
)
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1622
1775
|
// Reset a per-element timer; only enqueue the round trip after `ms` of quiet.
|
|
1623
1776
|
// Also flush immediately on blur so leaving the field never drops the last
|
|
1624
1777
|
// edit (a long debounce shouldn't swallow a value the user tabbed away from).
|
|
1625
|
-
#debounceDispatch(target, ms, action, params, optimistic,
|
|
1778
|
+
#debounceDispatch(target, ms, action, params, optimistic, busy) {
|
|
1626
1779
|
this.#clearDebounce(target)
|
|
1627
1780
|
|
|
1628
1781
|
const flush = () => {
|
|
1629
1782
|
this.#clearDebounce(target)
|
|
1630
|
-
this.#enqueue(action, params, optimistic, target,
|
|
1783
|
+
this.#enqueue(action, params, optimistic, target, busy)
|
|
1631
1784
|
}
|
|
1632
1785
|
const timer = setTimeout(flush, ms)
|
|
1633
1786
|
target?.addEventListener?.("blur", flush, { once: true })
|
|
@@ -1655,7 +1808,7 @@ export default class extends Controller {
|
|
|
1655
1808
|
// are keyed on action + target, NOT target alone: window-bound scroll/resize
|
|
1656
1809
|
// events all share event.target === document, so two window-bound triggers
|
|
1657
1810
|
// on one component would otherwise collide on one timer.
|
|
1658
|
-
#throttleDispatch(target, ms, action, params, optimistic,
|
|
1811
|
+
#throttleDispatch(target, ms, action, params, optimistic, busy) {
|
|
1659
1812
|
const timers = this.#throttleTimers.get(target) ?? new Map()
|
|
1660
1813
|
if (timers.has(action)) return // inside the window — suppress
|
|
1661
1814
|
|
|
@@ -1665,7 +1818,7 @@ export default class extends Controller {
|
|
|
1665
1818
|
}, ms)
|
|
1666
1819
|
timers.set(action, timer)
|
|
1667
1820
|
this.#throttleTimers.set(target, timers)
|
|
1668
|
-
return this.#enqueue(action, params, optimistic, target,
|
|
1821
|
+
return this.#enqueue(action, params, optimistic, target, busy) // leading edge: fire NOW
|
|
1669
1822
|
}
|
|
1670
1823
|
|
|
1671
1824
|
// Clear every throttle suppression timer (used on disconnect, alongside
|
|
@@ -1826,7 +1979,7 @@ export default class extends Controller {
|
|
|
1826
1979
|
/* eslint-enable no-console */
|
|
1827
1980
|
}
|
|
1828
1981
|
|
|
1829
|
-
async #perform(action, params, inverse, settle) {
|
|
1982
|
+
async #perform(action, params, inverse, settle, resurrect) {
|
|
1830
1983
|
// Auto-collect named field values inside this component so a button-
|
|
1831
1984
|
// triggered action still receives sibling inputs (Livewire-style), plus any
|
|
1832
1985
|
// chosen file inputs in the SAME walk. Explicit params
|
|
@@ -2020,6 +2173,10 @@ export default class extends Controller {
|
|
|
2020
2173
|
// replace (Response.morph) or an update morphs in place, preserving the
|
|
2021
2174
|
// focused input + caret on unchanged nodes — see issue #28.
|
|
2022
2175
|
window.Turbo.renderStreamMessage(html)
|
|
2176
|
+
// Debug-only (issue #181): the morph may apply a microtask later, so check
|
|
2177
|
+
// the resurrected-hide case AFTER it lands. Off the debug path, resurrect is
|
|
2178
|
+
// null — zero cost.
|
|
2179
|
+
if (resurrect) queueMicrotask(resurrect)
|
|
2023
2180
|
// A successful apply CLEARS any prior failure marker (issue #100), so
|
|
2024
2181
|
// error-driven CSS on the root (a red border, a shake) resets on recovery.
|
|
2025
2182
|
this.#clearError()
|
|
@@ -2166,6 +2323,54 @@ export default class extends Controller {
|
|
|
2166
2323
|
return (el) => this.#ownsField(el)
|
|
2167
2324
|
}
|
|
2168
2325
|
|
|
2326
|
+
// Resolve the effective confirm message (issue #179). A plain string is the
|
|
2327
|
+
// static #52 form (always shown). A confirmWhen JSON payload is the CONDITIONAL
|
|
2328
|
+
// form — evaluated over the SAME collected fields reactive_compute reads — and
|
|
2329
|
+
// returns the message ONLY when it fires, else null (proceed, no dialog):
|
|
2330
|
+
// { groups, message } — the reactive_show conditions fold (anyOfAllsMatches)
|
|
2331
|
+
// { predicate, message } — a registered fn (setConfirmPredicate) over the fields
|
|
2332
|
+
// A missing predicate warns and returns null (PROCEED without a dialog) — the
|
|
2333
|
+
// compute unknown-reducer posture. This is soft-validation UX; the endpoint's
|
|
2334
|
+
// authorize/default-deny is the real gate, so failing OPEN here never grants
|
|
2335
|
+
// anything the server wouldn't already allow.
|
|
2336
|
+
#effectiveConfirmMessage(confirm, confirmWhen) {
|
|
2337
|
+
if (confirm) return confirm
|
|
2338
|
+
if (!confirmWhen) return null
|
|
2339
|
+
|
|
2340
|
+
// Stimulus auto-parses a JSON-object -param value, so confirmWhen usually
|
|
2341
|
+
// arrives ALREADY parsed. Accept an object as-is; parse a string defensively
|
|
2342
|
+
// (a hand-built attr, or a non-Stimulus caller). A malformed string warns and
|
|
2343
|
+
// proceeds without a dialog (default-deny UX — the server is the real gate).
|
|
2344
|
+
let payload = confirmWhen
|
|
2345
|
+
if (typeof confirmWhen === "string") {
|
|
2346
|
+
try {
|
|
2347
|
+
payload = JSON.parse(confirmWhen)
|
|
2348
|
+
} catch {
|
|
2349
|
+
console.warn(`[phlex-reactive] malformed conditional confirm payload ${JSON.stringify(confirmWhen)} — skipped`)
|
|
2350
|
+
return null
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
if (!payload || typeof payload !== "object") return null
|
|
2354
|
+
|
|
2355
|
+
const { fields } = this.#collectFields()
|
|
2356
|
+
const fieldValue = (name) => fields[name]
|
|
2357
|
+
|
|
2358
|
+
let fires
|
|
2359
|
+
if (typeof payload.predicate === "string") {
|
|
2360
|
+
const fn = confirmPredicate(payload.predicate)
|
|
2361
|
+
if (!fn) {
|
|
2362
|
+
console.warn(`[phlex-reactive] confirm predicate "${payload.predicate}" is not registered — proceeding without a dialog (register it with setConfirmPredicate)`)
|
|
2363
|
+
return null
|
|
2364
|
+
}
|
|
2365
|
+
fires = !!fn(fields)
|
|
2366
|
+
} else {
|
|
2367
|
+
// Declarative: the DNF groups fold, identical to reactive_show — matches → fire.
|
|
2368
|
+
fires = anyOfAllsMatches(payload.groups?.any, fieldValue) === true
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
return fires ? payload.message : null
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2169
2374
|
// One walk over THIS root's named controls (not a nested reactive root's),
|
|
2170
2375
|
// returning both the scalar `fields` and any chosen `files`. The ownership
|
|
2171
2376
|
// predicate is hoisted ONCE (issue #117) via #ownershipFilter — in the common
|
|
@@ -2345,37 +2550,57 @@ export default class extends Controller {
|
|
|
2345
2550
|
if (typeof this.element?.querySelectorAll !== "function") return
|
|
2346
2551
|
|
|
2347
2552
|
const owns = this.#ownershipFilter()
|
|
2553
|
+
const scope = this.element.getAttribute?.("data-reactive-scope") || null
|
|
2348
2554
|
const values = new Map()
|
|
2349
2555
|
// A memoized resolver shared by every binding in this pass — a field driving
|
|
2350
|
-
// several bindings (and several terms
|
|
2556
|
+
// several bindings (and several DNF terms) reads exactly once. Scope-aware:
|
|
2557
|
+
// a bare field `director` resolves as `[name="scope[director]"]` (issue #180).
|
|
2351
2558
|
const fieldValue = (name) => {
|
|
2352
|
-
if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns))
|
|
2559
|
+
if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns, scope))
|
|
2353
2560
|
return values.get(name)
|
|
2354
2561
|
}
|
|
2355
2562
|
for (const el of this.element.querySelectorAll(SHOW_BINDING_SELECTOR)) {
|
|
2356
2563
|
if (!owns(el)) continue // a nested root's binding is its own controller's job
|
|
2357
2564
|
|
|
2358
|
-
//
|
|
2359
|
-
//
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2565
|
+
// The 0.10 DNF payload (issue #180): data-reactive-show carries
|
|
2566
|
+
// { any: [ [term,…], … ] }. The legacy flat-attr and 0.9.5-compound read
|
|
2567
|
+
// arms live in showPayloadMatches/showBindingMatches for deploy overlap.
|
|
2568
|
+
const payloadRaw = el.getAttribute("data-reactive-show")
|
|
2569
|
+
if (payloadRaw !== null) {
|
|
2570
|
+
const match = showPayloadMatches(parseShowCompound(payloadRaw), fieldValue)
|
|
2571
|
+
if (match !== null) this.#applyShowVisibility(el, match, owns, scope)
|
|
2364
2572
|
continue
|
|
2365
2573
|
}
|
|
2366
2574
|
|
|
2575
|
+
// LEGACY flat-attr binding (pre-0.10, deploy overlap — removed in 0.11).
|
|
2367
2576
|
const name = el.getAttribute("data-reactive-show-field")
|
|
2368
2577
|
if (!name) continue
|
|
2369
2578
|
const value = fieldValue(name)
|
|
2370
2579
|
if (value === null) continue // no owned field with that name — leave it be
|
|
2371
2580
|
const match = showBindingMatches(el, value)
|
|
2372
2581
|
if (match === null) continue // malformed predicate — warned + skipped
|
|
2373
|
-
el
|
|
2582
|
+
this.#applyShowVisibility(el, match, owns, scope)
|
|
2374
2583
|
}
|
|
2375
2584
|
|
|
2376
2585
|
// The cross-root pass (issue #164) shares the same owned-field memo, so a
|
|
2377
2586
|
// field driving both an owned binding and an outside target reads once.
|
|
2378
|
-
this.#syncShowTargets(owns, values)
|
|
2587
|
+
this.#syncShowTargets(owns, values, scope)
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
// Toggle `hidden` (and, when the binding declares data-reactive-show-disable,
|
|
2591
|
+
// the `disabled` of every owned named control inside it) from a match result
|
|
2592
|
+
// (issue #180). Disabling a hidden section's controls stops them submitting —
|
|
2593
|
+
// the stale-value fix. A visible section re-enables them. Controls a nested
|
|
2594
|
+
// reactive root owns are left alone (#15 ownership).
|
|
2595
|
+
#applyShowVisibility(el, match, owns, scope) {
|
|
2596
|
+
el.hidden = !match
|
|
2597
|
+
if (el.getAttribute("data-reactive-show-disable") !== "true") return
|
|
2598
|
+
if (typeof el.querySelectorAll !== "function") return
|
|
2599
|
+
for (const control of el.querySelectorAll("input[name], select[name], textarea[name]")) {
|
|
2600
|
+
if (owns(control)) control.disabled = !match
|
|
2601
|
+
}
|
|
2602
|
+
// The element itself may be a named control (a bare field with a binding).
|
|
2603
|
+
if (el.name && owns(el)) el.disabled = !match
|
|
2379
2604
|
}
|
|
2380
2605
|
|
|
2381
2606
|
// Apply the declared cross-root show targets (issue #164) — the visibility
|
|
@@ -2388,19 +2613,35 @@ export default class extends Controller {
|
|
|
2388
2613
|
// unrendered tab pane is normal); a malformed predicate warn-skips its one
|
|
2389
2614
|
// target while siblings still apply. With no map declared this is one
|
|
2390
2615
|
// getAttribute and out.
|
|
2391
|
-
#syncShowTargets(owns, values) {
|
|
2616
|
+
#syncShowTargets(owns, values, scope) {
|
|
2392
2617
|
const map = this.#parseShowTargets()
|
|
2393
2618
|
for (const [name, targets] of Object.entries(map)) {
|
|
2394
2619
|
if (!targets || typeof targets !== "object" || Array.isArray(targets)) continue
|
|
2395
|
-
if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns))
|
|
2620
|
+
if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns, scope))
|
|
2396
2621
|
const value = values.get(name)
|
|
2397
2622
|
if (value === null) continue // no owned field with that name — leave them be
|
|
2398
|
-
|
|
2623
|
+
// Every target's terms share this one field, so a constant resolver folds
|
|
2624
|
+
// the group (issue #180): a target's value is a DNF GROUP (terms ANDed).
|
|
2625
|
+
const resolve = () => value
|
|
2626
|
+
for (const [selector, group] of Object.entries(targets)) {
|
|
2399
2627
|
if (!guardShowTargetSelector(selector)) continue
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2628
|
+
// 0.10 wire: the value is a DNF GROUP (an array of terms, ANDed).
|
|
2629
|
+
// LEGACY (0.9.5, deploy overlap — DELETE in 0.11): a flat predicate
|
|
2630
|
+
// OBJECT ({ equals/not/in/gte… }) routed through showPredicateMatches.
|
|
2631
|
+
let match
|
|
2632
|
+
if (Array.isArray(group)) {
|
|
2633
|
+
if (group.length === 0) {
|
|
2634
|
+
console.warn(`[phlex-reactive] malformed reactive_show_targets group for ${selector} — skipped`)
|
|
2635
|
+
continue
|
|
2636
|
+
}
|
|
2637
|
+
match = group.every((term) => dnfTermMatches(term, resolve))
|
|
2638
|
+
} else {
|
|
2639
|
+
const legacy = showPredicateMatches(group, value)
|
|
2640
|
+
if (legacy === null) {
|
|
2641
|
+
console.warn(`[phlex-reactive] malformed reactive_show_targets predicate for ${selector} — skipped`)
|
|
2642
|
+
continue
|
|
2643
|
+
}
|
|
2644
|
+
match = legacy
|
|
2404
2645
|
}
|
|
2405
2646
|
for (const node of document.querySelectorAll(selector)) node.hidden = !match
|
|
2406
2647
|
}
|
|
@@ -2438,10 +2679,14 @@ export default class extends Controller {
|
|
|
2438
2679
|
// Rails pairs with it); a radio group reports the CHECKED radio's value (""
|
|
2439
2680
|
// when none is); anything else reports .value first-wins. Returns null when
|
|
2440
2681
|
// no owned field carries the name — the caller then leaves visibility alone.
|
|
2441
|
-
#showFieldValue(name, owns) {
|
|
2682
|
+
#showFieldValue(name, owns, scope) {
|
|
2683
|
+
// Scope (issue #180): a bare field `director` under `data-reactive-scope=
|
|
2684
|
+
// "form"` resolves as `[name="form[director]"]`. A name already carrying a
|
|
2685
|
+
// bracket (a raw wire name the author passed) is used verbatim.
|
|
2686
|
+
const domName = scope && !name.includes("[") ? `${scope}[${name}]` : name
|
|
2442
2687
|
let sawRadio = false
|
|
2443
2688
|
let first = null
|
|
2444
|
-
for (const el of this.element.querySelectorAll(`[name="${
|
|
2689
|
+
for (const el of this.element.querySelectorAll(`[name="${domName}"]`)) {
|
|
2445
2690
|
if (!owns(el)) continue
|
|
2446
2691
|
if (el.type === "checkbox") return el.checked ? "true" : "false"
|
|
2447
2692
|
if (el.type === "radio") {
|
|
@@ -2657,79 +2902,30 @@ export default class extends Controller {
|
|
|
2657
2902
|
return type === "checkbox" || type === "radio"
|
|
2658
2903
|
}
|
|
2659
2904
|
|
|
2660
|
-
// Apply the
|
|
2661
|
-
//
|
|
2662
|
-
//
|
|
2663
|
-
// the
|
|
2664
|
-
//
|
|
2665
|
-
// browser already flipped it). Returns null when there is nothing to do, so
|
|
2666
|
-
// the success/failure paths can cheaply skip.
|
|
2905
|
+
// Apply the OPTIMISTIC hint (issue #98) NOW and return its `undo` closure — the
|
|
2906
|
+
// exact ops to replay on FAILURE (optimistic reverts only when the round trip
|
|
2907
|
+
// fails; success leaves server truth or the deliberately-standing hint). It is
|
|
2908
|
+
// the same op vocabulary busy: uses (issue #181) via the one #applyHint engine;
|
|
2909
|
+
// the ONLY optimistic-specific op is checked: :keep (honorChecked = true).
|
|
2667
2910
|
#applyOptimistic(optimistic, trigger) {
|
|
2668
2911
|
if (!optimistic) return null
|
|
2669
|
-
|
|
2670
|
-
// Class + hidden ops share one target set (trigger, or the `to:` selector).
|
|
2671
|
-
const targets = this.#optimisticTargets(optimistic, trigger)
|
|
2672
|
-
const undo = []
|
|
2673
|
-
for (const el of targets) {
|
|
2674
|
-
if (optimistic.add_class) {
|
|
2675
|
-
// Undo only the classes this op ACTUALLY added — a class already present
|
|
2676
|
-
// was not our change, so reverting it would strip a class the element
|
|
2677
|
-
// legitimately had (the add was a no-op). Capture the real delta now.
|
|
2678
|
-
const added = optimistic.add_class.filter((c) => !el.classList.contains(c))
|
|
2679
|
-
el.classList.add(...added)
|
|
2680
|
-
if (added.length) undo.push(() => el.classList.remove(...added))
|
|
2681
|
-
}
|
|
2682
|
-
if (optimistic.remove_class) {
|
|
2683
|
-
// Symmetric: undo only the classes actually removed — one already absent
|
|
2684
|
-
// wasn't our change, so re-adding it would introduce a class that wasn't
|
|
2685
|
-
// there before.
|
|
2686
|
-
const removed = optimistic.remove_class.filter((c) => el.classList.contains(c))
|
|
2687
|
-
el.classList.remove(...removed)
|
|
2688
|
-
if (removed.length) undo.push(() => el.classList.add(...removed))
|
|
2689
|
-
}
|
|
2690
|
-
if (optimistic.toggle_class) {
|
|
2691
|
-
// toggle_class is its own inverse regardless of prior state — toggling
|
|
2692
|
-
// the same classes back exactly restores it, no delta tracking needed.
|
|
2693
|
-
optimistic.toggle_class.forEach((c) => el.classList.toggle(c))
|
|
2694
|
-
undo.push(() => optimistic.toggle_class.forEach((c) => el.classList.toggle(c)))
|
|
2695
|
-
}
|
|
2696
|
-
if (optimistic.hide) {
|
|
2697
|
-
el.hidden = true
|
|
2698
|
-
undo.push(() => (el.hidden = false))
|
|
2699
|
-
}
|
|
2700
|
-
}
|
|
2701
|
-
|
|
2702
|
-
// checked: :keep — the native flip already happened on the trigger; record
|
|
2703
|
-
// the inverse (flip it back) so a failure reverts the control's state.
|
|
2704
|
-
if (optimistic.checked === "keep" && trigger && "checked" in trigger) {
|
|
2705
|
-
const flipped = trigger.checked
|
|
2706
|
-
undo.push(() => (trigger.checked = !flipped))
|
|
2707
|
-
}
|
|
2708
|
-
|
|
2912
|
+
const undo = this.#applyHint(optimistic, trigger, true)
|
|
2709
2913
|
return undo.length ? undo : null
|
|
2710
2914
|
}
|
|
2711
2915
|
|
|
2712
|
-
// Replay the recorded
|
|
2713
|
-
//
|
|
2714
|
-
//
|
|
2715
|
-
//
|
|
2716
|
-
//
|
|
2717
|
-
//
|
|
2916
|
+
// Replay the recorded undo ops on failure (issue #98), guarded by isConnected:
|
|
2917
|
+
// a plain (non-morph) replace can detach this subtree before the failure lands,
|
|
2918
|
+
// and reverting a stale/detached node is pointless (it's gone) — so a
|
|
2919
|
+
// disconnected root skips the revert entirely. On success NOTHING calls this:
|
|
2920
|
+
// the server re-render overwrites the hint, or (reply.remove / streams-only)
|
|
2921
|
+
// the hint is deliberately left standing.
|
|
2718
2922
|
#revertOptimistic(inverse) {
|
|
2719
2923
|
if (!inverse) return
|
|
2720
2924
|
if (!this.element.isConnected) return
|
|
2721
2925
|
for (const undo of inverse) undo()
|
|
2722
2926
|
}
|
|
2723
2927
|
|
|
2724
|
-
//
|
|
2725
|
-
// (resolved like an op target — "@root" is the root, a selector is scoped to
|
|
2726
|
-
// this root's owned matches) or, with no `to:`, the trigger itself.
|
|
2727
|
-
#optimisticTargets(optimistic, trigger) {
|
|
2728
|
-
if (optimistic.to == null) return trigger ? [trigger] : []
|
|
2729
|
-
return this.#opTargets({ to: optimistic.to })
|
|
2730
|
-
}
|
|
2731
|
-
|
|
2732
|
-
// Apply the loading state for THIS enqueue (issue #99) and return a `settle`
|
|
2928
|
+
// Apply the BUSY state for THIS enqueue (issue #181) and return a `settle`
|
|
2733
2929
|
// closure that undoes exactly this enqueue's contribution when the round trip
|
|
2734
2930
|
// finishes (success OR any failure). Everything is refcounted so overlapping
|
|
2735
2931
|
// enqueues never clobber: A's settle can't clear busy while B is still pending.
|
|
@@ -2740,21 +2936,119 @@ export default class extends Controller {
|
|
|
2740
2936
|
// space-separated, per-action refcounted set), aria-busy on the root (a
|
|
2741
2937
|
// pending counter), and data-reactive-busy on any busy_on element scoped
|
|
2742
2938
|
// to this action. Apps style a spinner with pure CSS and zero Ruby.
|
|
2743
|
-
// 2. The
|
|
2744
|
-
//
|
|
2745
|
-
//
|
|
2746
|
-
//
|
|
2747
|
-
|
|
2939
|
+
// 2. The busy HINT (only when busy: was declared): the SAME cosmetic op set
|
|
2940
|
+
// as optimistic: (class ops, hide/show, disable, text), applied through
|
|
2941
|
+
// the one #applyHint engine and reverted on SETTLE (not on failure). These
|
|
2942
|
+
// apply at ENQUEUE — never during a debounce quiet period — so a debounced
|
|
2943
|
+
// input is not disabled mid-typing. checked: is optimistic-only, so busy:
|
|
2944
|
+
// passes honorChecked = false (the Ruby on() already rejects it — this is
|
|
2945
|
+
// belt-and-braces).
|
|
2946
|
+
#applyBusy(action, trigger, busy) {
|
|
2748
2947
|
this.#markBusy(action, trigger)
|
|
2749
|
-
const
|
|
2948
|
+
const undo = busy ? this.#applyHint(busy, trigger, false) : []
|
|
2750
2949
|
|
|
2751
2950
|
let settled = false
|
|
2752
2951
|
return () => {
|
|
2753
2952
|
if (settled) return // one settle per enqueue, even if called twice
|
|
2754
2953
|
settled = true
|
|
2755
2954
|
this.#unmarkBusy(action, trigger)
|
|
2756
|
-
|
|
2955
|
+
// Busy reverts on SETTLE regardless of outcome, guarded per element.
|
|
2956
|
+
for (const op of undo) op()
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
// The ONE pending-state hint engine (issue #181), shared by optimistic: (revert
|
|
2961
|
+
// on failure) and busy: (revert on settle) — they differ only in WHEN the
|
|
2962
|
+
// returned undo ops run, never in the ops themselves. Applies the hint's
|
|
2963
|
+
// cosmetic ops to their targets (the trigger by default, or a `to:` selector
|
|
2964
|
+
// scoped to the root) and returns an array of undo closures. Class ops and
|
|
2965
|
+
// hide/show use a DELTA inverse (undo only what THIS call changed, so it
|
|
2966
|
+
// composes across overlapping enqueues); disable/text use a REFCOUNTED snapshot
|
|
2967
|
+
// (the true pre-hint value survives an overlapping enqueue that would otherwise
|
|
2968
|
+
// capture the already-swapped label as the "original"). `honorChecked` gates
|
|
2969
|
+
// checked: :keep — an optimistic-only native-control revert.
|
|
2970
|
+
#applyHint(hint, trigger, honorChecked) {
|
|
2971
|
+
const undo = []
|
|
2972
|
+
for (const el of this.#hintTargets(hint, trigger)) {
|
|
2973
|
+
if (hint.add_class) {
|
|
2974
|
+
// Undo only the classes this op ACTUALLY added — a class already present
|
|
2975
|
+
// was not our change, so reverting it would strip a class the element
|
|
2976
|
+
// legitimately had. Capture the real delta now.
|
|
2977
|
+
const added = hint.add_class.filter((c) => !el.classList.contains(c))
|
|
2978
|
+
el.classList.add(...added)
|
|
2979
|
+
if (added.length) undo.push(() => el.classList.remove(...added))
|
|
2980
|
+
}
|
|
2981
|
+
if (hint.remove_class) {
|
|
2982
|
+
// Symmetric: undo only the classes actually removed.
|
|
2983
|
+
const removed = hint.remove_class.filter((c) => el.classList.contains(c))
|
|
2984
|
+
el.classList.remove(...removed)
|
|
2985
|
+
if (removed.length) undo.push(() => el.classList.add(...removed))
|
|
2986
|
+
}
|
|
2987
|
+
if (hint.toggle_class) {
|
|
2988
|
+
// toggle_class is its own inverse regardless of prior state.
|
|
2989
|
+
hint.toggle_class.forEach((c) => el.classList.toggle(c))
|
|
2990
|
+
undo.push(() => hint.toggle_class.forEach((c) => el.classList.toggle(c)))
|
|
2991
|
+
}
|
|
2992
|
+
if (hint.hide) {
|
|
2993
|
+
el.hidden = true
|
|
2994
|
+
undo.push(() => (el.hidden = false))
|
|
2995
|
+
}
|
|
2996
|
+
if (hint.show) {
|
|
2997
|
+
el.hidden = false
|
|
2998
|
+
undo.push(() => (el.hidden = true))
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
// disable/text swap the TRIGGER (a `to:` retargets only the class/hide/show
|
|
3003
|
+
// ops above — disable/text are inherently trigger affordances). Refcounted so
|
|
3004
|
+
// overlapping enqueues restore correctly.
|
|
3005
|
+
if (trigger && (hint.disable || hint.text != null)) {
|
|
3006
|
+
undo.push(this.#applyTextDisable(hint, trigger))
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
// checked: :keep — the native flip already happened on the trigger; record
|
|
3010
|
+
// the inverse (flip it back) so a revert restores the control's state.
|
|
3011
|
+
if (honorChecked && hint.checked === "keep" && trigger && "checked" in trigger) {
|
|
3012
|
+
const flipped = trigger.checked
|
|
3013
|
+
undo.push(() => (trigger.checked = !flipped))
|
|
2757
3014
|
}
|
|
3015
|
+
|
|
3016
|
+
return undo
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
// The elements a hint's class/hide/show ops apply to: the `to:` selector
|
|
3020
|
+
// (resolved like an op target — "@root" is the root, a selector is scoped to
|
|
3021
|
+
// this root's owned matches) or, with no `to:`, the trigger itself.
|
|
3022
|
+
#hintTargets(hint, trigger) {
|
|
3023
|
+
if (hint.to == null) return trigger ? [trigger] : []
|
|
3024
|
+
return this.#opTargets({ to: hint.to })
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
// Swap the trigger's disabled/innerHTML for a pending hint, snapshotting the
|
|
3028
|
+
// ORIGINAL once per trigger (refcounted so an overlapping enqueue never
|
|
3029
|
+
// snapshots the already-swapped "Saving…" as the original), and return the undo
|
|
3030
|
+
// closure. text swaps innerHTML (issue #181), NOT textContent: a composite
|
|
3031
|
+
// trigger like `<button><svg/> Save</button>` has child nodes, and
|
|
3032
|
+
// textContent = "Saving…" would DESTROY the icon; innerHTML preserves the
|
|
3033
|
+
// markup structure and restores it byte-for-byte.
|
|
3034
|
+
#applyTextDisable(hint, trigger) {
|
|
3035
|
+
const snap = this.#textDisableSnapshots.get(trigger)
|
|
3036
|
+
if (snap) {
|
|
3037
|
+
snap.count++
|
|
3038
|
+
} else {
|
|
3039
|
+
this.#textDisableSnapshots.set(trigger, {
|
|
3040
|
+
count: 1,
|
|
3041
|
+
disabled: trigger.disabled,
|
|
3042
|
+
html: trigger.innerHTML,
|
|
3043
|
+
hadText: hint.text != null,
|
|
3044
|
+
swappedTo: hint.text,
|
|
3045
|
+
})
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
if (hint.disable) trigger.disabled = true
|
|
3049
|
+
if (hint.text != null) trigger.innerHTML = hint.text
|
|
3050
|
+
|
|
3051
|
+
return () => this.#restoreTextDisable(trigger, hint)
|
|
2758
3052
|
}
|
|
2759
3053
|
|
|
2760
3054
|
// Layer 1 — the always-on busy markers. Trigger + root carry the action token;
|
|
@@ -2820,72 +3114,35 @@ export default class extends Controller {
|
|
|
2820
3114
|
)
|
|
2821
3115
|
}
|
|
2822
3116
|
|
|
2823
|
-
//
|
|
2824
|
-
//
|
|
2825
|
-
// (refcounted so an overlapping enqueue never snapshots the already-swapped
|
|
2826
|
-
// "Saving…" as the original), applies the swap, and returns a restore closure.
|
|
2827
|
-
// With no hint, returns a no-op restore (the always-on busy markers still ran).
|
|
2828
|
-
#applyLoadingHint(action, trigger, loading) {
|
|
2829
|
-
if (!loading || !trigger) return () => {}
|
|
2830
|
-
|
|
2831
|
-
const classTargets = this.#loadingTargets(loading, trigger)
|
|
2832
|
-
const classes = Array.isArray(loading.class) ? loading.class : []
|
|
2833
|
-
const addedByTarget = []
|
|
2834
|
-
for (const el of classTargets) {
|
|
2835
|
-
const added = classes.filter((c) => !el.classList.contains(c))
|
|
2836
|
-
el.classList.add(...added)
|
|
2837
|
-
if (added.length) addedByTarget.push([el, added])
|
|
2838
|
-
}
|
|
2839
|
-
|
|
2840
|
-
// Snapshot disabled/text ONCE per trigger (refcounted). A second overlapping
|
|
2841
|
-
// enqueue increments the count but does NOT re-snapshot — so the recorded
|
|
2842
|
-
// "original" is the true pre-loading state, never the swapped label.
|
|
2843
|
-
const snap = this.#loadingSnapshots.get(trigger)
|
|
2844
|
-
if (snap) {
|
|
2845
|
-
snap.count++
|
|
2846
|
-
} else if (loading.disable || loading.text != null) {
|
|
2847
|
-
this.#loadingSnapshots.set(trigger, {
|
|
2848
|
-
count: 1,
|
|
2849
|
-
disabled: trigger.disabled,
|
|
2850
|
-
text: trigger.textContent,
|
|
2851
|
-
hadText: loading.text != null,
|
|
2852
|
-
})
|
|
2853
|
-
}
|
|
2854
|
-
|
|
2855
|
-
if (loading.disable) trigger.disabled = true
|
|
2856
|
-
if (loading.text != null) trigger.textContent = loading.text
|
|
2857
|
-
|
|
2858
|
-
return () => {
|
|
2859
|
-
for (const [el, added] of addedByTarget) if (el.isConnected) el.classList.remove(...added)
|
|
2860
|
-
this.#restoreLoadingSnapshot(trigger, loading)
|
|
2861
|
-
}
|
|
2862
|
-
}
|
|
2863
|
-
|
|
2864
|
-
// Restore the trigger's disabled/text from its snapshot when the LAST enqueue
|
|
2865
|
-
// for that trigger settles (refcount → 0). GUARDED: skip a disconnected
|
|
3117
|
+
// Restore the trigger's disabled/innerHTML from its snapshot when the LAST
|
|
3118
|
+
// enqueue for that trigger settles (refcount → 0). GUARDED: skip a disconnected
|
|
2866
3119
|
// trigger (a plain replace detached it — the node is gone), and do NOT restore
|
|
2867
|
-
// the
|
|
2868
|
-
// server label — clobbering it with the old
|
|
2869
|
-
|
|
2870
|
-
|
|
3120
|
+
// the label if it no longer equals what we swapped IN (a morph rendered a new
|
|
3121
|
+
// server label — clobbering it with the old markup would fight server truth).
|
|
3122
|
+
// The comparison + restore both use innerHTML so a composite trigger (icon +
|
|
3123
|
+
// label) round-trips its full markup, not a flattened text run (issue #181).
|
|
3124
|
+
#restoreTextDisable(trigger, hint) {
|
|
3125
|
+
const snap = this.#textDisableSnapshots.get(trigger)
|
|
2871
3126
|
if (!snap) return
|
|
2872
3127
|
if (--snap.count > 0) return // another enqueue for this trigger is still pending
|
|
2873
|
-
this.#
|
|
3128
|
+
this.#textDisableSnapshots.delete(trigger)
|
|
2874
3129
|
|
|
2875
3130
|
if (!trigger.isConnected) return // detached — nothing to restore
|
|
2876
3131
|
|
|
2877
|
-
if (
|
|
2878
|
-
|
|
2879
|
-
// changed textContent means the server morph relabeled it — leave it.
|
|
2880
|
-
if (snap.hadText && trigger.textContent === loading.text) trigger.textContent = snap.text
|
|
3132
|
+
if (hint.disable) trigger.disabled = snap.disabled
|
|
3133
|
+
if (snap.hadText && trigger.innerHTML === snap.swappedTo) trigger.innerHTML = snap.html
|
|
2881
3134
|
}
|
|
2882
3135
|
|
|
2883
|
-
//
|
|
2884
|
-
//
|
|
2885
|
-
//
|
|
2886
|
-
#
|
|
2887
|
-
|
|
2888
|
-
|
|
3136
|
+
// Deploy-overlap read shim (issue #181): a page still rendered by the PREVIOUS
|
|
3137
|
+
// gem emits the old data-reactive-loading-param, whose `class:` key is the busy
|
|
3138
|
+
// vocabulary's `add_class:`. Remap it so an in-flight legacy page keeps its
|
|
3139
|
+
// pending affordance through the one #applyHint engine. Returns null for the
|
|
3140
|
+
// common (no legacy param) case so the fast path is untouched. Drop this shim
|
|
3141
|
+
// one minor after #181 ships (no page can still carry the old attr by then).
|
|
3142
|
+
#legacyLoadingHint(loading) {
|
|
3143
|
+
if (!loading || typeof loading !== "object") return null
|
|
3144
|
+
const { class: cls, ...rest } = loading
|
|
3145
|
+
return cls == null ? loading : { ...rest, add_class: cls }
|
|
2889
3146
|
}
|
|
2890
3147
|
|
|
2891
3148
|
// The action path comes from a <meta> tag that is fixed for the page's life,
|