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.
@@ -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)) fields[name] = el.value
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
- fields[name] = el.textContent ?? ""
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
- fields[name] = el.checked
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
- fields[name] = [...el.options].filter((o) => o.selected).map((o) => o.value)
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
- const value = fields[name]
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
- for (const { el, name, kind } of persistControls(root, payload)) {
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) => setHidden(el, false, args),
1394
- hide: (el, args) => setHidden(el, true, args),
1395
- toggle: (el, args) => setHidden(el, !el.hidden, 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; remove_attr removes it. A refused name warns + skips.
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 (el.hasAttribute(args.name)) el.removeAttribute(args.name)
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
- const { ops, confirm, confirmWhen, outside, window: windowBound } = event.params
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, FIRST-WINS, so a name read as an input AND written as an
2484
- // output resolves to the SAME element and is queried once.
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 ownedField = (name) => {
2504
- if (byName.has(name)) return byName.get(name)
2505
- let found = null
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
- found = el // FIRST-WINS (radio groups, Rails hidden+checkbox name pairs)
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, found)
2513
- return found
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 string value is written to
2519
- // its owned [data-reactive-text="<name>"] node(s). It runs BEFORE the reducer
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, ownedField(name)?.value ?? "")
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({}, ownedField)
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 (issue #104): "string" → the raw
2536
- // display string (blank/absent → ""); else ("number", the array-form default)
2537
- // → the numeric coercion (blank/NaN → 0, the nanToZero the hand-written
2538
- // calculators use). Reads from the memoized resolver — no re-query.
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 field = ownedField(name)
2582
- if (!field) continue // a non-field output paints as a text sink in phase 2
2583
- if (String(result[name]) === field.value) continue // change-guard — unchanged, skip
2584
- field.value = result[name]
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, ownedField)
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.#moveHighlight(event, +1)
3103
+ this.#listnavMove(event, (current, length) => (current < 0 ? 0 : (current + 1) % length))
2655
3104
  }
2656
3105
 
2657
3106
  listnavPrev(event) {
2658
- this.#moveHighlight(event, -1)
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 the highlight by `step` (with wrap-around) among THIS root's options.
2677
- // preventDefault stops Arrow keys from moving the caret in the search input.
2678
- #moveHighlight(event, step) {
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 current = options.findIndex((el) => el.hasAttribute("data-reactive-highlighted"))
2684
- // From nothing: Down highlights the first option, Up the last.
2685
- const next = current < 0 ? (step > 0 ? 0 : options.length - 1) : (current + step + options.length) % options.length
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
- for (const el of options) el.removeAttribute("data-reactive-highlighted")
2688
- const chosen = options[next]
2689
- chosen.setAttribute("data-reactive-highlighted", "true")
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, ownedField) {
3643
+ #applyComputeMirrors(result, ownedControl) {
3171
3644
  const mirror = this.#parseComputeMirror()
3172
3645
  for (const [name, selectors] of Object.entries(mirror)) {
3173
- const value = name in result ? result[name] : ownedField(name)?.value
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
- // Only fill a name the standard controls left absent or empty, so a synced
3918
- // hidden input (e.g. Trix mirrors into one) still wins when populated.
3919
- this.element
3920
- .querySelectorAll("[name]:is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])")
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] = el.value ?? el.textContent ?? el.innerHTML ?? ""
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
- this.#appendField(fd, this.#wireKey(key), value)
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