phlex-reactive 0.13.2 → 0.13.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 +182 -0
- data/README.md +155 -12
- data/app/controllers/phlex/reactive/actions_controller.rb +72 -3
- data/app/javascript/phlex/reactive/compute.js +9 -1
- data/app/javascript/phlex/reactive/compute.min.js.map +2 -2
- data/app/javascript/phlex/reactive/confirm_predicate.js +7 -1
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +2 -2
- data/app/javascript/phlex/reactive/inspect.js +21 -9
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +676 -69
- 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 +7 -0
- data/lib/phlex/reactive/component/helpers.rb +69 -22
- data/lib/phlex/reactive/component.rb +2 -1
- data/lib/phlex/reactive/engine.rb +1 -0
- data/lib/phlex/reactive/js.rb +44 -10
- data/lib/phlex/reactive/param_schema.rb +16 -0
- data/lib/phlex/reactive/pending/markup.rb +128 -0
- data/lib/phlex/reactive/pending.rb +25 -21
- data/lib/phlex/reactive/test_helpers.rb +10 -3
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +2 -1
|
@@ -1077,6 +1077,7 @@ const PERSIST_PREFIX = "phlex-reactive:persist:"
|
|
|
1077
1077
|
// Never persisted regardless of author intent: no server default to restore
|
|
1078
1078
|
// into (hidden, file), secrets (password), and non-value controls.
|
|
1079
1079
|
const PERSIST_EXCLUDED_TYPES = new Set(["hidden", "file", "password", "submit", "button", "reset", "image"])
|
|
1080
|
+
const PERSIST_NO_VALUE = Symbol("persist-no-value")
|
|
1080
1081
|
const PERSIST_STATE_ATTR = "data-reactive-persist-state"
|
|
1081
1082
|
// The editor query #collectFields reads (minus the [name] guard — an editor's
|
|
1082
1083
|
// name may live on its IDL `name` getter: Trix's `input=`-paired hidden input).
|
|
@@ -1235,6 +1236,14 @@ function persistEditorReady(el) {
|
|
|
1235
1236
|
return typeof el.value === "string"
|
|
1236
1237
|
}
|
|
1237
1238
|
|
|
1239
|
+
// The same question for #collectFields. A RICH editor (lexxy/trix) is only
|
|
1240
|
+
// ready once its custom element upgraded — Trix defines its elements in a
|
|
1241
|
+
// setTimeout after load — and reading it before that yields "", which is issue
|
|
1242
|
+
// #8. A bare [contenteditable] is plain DOM and always ready.
|
|
1243
|
+
function collectorEditorReady(el) {
|
|
1244
|
+
return PERSIST_EDITOR_TAGS.has(el.localName) ? persistEditorReady(el) : true
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1238
1247
|
// Ask the editor whether it is empty (Lexxy `isEmpty`; Trix
|
|
1239
1248
|
// `editor.getDocument().isEmpty()`), else the exact-string fallback. An
|
|
1240
1249
|
// attachment-only server body is therefore NON-blank and never overwritten.
|
|
@@ -1258,6 +1267,33 @@ function persistSelectMultiple(el) {
|
|
|
1258
1267
|
return el.tagName === "SELECT" && el.multiple
|
|
1259
1268
|
}
|
|
1260
1269
|
|
|
1270
|
+
// How many controls can contribute a value under each `[]` name, counted the
|
|
1271
|
+
// way persistSnapshot fills the slot — a radio is the exception there and here.
|
|
1272
|
+
// A group of ONE is unambiguous: its single entry can only have come from that
|
|
1273
|
+
// control.
|
|
1274
|
+
function persistGroupSizes(controls) {
|
|
1275
|
+
const sizes = new Map()
|
|
1276
|
+
for (const { el, name } of controls) {
|
|
1277
|
+
if (el.type === "radio" || !String(name).endsWith("[]")) continue
|
|
1278
|
+
sizes.set(name, (sizes.get(name) ?? 0) + 1)
|
|
1279
|
+
}
|
|
1280
|
+
return sizes
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// The value a control that cannot pick its own entry out of a list may take.
|
|
1284
|
+
// A list belongs to a `[]` group, and in a group of two or more nothing says
|
|
1285
|
+
// which entry came from which control — restoring it would paste
|
|
1286
|
+
// "freeform,news" into a text field. A group of ONE has no such ambiguity, and
|
|
1287
|
+
// refusing it would silently drop the draft of a plain field whose name merely
|
|
1288
|
+
// ends in `[]` (a list JS maintains), which worked before groups existed.
|
|
1289
|
+
// Returns PERSIST_NO_VALUE when the control must keep what the server rendered.
|
|
1290
|
+
function persistGenericValue(value, name, sizes) {
|
|
1291
|
+
if (!Array.isArray(value)) return value
|
|
1292
|
+
if (sizes.get(name) !== 1 || value.length !== 1) return PERSIST_NO_VALUE
|
|
1293
|
+
|
|
1294
|
+
return value[0]
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1261
1297
|
// Snapshot the owned controls: radio → the checked value (null when the group
|
|
1262
1298
|
// has none, so a restore leaves it alone), checkbox → checked, multi-select →
|
|
1263
1299
|
// the selected values, a rich editor → its serialized `value` (omitted while
|
|
@@ -1265,18 +1301,52 @@ function persistSelectMultiple(el) {
|
|
|
1265
1301
|
// its textContent, else .value. Mirrors #collectFields' reads.
|
|
1266
1302
|
function persistSnapshot(root, payload) {
|
|
1267
1303
|
const fields = {}
|
|
1304
|
+
// A `[]` name is a group for every control that can contribute a value —
|
|
1305
|
+
// checkboxes, selects, text inputs, editors and contenteditables all append
|
|
1306
|
+
// to one array, mirroring #collectFields. The one exception is a RADIO group,
|
|
1307
|
+
// which means "pick one" and keeps its single value with or without the
|
|
1308
|
+
// suffix, exactly as the collector treats it.
|
|
1309
|
+
//
|
|
1310
|
+
// Reading the slot without this (fields[name] ?? []) breaks as soon as a
|
|
1311
|
+
// non-checkbox shares the group's name: a text input or an editor leaves a
|
|
1312
|
+
// string there, `.push` on it throws inside the draft write, and that write
|
|
1313
|
+
// is swallowed — the root then persists nothing at all, silently. Editors
|
|
1314
|
+
// are collected AFTER the native controls, so they always land last.
|
|
1315
|
+
const groupSlot = (name) => {
|
|
1316
|
+
const existing = fields[name]
|
|
1317
|
+
return Array.isArray(existing) ? existing : (fields[name] = [])
|
|
1318
|
+
}
|
|
1268
1319
|
for (const { el, name, kind } of persistControls(root, payload)) {
|
|
1320
|
+
const group = String(name).endsWith("[]")
|
|
1269
1321
|
if (kind === "editor") {
|
|
1270
|
-
if (persistEditorReady(el))
|
|
1322
|
+
if (persistEditorReady(el)) {
|
|
1323
|
+
if (group) groupSlot(name).push(el.value)
|
|
1324
|
+
else fields[name] = el.value
|
|
1325
|
+
}
|
|
1271
1326
|
} else if (kind === "contenteditable") {
|
|
1272
|
-
|
|
1327
|
+
const text = el.textContent ?? ""
|
|
1328
|
+
if (group) groupSlot(name).push(text)
|
|
1329
|
+
else fields[name] = text
|
|
1273
1330
|
} else if (el.type === "radio") {
|
|
1274
1331
|
if (el.checked) fields[name] = el.value
|
|
1275
1332
|
else if (!Object.hasOwn(fields, name)) fields[name] = null
|
|
1276
1333
|
} else if (el.type === "checkbox") {
|
|
1277
|
-
|
|
1334
|
+
// A `[]` group drafts the list of ticked values, mirroring #collectFields
|
|
1335
|
+
// (issue #258). Without this the boxes overwrote each other and the draft
|
|
1336
|
+
// held one boolean, which the restore then applied to every box of the
|
|
1337
|
+
// group. A lone checkbox keeps the boolean it has always been.
|
|
1338
|
+
if (group) {
|
|
1339
|
+
const slot = groupSlot(name)
|
|
1340
|
+
if (el.checked) slot.push(el.value)
|
|
1341
|
+
} else {
|
|
1342
|
+
fields[name] = el.checked
|
|
1343
|
+
}
|
|
1278
1344
|
} else if (persistSelectMultiple(el)) {
|
|
1279
|
-
|
|
1345
|
+
const selected = [...el.options].filter((o) => o.selected).map((o) => o.value)
|
|
1346
|
+
if (group) groupSlot(name).push(...selected)
|
|
1347
|
+
else fields[name] = selected
|
|
1348
|
+
} else if (group) {
|
|
1349
|
+
groupSlot(name).push(el.value)
|
|
1280
1350
|
} else {
|
|
1281
1351
|
fields[name] = el.value
|
|
1282
1352
|
}
|
|
@@ -1292,10 +1362,61 @@ function persistSnapshot(root, payload) {
|
|
|
1292
1362
|
function persistApply(root, payload, fields) {
|
|
1293
1363
|
const always = payload.restore === "always"
|
|
1294
1364
|
const controls = persistControls(root, payload)
|
|
1365
|
+
// Which group names the SERVER rendered with a box already ticked. Computed
|
|
1366
|
+
// BEFORE the loop on purpose: the loop writes `checked`, so asking this
|
|
1367
|
+
// question from inside it would read THIS restore's own work — the first box
|
|
1368
|
+
// it ticks makes every later box of the same group look server-rendered, and
|
|
1369
|
+
// a draft of two values comes back as one. The radio branch below asks the
|
|
1370
|
+
// same question inline and stays correct only because a radio group holds a
|
|
1371
|
+
// single value.
|
|
1372
|
+
const groupSizes = persistGroupSizes(controls)
|
|
1373
|
+
const serverTicked = new Set()
|
|
1374
|
+
for (const control of controls) {
|
|
1375
|
+
if (control.kind === "native" && control.el.type === "checkbox" && control.el.checked) {
|
|
1376
|
+
serverTicked.add(control.name)
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1295
1379
|
for (const { el, name, kind } of controls) {
|
|
1296
1380
|
if (!Object.hasOwn(fields, name)) continue
|
|
1297
|
-
|
|
1381
|
+
let value = fields[name]
|
|
1298
1382
|
if (value === null || value === undefined) continue
|
|
1383
|
+
// A multi-select reads a list by matching option values, which is only
|
|
1384
|
+
// sound when the list is ITS list. In a group with another contributor the
|
|
1385
|
+
// entries are mixed, and a text value that happens to equal an option
|
|
1386
|
+
// would select it — measured, a draft of ["blue","freitext"] from a select
|
|
1387
|
+
// plus a text field selected both options. `?? 0` because a name without
|
|
1388
|
+
// the suffix is not in the map at all, and a plain `<select multiple
|
|
1389
|
+
// name="colors">` must keep restoring.
|
|
1390
|
+
if (Array.isArray(value) && persistSelectMultiple(el) && (groupSizes.get(name) ?? 0) > 1) continue
|
|
1391
|
+
|
|
1392
|
+
// An array belongs to a `[]` group, and only a control that can pick ITS
|
|
1393
|
+
// entry out of the list may read it: a checkbox matches by value, a
|
|
1394
|
+
// multi-select by option. Everything else — editors, contenteditables,
|
|
1395
|
+
// text inputs — keeps what the server rendered, because the list does not
|
|
1396
|
+
// record which entry came from which control. This sits ABOVE the branch
|
|
1397
|
+
// chain on purpose: below the editor branch it would never fire for the
|
|
1398
|
+
// very controls that land last in the snapshot.
|
|
1399
|
+
if (Array.isArray(value) && !(el.type === "checkbox" || persistSelectMultiple(el))) {
|
|
1400
|
+
value = persistGenericValue(value, name, groupSizes)
|
|
1401
|
+
if (value === PERSIST_NO_VALUE) continue
|
|
1402
|
+
}
|
|
1403
|
+
// The mirror, for a draft written BEFORE a group was drafted as a list
|
|
1404
|
+
// (#258): there `features[]` held ONE boolean, and applying it here ticks
|
|
1405
|
+
// every box of the group — precisely the state this fix removes, for as
|
|
1406
|
+
// long as the draft lives (default ttl 7 days). A group key that is not a
|
|
1407
|
+
// list is stale by definition, so the control keeps what the server
|
|
1408
|
+
// rendered and the next snapshot overwrites the key. It reads for a
|
|
1409
|
+
// multi-select too: 0.13.2 wrote last-writer-wins per name, so a checkbox
|
|
1410
|
+
// in a mixed group could leave its boolean under the select's name, and
|
|
1411
|
+
// under `restore: "always"` the select would then deselect everything —
|
|
1412
|
+
// `wanted` being Set{"true"} matches no option. Asking for the `[]` suffix
|
|
1413
|
+
// is what leaves a lone `gift` checkbox on the boolean it has always held —
|
|
1414
|
+
// and scoping the rule to the one key whose meaning changed is why
|
|
1415
|
+
// PERSIST_VERSION stays at 1: bumping it would also throw away the drafted
|
|
1416
|
+
// prose of every form that has no checkbox group at all.
|
|
1417
|
+
if ((el.type === "checkbox" || persistSelectMultiple(el)) && String(name).endsWith("[]") && !Array.isArray(value)) {
|
|
1418
|
+
continue
|
|
1419
|
+
}
|
|
1299
1420
|
if (kind === "editor") {
|
|
1300
1421
|
persistApplyEditor(root, el, name, value, always)
|
|
1301
1422
|
} else if (kind === "contenteditable") {
|
|
@@ -1304,6 +1425,12 @@ function persistApply(root, payload, fields) {
|
|
|
1304
1425
|
} else if (el.type === "radio") {
|
|
1305
1426
|
if (!always && controls.some((c) => c.kind === "native" && c.el.type === "radio" && c.name === name && c.el.checked)) continue
|
|
1306
1427
|
el.checked = el.value === String(value)
|
|
1428
|
+
} else if (el.type === "checkbox" && Array.isArray(value)) {
|
|
1429
|
+
// A drafted group ticks exactly the boxes it held. One box the SERVER
|
|
1430
|
+
// rendered ticked means it had a say, and the draft yields for the whole
|
|
1431
|
+
// group.
|
|
1432
|
+
if (!always && serverTicked.has(name)) continue
|
|
1433
|
+
el.checked = value.map(String).includes(el.value)
|
|
1307
1434
|
} else if (el.type === "checkbox") {
|
|
1308
1435
|
if (!always && el.checked) continue
|
|
1309
1436
|
el.checked = Boolean(value)
|
|
@@ -1323,6 +1450,11 @@ function persistApply(root, payload, fields) {
|
|
|
1323
1450
|
// editor's sanitizing import (Trix HTMLParser, Lexxy $generateNodesFromDOM +
|
|
1324
1451
|
// sanitizer). A throw (Lexxy before its editor exists) never escapes connect.
|
|
1325
1452
|
function persistApplyEditor(root, el, name, value, always) {
|
|
1453
|
+
// A list never reaches here: both callers resolve it through
|
|
1454
|
+
// persistGenericValue first — the group of two or more has no mapping back to
|
|
1455
|
+
// this editor, the group of one does. Belt and braces, because this is the
|
|
1456
|
+
// one apply path with a second entry point.
|
|
1457
|
+
if (Array.isArray(value)) return
|
|
1326
1458
|
if (!persistEditorReady(el)) return // not upgraded yet — persistDeferEditors re-applies after define
|
|
1327
1459
|
if (!always && !persistEditorBlank(el)) return
|
|
1328
1460
|
try {
|
|
@@ -1348,10 +1480,12 @@ function persistDeferEditors(root, payload, fields) {
|
|
|
1348
1480
|
for (const tag of pending) {
|
|
1349
1481
|
registry.whenDefined(tag).then(() => {
|
|
1350
1482
|
if (!root.isConnected) return
|
|
1351
|
-
|
|
1483
|
+
const deferred = persistControls(root, payload)
|
|
1484
|
+
const sizes = persistGroupSizes(deferred)
|
|
1485
|
+
for (const { el, name, kind } of deferred) {
|
|
1352
1486
|
if (kind !== "editor" || el.localName !== tag || !Object.hasOwn(fields, name)) continue
|
|
1353
|
-
const value = fields[name]
|
|
1354
|
-
if (value === null || value === undefined) continue
|
|
1487
|
+
const value = persistGenericValue(fields[name], name, sizes)
|
|
1488
|
+
if (value === null || value === undefined || value === PERSIST_NO_VALUE) continue
|
|
1355
1489
|
persistApplyEditor(root, el, name, value, always)
|
|
1356
1490
|
}
|
|
1357
1491
|
})
|
|
@@ -1390,16 +1524,18 @@ function persistClearRoot(root) {
|
|
|
1390
1524
|
// Frozen so nothing can be registered into it at runtime — extending the
|
|
1391
1525
|
// vocabulary is a gem change, not an app hook.
|
|
1392
1526
|
const CLIENT_OPS = Object.freeze({
|
|
1393
|
-
show: (el, args) =>
|
|
1394
|
-
hide: (el, args) =>
|
|
1395
|
-
toggle: (el, args) =>
|
|
1527
|
+
show: (el, args, resolveTargets) => setVisibility(el, false, args, resolveTargets),
|
|
1528
|
+
hide: (el, args, resolveTargets) => setVisibility(el, true, args, resolveTargets),
|
|
1529
|
+
toggle: (el, args, resolveTargets) => setVisibility(el, !el.hidden, args, resolveTargets),
|
|
1396
1530
|
add_class: (el, args) => el.classList.add(...(args.classes ?? [])),
|
|
1397
1531
|
remove_class: (el, args) => el.classList.remove(...(args.classes ?? [])),
|
|
1398
1532
|
toggle_class: (el, args) => (args.classes ?? []).forEach((c) => el.classList.toggle(c)),
|
|
1399
1533
|
|
|
1400
1534
|
// Attribute ops (issue #96), interpret-time allowlisted. set_attr writes the
|
|
1401
1535
|
// (already-stringified) value; toggle_attr adds a missing attr (value "") or
|
|
1402
|
-
// removes a present one
|
|
1536
|
+
// removes a present one — or, given values [on, off] (issue #271), flips
|
|
1537
|
+
// between them (an absent attr becomes `on`); remove_attr removes it. A
|
|
1538
|
+
// refused name warns + skips.
|
|
1403
1539
|
set_attr: (el, args) => {
|
|
1404
1540
|
if (guardAttr(args.name)) el.setAttribute(args.name, args.value ?? "")
|
|
1405
1541
|
},
|
|
@@ -1408,7 +1544,10 @@ const CLIENT_OPS = Object.freeze({
|
|
|
1408
1544
|
},
|
|
1409
1545
|
toggle_attr: (el, args) => {
|
|
1410
1546
|
if (!guardAttr(args.name)) return
|
|
1411
|
-
if (
|
|
1547
|
+
if (Array.isArray(args.values)) {
|
|
1548
|
+
const [on, off] = args.values
|
|
1549
|
+
el.setAttribute(args.name, el.getAttribute(args.name) === on ? off : on)
|
|
1550
|
+
} else if (el.hasAttribute(args.name)) el.removeAttribute(args.name)
|
|
1412
1551
|
else el.setAttribute(args.name, "")
|
|
1413
1552
|
},
|
|
1414
1553
|
|
|
@@ -1508,6 +1647,18 @@ function setHidden(el, hidden, args) {
|
|
|
1508
1647
|
else el.hidden = hidden
|
|
1509
1648
|
}
|
|
1510
1649
|
|
|
1650
|
+
// show/hide/toggle (issue #271): flip visibility, then mirror the INTENDED
|
|
1651
|
+
// state into the `expanded:` target's aria-expanded — computed before the flip,
|
|
1652
|
+
// so a transition never delays or races it. The expanded target resolves with
|
|
1653
|
+
// the op's own scoping (root-scoped, or document-wide under global: true).
|
|
1654
|
+
function setVisibility(el, hidden, args, resolveTargets) {
|
|
1655
|
+
setHidden(el, hidden, args)
|
|
1656
|
+
if (args?.expanded == null || typeof resolveTargets !== "function") return
|
|
1657
|
+
for (const target of resolveTargets({ ...args, to: args.expanded })) {
|
|
1658
|
+
target.setAttribute?.("aria-expanded", String(!hidden))
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1511
1662
|
// The interpret-time attribute guard: refuse (warn + skip) a name off the
|
|
1512
1663
|
// allowlist. Returns true when the op may proceed.
|
|
1513
1664
|
function guardAttr(name) {
|
|
@@ -1813,6 +1964,256 @@ function parseOps(raw) {
|
|
|
1813
1964
|
}
|
|
1814
1965
|
}
|
|
1815
1966
|
|
|
1967
|
+
// --- on_client binding records (issue #271) -----------------------------------
|
|
1968
|
+
// Each on_client call emits ONE record ({on, ops, window?, outside?, confirm?,
|
|
1969
|
+
// confirmWhen?}); mix space-joins several onto one element (spaces inside a
|
|
1970
|
+
// record ride as \u0020, so the join is unambiguous). Stimulus hands every
|
|
1971
|
+
// runOps descriptor on an element the SAME event.params, so runOps selects the
|
|
1972
|
+
// record(s) whose descriptor matches the firing event: event.type, the key
|
|
1973
|
+
// filter, and window-boundness.
|
|
1974
|
+
|
|
1975
|
+
// Stimulus's default keyMappings (letters and digits map to themselves). A
|
|
1976
|
+
// custom Stimulus schema is not mirrored — an unknown filter never matches.
|
|
1977
|
+
const KEY_FILTER_MAP = Object.freeze({
|
|
1978
|
+
enter: "Enter",
|
|
1979
|
+
tab: "Tab",
|
|
1980
|
+
esc: "Escape",
|
|
1981
|
+
space: " ",
|
|
1982
|
+
up: "ArrowUp",
|
|
1983
|
+
down: "ArrowDown",
|
|
1984
|
+
left: "ArrowLeft",
|
|
1985
|
+
right: "ArrowRight",
|
|
1986
|
+
home: "Home",
|
|
1987
|
+
end: "End",
|
|
1988
|
+
page_up: "PageUp",
|
|
1989
|
+
page_down: "PageDown",
|
|
1990
|
+
})
|
|
1991
|
+
const KEY_FILTER_MODIFIERS = Object.freeze(["meta", "ctrl", "alt", "shift"])
|
|
1992
|
+
|
|
1993
|
+
// Stimulus's rule: the four modifiers must match EXACTLY (ctrl+k is not k);
|
|
1994
|
+
// the remaining token is compared to event.key case-insensitively. A
|
|
1995
|
+
// non-keyboard event (no event.key) checks the modifiers only. `keyMappings`
|
|
1996
|
+
// is the app's Stimulus schema table (custom keys included) when the
|
|
1997
|
+
// controller has one; the default table is the fallback.
|
|
1998
|
+
function keyFilterMatches(filter, event, keyMappings) {
|
|
1999
|
+
const parts = filter.split("+")
|
|
2000
|
+
for (const mod of KEY_FILTER_MODIFIERS) {
|
|
2001
|
+
if (parts.includes(mod) !== Boolean(event[`${mod}Key`])) return false
|
|
2002
|
+
}
|
|
2003
|
+
const token = parts.find((part) => !KEY_FILTER_MODIFIERS.includes(part))
|
|
2004
|
+
if (token === undefined || typeof event.key !== "string") return true
|
|
2005
|
+
const key =
|
|
2006
|
+
keyMappings && Object.hasOwn(keyMappings, token)
|
|
2007
|
+
? keyMappings[token]
|
|
2008
|
+
: Object.hasOwn(KEY_FILTER_MAP, token)
|
|
2009
|
+
? KEY_FILTER_MAP[token]
|
|
2010
|
+
: /^[a-z0-9]$/.test(token)
|
|
2011
|
+
? token
|
|
2012
|
+
: null
|
|
2013
|
+
return typeof key === "string" && key.toLowerCase() === event.key.toLowerCase()
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
// Parse data-reactive-ops-param into binding records. Stimulus typecasts a
|
|
2017
|
+
// lone record to an object; several records stay a string (JSON.parse fails on
|
|
2018
|
+
// the join) and are split here. A LEGACY [[op, args]] list (array or JSON
|
|
2019
|
+
// string — a hand-built attr) becomes one record flagged `legacy`: it has no
|
|
2020
|
+
// `on`, so it always matches, and runOps reads its flags from event.params. A
|
|
2021
|
+
// malformed piece warns and is skipped; its siblings still run (default-deny).
|
|
2022
|
+
function parseBindingRecords(raw) {
|
|
2023
|
+
if (Array.isArray(raw)) return [{ ops: raw, legacy: true }]
|
|
2024
|
+
if (raw && typeof raw === "object") return [raw]
|
|
2025
|
+
if (typeof raw !== "string") return []
|
|
2026
|
+
const text = raw.trim()
|
|
2027
|
+
if (text.startsWith("[")) return [{ ops: parseOps(text), legacy: true }]
|
|
2028
|
+
const records = []
|
|
2029
|
+
for (const piece of text.split(" ")) {
|
|
2030
|
+
if (piece === "") continue
|
|
2031
|
+
let record = null
|
|
2032
|
+
try {
|
|
2033
|
+
record = JSON.parse(piece)
|
|
2034
|
+
} catch {
|
|
2035
|
+
record = null
|
|
2036
|
+
}
|
|
2037
|
+
if (record && typeof record === "object" && !Array.isArray(record)) records.push(record)
|
|
2038
|
+
else console.warn(`[phlex-reactive] malformed on_client binding record ${JSON.stringify(piece)} — skipped`)
|
|
2039
|
+
}
|
|
2040
|
+
return records
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
// Does this record's descriptor match the firing event? The window check
|
|
2044
|
+
// matters for an element carrying both `click` and `click@window`: one inside
|
|
2045
|
+
// click reaches both listeners, and only currentTarget tells them apart. The
|
|
2046
|
+
// `window != null` guard keeps a window-less harness from classifying every
|
|
2047
|
+
// undefined currentTarget as window-bound. Only a LEGACY record (no `on` by
|
|
2048
|
+
// construction) matches everything; any other record without a descriptor is
|
|
2049
|
+
// malformed and never matches (default-deny). `keyMappings === null` skips the
|
|
2050
|
+
// key filter (the caller's single-candidate shortcut).
|
|
2051
|
+
function bindingMatches(record, event, keyMappings) {
|
|
2052
|
+
if (record.legacy) return true
|
|
2053
|
+
const on = record.on
|
|
2054
|
+
if (typeof on !== "string" || on === "") return false
|
|
2055
|
+
const dot = on.indexOf(".")
|
|
2056
|
+
if (event.type !== (dot < 0 ? on : on.slice(0, dot))) return false
|
|
2057
|
+
const win = globalThis.window
|
|
2058
|
+
const windowBound = win != null && event.currentTarget === win
|
|
2059
|
+
if (Boolean(record.window) !== windowBound) return false
|
|
2060
|
+
return dot < 0 || keyMappings === null || keyFilterMatches(on.slice(dot + 1), event, keyMappings)
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
// A `once: true` record (PR #272) is spent after its first run. Stimulus
|
|
2064
|
+
// removes only that binding's own listener; a regular same-event sibling on
|
|
2065
|
+
// the element keeps calling runOps with BOTH records, so the spent one is
|
|
2066
|
+
// skipped here. Spent state belongs to the TRIGGER element (a WeakMap, so it
|
|
2067
|
+
// is collected with it): a morph/stream that swaps in a fresh element starts
|
|
2068
|
+
// fresh, and a byte-identical sibling trigger has its own. A window-bound
|
|
2069
|
+
// binding's currentTarget is the window, so there the element's ops attr joins
|
|
2070
|
+
// the key (a re-rendered window-bound once trigger with the same markup stays
|
|
2071
|
+
// spent — a known limit).
|
|
2072
|
+
const spentOnceBindings = new WeakMap()
|
|
2073
|
+
function onceBindingSpent(controller, event, record) {
|
|
2074
|
+
if (!record.once) return false
|
|
2075
|
+
const owner = event.currentTarget ?? controller.element
|
|
2076
|
+
let byOwner = spentOnceBindings.get(controller)
|
|
2077
|
+
if (!byOwner) {
|
|
2078
|
+
byOwner = new WeakMap()
|
|
2079
|
+
spentOnceBindings.set(controller, byOwner)
|
|
2080
|
+
}
|
|
2081
|
+
let spent = byOwner.get(owner)
|
|
2082
|
+
if (!spent) {
|
|
2083
|
+
spent = new Set()
|
|
2084
|
+
byOwner.set(owner, spent)
|
|
2085
|
+
}
|
|
2086
|
+
const raw = event.params?.ops
|
|
2087
|
+
const attr = owner === globalThis.window ? `${typeof raw === "string" ? raw : JSON.stringify(raw ?? null)}|` : ""
|
|
2088
|
+
const key = attr + JSON.stringify(record)
|
|
2089
|
+
if (spent.has(key)) return true
|
|
2090
|
+
spent.add(key)
|
|
2091
|
+
return false
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
// Two identical descriptors on one element (two mix-ed on_client calls with
|
|
2095
|
+
// the same event) are two Stimulus bindings, so runOps runs twice for ONE
|
|
2096
|
+
// event. The first call runs every matching record; a repeat is a no-op.
|
|
2097
|
+
// "Repeat" is (controller, listener target, the element's ops attr): Stimulus
|
|
2098
|
+
// walks EVERY binding of one window listener with the SAME event object, so
|
|
2099
|
+
// keying on currentTarget alone (= window) would drop every other root's
|
|
2100
|
+
// window-bound binding — and a sibling element's within one root. The ops attr
|
|
2101
|
+
// is the per-element key (a lone record is typecast to a fresh object per
|
|
2102
|
+
// binding, so it is compared by its JSON). Two elements in one root carrying
|
|
2103
|
+
// byte-identical window-bound records run once — the same ops, applied once.
|
|
2104
|
+
const ranBindings = new WeakMap()
|
|
2105
|
+
function bindingsAlreadyRan(event, controller) {
|
|
2106
|
+
if (event === null || typeof event !== "object") return false
|
|
2107
|
+
const raw = event.params?.ops
|
|
2108
|
+
const key = typeof raw === "string" ? raw : JSON.stringify(raw ?? null)
|
|
2109
|
+
let byController = ranBindings.get(event)
|
|
2110
|
+
if (!byController) {
|
|
2111
|
+
byController = new WeakMap()
|
|
2112
|
+
ranBindings.set(event, byController)
|
|
2113
|
+
}
|
|
2114
|
+
let byTarget = byController.get(controller)
|
|
2115
|
+
if (!byTarget) {
|
|
2116
|
+
byTarget = new Map()
|
|
2117
|
+
byController.set(controller, byTarget)
|
|
2118
|
+
}
|
|
2119
|
+
let keys = byTarget.get(event.currentTarget)
|
|
2120
|
+
if (!keys) {
|
|
2121
|
+
keys = new Set()
|
|
2122
|
+
byTarget.set(event.currentTarget, keys)
|
|
2123
|
+
}
|
|
2124
|
+
if (keys.has(key)) return true
|
|
2125
|
+
keys.add(key)
|
|
2126
|
+
return false
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// The TEXT reading of a compute control (issue #262) — what a :string input
|
|
2130
|
+
// hands the reducer and what the identity/cross-root mirrors paint. A checkbox
|
|
2131
|
+
// reads its CHECKED STATE ("true"/"false", the strings reactive_show compares
|
|
2132
|
+
// against): its .value is a constant — "1", "on", whatever the markup says —
|
|
2133
|
+
// so reading it told the reducer nothing. A radio reads its value only while
|
|
2134
|
+
// checked ("" otherwise; the resolver hands over the checked radio of a group).
|
|
2135
|
+
// Anything else reads .value, as it always has.
|
|
2136
|
+
//
|
|
2137
|
+
// Every compute helper takes a CONTROL — the { el, kind } record #recompute's
|
|
2138
|
+
// resolver builds, kind being "checkbox", "radio" or "" — and never re-reads
|
|
2139
|
+
// el.type: the resolver reads it ONCE per name. Re-reading it in each helper
|
|
2140
|
+
// cost the 30-input calculator bench ~60% (16.8 → 27 µs/iter, measured).
|
|
2141
|
+
function computeText({ el, kind }) {
|
|
2142
|
+
if (!el) return ""
|
|
2143
|
+
if (kind === "checkbox") return el.checked ? "true" : "false"
|
|
2144
|
+
if (kind === "radio") return el.checked ? (el.value ?? "") : ""
|
|
2145
|
+
return el.value ?? ""
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// The control for a name nothing owned resolves to.
|
|
2149
|
+
const COMPUTE_NO_CONTROL = Object.freeze({ el: null, kind: "" })
|
|
2150
|
+
|
|
2151
|
+
// Whether a value counts as "on" — for a :boolean input read off a control that
|
|
2152
|
+
// is not a checkbox, and for an output written INTO a checkbox. A boolean is
|
|
2153
|
+
// itself; otherwise "", "0" and "false" are off (what a hidden flag field or a
|
|
2154
|
+
// reducer returning 0 means) and anything else is on.
|
|
2155
|
+
function computeTruthy(value) {
|
|
2156
|
+
if (typeof value === "boolean") return value
|
|
2157
|
+
if (value == null) return false
|
|
2158
|
+
const text = String(value)
|
|
2159
|
+
return text !== "" && text !== "0" && text !== "false"
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
// One declared input's value for the reducer, coerced by its declared type
|
|
2163
|
+
// (issue #104; checked-state controls issue #262):
|
|
2164
|
+
//
|
|
2165
|
+
// "string" → the text reading, raw (blank/absent → "")
|
|
2166
|
+
// "boolean" → a checkbox's checked state; any other control by computeTruthy
|
|
2167
|
+
// "number" → a checkbox is 1/0; anything else through Number (blank/NaN → 0,
|
|
2168
|
+
// the nanToZero the hand-written calculators use)
|
|
2169
|
+
//
|
|
2170
|
+
// A checkbox is 1/0 and never Number(its value): a box is a yes/no, and a
|
|
2171
|
+
// reducer that wants an amount writes `gift ? 25 : 0`.
|
|
2172
|
+
function computeValue(control, type) {
|
|
2173
|
+
if (type === "string") return computeText(control)
|
|
2174
|
+
const box = control.kind === "checkbox"
|
|
2175
|
+
if (type === "boolean") return box ? Boolean(control.el.checked) : computeTruthy(computeText(control))
|
|
2176
|
+
if (box) return control.el.checked ? 1 : 0
|
|
2177
|
+
const n = Number(computeText(control))
|
|
2178
|
+
return Number.isFinite(n) ? n : 0
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// Write one reducer output into the control its name resolved to (issue #262),
|
|
2182
|
+
// change-guarded. Returns the element to announce with an `input` event, or
|
|
2183
|
+
// null when nothing changed. A checkbox takes the result as its checked state
|
|
2184
|
+
// and a radio group checks the radio carrying it — neither ever has its value
|
|
2185
|
+
// attribute rewritten, which would change what the control SUBMITS. Anything
|
|
2186
|
+
// else takes the result as its .value (issue #76).
|
|
2187
|
+
function computeWrite(root, owns, { el, kind }, domName, value) {
|
|
2188
|
+
if (kind === "checkbox") {
|
|
2189
|
+
const checked = computeTruthy(value)
|
|
2190
|
+
if (Boolean(el.checked) === checked) return null
|
|
2191
|
+
el.checked = checked
|
|
2192
|
+
return el
|
|
2193
|
+
}
|
|
2194
|
+
if (kind === "radio") return computeCheckRadio(root, owns, domName, String(value))
|
|
2195
|
+
if (String(value) === el.value) return null
|
|
2196
|
+
el.value = value
|
|
2197
|
+
return el
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// Check the owned radio of a group whose value is `wanted`, unchecking the
|
|
2201
|
+
// rest — the same per-radio rule a restored draft applies. A value no radio
|
|
2202
|
+
// carries clears the group. Returns the radio that GAINED the check, or, for a
|
|
2203
|
+
// cleared group, the one that lost it; null when the selection already matched.
|
|
2204
|
+
function computeCheckRadio(root, owns, domName, wanted) {
|
|
2205
|
+
let announced = null
|
|
2206
|
+
for (const el of root.querySelectorAll(`[name="${domName}"]`)) {
|
|
2207
|
+
if (el.type !== "radio" || !owns(el)) continue
|
|
2208
|
+
const checked = el.value === wanted
|
|
2209
|
+
if (Boolean(el.checked) === checked) continue
|
|
2210
|
+
el.checked = checked
|
|
2211
|
+
if (checked) announced = el
|
|
2212
|
+
else announced ??= el
|
|
2213
|
+
}
|
|
2214
|
+
return announced
|
|
2215
|
+
}
|
|
2216
|
+
|
|
1816
2217
|
// Normalize a reducer's reserved $ops output (issue #226): the compute `ops`
|
|
1817
2218
|
// builder (its .ops list), a raw [[name, args], ...] array, or null/undefined
|
|
1818
2219
|
// (no effect this pass). An EMPTY list is the same as null — "nothing to run"
|
|
@@ -1845,7 +2246,7 @@ function applyOps(list, resolveTargets, onZeroTargets) {
|
|
|
1845
2246
|
}
|
|
1846
2247
|
const targets = resolveTargets(args)
|
|
1847
2248
|
if (targets.length === 0 && onZeroTargets) onZeroTargets(name, args)
|
|
1848
|
-
for (const el of targets) CLIENT_OPS[name](el, args)
|
|
2249
|
+
for (const el of targets) CLIENT_OPS[name](el, args, resolveTargets)
|
|
1849
2250
|
}
|
|
1850
2251
|
}
|
|
1851
2252
|
|
|
@@ -2384,11 +2785,33 @@ export default class extends Controller {
|
|
|
2384
2785
|
// the component resets whatever they toggled (by design — a signed action
|
|
2385
2786
|
// owns state that must survive re-renders).
|
|
2386
2787
|
runOps(event) {
|
|
2387
|
-
|
|
2788
|
+
if (bindingsAlreadyRan(event, this)) return
|
|
2789
|
+
const params = event.params ?? {}
|
|
2388
2790
|
// The trigger element on_client was spread onto (issue #222 ctx: { el }),
|
|
2389
2791
|
// captured now — currentTarget resets before the confirm resolver's microtask.
|
|
2390
2792
|
const trigger = event.currentTarget ?? event.target
|
|
2793
|
+
// Issue #271: run only the binding record(s) whose descriptor fired. A
|
|
2794
|
+
// legacy [[op, args]] attr reads its flags from the element-wide params.
|
|
2795
|
+
const records = parseBindingRecords(params.ops)
|
|
2796
|
+
// Stimulus already applied the firing descriptor's key filter (custom
|
|
2797
|
+
// schema keys included), so a lone candidate on type + window-boundness
|
|
2798
|
+
// runs without a second key check; several same-type candidates are told
|
|
2799
|
+
// apart by their key filters, read through the app's Stimulus schema.
|
|
2800
|
+
const candidates = records.filter((record) => bindingMatches(record, event, null))
|
|
2801
|
+
const matching =
|
|
2802
|
+
candidates.length <= 1
|
|
2803
|
+
? candidates
|
|
2804
|
+
: candidates.filter((record) => bindingMatches(record, event, this.application?.schema?.keyMappings))
|
|
2805
|
+
if (matching.length === 0 && records.length > 0) return this.#warnNoBinding(event)
|
|
2806
|
+
for (const record of matching) {
|
|
2807
|
+
if (onceBindingSpent(this, event, record)) continue
|
|
2808
|
+
this.#runBinding(record.legacy ? { ...params, ops: record.ops } : record, event, trigger)
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2391
2811
|
|
|
2812
|
+
// One on_client binding (issue #95): its outside guard, preventDefault rule,
|
|
2813
|
+
// confirm gate, then its ops — every flag read from the binding itself.
|
|
2814
|
+
#runBinding({ ops, confirm, confirmWhen, outside, window: windowBound }, event, trigger) {
|
|
2392
2815
|
// Outside guard FIRST — identical semantics to dispatch() (issue #80): an
|
|
2393
2816
|
// outside: trigger is a COMPLETE no-op for events inside this root, before
|
|
2394
2817
|
// preventDefault and before any op runs.
|
|
@@ -2426,6 +2849,17 @@ export default class extends Controller {
|
|
|
2426
2849
|
})
|
|
2427
2850
|
}
|
|
2428
2851
|
|
|
2852
|
+
// Issue #271: runOps fired but no record matched the event — a hand-edited
|
|
2853
|
+
// attr or a descriptor the matcher doesn't know. Verbose gate only, deduped.
|
|
2854
|
+
#warnNoBinding(event) {
|
|
2855
|
+
if (!this.#verboseEnabled()) return
|
|
2856
|
+
const key = `no-binding|${event.type}|${this.element?.id || "?"}`
|
|
2857
|
+
if (zeroTargetAlreadyWarned(key)) return
|
|
2858
|
+
console.warn(
|
|
2859
|
+
`[phlex-reactive] runOps on #${this.element?.id || "?"} found no on_client binding matching a "${event.type}" event — nothing ran`,
|
|
2860
|
+
)
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2429
2863
|
// Dirty tracking (issue #103). Wired by reactive_field(dirty: true) /
|
|
2430
2864
|
// reactive_root(track_dirty: true): an `input` on an owned field runs a FULL
|
|
2431
2865
|
// re-scan of every field this root owns. NO round trip, NO shipped state — the
|
|
@@ -2480,8 +2914,14 @@ export default class extends Controller {
|
|
|
2480
2914
|
// predicate in the common no-nested-root case (skipping closest() entirely)
|
|
2481
2915
|
// and the exact #ownsField check when a nested reactive root is present
|
|
2482
2916
|
// (issue #15 scoping, byte-identical to before). Resolution is memoized in a
|
|
2483
|
-
// per-CALL Map,
|
|
2484
|
-
//
|
|
2917
|
+
// per-CALL Map, so a name read as an input AND written as an output
|
|
2918
|
+
// resolves to the SAME element and is queried once.
|
|
2919
|
+
//
|
|
2920
|
+
// Which element a name resolves to (issue #262, mirroring #showFieldValue): a
|
|
2921
|
+
// CHECKBOX wins over the hidden companion Rails renders before it; a radio
|
|
2922
|
+
// group resolves to its CHECKED radio (any radio of the group when none is);
|
|
2923
|
+
// anything else is first-wins. Resolving first-wins across the board handed
|
|
2924
|
+
// the reducer the companion's constant "0" and the first radio's value.
|
|
2485
2925
|
//
|
|
2486
2926
|
// Why per-name `[name="X"]` queries and not one bare `[name]` sweep: a single
|
|
2487
2927
|
// sweep is the natural "one walk", but the resolver must issue the SAME
|
|
@@ -2500,25 +2940,37 @@ export default class extends Controller {
|
|
|
2500
2940
|
|
|
2501
2941
|
const owns = this.#ownershipFilter()
|
|
2502
2942
|
const byName = new Map()
|
|
2503
|
-
const
|
|
2504
|
-
|
|
2505
|
-
|
|
2943
|
+
const ownedControl = (name) => {
|
|
2944
|
+
const known = byName.get(name)
|
|
2945
|
+
if (known) return known
|
|
2946
|
+
let radio = null
|
|
2947
|
+
let first = null
|
|
2948
|
+
let control = null
|
|
2506
2949
|
for (const el of this.element.querySelectorAll(`[name="${scoped(name)}"]`)) {
|
|
2507
|
-
if (owns(el))
|
|
2508
|
-
|
|
2950
|
+
if (!owns(el)) continue
|
|
2951
|
+
const kind = el.type // read ONCE per element — see computeText
|
|
2952
|
+
if (kind === "checkbox" || (kind === "radio" && el.checked)) {
|
|
2953
|
+
control = { el, kind }
|
|
2509
2954
|
break
|
|
2510
2955
|
}
|
|
2956
|
+
if (kind === "radio") radio ??= el
|
|
2957
|
+
else first ??= el
|
|
2958
|
+
}
|
|
2959
|
+
if (!control) {
|
|
2960
|
+
if (radio) control = { el: radio, kind: "radio" }
|
|
2961
|
+
else control = first ? { el: first, kind: "" } : COMPUTE_NO_CONTROL
|
|
2511
2962
|
}
|
|
2512
|
-
byName.set(name,
|
|
2513
|
-
return
|
|
2963
|
+
byName.set(name, control)
|
|
2964
|
+
return control
|
|
2514
2965
|
}
|
|
2515
2966
|
|
|
2516
2967
|
// Identity-mirror pass (issue #104), ALWAYS run — even with NO registered
|
|
2517
2968
|
// reducer, so reactive_text(:title) mirrors a field into its text node with
|
|
2518
|
-
// zero reducer wiring. Each declared input's RAW
|
|
2519
|
-
//
|
|
2969
|
+
// zero reducer wiring. Each declared input's RAW text reading (computeText —
|
|
2970
|
+
// a checkbox paints "true"/"false", issue #262) is written to its owned
|
|
2971
|
+
// [data-reactive-text="<name>"] node(s). It runs BEFORE the reducer
|
|
2520
2972
|
// early-return below so a reducer-less binding still mirrors.
|
|
2521
|
-
for (const name of inputs) this.#mirrorText(name,
|
|
2973
|
+
for (const name of inputs) this.#mirrorText(name, computeText(ownedControl(name)))
|
|
2522
2974
|
|
|
2523
2975
|
const key = this.element.getAttribute("data-reactive-compute-reducer-param")
|
|
2524
2976
|
const reduce = key ? computeReducer(key) : null
|
|
@@ -2526,26 +2978,18 @@ export default class extends Controller {
|
|
|
2526
2978
|
// No reducer registered: the identity pass above still ran, so declared
|
|
2527
2979
|
// cross-root mirrors of the INPUT names still paint (issue #159) — a
|
|
2528
2980
|
// reducer-less binding mirrors, exactly like the owned-text-node case.
|
|
2529
|
-
this.#applyComputeMirrors({},
|
|
2981
|
+
this.#applyComputeMirrors({}, ownedControl)
|
|
2530
2982
|
return
|
|
2531
2983
|
}
|
|
2532
2984
|
|
|
2533
2985
|
const outputs = this.#parseComputeList("data-reactive-compute-outputs-param")
|
|
2534
2986
|
|
|
2535
|
-
// Coerce each input per its declared type (
|
|
2536
|
-
//
|
|
2537
|
-
//
|
|
2538
|
-
//
|
|
2987
|
+
// Coerce each input per its declared type (computeValue): "string" raw,
|
|
2988
|
+
// "boolean" a real boolean, else ("number", the array-form default) the
|
|
2989
|
+
// numeric coercion. A checkbox contributes its CHECKED STATE under every
|
|
2990
|
+
// type (issue #262). Reads from the memoized resolver — no re-query.
|
|
2539
2991
|
const values = {}
|
|
2540
|
-
for (const [name, type] of inputPairs)
|
|
2541
|
-
const field = ownedField(name)
|
|
2542
|
-
if (type === "string") {
|
|
2543
|
-
values[name] = field?.value ?? ""
|
|
2544
|
-
} else {
|
|
2545
|
-
const n = Number(field?.value)
|
|
2546
|
-
values[name] = Number.isFinite(n) ? n : 0
|
|
2547
|
-
}
|
|
2548
|
-
}
|
|
2992
|
+
for (const [name, type] of inputPairs) values[name] = computeValue(ownedControl(name), type)
|
|
2549
2993
|
|
|
2550
2994
|
// meta.changed stays on #changedComputeField (its own #ownsField check over
|
|
2551
2995
|
// the raw event target) — NOT this resolver. The issue-#15 nested-rejection
|
|
@@ -2564,7 +3008,8 @@ export default class extends Controller {
|
|
|
2564
3008
|
// 1. BATCH the field writes from the ONE result. Each output name in the
|
|
2565
3009
|
// allowlist (outputs:) whose owned field's value actually changes is
|
|
2566
3010
|
// written now (change-guarded) and remembered — but NO `input` event is
|
|
2567
|
-
// dispatched yet, so nothing re-enters mid-batch.
|
|
3011
|
+
// dispatched yet, so nothing re-enters mid-batch. A checkbox or radio
|
|
3012
|
+
// output is written as its CHECKED state (computeWrite, issue #262).
|
|
2568
3013
|
// 2. PAINT the sinks from the SETTLED values: any owned reactive_text node by
|
|
2569
3014
|
// presence (issue #183 change #4 — a text node no longer needs its name in
|
|
2570
3015
|
// outputs:), then the cross-root mirror: ids (issue #159).
|
|
@@ -2578,11 +3023,10 @@ export default class extends Controller {
|
|
|
2578
3023
|
const changedFields = []
|
|
2579
3024
|
for (const name of outputs) {
|
|
2580
3025
|
if (name === "$ops" || !(name in result)) continue
|
|
2581
|
-
const
|
|
2582
|
-
if (!
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
changedFields.push(field)
|
|
3026
|
+
const control = ownedControl(name)
|
|
3027
|
+
if (!control.el) continue // a non-field output paints as a text sink in phase 2
|
|
3028
|
+
const written = computeWrite(this.element, owns, control, scoped(name), result[name])
|
|
3029
|
+
if (written) changedFields.push(written) // null = change-guard, unchanged
|
|
2586
3030
|
}
|
|
2587
3031
|
|
|
2588
3032
|
// Phase 2 — text sinks declare themselves (issue #183 change #4): every result
|
|
@@ -2599,7 +3043,7 @@ export default class extends Controller {
|
|
|
2599
3043
|
|
|
2600
3044
|
// Cross-root text mirrors (issue #159) — AFTER the batch + text sinks, so a
|
|
2601
3045
|
// mirror keyed on a just-written output paints the settled value.
|
|
2602
|
-
this.#applyComputeMirrors(result,
|
|
3046
|
+
this.#applyComputeMirrors(result, ownedControl)
|
|
2603
3047
|
|
|
2604
3048
|
// Phase 3 — dispatch the deferred `input` events (issue #183). Real browsers
|
|
2605
3049
|
// do NOT fire `input` on a programmatic .value write (issue #76), so we do it
|
|
@@ -2650,12 +3094,25 @@ export default class extends Controller {
|
|
|
2650
3094
|
// CLICKING IT (so its own on(:select) reactive trigger fires — selection stays
|
|
2651
3095
|
// a signed action); Escape clears. Ephemeral highlight state lives on the DOM
|
|
2652
3096
|
// (data-reactive-highlighted), never shipped to the client as trusted state.
|
|
3097
|
+
//
|
|
3098
|
+
// Issue #271: a container spread with reactive_listnav(focus: true) carries
|
|
3099
|
+
// data-reactive-listnav-focus-param="true" — ROVING-FOCUS mode for a
|
|
3100
|
+
// role=menu: the same moves shift real focus among the items instead of a
|
|
3101
|
+
// highlight, and Home/End (listnavFirst/listnavLast) jump to the edges.
|
|
2653
3102
|
listnavNext(event) {
|
|
2654
|
-
this.#
|
|
3103
|
+
this.#listnavMove(event, (current, length) => (current < 0 ? 0 : (current + 1) % length))
|
|
2655
3104
|
}
|
|
2656
3105
|
|
|
2657
3106
|
listnavPrev(event) {
|
|
2658
|
-
this.#
|
|
3107
|
+
this.#listnavMove(event, (current, length) => (current < 0 ? length - 1 : (current - 1 + length) % length))
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
listnavFirst(event) {
|
|
3111
|
+
this.#listnavMove(event, () => 0)
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
listnavLast(event) {
|
|
3115
|
+
this.#listnavMove(event, (_current, length) => length - 1)
|
|
2659
3116
|
}
|
|
2660
3117
|
|
|
2661
3118
|
// Enter: activate the highlighted option (fires its reactive select). No-op if
|
|
@@ -2673,23 +3130,39 @@ export default class extends Controller {
|
|
|
2673
3130
|
for (const el of this.#listnavOptions(event)) el.removeAttribute("data-reactive-highlighted")
|
|
2674
3131
|
}
|
|
2675
3132
|
|
|
2676
|
-
// Move
|
|
2677
|
-
//
|
|
2678
|
-
|
|
3133
|
+
// Move among THIS root's options: `pick(current, length)` returns the next
|
|
3134
|
+
// index (current is -1 when nothing is highlighted/focused). preventDefault
|
|
3135
|
+
// stops Arrow keys moving the caret in a search input, and the page scrolling
|
|
3136
|
+
// under a menu. Highlight mode writes data-reactive-highlighted; focus mode
|
|
3137
|
+
// (issue #271) focuses the chosen item, current being the item that is or
|
|
3138
|
+
// contains document.activeElement.
|
|
3139
|
+
#listnavMove(event, pick) {
|
|
2679
3140
|
const options = this.#listnavOptions(event)
|
|
2680
3141
|
if (!options.length) return
|
|
2681
3142
|
event.preventDefault()
|
|
2682
3143
|
|
|
2683
|
-
const
|
|
2684
|
-
|
|
2685
|
-
const
|
|
3144
|
+
const focusMode = this.#listnavFocusMode(event)
|
|
3145
|
+
const active = globalThis.document?.activeElement
|
|
3146
|
+
const current = focusMode
|
|
3147
|
+
? options.findIndex((el) => el === active || (active != null && el.contains?.(active)))
|
|
3148
|
+
: options.findIndex((el) => el.hasAttribute("data-reactive-highlighted"))
|
|
3149
|
+
const chosen = options[pick(current, options.length)]
|
|
2686
3150
|
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
3151
|
+
if (focusMode) {
|
|
3152
|
+
chosen.focus?.()
|
|
3153
|
+
} else {
|
|
3154
|
+
for (const el of options) el.removeAttribute("data-reactive-highlighted")
|
|
3155
|
+
chosen.setAttribute("data-reactive-highlighted", "true")
|
|
3156
|
+
}
|
|
2690
3157
|
chosen.scrollIntoView?.({ block: "nearest" })
|
|
2691
3158
|
}
|
|
2692
3159
|
|
|
3160
|
+
// Focus mode is read off the TRIGGER like the option selector (issue #271).
|
|
3161
|
+
#listnavFocusMode(event) {
|
|
3162
|
+
const trigger = event?.currentTarget ?? event?.target ?? this.element
|
|
3163
|
+
return trigger.getAttribute?.("data-reactive-listnav-focus-param") === "true"
|
|
3164
|
+
}
|
|
3165
|
+
|
|
2693
3166
|
// The option elements this root owns (skips nested reactive roots, issue #15),
|
|
2694
3167
|
// per the selector on data-reactive-listnav-option-param. The attr rides on the
|
|
2695
3168
|
// TRIGGER element (the search input on(...) is spread onto), read from the
|
|
@@ -3167,10 +3640,11 @@ export default class extends Controller {
|
|
|
3167
3640
|
// only (never innerHTML), change-guarded, and NO input dispatch — same
|
|
3168
3641
|
// contract as #mirrorText. With no mirror declared this is one getAttribute
|
|
3169
3642
|
// and out — the shipped compute path never touches the document.
|
|
3170
|
-
#applyComputeMirrors(result,
|
|
3643
|
+
#applyComputeMirrors(result, ownedControl) {
|
|
3171
3644
|
const mirror = this.#parseComputeMirror()
|
|
3172
3645
|
for (const [name, selectors] of Object.entries(mirror)) {
|
|
3173
|
-
const
|
|
3646
|
+
const control = name in result ? null : ownedControl(name)
|
|
3647
|
+
const value = name in result ? result[name] : control.el ? computeText(control) : undefined
|
|
3174
3648
|
if (value === undefined || value === null) continue
|
|
3175
3649
|
const text = String(value)
|
|
3176
3650
|
for (const sel of Array.isArray(selectors) ? selectors : [selectors]) {
|
|
@@ -3894,6 +4368,7 @@ export default class extends Controller {
|
|
|
3894
4368
|
const fields = {}
|
|
3895
4369
|
const files = []
|
|
3896
4370
|
const owns = this.#ownershipFilter() // compute ONCE per dispatch (issue #117)
|
|
4371
|
+
const controls = []
|
|
3897
4372
|
this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
|
|
3898
4373
|
if (!owns(field)) return
|
|
3899
4374
|
if (field.type === "file") {
|
|
@@ -3901,6 +4376,39 @@ export default class extends Controller {
|
|
|
3901
4376
|
// shape (params[name][]) even when the user picked exactly one file —
|
|
3902
4377
|
// otherwise a [:file] schema would see a lone scalar upload and drop it.
|
|
3903
4378
|
for (const file of field.files ?? []) files.push({ name: field.name, file, multiple: field.multiple })
|
|
4379
|
+
} else {
|
|
4380
|
+
// Held for a second pass: a hidden input is only a companion if a
|
|
4381
|
+
// checkbox somewhere in the root shares its name, which the first
|
|
4382
|
+
// occurrence cannot know yet.
|
|
4383
|
+
controls.push(field)
|
|
4384
|
+
}
|
|
4385
|
+
})
|
|
4386
|
+
// Collected before the first pass so the editor DOM is walked once.
|
|
4387
|
+
const editors = []
|
|
4388
|
+
this.element.querySelectorAll(`[name]${PERSIST_EDITOR_SELECTOR}`).forEach((el) => {
|
|
4389
|
+
if (owns(el)) editors.push(el) // the SAME hoisted predicate (nested reactive root, issue #15)
|
|
4390
|
+
})
|
|
4391
|
+
const arrayNames = this.#arrayFieldNames(controls)
|
|
4392
|
+
const companionNames = this.#companionNames(controls)
|
|
4393
|
+
for (const field of controls) {
|
|
4394
|
+
if (arrayNames.has(field.name)) {
|
|
4395
|
+
const slot = fields[field.name] ?? (fields[field.name] = [])
|
|
4396
|
+
if (field.type === "checkbox" || field.type === "radio") {
|
|
4397
|
+
// An unchecked box contributes NOTHING, the way a native submission
|
|
4398
|
+
// leaves it out. The group's value is the list of checked values, and
|
|
4399
|
+
// with none checked that list stays an EMPTY ARRAY rather than
|
|
4400
|
+
// vanishing: the action can tell "the operator cleared them" from
|
|
4401
|
+
// "the group never rendered", and an [:string] schema coerces [] to
|
|
4402
|
+
// []. A form body cannot carry the empty array, so #buildFormData
|
|
4403
|
+
// announces the group there instead.
|
|
4404
|
+
if (field.checked) slot.push(field.value)
|
|
4405
|
+
} else if (field.type === "hidden") {
|
|
4406
|
+
if (!companionNames.has(field.name)) slot.push(field.value)
|
|
4407
|
+
} else if (field.multiple && field.options) {
|
|
4408
|
+
for (const option of field.options) if (option.selected) slot.push(option.value)
|
|
4409
|
+
} else {
|
|
4410
|
+
slot.push(field.value)
|
|
4411
|
+
}
|
|
3904
4412
|
} else if (field.type === "checkbox") {
|
|
3905
4413
|
fields[field.name] = field.checked
|
|
3906
4414
|
} else if (field.type === "radio") {
|
|
@@ -3908,30 +4416,103 @@ export default class extends Controller {
|
|
|
3908
4416
|
} else {
|
|
3909
4417
|
fields[field.name] = field.value
|
|
3910
4418
|
}
|
|
3911
|
-
}
|
|
4419
|
+
}
|
|
3912
4420
|
// Named rich-text / custom editors (lexxy-editor, trix-editor) and bare
|
|
3913
4421
|
// [contenteditable]. These aren't input/select/textarea, so the query above
|
|
3914
4422
|
// skips them — without this, a reactive save posts an empty value and
|
|
3915
4423
|
// silently wipes the field (issue #8). Read whatever the element exposes:
|
|
3916
4424
|
// a custom editor's serialized `.value`, else its contenteditable text.
|
|
3917
|
-
//
|
|
3918
|
-
// hidden input (e.g. Trix mirrors into one) still wins
|
|
3919
|
-
|
|
3920
|
-
|
|
4425
|
+
// Under a plain name: only fill what the standard controls left absent or
|
|
4426
|
+
// empty, so a synced hidden input (e.g. Trix mirrors into one) still wins
|
|
4427
|
+
// when populated. Under a `[]` name the editor APPENDS to the group slot
|
|
4428
|
+
// instead, and a same-named hidden keeps its own say: nothing here can
|
|
4429
|
+
// tell a hidden that mirrors this editor from one that is a list JS
|
|
4430
|
+
// maintains.
|
|
4431
|
+
editors
|
|
3921
4432
|
.forEach((el) => {
|
|
3922
|
-
if (!owns(el)) return // reuse the SAME hoisted predicate (nested reactive root — issue #15)
|
|
3923
4433
|
// A plain element (e.g. a <div contenteditable>) has no `name` IDL
|
|
3924
4434
|
// property — only the attribute — so read getAttribute, not el.name.
|
|
3925
4435
|
const name = el.getAttribute("name")
|
|
3926
4436
|
if (!name) return
|
|
4437
|
+
const own = el.value ?? el.textContent ?? el.innerHTML ?? ""
|
|
4438
|
+
// A `[]` name is a group here too: the editor APPENDS its value
|
|
4439
|
+
// instead of replacing the slot. Assigning a scalar posted
|
|
4440
|
+
// `{"notes[]": "<p>x</p>"}` while the draft snapshot pushed the same
|
|
4441
|
+
// control into an array — the wire and the draft disagreeing about one
|
|
4442
|
+
// field, and a declared array type seeing a string. A same-named hidden
|
|
4443
|
+
// is NOT read as this editor's twin: nothing here can tell a mirror
|
|
4444
|
+
// from a list JS maintains, and a value posted twice is visible while a
|
|
4445
|
+
// suppressed one is not.
|
|
3927
4446
|
const existing = fields[name]
|
|
4447
|
+
// An editor that has not upgraded yet contributes NOTHING to a group.
|
|
4448
|
+
// Trix defines its elements in a setTimeout after load, and its "" is
|
|
4449
|
+
// not an empty value but an absent one: persistSnapshot omits such an
|
|
4450
|
+
// editor for the same reason, so this keeps the wire and the draft
|
|
4451
|
+
// saying the same thing.
|
|
4452
|
+
if (String(name).endsWith("[]") && !collectorEditorReady(el)) return
|
|
4453
|
+
if (String(name).endsWith("[]") && (existing === undefined || Array.isArray(existing))) {
|
|
4454
|
+
const slot = Array.isArray(existing) ? existing : (fields[name] = [])
|
|
4455
|
+
slot.push(own)
|
|
4456
|
+
return
|
|
4457
|
+
}
|
|
4458
|
+
// A scalar already under a `[]` name can only be a RADIO's: a radio
|
|
4459
|
+
// means "pick one" and keeps its single value with or without the
|
|
4460
|
+
// suffix, which is why #arrayFieldNames excepts it. Converting that to
|
|
4461
|
+
// a group here would discard the chosen value — measured, the post lost
|
|
4462
|
+
// it — so the editor stands down and the rule below applies, which
|
|
4463
|
+
// never overwrites a populated name.
|
|
4464
|
+
// Only fill what the standard controls left absent or empty, so a
|
|
4465
|
+
// synced hidden input still wins when populated.
|
|
3928
4466
|
if (existing == null || existing === "") {
|
|
3929
|
-
fields[name] =
|
|
4467
|
+
fields[name] = own
|
|
3930
4468
|
}
|
|
3931
4469
|
})
|
|
3932
4470
|
return { fields, files }
|
|
3933
4471
|
}
|
|
3934
4472
|
|
|
4473
|
+
// Names collected as an ARRAY rather than a single value: a name carrying the
|
|
4474
|
+
// `[]` suffix, the HTML convention for a group. That suffix is the ONLY
|
|
4475
|
+
// trigger — a group says so, it is not inferred from two controls happening
|
|
4476
|
+
// to share a name. Radios are excluded BY DESIGN: a radio group shares one
|
|
4477
|
+
// name to mean "pick one", and it keeps posting the single checked value,
|
|
4478
|
+
// suffix or not.
|
|
4479
|
+
//
|
|
4480
|
+
// Issue #258: without this, `fields[name] = field.checked` wrote a boolean per
|
|
4481
|
+
// checkbox and same-named boxes overwrote each other, so three boxes with two
|
|
4482
|
+
// ticked left the browser as the LAST box's checked state — the chosen values
|
|
4483
|
+
// never reached the wire, whatever the action's schema declared.
|
|
4484
|
+
#arrayFieldNames(controls) {
|
|
4485
|
+
const names = new Set()
|
|
4486
|
+
for (const field of controls) {
|
|
4487
|
+
// A radio group shares one name BY DESIGN to mean "pick one" and keeps
|
|
4488
|
+
// its single checked value, `[]` suffix or not.
|
|
4489
|
+
if (field.type === "radio") continue
|
|
4490
|
+
if (String(field.name).endsWith("[]")) names.add(field.name)
|
|
4491
|
+
}
|
|
4492
|
+
return names
|
|
4493
|
+
}
|
|
4494
|
+
|
|
4495
|
+
// Names whose group carries a hidden COMPANION: a hidden input is Rails' way
|
|
4496
|
+
// of giving a checkbox a value for the unchecked case, and it is never a
|
|
4497
|
+
// chosen value. Measured from the helpers, the three shapes are:
|
|
4498
|
+
//
|
|
4499
|
+
// check_box(:u, :sub)
|
|
4500
|
+
// <input name="u[sub]" type="hidden" value="0"><input type="checkbox" value="1" name="u[sub]">
|
|
4501
|
+
// check_box(:u, :ids, {multiple: true}, "3")
|
|
4502
|
+
// <input name="u[ids][]" type="hidden" value="0"><input type="checkbox" value="3" name="u[ids][]">
|
|
4503
|
+
// collection_check_boxes(...) / an unchecked_value of nil
|
|
4504
|
+
// <input type="hidden" name="u[ids][]" value=""> — or no hidden at all
|
|
4505
|
+
//
|
|
4506
|
+
// The value differs (unchecked_value, blank, absent), so the value cannot be
|
|
4507
|
+
// the test. What identifies a companion is that a checkbox shares its name.
|
|
4508
|
+
// A hidden WITHOUT a same-named checkbox is a list JS maintains, and its
|
|
4509
|
+
// value is a chosen value like any other.
|
|
4510
|
+
#companionNames(controls) {
|
|
4511
|
+
const names = new Set()
|
|
4512
|
+
for (const field of controls) if (field.type === "checkbox") names.add(field.name)
|
|
4513
|
+
return names
|
|
4514
|
+
}
|
|
4515
|
+
|
|
3935
4516
|
// Re-compute the dirty flag for EVERY field this root owns in one pass (issue
|
|
3936
4517
|
// #103), then reflect the total onto the root. Called on an owned field's input
|
|
3937
4518
|
// (trackDirty), on connect (baseline seed), and after a turbo:morph-element
|
|
@@ -4759,9 +5340,35 @@ export default class extends Controller {
|
|
|
4759
5340
|
const fd = new FormData()
|
|
4760
5341
|
fd.append("token", token)
|
|
4761
5342
|
fd.append("act", action)
|
|
5343
|
+
const emptyGroups = []
|
|
4762
5344
|
for (const [key, value] of Object.entries(params)) {
|
|
4763
|
-
|
|
5345
|
+
// A `[]` name carrying an array is the group shape (issue #258): every
|
|
5346
|
+
// element goes to params[name][], which Rack parses as an array. The
|
|
5347
|
+
// indexed form #appendField writes for a plain array (params[name][0],
|
|
5348
|
+
// params[name][1]) arrives as a hash of index keys — ParamSchema's array
|
|
5349
|
+
// type normalizes that back, but only an array type does, so the two
|
|
5350
|
+
// bodies would stop coercing identically for the same fields.
|
|
5351
|
+
//
|
|
5352
|
+
// An EMPTY group cannot be an empty array in a form body, so it is
|
|
5353
|
+
// ANNOUNCED instead: its key stays absent from params and its name goes
|
|
5354
|
+
// into `empty_groups[]`, a field of its own beside token/act/params. A
|
|
5355
|
+
// blank entry was the obvious alternative and is ambiguous — Rails leaves
|
|
5356
|
+
// `[""]` to the caller, and a `[:date]` or `[:file]` element reads it as
|
|
5357
|
+
// "did not come in", so treating it as "cleared" would change what those
|
|
5358
|
+
// params mean. The field is additive: a server that ignores it behaves
|
|
5359
|
+
// exactly as it does today, and so does a client that never sends it.
|
|
5360
|
+
if (Array.isArray(value) && String(key).endsWith("[]")) {
|
|
5361
|
+
if (value.length === 0) {
|
|
5362
|
+
emptyGroups.push(String(key).slice(0, -2))
|
|
5363
|
+
} else {
|
|
5364
|
+
const wire = `${this.#wireKey(key)}[]`
|
|
5365
|
+
for (const element of value) fd.append(wire, String(element))
|
|
5366
|
+
}
|
|
5367
|
+
} else {
|
|
5368
|
+
this.#appendField(fd, this.#wireKey(key), value)
|
|
5369
|
+
}
|
|
4764
5370
|
}
|
|
5371
|
+
for (const name of emptyGroups) fd.append("empty_groups[]", name)
|
|
4765
5372
|
const multiNames = this.#multiFileNames(files)
|
|
4766
5373
|
for (const { name, file, multiple } of files) {
|
|
4767
5374
|
// params[name][] when the input is `multiple` (array shape even for one
|