From dc99159180833948dfe36b6e336a5a99fc5ece43 Mon Sep 17 00:00:00 2001 From: Stefan Exner Date: Tue, 22 Sep 2026 09:46:44 +0200 Subject: [PATCH 1/6] fix(client): collect checkbox groups as arrays (#258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#collectFields` wrote `fields[name] = field.checked` for every checkbox, a boolean under the control's own name. Three boxes named `features[]` with two ticked therefore left the browser as a single `false` — the last box's checked state — and same-named boxes overwrote each other before any schema could see them. Measured against a `[:string]` schema the action received `{}`, against a flat `:string` schema `"false"`; both silent. A native submission of the same three boxes sends `features[]=news&features[]=events`. A name ending in `[]` is now collected as an array of the chosen values: a ticked box contributes its `value`, an unticked one nothing, a `` used to post `"abc"` and now posts `["abc"]`; measured against the real ParamSchema, a flat `params: { tags: :string }` coerces that to the literal `"[\"abc\"]"` — silently, with a 200. Such a param has to be declared as an array type, or the suffix dropped from a name that never meant a list. A hidden input sharing a name with a checkbox is that box's COMPANION and contributes nothing. The value cannot be the test, because Rails renders three different ones, measured from the helpers: check_box(:u, :sub) hidden value="0" + box value="1" check_box(:u, :ids, {multiple: true}, v) hidden value="0" + box value=v, name ends in [] collection_check_boxes / unchecked_value nil hidden value="" — or none at all What identifies a companion is that a checkbox shares its name. Reading the second shape by value put every companion into the group: three boxes with the third ticked collected as `["0","0","0","3"]`, and against `[:integer]` the action would have written tag id 0 — worse than the bug, which dropped the param and let the keyword default stand. A hidden input WITHOUT a same-named checkbox is an ordinary value, the usual shape for a list JS maintains. Over a form body a group's values are written as `params[name][]`, the shape Rack parses back into an array. The indexed form `#appendField` writes for a plain array (`params[name][0]`, `params[name][1]`) arrives as a hash of index keys — an array param type normalizes that back, but only an array type does, so the two encodings would stop coercing identically for the same fields. An EMPTY group has no form-body spelling: a repeated key with no values is nothing, and `[""]` means something else per element type (`[:string]` keeps it, `[:integer]` coerces `[0]`, `[:date]` and `[:file]` drop the key). Over the JSON path a cleared group arrives as `[]`; over a form body — which the client uses when a file input carries a file — its key is absent and the action's keyword default applies. The README caveat says so. `reactive_persist` drafts such a group as the list of ticked values and restores exactly those boxes; before, the draft held one boolean and the restore ticked every box of the group. Its slot is an array for every control that can contribute a value — checkboxes, selects, text inputs, editors and contenteditables — with a radio group the single exception, as in the collector. A non-checkbox sharing the group's name left a string there, and `.push` on it threw inside the draft write; that write is swallowed, so the root persisted nothing at all, silently. Editors are collected after the native controls, so they always landed last and always clobbered the array. On restore, a control that cannot pick its own entry out of the list keeps what the server rendered — as long as the group has two or more contributors. The draft records values in document order with nothing saying which control each one came from, so replaying it would paste "freeform,news" into a text field. A group of ONE contributor has no such ambiguity, and refusing it would silently drop the draft of a plain field whose name merely ends in `[]`, the usual shape for a list JS maintains: measured, `` used to round-trip its draft and stopped doing so once the group slot existed. Both entry points resolve the list through the same rule, including the deferred editor path, which applies an editor that upgraded after connect without passing the branch chain at all. Whether the server already rendered a box of a group ticked is decided ONCE before the restore walks the controls. The walk writes `checked` as it goes, so asking from inside it reads the restore's own work: the first box it ticks makes every later box of the same group look server-rendered. Measured, a draft of ["news","maps"] came back as ["news"] alone, and the single-value case the tests covered is exactly the one where that is invisible. The radio branch asks the same question inline and stays correct only because a radio group holds a single value. A draft written before this change survives the upgrade, but its group key no longer reaches the controls that read a list. It holds one boolean — or, in a mixed group, whatever control wrote last — and applying it kept causing damage for as long as the draft lived, seven days by default: a checkbox group came back fully ticked, while a `` its selected options. The suffix is + the only trigger — a group says so rather than being inferred from two controls + sharing a name. + + **That makes the suffix a migration point.** ANY `[]`-named control now + contributes to an array, including a single one: a lone `` used to post `"abc"` and now posts `["abc"]`. Against a flat + `params: { tags: :string }` that coerces to the literal `"[\"abc\"]"` — + silently, with a 200. A control whose name ends in `[]` has to be declared as + an array type (`tags: [:string]`), or renamed without the suffix if it was + never meant as a list. + + Three shapes keep their meaning on purpose: a lone checkbox without `[]` stays + the documented yes/no boolean, a radio group keeps its single checked value + with or without the suffix, and a hidden input sharing a name with a checkbox + is that box's companion. The companion is identified by the shared name, not + by its value, because Rails renders three of them: `check_box` emits + `value="0"`, `check_box(..., multiple: true)` the same under a `[]` name, and + `collection_check_boxes` a blank one — or none at all with + `unchecked_value: nil`. Reading the second shape by value collected + `["0","0","0","3"]` for three boxes with the third ticked. + + A form body cannot carry an empty array. Over the JSON path a cleared group + arrives as `[]`; over a form body — which the client uses when a file input + carries a file — the group's key is simply absent, and the action's keyword + default applies. The two encodings therefore differ for that one case. + + `reactive_persist` drafts such a group as the list of ticked values and + restores exactly those boxes; before, the draft held one boolean and the + restore ticked every box of the group. Whether the server already rendered a + box ticked — in which case it keeps its say and the draft yields — is decided + once before the restore walks the controls, because the walk writes `checked` + as it goes and asking from inside it would read the restore's own work: the + first box it ticks would make every later box of the group look + server-rendered, and a draft of two values would come back as one. A + non-checkbox control sharing the group's name additionally threw inside the + draft write, which is swallowed — the root then persisted nothing at all, + silently. On restore, such a control keeps what the server rendered whenever + the group has two or more contributors: the list records the values, not + which control each one came from, so replaying it would paste `freeform,news` + into a text field, an editor, or a contenteditable. A group of ONE + contributor has no such ambiguity — its single entry can only have come from + that control — so a plain field whose name merely ends in `[]`, the usual + shape for a list JS maintains, keeps its draft exactly as it did before + groups existed. + + Drafts written before this release are not discarded, but their group key is + no longer applied to the controls that read a list: it holds one boolean (or, + in a mixed group, whichever control wrote last), and applying that kept + causing damage for as long as the draft lived — by default seven days after + the upgrade. The damage differed by control. A checkbox group came back fully + ticked. A `` its selected options. Nothing ticked is +an empty array, not a missing key, so an action can tell a cleared group from one +that never rendered — over the JSON path; a form body cannot carry the empty array, +see the caveat above. Declare it as an array type: + +```ruby +action :save, params: { features: [:string] } # +``` + +Three shapes keep their own meaning: a lone checkbox without `[]` stays the +documented yes/no boolean; a radio group keeps its single checked value, `[]` or +not; and a hidden input sharing a name with a checkbox is that box's **companion** +(Rails' `check_box` emits one, carrying the `unchecked_value`) and contributes +nothing. A hidden input *without* a same-named checkbox is an ordinary value — the +usual shape for a list maintained by JS. + +The suffix is the only trigger, and it applies to a single control too: a lone +`` posts `["abc"]` where it used to post +`"abc"`. Declare such a param as an array type (`tags: [:string]`) — against a +flat `tags: :string` the array coerces to its literal `to_s`. If the `[]` was +never meant as a list, drop it from the name. **Array & nested params.** Wrap a type in an array for an array param, or a hash schema in an array for Rails-style nested attributes — so one reactive action can diff --git a/app/javascript/phlex/reactive/confirm_predicate.js b/app/javascript/phlex/reactive/confirm_predicate.js index a8c30d41..f382d4d2 100644 --- a/app/javascript/phlex/reactive/confirm_predicate.js +++ b/app/javascript/phlex/reactive/confirm_predicate.js @@ -19,7 +19,12 @@ // // fields — a plain object of { name: value } over the trigger root's collected // controls (the SAME snapshot reactive_compute reads — #collectFields). -// Values are the raw control values (strings; a checkbox is a boolean). +// Values are the raw control values (strings; a lone checkbox is a +// boolean; a `[]` group is an array of the TICKED values, issue #258 — +// before that fix such a group arrived as one box's checked state). +// A group with nothing ticked is PRESENT as an empty array, not +// missing — and `[]` is truthy in JS, so test `fields.tags.length`, +// never `if (fields.tags)`. // // It returns truthy to WARN (the confirm dialog fires with the declared message) // or falsy to PROCEED with no dialog. The predicate is soft-validation UX, NOT diff --git a/app/javascript/phlex/reactive/confirm_predicate.min.js.map b/app/javascript/phlex/reactive/confirm_predicate.min.js.map index e610fbdb..205e9cbb 100644 --- a/app/javascript/phlex/reactive/confirm_predicate.min.js.map +++ b/app/javascript/phlex/reactive/confirm_predicate.min.js.map @@ -2,9 +2,9 @@ "version": 3, "sources": ["confirm_predicate.js"], "sourcesContent": [ - "// The client-side confirm-predicate registry — the multi-field escape hatch for\n// conditional confirmation (issue #179).\n//\n// confirm: { when: { total: 0 }, message: } handles the single-field 80% case\n// declaratively (the reactive_show conditions language), with NO JS. But some\n// soft-validation is multi-field — \"warn if the end date precedes the start\",\n// \"warn if two related totals disagree\" — which a single field=value condition\n// can't express. This registry is the seam for that logic: name a pure function\n// with `confirm: { predicate: \"name\", message: }` (Ruby) and register it here.\n//\n// The seam mirrors compute.js (setComputeReducer) and confirm.js: a settable\n// registry with a lookup the controller calls. Register once at boot:\n//\n// import { setConfirmPredicate } from \"phlex/reactive/confirm_predicate\"\n// setConfirmPredicate(\"end_before_start\", ({ starts_at, ends_at }) =>\n// ends_at !== \"\" && ends_at < starts_at)\n//\n// The predicate's signature is (fields) => boolean:\n//\n// fields — a plain object of { name: value } over the trigger root's collected\n// controls (the SAME snapshot reactive_compute reads — #collectFields).\n// Values are the raw control values (strings; a checkbox is a boolean).\n//\n// It returns truthy to WARN (the confirm dialog fires with the declared message)\n// or falsy to PROCEED with no dialog. The predicate is soft-validation UX, NOT\n// authorization: a user can bypass it (devtools, an unregistered name) and the\n// action still hits the endpoint's real authorize/default-deny — never let a\n// predicate stand in for a server-side check.\n//\n// A missing predicate (name never registered) makes the gate a NO-OP: the\n// controller proceeds WITHOUT a dialog and warns, exactly like compute.js's\n// unknown-reducer posture — a stale/typo'd name must not break the page or\n// (worse) block a legitimate action behind a dialog that can never resolve.\n\nconst predicates = new Map()\n\n// Register (or replace) the predicate for `key`. `fn` is\n// (fields: Record) => boolean — truthy warns, falsy proceeds.\nexport function setConfirmPredicate(key, fn) {\n predicates.set(key, fn)\n}\n\n// Look up a registered predicate; undefined when none — the controller then\n// proceeds without a dialog (and warns) rather than throwing or blocking.\nexport function confirmPredicate(key) {\n return predicates.get(key)\n}\n\n// Test seam: clear the registry so a predicate registered in one test can't leak.\nexport function __resetConfirmPredicateRegistryForTest() {\n predicates.clear()\n}\n" + "// The client-side confirm-predicate registry — the multi-field escape hatch for\n// conditional confirmation (issue #179).\n//\n// confirm: { when: { total: 0 }, message: } handles the single-field 80% case\n// declaratively (the reactive_show conditions language), with NO JS. But some\n// soft-validation is multi-field — \"warn if the end date precedes the start\",\n// \"warn if two related totals disagree\" — which a single field=value condition\n// can't express. This registry is the seam for that logic: name a pure function\n// with `confirm: { predicate: \"name\", message: }` (Ruby) and register it here.\n//\n// The seam mirrors compute.js (setComputeReducer) and confirm.js: a settable\n// registry with a lookup the controller calls. Register once at boot:\n//\n// import { setConfirmPredicate } from \"phlex/reactive/confirm_predicate\"\n// setConfirmPredicate(\"end_before_start\", ({ starts_at, ends_at }) =>\n// ends_at !== \"\" && ends_at < starts_at)\n//\n// The predicate's signature is (fields) => boolean:\n//\n// fields — a plain object of { name: value } over the trigger root's collected\n// controls (the SAME snapshot reactive_compute reads — #collectFields).\n// Values are the raw control values (strings; a lone checkbox is a\n// boolean; a `[]` group is an array of the TICKED values, issue #258 —\n// before that fix such a group arrived as one box's checked state).\n// A group with nothing ticked is PRESENT as an empty array, not\n// missing — and `[]` is truthy in JS, so test `fields.tags.length`,\n// never `if (fields.tags)`.\n//\n// It returns truthy to WARN (the confirm dialog fires with the declared message)\n// or falsy to PROCEED with no dialog. The predicate is soft-validation UX, NOT\n// authorization: a user can bypass it (devtools, an unregistered name) and the\n// action still hits the endpoint's real authorize/default-deny — never let a\n// predicate stand in for a server-side check.\n//\n// A missing predicate (name never registered) makes the gate a NO-OP: the\n// controller proceeds WITHOUT a dialog and warns, exactly like compute.js's\n// unknown-reducer posture — a stale/typo'd name must not break the page or\n// (worse) block a legitimate action behind a dialog that can never resolve.\n\nconst predicates = new Map()\n\n// Register (or replace) the predicate for `key`. `fn` is\n// (fields: Record) => boolean — truthy warns, falsy proceeds.\nexport function setConfirmPredicate(key, fn) {\n predicates.set(key, fn)\n}\n\n// Look up a registered predicate; undefined when none — the controller then\n// proceeds without a dialog (and warns) rather than throwing or blocking.\nexport function confirmPredicate(key) {\n return predicates.get(key)\n}\n\n// Test seam: clear the registry so a predicate registered in one test can't leak.\nexport function __resetConfirmPredicateRegistryForTest() {\n predicates.clear()\n}\n" ], - "mappings": "AAkCA,IAAM,EAAa,IAAI,IAIhB,SAAS,CAAmB,CAAC,EAAK,EAAI,CAC3C,EAAW,IAAI,EAAK,CAAE,EAKjB,SAAS,CAAgB,CAAC,EAAK,CACpC,OAAO,EAAW,IAAI,CAAG,EAIpB,SAAS,CAAsC,EAAG,CACvD,EAAW,MAAM", + "mappings": "AAuCA,IAAM,EAAa,IAAI,IAIhB,SAAS,CAAmB,CAAC,EAAK,EAAI,CAC3C,EAAW,IAAI,EAAK,CAAE,EAKjB,SAAS,CAAgB,CAAC,EAAK,CACpC,OAAO,EAAW,IAAI,CAAG,EAIpB,SAAS,CAAsC,EAAG,CACvD,EAAW,MAAM", "debugId": "64DE097A0F67E08564756E2164756E21", "names": [] } \ No newline at end of file diff --git a/app/javascript/phlex/reactive/reactive_controller.js b/app/javascript/phlex/reactive/reactive_controller.js index 687829af..c545cfd4 100644 --- a/app/javascript/phlex/reactive/reactive_controller.js +++ b/app/javascript/phlex/reactive/reactive_controller.js @@ -1077,6 +1077,7 @@ const PERSIST_PREFIX = "phlex-reactive:persist:" // Never persisted regardless of author intent: no server default to restore // into (hidden, file), secrets (password), and non-value controls. const PERSIST_EXCLUDED_TYPES = new Set(["hidden", "file", "password", "submit", "button", "reset", "image"]) +const PERSIST_NO_VALUE = Symbol("persist-no-value") const PERSIST_STATE_ATTR = "data-reactive-persist-state" // The editor query #collectFields reads (minus the [name] guard — an editor's // name may live on its IDL `name` getter: Trix's `input=`-paired hidden input). @@ -1258,6 +1259,33 @@ function persistSelectMultiple(el) { return el.tagName === "SELECT" && el.multiple } +// How many controls can contribute a value under each `[]` name, counted the +// way persistSnapshot fills the slot — a radio is the exception there and here. +// A group of ONE is unambiguous: its single entry can only have come from that +// control. +function persistGroupSizes(controls) { + const sizes = new Map() + for (const { el, name } of controls) { + if (el.type === "radio" || !String(name).endsWith("[]")) continue + sizes.set(name, (sizes.get(name) ?? 0) + 1) + } + return sizes +} + +// The value a control that cannot pick its own entry out of a list may take. +// A list belongs to a `[]` group, and in a group of two or more nothing says +// which entry came from which control — restoring it would paste +// "freeform,news" into a text field. A group of ONE has no such ambiguity, and +// refusing it would silently drop the draft of a plain field whose name merely +// ends in `[]` (a list JS maintains), which worked before groups existed. +// Returns PERSIST_NO_VALUE when the control must keep what the server rendered. +function persistGenericValue(value, name, sizes) { + if (!Array.isArray(value)) return value + if (sizes.get(name) !== 1 || value.length !== 1) return PERSIST_NO_VALUE + + return value[0] +} + // Snapshot the owned controls: radio → the checked value (null when the group // has none, so a restore leaves it alone), checkbox → checked, multi-select → // the selected values, a rich editor → its serialized `value` (omitted while @@ -1265,18 +1293,52 @@ function persistSelectMultiple(el) { // its textContent, else .value. Mirrors #collectFields' reads. function persistSnapshot(root, payload) { const fields = {} + // A `[]` name is a group for every control that can contribute a value — + // checkboxes, selects, text inputs, editors and contenteditables all append + // to one array, mirroring #collectFields. The one exception is a RADIO group, + // which means "pick one" and keeps its single value with or without the + // suffix, exactly as the collector treats it. + // + // Reading the slot without this (fields[name] ?? []) breaks as soon as a + // non-checkbox shares the group's name: a text input or an editor leaves a + // string there, `.push` on it throws inside the draft write, and that write + // is swallowed — the root then persists nothing at all, silently. Editors + // are collected AFTER the native controls, so they always land last. + const groupSlot = (name) => { + const existing = fields[name] + return Array.isArray(existing) ? existing : (fields[name] = []) + } for (const { el, name, kind } of persistControls(root, payload)) { + const group = String(name).endsWith("[]") if (kind === "editor") { - if (persistEditorReady(el)) fields[name] = el.value + if (persistEditorReady(el)) { + if (group) groupSlot(name).push(el.value) + else fields[name] = el.value + } } else if (kind === "contenteditable") { - fields[name] = el.textContent ?? "" + const text = el.textContent ?? "" + if (group) groupSlot(name).push(text) + else fields[name] = text } else if (el.type === "radio") { if (el.checked) fields[name] = el.value else if (!Object.hasOwn(fields, name)) fields[name] = null } else if (el.type === "checkbox") { - fields[name] = el.checked + // A `[]` group drafts the list of ticked values, mirroring #collectFields + // (issue #258). Without this the boxes overwrote each other and the draft + // held one boolean, which the restore then applied to every box of the + // group. A lone checkbox keeps the boolean it has always been. + if (group) { + const slot = groupSlot(name) + if (el.checked) slot.push(el.value) + } else { + fields[name] = el.checked + } } else if (persistSelectMultiple(el)) { - fields[name] = [...el.options].filter((o) => o.selected).map((o) => o.value) + const selected = [...el.options].filter((o) => o.selected).map((o) => o.value) + if (group) groupSlot(name).push(...selected) + else fields[name] = selected + } else if (group) { + groupSlot(name).push(el.value) } else { fields[name] = el.value } @@ -1292,10 +1354,52 @@ function persistSnapshot(root, payload) { function persistApply(root, payload, fields) { const always = payload.restore === "always" const controls = persistControls(root, payload) + // Which group names the SERVER rendered with a box already ticked. Computed + // BEFORE the loop on purpose: the loop writes `checked`, so asking this + // question from inside it would read THIS restore's own work — the first box + // it ticks makes every later box of the same group look server-rendered, and + // a draft of two values comes back as one. The radio branch below asks the + // same question inline and stays correct only because a radio group holds a + // single value. + const groupSizes = persistGroupSizes(controls) + const serverTicked = new Set() + for (const control of controls) { + if (control.kind === "native" && control.el.type === "checkbox" && control.el.checked) { + serverTicked.add(control.name) + } + } for (const { el, name, kind } of controls) { if (!Object.hasOwn(fields, name)) continue - const value = fields[name] + let value = fields[name] if (value === null || value === undefined) continue + // An array belongs to a `[]` group, and only a control that can pick ITS + // entry out of the list may read it: a checkbox matches by value, a + // multi-select by option. Everything else — editors, contenteditables, + // text inputs — keeps what the server rendered, because the list does not + // record which entry came from which control. This sits ABOVE the branch + // chain on purpose: below the editor branch it would never fire for the + // very controls that land last in the snapshot. + if (Array.isArray(value) && !(el.type === "checkbox" || persistSelectMultiple(el))) { + value = persistGenericValue(value, name, groupSizes) + if (value === PERSIST_NO_VALUE) continue + } + // The mirror, for a draft written BEFORE a group was drafted as a list + // (#258): there `features[]` held ONE boolean, and applying it here ticks + // every box of the group — precisely the state this fix removes, for as + // long as the draft lives (default ttl 7 days). A group key that is not a + // list is stale by definition, so the control keeps what the server + // rendered and the next snapshot overwrites the key. It reads for a + // multi-select too: 0.13.2 wrote last-writer-wins per name, so a checkbox + // in a mixed group could leave its boolean under the select's name, and + // under `restore: "always"` the select would then deselect everything — + // `wanted` being Set{"true"} matches no option. Asking for the `[]` suffix + // is what leaves a lone `gift` checkbox on the boolean it has always held — + // and scoping the rule to the one key whose meaning changed is why + // PERSIST_VERSION stays at 1: bumping it would also throw away the drafted + // prose of every form that has no checkbox group at all. + if ((el.type === "checkbox" || persistSelectMultiple(el)) && String(name).endsWith("[]") && !Array.isArray(value)) { + continue + } if (kind === "editor") { persistApplyEditor(root, el, name, value, always) } else if (kind === "contenteditable") { @@ -1304,6 +1408,12 @@ function persistApply(root, payload, fields) { } else if (el.type === "radio") { if (!always && controls.some((c) => c.kind === "native" && c.el.type === "radio" && c.name === name && c.el.checked)) continue el.checked = el.value === String(value) + } else if (el.type === "checkbox" && Array.isArray(value)) { + // A drafted group ticks exactly the boxes it held. One box the SERVER + // rendered ticked means it had a say, and the draft yields for the whole + // group. + if (!always && serverTicked.has(name)) continue + el.checked = value.map(String).includes(el.value) } else if (el.type === "checkbox") { if (!always && el.checked) continue el.checked = Boolean(value) @@ -1323,6 +1433,11 @@ function persistApply(root, payload, fields) { // editor's sanitizing import (Trix HTMLParser, Lexxy $generateNodesFromDOM + // sanitizer). A throw (Lexxy before its editor exists) never escapes connect. function persistApplyEditor(root, el, name, value, always) { + // A list never reaches here: both callers resolve it through + // persistGenericValue first — the group of two or more has no mapping back to + // this editor, the group of one does. Belt and braces, because this is the + // one apply path with a second entry point. + if (Array.isArray(value)) return if (!persistEditorReady(el)) return // not upgraded yet — persistDeferEditors re-applies after define if (!always && !persistEditorBlank(el)) return try { @@ -1348,10 +1463,12 @@ function persistDeferEditors(root, payload, fields) { for (const tag of pending) { registry.whenDefined(tag).then(() => { if (!root.isConnected) return - for (const { el, name, kind } of persistControls(root, payload)) { + const deferred = persistControls(root, payload) + const sizes = persistGroupSizes(deferred) + for (const { el, name, kind } of deferred) { if (kind !== "editor" || el.localName !== tag || !Object.hasOwn(fields, name)) continue - const value = fields[name] - if (value === null || value === undefined) continue + const value = persistGenericValue(fields[name], name, sizes) + if (value === null || value === undefined || value === PERSIST_NO_VALUE) continue persistApplyEditor(root, el, name, value, always) } }) @@ -3894,6 +4011,7 @@ export default class extends Controller { const fields = {} const files = [] const owns = this.#ownershipFilter() // compute ONCE per dispatch (issue #117) + const controls = [] this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => { if (!owns(field)) return if (field.type === "file") { @@ -3901,6 +4019,35 @@ export default class extends Controller { // shape (params[name][]) even when the user picked exactly one file — // otherwise a [:file] schema would see a lone scalar upload and drop it. for (const file of field.files ?? []) files.push({ name: field.name, file, multiple: field.multiple }) + } else { + // Held for a second pass: a hidden input is only a companion if a + // checkbox somewhere in the root shares its name, which the first + // occurrence cannot know yet. + controls.push(field) + } + }) + const arrayNames = this.#arrayFieldNames(controls) + const companionNames = this.#companionNames(controls) + for (const field of controls) { + if (arrayNames.has(field.name)) { + const slot = fields[field.name] ?? (fields[field.name] = []) + if (field.type === "checkbox" || field.type === "radio") { + // An unchecked box contributes NOTHING, the way a native submission + // leaves it out. The group's value is the list of checked values, and + // with none checked that list stays an EMPTY ARRAY rather than + // vanishing, so over the JSON path the action can tell "the operator + // cleared them" from "the group never rendered" and an [:string] schema + // coerces [] to []. A form body cannot carry the empty array at all, and + // the client uses one as soon as a file input holds a file — there the key + // is simply absent; see the README's multipart caveat. + if (field.checked) slot.push(field.value) + } else if (field.type === "hidden") { + if (!companionNames.has(field.name)) slot.push(field.value) + } else if (field.multiple && field.options) { + for (const option of field.options) if (option.selected) slot.push(option.value) + } else { + slot.push(field.value) + } } else if (field.type === "checkbox") { fields[field.name] = field.checked } else if (field.type === "radio") { @@ -3908,7 +4055,7 @@ export default class extends Controller { } else { fields[field.name] = field.value } - }) + } // Named rich-text / custom editors (lexxy-editor, trix-editor) and bare // [contenteditable]. These aren't input/select/textarea, so the query above // skips them — without this, a reactive save posts an empty value and @@ -3932,6 +4079,49 @@ export default class extends Controller { return { fields, files } } + // Names collected as an ARRAY rather than a single value: a name carrying the + // `[]` suffix, the HTML convention for a group. That suffix is the ONLY + // trigger — a group says so, it is not inferred from two controls happening + // to share a name. Radios are excluded BY DESIGN: a radio group shares one + // name to mean "pick one", and it keeps posting the single checked value, + // suffix or not. + // + // Issue #258: without this, `fields[name] = field.checked` wrote a boolean per + // checkbox and same-named boxes overwrote each other, so three boxes with two + // ticked left the browser as the LAST box's checked state — the chosen values + // never reached the wire, whatever the action's schema declared. + #arrayFieldNames(controls) { + const names = new Set() + for (const field of controls) { + // A radio group shares one name BY DESIGN to mean "pick one" and keeps + // its single checked value, `[]` suffix or not. + if (field.type === "radio") continue + if (String(field.name).endsWith("[]")) names.add(field.name) + } + return names + } + + // Names whose group carries a hidden COMPANION: a hidden input is Rails' way + // of giving a checkbox a value for the unchecked case, and it is never a + // chosen value. Measured from the helpers, the three shapes are: + // + // check_box(:u, :sub) + // + // check_box(:u, :ids, {multiple: true}, "3") + // + // collection_check_boxes(...) / an unchecked_value of nil + // — or no hidden at all + // + // The value differs (unchecked_value, blank, absent), so the value cannot be + // the test. What identifies a companion is that a checkbox shares its name. + // A hidden WITHOUT a same-named checkbox is a list JS maintains, and its + // value is a chosen value like any other. + #companionNames(controls) { + const names = new Set() + for (const field of controls) if (field.type === "checkbox") names.add(field.name) + return names + } + // Re-compute the dirty flag for EVERY field this root owns in one pass (issue // #103), then reflect the total onto the root. Called on an owned field's input // (trackDirty), on connect (baseline seed), and after a turbo:morph-element @@ -4760,7 +4950,23 @@ export default class extends Controller { fd.append("token", token) fd.append("act", action) for (const [key, value] of Object.entries(params)) { - this.#appendField(fd, this.#wireKey(key), value) + // A `[]` name carrying an array is the group shape (issue #258): every + // element goes to params[name][], which Rack parses as an array. The + // indexed form #appendField writes for a plain array (params[name][0], + // params[name][1]) arrives as a hash of index keys — ParamSchema's array + // type normalizes that back, but only an array type does, so the two + // bodies would stop coercing identically for the same fields. + // + // An EMPTY group cannot be expressed in a form body at all: a repeated + // key with no values is nothing, and `[""]` means something else per + // element type. Such a group is therefore ABSENT here while the JSON + // path sends `[]` — see the README caveat. + if (Array.isArray(value) && String(key).endsWith("[]")) { + const wire = `${this.#wireKey(key)}[]` + for (const element of value) fd.append(wire, String(element)) + } else { + this.#appendField(fd, this.#wireKey(key), value) + } } const multiNames = this.#multiFileNames(files) for (const { name, file, multiple } of files) { diff --git a/app/javascript/phlex/reactive/reactive_controller.min.js b/app/javascript/phlex/reactive/reactive_controller.min.js index 48b0cc82..279dc283 100644 --- a/app/javascript/phlex/reactive/reactive_controller.min.js +++ b/app/javascript/phlex/reactive/reactive_controller.min.js @@ -1,4 +1,4 @@ -import{Controller as De}from"@hotwired/stimulus";import{confirmResolver as _}from"phlex/reactive/confirm";import{computeReducer as Fe}from"phlex/reactive/compute";import{confirmPredicate as Me}from"phlex/reactive/confirm_predicate";function Pe(){let e=window.Turbo?.StreamActions;if(!e||e["reactive:visit"])return;e["reactive:visit"]=function(){let t=this.getAttribute("data-url");if(t)window.Turbo.visit(t,{action:"advance"})}}function je(){let e=window.Turbo?.StreamActions;if(!e||e["reactive:token"])return;e["reactive:token"]=function(){let t=this.getAttribute("data-reactive-token-value"),n=this.getAttribute("target");if(!t||!n)return;let r=document.getElementById(n);if(r)r.setAttribute("data-reactive-token-value",t)}}function Je(){let e=window.Turbo?.StreamActions;if(!e||e["reactive:js"])return;e["reactive:js"]=function(){let t=Re(this.getAttribute("data-reactive-ops"));if(!t.length)return;let n=this.getAttribute("target"),r=this.getAttribute("data-reactive-verbose")==="true",i=n?document.getElementById(n):null;if(n&&!i){if(r&&!qe(`missing-root|#${n}`))console.warn(`[phlex-reactive] reactive:js stream target root #${n} is not in the DOM — its ops were dropped`);return}S(t,(s)=>Ht(s,i),r?(s,o)=>Wt(s,o,i):void 0)}}var g=new Map;function fe(e,t){let n=!g.has(e);if(g.set(e,t),n)Ae()}function k(e){if(g.delete(e))we()}function Ut(){g.clear(),D=!1}function Yt(e){return g.get(e)?.via}var D=!1;function Ie(){let e=window.Turbo?.StreamActions;if(!e||e["reactive:defer"])return;if(e["reactive:defer"]=function(){let t=this.getAttribute("target");if(!t)return;if(this.getAttribute("data-reactive-defer-via")==="stream"){We(t,this);return}let n=this.getAttribute("data-reactive-defer-token");if(!n)return;T(t,n)},!D&&typeof document<"u"&&document.addEventListener)D=!0,document.addEventListener("turbo:before-stream-render",Be)}function Be(e){let n=e.target?.getAttribute?.("target");if(!n)return;let r=n.startsWith("reactive-defer-src-")?n.slice(19):n;if(g.get(r)?.via==="stream")k(r)}function T(e,t){let n=document.getElementById(e);if(!n){console.warn(`[phlex-reactive] reactive:defer target #${e} is not on the page — skipped`);return}he(e),me(n);let r={via:"fetch",abort:new AbortController,timedOut:!1};fe(e,r),He(e,r,t)}function We(e,t){let n=document.getElementById(e);if(!n){console.warn(`[phlex-reactive] reactive:defer target #${e} is not on the page — skipped`);return}let r=t.getAttribute("data-reactive-defer-src");if(!r)return;if(!globalThis.customElements?.get?.("pgbus-stream-source")){let s=t.getAttribute("data-reactive-defer-token");if(s){T(e,s);return}console.error("[phlex-reactive] reactive:defer via=stream but is not registered "+"and no fallback token was provided — is the pgbus client loaded on this page?");return}he(e),me(n);let i=document.createElement("pgbus-stream-source");i.id=de(e),i.setAttribute("src",r),i.setAttribute("since-id",t.getAttribute("data-reactive-defer-since-id")??"0"),i.setAttribute("hidden",""),document.body.appendChild(i),fe(e,{via:"stream"})}function de(e){return`reactive-defer-src-${e}`}async function He(e,t,n){let r=setTimeout(()=>{t.timedOut=!0,t.abort.abort()},Ge()),i;try{i=await fetch(Ve(),{method:"POST",headers:{Accept:"text/vnd.turbo-stream.html","Content-Type":"application/json","X-CSRF-Token":ze()},body:JSON.stringify({token:n}),credentials:"same-origin",signal:t.abort.signal})}catch(o){if(clearTimeout(r),g.get(e)!==t)return;console.error("[phlex-reactive] deferred render failed",o),N(e,n);return}if(g.get(e)!==t){clearTimeout(r);return}if(i.status===204){clearTimeout(r),ee(e);return}if(!i.ok){clearTimeout(r),console.error(`[phlex-reactive] deferred render failed: HTTP ${i.status}`),N(e,n,i.status);return}let s;try{s=await i.text()}catch(o){if(clearTimeout(r),g.get(e)!==t)return;console.error("[phlex-reactive] deferred render failed reading the body",o),N(e,n);return}if(clearTimeout(r),g.get(e)!==t)return;ee(e),window.Turbo.renderStreamMessage(s)}function he(e){let t=g.get(e);if(!t)return;if(k(e),t.via==="fetch")t.abort.abort();else document.getElementById(de(e))?.remove?.()}function me(e){e.setAttribute("data-reactive-defer-pending","true"),e.setAttribute("aria-busy","true")}function pe(e){e.removeAttribute("data-reactive-defer-pending"),e.removeAttribute("aria-busy")}function ee(e){k(e);let t=document.getElementById(e);if(!t)return;pe(t),t.removeAttribute("data-reactive-error")}function N(e,t,n){k(e);let r=document.getElementById(e);if(!r)return;pe(r),r.setAttribute("data-reactive-error","defer");let i=()=>{let s=document.getElementById(e);if(!s){console.warn("[phlex-reactive] defer retry() ignored — the target left the DOM");return}s.removeAttribute("data-reactive-error"),T(e,t)};r.dispatchEvent(new CustomEvent("reactive:error",{bubbles:!0,composed:!0,detail:{kind:"defer",target:e,status:n,retry:i}}))}function Ve(){return document.querySelector('meta[name="phlex-reactive-defer-path"]')?.content||"/reactive/defer"}function ze(){return document.querySelector('meta[name="csrf-token"]')?.content??""}function Ge(){let e=document.querySelector('meta[name="phlex-reactive-timeout"]')?.content,t=Number(e);return Number.isFinite(t)&&t>0?t:30000}var F=!1;function Ke(){if(F)return;if(typeof document>"u"||!document.addEventListener)return;F=!0,document.addEventListener("turbo:before-stream-render",Ue)}function Ue(e){let t=e.detail,n=t?.render;if(typeof n!=="function"||n.__reactiveDismissWrapped){if(typeof requestAnimationFrame==="function")requestAnimationFrame(C);else setTimeout(C,0);return}let r=async(i)=>{await n(i),C()};r.__reactiveDismissWrapped=!0,t.render=r}function C(){let e=document.querySelectorAll("[data-reactive-dismiss-after]");for(let t of e){if(t.hasAttribute("data-reactive-dismiss-scheduled"))continue;let n=Number(t.getAttribute("data-reactive-dismiss-after"));if(!Number.isFinite(n)||n<=0)continue;t.setAttribute("data-reactive-dismiss-scheduled",""),setTimeout(()=>t.remove(),n)}}function Xt(){F=!1}var Ye=Object.freeze({append:"enter",prepend:"enter",replace:"update",update:"update",remove:"exit"}),O=Object.freeze(["fade","slide","scale","highlight","shake"]),M="data-reactive-fx-pending",ve=1000,P=!1;function Xe(){if(P)return;if(typeof document>"u"||typeof document.addEventListener!=="function")return;P=!0,document.addEventListener("turbo:before-stream-render",Ze)}function Zt(){P=!1}function Ze(e){let t=e.detail,n=t?.render;if(typeof n!=="function"||n.__reactiveEffectsWrapped)return;let r=t?.newStream??e.target,i=Ye[r?.getAttribute?.("action")];if(!i||tt())return;let s=Qe(r,i);if(!s)return;let o=i==="exit"?async(c)=>{await it(E(r),s),await n(c)}:async(c)=>{let a=i==="enter"?nt(r):null;if(await n(c),i==="enter")rt(a,s);else ge(E(r),s)};o.__reactiveEffectsWrapped=!0,t.render=o}function Qe(e,t){let n=e.getAttribute?.("data-reactive-effect");if(n==="off")return null;if(n)return te(n,t);let i=(t==="enter"?et(e):E(e))?.getAttribute?.(`data-reactive-effect-${t}`);return i?te(i,t):null}function E(e){let t=e.getAttribute?.("target");return t?document.getElementById?.(t)??null:null}function et(e){return e.querySelector?.("template")?.content?.firstElementChild??null}function te(e,t){if(e.startsWith("[")){let r=null;try{let i=JSON.parse(e);if(Array.isArray(i)&&i.length===3)r=i.map(String)}catch{}if(r)return{legs:r};return console.warn(`[phlex-reactive] malformed effect legs ${JSON.stringify(e)} — skipped`),null}let n=e==="random"?O[Math.floor(Math.random()*O.length)]:e;if(!O.includes(n))return console.warn(`[phlex-reactive] unknown effect ${JSON.stringify(e)} — skipped`),null;return{className:`reactive-fx--${n}-${t}`}}function tt(){try{return typeof matchMedia==="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches}catch{return!1}}function nt(e){let t=e.querySelector?.("template")?.content;if(!t)return null;for(let n of Array.from(t.children??[]))n.setAttribute?.(M,"");return E(e)}function rt(e,t){if(typeof e?.querySelectorAll!=="function")return;for(let n of Array.from(e.querySelectorAll(`[${M}]`)))n.removeAttribute(M),ge(n,t)}async function it(e,t){if(!e?.classList)return;if(t.legs){await ye(e,t.legs);return}e.classList.add(t.className);let n=B(e);if(n<=0){e.classList.remove(t.className);return}await W(e,n),e.classList.remove(t.className)}function ge(e,t){if(!e?.classList)return;if(t.legs){ye(e,t.legs);return}if(e.classList.contains(t.className))e.classList.remove(t.className),e.offsetWidth;e.classList.add(t.className);let n=B(e);if(n<=0){e.classList.remove(t.className);return}let r=e.__reactiveFxToken=(e.__reactiveFxToken??0)+1;W(e,n).then(()=>{if(e.__reactiveFxToken===r)e.classList.remove(t.className)})}async function ye(e,t){let[n,r,i]=t.map(st),s=e.__reactiveFxToken=(e.__reactiveFxToken??0)+1;if(e.classList.remove(...n,...r,...i),e.classList.add(...n,...r),await ot(),e.__reactiveFxToken!==s)return;e.classList.remove(...r),e.classList.add(...i);let o=B(e);if(o>0)await W(e,o);if(e.__reactiveFxToken!==s)return;e.classList.remove(...n,...i)}function st(e){return String(e??"").split(/\s+/).filter(Boolean)}function B(e){if(typeof getComputedStyle!=="function")return 0;try{let t=getComputedStyle(e),n=(s)=>String(s??"").split(",").reduce((o,c)=>Math.max(o,parseFloat(c)||0),0),r=n(t.animationDuration)+n(t.animationDelay),i=n(t.transitionDuration)+n(t.transitionDelay);return Math.min(Math.max(r,i)*1000,ve)}catch{return 0}}function W(e,t){return new Promise((n)=>{let r=!1,i=()=>{if(r)return;r=!0,n()};e.addEventListener?.("animationend",i,{once:!0}),e.addEventListener?.("transitionend",i,{once:!0}),setTimeout(i,Math.min(t+50,ve))})}function ot(){return new Promise((e)=>{if(typeof requestAnimationFrame==="function")requestAnimationFrame(()=>e());else setTimeout(e,16)})}var j=!1;function at(){if(j)return;if(typeof window>"u"||typeof document>"u")return;if(typeof window.addEventListener!=="function")return;j=!0;let e=()=>{let t=document.documentElement;if(typeof t?.toggleAttribute!=="function")return;t.toggleAttribute("data-reactive-offline",globalThis.navigator?.onLine===!1)};e(),window.addEventListener("online",e),window.addEventListener("offline",e)}function Qt(){j=!1}var H="phlex-reactive:latency",x=!1;function ct(e){if(typeof sessionStorage>"u")return;sessionStorage.setItem(H,String(e))}function ut(){if(typeof sessionStorage>"u")return;sessionStorage.removeItem(H),x=!1}function lt(){if(typeof window>"u"||typeof document>"u")return;if(document.querySelector?.('meta[name="phlex-reactive-env"]')?.content!=="development")return;window.PhlexReactive={enableLatencySim:ct,disableLatencySim:ut}}function en(){x=!1}var be="data-reactive-active",y=0;function Ae(){if(y++,y===1)Se("reactive:busy")}function we(){if(y===0)return;if(y--,y===0)Se("reactive:idle")}function tn(){return y}function nn(){y=0,(typeof document<"u"?document.documentElement:null)?.removeAttribute?.(be)}function Se(e){if(typeof document>"u")return;let t=document.documentElement;if(typeof t?.toggleAttribute==="function")t.toggleAttribute(be,y>0);if(typeof document.dispatchEvent==="function"&&typeof CustomEvent==="function")document.dispatchEvent(new CustomEvent(e,{detail:{count:y}}))}function ne(){Pe(),je(),Je(),Ie(),Ke(),Xe(),at(),lt()}function ft(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}if(typeof window<"u")if(window.Turbo)ne();else document.addEventListener("turbo:load",ne,{once:!0});var L=!1;function dt(){if(L)return;if(typeof document>"u")return;let e=document.querySelectorAll('[data-controller~="reactive"]');if(!e||e.length===0)return;console.warn("[phlex-reactive] found "+e.length+' element(s) with data-controller="reactive" '+"but the reactive controller never connected. It is loaded but not registered — "+'add `application.register("reactive", ReactiveController)` (importmap) or import it into app/javascript/controllers/ for lazyLoadControllersFrom apps. See the README.')}function rn(){L=!1}function sn(){L=!0}if(typeof window<"u"&&typeof document<"u"){let e=()=>setTimeout(dt,0);if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",e,{once:!0});else e()}var ht=new Set(["href","src","srcdoc","action","formaction","xlink:href","style"]);function mt(e){let t=String(e).toLowerCase();return t.startsWith("on")||ht.has(t)}function pt(e,t,n){let[r,i,s]=t;e.classList.add(r,i),n(),requestAnimationFrame(()=>{e.classList.remove(i),e.classList.add(s)});let o=!1,c=()=>{if(o)return;o=!0,e.classList.remove(r,s)};e.addEventListener("animationend",c,{once:!0}),setTimeout(c,350)}var Ee=1,vt="phlex-reactive:persist:",gt=new Set(["hidden","file","password","submit","button","reset","image"]),V="data-reactive-persist-state",yt=":is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])",bt=new Set(["lexxy-editor","trix-editor"]),At="lexxy-editor, trix-editor, trix-toolbar",re=["lexxy:change","trix-change"],wt=new Set(["","

","


","

"]),ie=new WeakSet,se=new WeakSet,oe=new WeakSet;function z(e){let t=e?.getAttribute?.("data-reactive-persist");if(!t||t==="off")return null;try{let n=JSON.parse(t);if(n&&typeof n==="object"&&typeof n.key==="string"&&n.key!=="")return n}catch{}if(!ie.has(e))ie.add(e),console.warn(`[phlex-reactive] malformed reactive_persist payload ${JSON.stringify(t)} — persistence disabled`);return null}function G(){try{return typeof localStorage>"u"?null:localStorage}catch{return null}}function K(e,t){if(e?.getAttribute?.("data-reactive-debug")!=="true"||se.has(e))return;se.add(e),console.info(`[phlex-reactive] reactive_persist: storage unavailable — draft skipped (${t?.name??t})`)}function U(e){return vt+e.key}function J(e,t){let n=G();if(!n)return null;let r;try{r=n.getItem(U(t))}catch(a){return K(e,a),null}if(!r)return null;let i;try{i=JSON.parse(r)}catch{return null}if(!i||typeof i!=="object"||i.v!==Ee)return null;let s=Number(t.ttl)*1000;if(!(Number(i.savedAt)+s>Date.now()))return Y(e,t),null;let o=i.fields&&typeof i.fields==="object"?i.fields:{},c=i.state&&typeof i.state==="object"?i.state:null;return{fields:o,state:c}}function xe(e,t,{fields:n,state:r}){let i=G();if(!i)return!1;let s={v:Ee,savedAt:Date.now(),fields:n};if(r)s.state=r;try{return i.setItem(U(t),JSON.stringify(s)),!0}catch(o){return K(e,o),!1}}function Y(e,t){e?.removeAttribute?.(V);let n=G();if(!n)return;try{n.removeItem(U(t))}catch(r){K(e,r)}}function X(e,t){let n=Array.isArray(t.fields)?new Set(t.fields):null,r=[],i=(s)=>s.closest('[data-controller~="reactive"]')===e&&s.getAttribute("data-reactive-persist")!=="off";for(let s of e.querySelectorAll("input[name], select[name], textarea[name]")){if(!i(s)||gt.has(s.type)||s.closest(At))continue;if(n&&!n.has(s.name))continue;r.push({el:s,name:s.name,kind:"native"})}for(let s of e.querySelectorAll(yt)){if(!i(s))continue;let o=St(s);if(!o||n&&!n.has(o))continue;r.push({el:s,name:o,kind:bt.has(s.localName)?"editor":"contenteditable"})}return r}function St(e){return e.getAttribute("name")||typeof e.name==="string"&&e.name||null}function Z(e){return typeof e.value==="string"}function Et(e){if(typeof e.isEmpty==="boolean")return e.isEmpty;let t=e.editor?.getDocument?.();if(typeof t?.isEmpty==="function")return t.isEmpty();return wt.has(e.value.trim())}function xt(e,t,n){if(e?.getAttribute?.("data-reactive-debug")!=="true"||oe.has(e))return;oe.add(e),console.info(`[phlex-reactive] reactive_persist: could not restore editor ${JSON.stringify(t)} — ${n?.message??n}`)}function ke(e){return e.tagName==="SELECT"&&e.multiple}function Te(e,t){let n={};for(let{el:r,name:i,kind:s}of X(e,t))if(s==="editor"){if(Z(r))n[i]=r.value}else if(s==="contenteditable")n[i]=r.textContent??"";else if(r.type==="radio"){if(r.checked)n[i]=r.value;else if(!Object.hasOwn(n,i))n[i]=null}else if(r.type==="checkbox")n[i]=r.checked;else if(ke(r))n[i]=[...r.options].filter((o)=>o.selected).map((o)=>o.value);else n[i]=r.value;return n}function kt(e,t,n){let r=t.restore==="always",i=X(e,t);for(let{el:s,name:o,kind:c}of i){if(!Object.hasOwn(n,o))continue;let a=n[o];if(a===null||a===void 0)continue;if(c==="editor")Le(e,s,o,a,r);else if(c==="contenteditable"){if(!r&&(s.textContent??"").trim()!=="")continue;s.textContent=String(a)}else if(s.type==="radio"){if(!r&&i.some((u)=>u.kind==="native"&&u.el.type==="radio"&&u.name===o&&u.el.checked))continue;s.checked=s.value===String(a)}else if(s.type==="checkbox"){if(!r&&s.checked)continue;s.checked=Boolean(a)}else if(ke(s)){if(!r&&[...s.options].some((l)=>l.selected))continue;let u=new Set((Array.isArray(a)?a:[a]).map(String));for(let l of s.options)l.selected=u.has(l.value)}else{if(!r&&s.value!=="")continue;s.value=String(a)}}Tt(e,t,n)}function Le(e,t,n,r,i){if(!Z(t))return;if(!i&&!Et(t))return;try{t.value=String(r)}catch(s){xt(e,n,s)}}function Tt(e,t,n){let r=globalThis.customElements;if(typeof r?.whenDefined!=="function")return;let i=new Set;for(let o of e.querySelectorAll("lexxy-editor, trix-editor"))if(!Z(o)&&!r.get?.(o.localName))i.add(o.localName);let s=t.restore==="always";for(let o of i)r.whenDefined(o).then(()=>{if(!e.isConnected)return;for(let{el:c,name:a,kind:u}of X(e,t)){if(u!=="editor"||c.localName!==o||!Object.hasOwn(n,a))continue;let l=n[a];if(l===null||l===void 0)continue;Le(e,c,a,l,s)}})}function Lt(e,t){let n=z(e);if(!n){console.warn("[phlex-reactive] persist_state on a root without reactive_persist — skipped");return}if(!t||typeof t!=="object")return;let i={...J(e,n)?.state??{},...t};if(xe(e,n,{fields:Te(e,n),state:i}))e.setAttribute?.(V,JSON.stringify(i))}function _t(e){let t=z(e);if(t)Y(e,t)}var ae=Object.freeze({show:(e,t)=>R(e,!1,t),hide:(e,t)=>R(e,!0,t),toggle:(e,t)=>R(e,!e.hidden,t),add_class:(e,t)=>e.classList.add(...t.classes??[]),remove_class:(e,t)=>e.classList.remove(...t.classes??[]),toggle_class:(e,t)=>(t.classes??[]).forEach((n)=>e.classList.toggle(n)),set_attr:(e,t)=>{if(q(t.name))e.setAttribute(t.name,t.value??"")},remove_attr:(e,t)=>{if(q(t.name))e.removeAttribute(t.name)},toggle_attr:(e,t)=>{if(!q(t.name))return;if(e.hasAttribute(t.name))e.removeAttribute(t.name);else e.setAttribute(t.name,"")},focus:(e)=>e.focus?.(),focus_first:(e)=>It(e)?.focus?.(),text:(e,t)=>{let n=String(t.value??"");if(e.textContent!==n)e.textContent=n},dispatch:(e,t)=>{e.dispatchEvent(new CustomEvent(t.name,{bubbles:!0,composed:!0,detail:t.detail??{}}))},submit:(e)=>Nt(e)?.requestSubmit?.(),paste_into:(e)=>Ct(e),persist_state:(e,t)=>Lt(e,t.state),persist_clear:(e)=>_t(e)});function Nt(e){if(e?.tagName==="FORM")return e;return e?.form??e?.closest?.("form")??null}function Ct(e){let t=globalThis.navigator?.clipboard;if(typeof t?.readText!=="function")return;t.readText().then((n)=>{if(!n)return;if(e.value=n,typeof e.dispatchEvent==="function")e.dispatchEvent(new Event("input",{bubbles:!0}));e.focus?.()}).catch(()=>{})}function R(e,t,n){if(n?.transition)pt(e,n.transition,()=>e.hidden=t);else e.hidden=t}function q(e){if(!mt(e))return!0;return console.warn(`[phlex-reactive] refused client attr op on ${JSON.stringify(e)} — skipped`),!1}var _e=/^#[A-Za-z_][\w-]*$/;function Ot(e){if(typeof e==="string"&&_e.test(e))return!0;return console.warn(`[phlex-reactive] refused cross-root mirror target ${JSON.stringify(e)} — skipped`),!1}function Rt(e,t){let n=e.getAttribute("data-reactive-show-equals");if(n!==null)return t===n;let r=e.getAttribute("data-reactive-show-not");if(r!==null)return t!==r;let i=e.getAttribute("data-reactive-show-in");if(i!==null){try{let s=JSON.parse(i);if(Array.isArray(s))return s.includes(t)}catch{}return console.warn(`[phlex-reactive] malformed reactive_show in: list ${JSON.stringify(i)} — skipped`),null}for(let s of Ne){let o=e.getAttribute(`data-reactive-show-${s}`);if(o!==null)return Ce(s,o,t)}return console.warn("[phlex-reactive] a reactive_show binding declares no predicate — skipped"),null}var Ne=["gte","gt","lte","lt"],qt=["len_eq","len_gte","len_gt","len_lte","len_lt"];function $t(e,t,n){if(!Number.isInteger(t))return console.warn(`[phlex-reactive] reactive_show ${e}: needs an integer literal, got ${JSON.stringify(t)} — skipped`),null;let r=[...String(n??"")].length;switch(e){case"len_eq":return r===t;case"len_gte":return r>=t;case"len_gt":return r>t;case"len_lte":return r<=t;case"len_lt":return r=r;case"gt":return s>r;case"lte":return s<=r;case"lt":return sn&&typeof n==="object"&&Array.isArray(n.any)&&Array.isArray(n.ops)))return t}catch{}return console.warn(`[phlex-reactive] malformed reactive_on_complete payload ${JSON.stringify(e)} — skipped`),[]}function Q(e,t){if(!e||typeof e!=="object"||typeof e.field!=="string")return!1;let n=t(e.field)??"";return Oe(e,n)===!0}function w(e,t){if(!Array.isArray(e)||e.length===0)return null;return e.some((n)=>Array.isArray(n)&&n.length>0&&n.every((r)=>Q(r,t)))}function Mt(e){if(!Array.isArray(e)||e.length===0)return null;let t=new Set;for(let n of e){if(!Array.isArray(n))continue;for(let r of n)if(r&&typeof r==="object"&&typeof r.field==="string")t.add(r.field)}return t.size>0?[...t]:null}function Pt(e,t){if(!e||typeof e!=="object")return null;let n=e.any;if(Array.isArray(n)&&(n.length===0||Array.isArray(n[0])))return w(n,t);return jt(e,t)}function jt(e,t){let n=Array.isArray(e.all)?"all":Array.isArray(e.any)?"any":null;if(!n)return null;let r=e[n];if(r.length===0)return null;let i=r.map((s)=>Q(s,t));return n==="all"?i.every(Boolean):i.some(Boolean)}function ue(e){if(typeof e==="string"&&_e.test(e))return!0;return console.warn(`[phlex-reactive] refused cross-root show target ${JSON.stringify(e)} — skipped`),!1}var Jt='a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';function It(e){return e.querySelectorAll?.(Jt)?.[0]??null}function Re(e){if(Array.isArray(e))return e;if(typeof e!=="string")return[];try{let t=JSON.parse(e);return Array.isArray(t)?t:[]}catch{return[]}}function Bt(e){if(e==null)return null;let t=Array.isArray(e)?e:e.ops;if(Array.isArray(t))return t.length>0?t:null;return console.warn("[phlex-reactive] $ops must be an ops chain or a [[op, args], ...] list — skipped"),null}function S(e,t,n){for(let r of e){if(!Array.isArray(r))continue;let[i,s={}]=r;if(!Object.hasOwn(ae,i)){console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(i)} — skipped`);continue}let o=t(s);if(o.length===0&&n)n(i,s);for(let c of o)ae[i](c,s)}}var le=new WeakMap;function qe(e){let t=globalThis.document;if(!t)return!0;let n=le.get(t);if(!n)n=new Set,le.set(t,n);if(n.has(e))return!0;return n.add(e),!1}function I(e,t){try{return e?.querySelectorAll?.(t)?.length??0}catch{return 0}}function $e(e,t,n,r){if(qe(`${e}|${t}|${n}`))return;console.warn(`[phlex-reactive] ${e} matched zero targets for selector "${t}" (${n})${r}`)}function Wt(e,t,n){let r=t.to;if(typeof r!=="string"||r===""||r==="@root")return;let i="";if(n&&!t.global){let s=I(globalThis.document,r);if(s>0)i=` — it matches ${s} element(s) outside the stream's target root; use global: true`}$e(`client op "${e}"`,r,n?`scoped to #${n.id||"?"}`:"document-scoped",i)}function Ht(e,t){let n=e.to;if(t){if(n==="@root")return[t];if(typeof n!=="string"||n==="")return[];if(e.global)return[...document.querySelectorAll(n)];return[...t.querySelectorAll(n)]}if(typeof n!=="string"||n===""||n==="@root")return[];return[...document.querySelectorAll(n)]}class on extends De{static values={token:String};#re;#T=new Map;#m=new Map;#Fe;#j;#ie;#J=0;#p=new Map;#I=new WeakMap;#L=new Map;#se=new WeakSet;#oe=null;#v;#g;#y;#r;#a;#B;#ae;#W;#H;#s;#b;#ce=!1;#V=0;#ue=!1;#o;#A;#w;#_;#S;#i=null;#E=!1;#t=null;#c;#N;#C;connect(){if(L=!0,this.element.id==="")console.warn("[phlex-reactive] a reactive root has no id; its next-action token can't self-match "+"and may fall back to the first token in the response → a silent HTTP 403 on the NEXT action. "+"Put id: on the SAME element as reactive_attrs — use div(**reactive_root) (emits id + token together), "+"or div(id:, **reactive_attrs). The id: must NOT be on a child. See the README.");if(this.element.getAttribute?.("data-reactive-defer-token"))this.#le(),this.#_=()=>this.#le(),this.element.addEventListener?.("turbo:morph-element",this.#_);if(this.#i=z(this.element),this.#i)this.#At();if(this.#Me()){if(this.#v=()=>this.#Y(),this.element.addEventListener?.("turbo:morph-element",this.#v),this.#Y(),this.element.getAttribute?.("data-reactive-warn-unsaved")==="true")this.#ut()}if(this.#ft())this.#r=()=>this.#ke(),this.element.addEventListener?.("input",this.#r),this.element.addEventListener?.("change",this.#r),this.element.addEventListener?.("turbo:morph-element",this.#r),this.#ke();if(this.#dt())this.#a=(e)=>this.#X(e),this.#B=()=>this.#X(null),this.element.addEventListener?.("input",this.#a),this.element.addEventListener?.("change",this.#a),this.element.addEventListener?.("turbo:morph-element",this.#B),this.#X(null);if(this.#D())this.#s=(e)=>{if(e?.type==="input"&&!this.#Tt(e))return;this.#k()},this.element.addEventListener?.("input",this.#s),this.element.addEventListener?.("turbo:morph-element",this.#s),this.#k();if(this.#F())this.#b=()=>this.#ee(),this.element.addEventListener?.("turbo:morph-element",this.#b),this.#ee();if(this.#_t())this.#o=(e)=>this.syncNestedJson(e),this.#A=()=>this.#R(),this.element.addEventListener?.("input",this.#o),this.element.addEventListener?.("change",this.#o),this.element.addEventListener?.("turbo:morph-element",this.#A),this.#R();if(this.#kt())this.#w=()=>this.recompute(),this.element.addEventListener?.("turbo:morph-element",this.#w),this.recompute();if(this.#ht())this.#S=()=>this.#xe(),this.element.addEventListener?.("turbo:morph-element",this.#S),this.#xe()}#Me(){if((this.element.getAttribute?.("data-action")??"").includes("reactive#trackDirty"))return!0;let e=this.element.querySelectorAll?.('[data-action*="reactive#trackDirty"]')??[];for(let t of e)if(this.#n(t))return!0;return!1}disconnect(){if(this.#Et(),this.#Ze(),this.#et(),this.#lt(),this.#xt(),this.#vt(),this.#Lt(),this.#Dt(),this.#Ft(),this.#Mt(),this.#mt(),this.#_)this.element.removeEventListener?.("turbo:morph-element",this.#_)}#le(){let e=this.element;if(!e?.id)return;let t=e.getAttribute?.("data-reactive-defer-token");if(!t)return;if(e.getAttribute?.("data-reactive-defer-pending")!=="true")return;T(e.id,t)}dispatch(e){let{action:t,params:n,debounce:r,throttle:i,confirm:s,confirmWhen:o,outside:c,window:a,optimistic:u}=e.params;if(!t)return;let l=e.params.busy??this.#Kt(e.params.loading);if(c&&this.element.contains(e.target))return;let v=e.currentTarget??e.target;if(!a&&!this.#It(u,v))e.preventDefault();let d=this.#U(s,o);if(!d)return this.#ye(v,t,n,r,i,u,l);Promise.resolve().then(()=>_(d,{el:v})).catch(()=>!1).then((p)=>{if(p)this.#ye(v,t,n,r,i,u,l)})}runOps(e){let{ops:t,confirm:n,confirmWhen:r,outside:i,window:s}=e.params,o=e.currentTarget??e.target;if(i&&this.element.contains(e.target))return;if(!s)e.preventDefault();let c=this.#U(n,r);if(!c)return this.#Re(this.#Oe(t));Promise.resolve().then(()=>_(c,{el:o})).catch(()=>!1).then((a)=>{if(a)this.#Re(this.#Oe(t))})}trackDirty(){this.#Y()}recompute(e){if(e&&this.#se.has(e))return;let t=this.#Ve(),n=t.map(([f])=>f),r=this.element.getAttribute?.("data-reactive-scope")||null,i=(f)=>r&&!f.includes("[")?`${r}[${f}]`:f,s=this.#e(),o=new Map,c=(f)=>{if(o.has(f))return o.get(f);let h=null;for(let m of this.element.querySelectorAll(`[name="${i(f)}"]`))if(s(m)){h=m;break}return o.set(f,h),h};for(let f of n)this.#ve(f,c(f)?.value??"");let a=this.element.getAttribute("data-reactive-compute-reducer-param"),u=a?Fe(a):null;if(!u){this.#ge({},c);return}let l=this.#He("data-reactive-compute-outputs-param"),v={};for(let[f,h]of t){let m=c(f);if(h==="string")v[f]=m?.value??"";else{let b=Number(m?.value);v[f]=Number.isFinite(b)?b:0}}let d=u(v,{changed:this.#ze(e,n,r)})||{},p=Bt(d.$ops),A=[];for(let f of l){if(f==="$ops"||!(f in d))continue;let h=c(f);if(!h)continue;if(String(d[f])===h.value)continue;h.value=d[f],A.push(h)}for(let f of Object.keys(d)){if(f==="$ops")continue;let h=d[f];if(h===void 0||h===null)continue;this.#ve(f,h)}this.#ge(d,c);for(let f of A){let h=new Event("input",{bubbles:!0});this.#se.add(h),f.dispatchEvent(h)}this.#Pe(p,Boolean(e))}#Pe(e,t){let n=e===null?null:JSON.stringify(e),r=n!==null&&n!==this.#oe&&t;if(this.#oe=n,!r)return;S(e,(i)=>this.#P(i.to==null?{...i,to:"@root"}:i),(i,s)=>this.#M(`client op "${i}"`,s))}listnavNext(e){this.#fe(e,1)}listnavPrev(e){this.#fe(e,-1)}listnavPick(e){let t=this.#O(e),n=t.findIndex((r)=>r.hasAttribute("data-reactive-highlighted"));if(n<0)return;e.preventDefault(),t[n].click()}listnavClose(e){for(let t of this.#O(e))t.removeAttribute("data-reactive-highlighted")}#fe(e,t){let n=this.#O(e);if(!n.length)return;e.preventDefault();let r=n.findIndex((o)=>o.hasAttribute("data-reactive-highlighted")),i=r<0?t>0?0:n.length-1:(r+t+n.length)%n.length;for(let o of n)o.removeAttribute("data-reactive-highlighted");let s=n[i];s.setAttribute("data-reactive-highlighted","true"),s.scrollIntoView?.({block:"nearest"})}#O(e){let n=(e?.currentTarget??e?.target??this.element).getAttribute?.("data-reactive-listnav-option-param")??this.element.getAttribute("data-reactive-listnav-option-param");if(!n)return[];let r=this.#e();return Array.from(this.element.querySelectorAll(n)).filter((i)=>!i.hidden&&r(i))}tagsAdd(e){if(!this.#F())return;if(e?.defaultPrevented)return;if(this.#O(e).some((r)=>r.hasAttribute?.("data-reactive-highlighted")))return;e?.preventDefault?.();let t=e?.currentTarget??e?.target;if(!t)return;if(!this.#_e(String(t.value??"").split(",")))return;if(t.value="",this.#D())this.#k()}tagsPick(e){if(!this.#F())return;e?.preventDefault?.();let n=(e?.currentTarget??e?.target)?.getAttribute?.("data-reactive-tag-param");if(!n)return;if(!this.#_e([n]))return;let r=this.#Nt();if(!r)return;r.value="",this.#k(),r.focus?.()}tagsRemove(e){if(!this.#F())return;e?.preventDefault?.();let n=(e?.currentTarget??e?.target)?.getAttribute?.("data-reactive-tag-param");if(!n)return;let r=this.#Z();if(!r)return;let i=this.#Q(r),s=i.filter((o)=>o.toLowerCase()!==n.toLowerCase());if(s.length===i.length)return;this.#Ne(r,s)}nestedAdd(e){e?.preventDefault?.();let t=e?.currentTarget??e?.target,n=t?.getAttribute?.("data-reactive-association-param");if(!n)return;if(typeof this.element?.querySelectorAll!=="function")return;let r=this.#e(),i=[...this.element.querySelectorAll(`[data-reactive-nested-list="${n}"]`)].find(r),o=[...this.element.querySelectorAll(`[data-reactive-nested-template="${n}"]`)].find(r)?.content?.firstElementChild;if(!i||!o){this.#$t(n);return}let c=o.cloneNode(!0);this.#qt(c,this.#Rt()),i.appendChild(c);let a=t?.getAttribute?.("data-reactive-nested-from-param"),u=t?.getAttribute?.("data-reactive-nested-clear-param")==="true",l=this.#je(c,a,u);if(a)l?.focus?.();else[...c.querySelectorAll?.("input, select, textarea")??[]][0]?.focus?.();if(i.getAttribute?.("data-reactive-nested-json")===n)this.#he(n)}#je(e,t,n){if(!t)return null;let r;try{r=JSON.parse(t)}catch{return null}if(!r||typeof r!=="object")return null;let i=this.#e(),s=[...e.querySelectorAll?.("input, select, textarea")??[]],o=[];for(let[c,a]of Object.entries(r)){let u=[...this.element.querySelectorAll?.(a)??[]].find(i);if(!u)continue;let l=s.find((v)=>this.#pe(v.getAttribute?.("name"))===c);if(!l)continue;this.#Je(l,u),o.push(u)}if(n)for(let c of o)this.#Ie(c);return o[0]??null}#Je(e,t){if(e.type==="checkbox")e.checked=t.type==="checkbox"?!!t.checked:this.#z(t)!=="";else e.value=this.#z(t);if(typeof e.dispatchEvent==="function")e.dispatchEvent(new Event("input",{bubbles:!0}))}#Ie(e){if(e.type==="checkbox")e.checked=!1;else e.value="";if(typeof e.dispatchEvent==="function")e.dispatchEvent(new Event("input",{bubbles:!0}))}nestedRemove(e){e?.preventDefault?.();let t=e?.currentTarget??e?.target,n=t?.closest?.("[data-reactive-nested-row]");if(!n)return;if(n.closest?.('[data-controller~="reactive"]')!==this.element)return;let r=t?.getAttribute?.("data-reactive-confirm-param"),i=t?.getAttribute?.("data-reactive-confirm-when-param"),s=this.#U(r,i);if(!s)return this.#de(n);let o=this.#me(n),c=this.#Be(s,o);return Promise.resolve().then(()=>_(c,{el:t,row:n,fields:o})).catch(()=>!1).then((a)=>{if(a)this.#de(n)})}#Be(e,t){if(!e.includes("%{"))return e;return e.replace(/%\{(\w+)\}/g,(n,r)=>Object.prototype.hasOwnProperty.call(t,r)?t[r]:n)}#de(e){let t=[...e.querySelectorAll?.('input[name$="[_destroy]"]')??[]][0];if(t){if(t.value="1",typeof t.dispatchEvent==="function")t.dispatchEvent(new Event("input",{bubbles:!0}));e.hidden=!0}else e.parentNode?.removeChild?.(e);this.#R()}syncNestedJson(e){let t=e?.target;if(!t||!this.#n(t))return;this.#R()}#R(){if(typeof this.element?.querySelectorAll!=="function")return;let e=this.#e();for(let t of[...this.element.querySelectorAll("[data-reactive-nested-json]")].filter(e))this.#he(t.getAttribute("data-reactive-nested-json"))}#he(e){let t=this.#e(),n=[...this.element.querySelectorAll(`[data-reactive-nested-list="${e}"]`)].find(t);if(!n)return;let r=this.#We(n);if(!r)return;let i=[];for(let o of[...n.querySelectorAll?.("[data-reactive-nested-row]")??[]]){if(!t(o)||o.hidden)continue;i.push(this.#me(o))}let s=JSON.stringify(i);if(r.value===s)return;if(r.value=s,typeof r.dispatchEvent==="function")r.dispatchEvent(new Event("input",{bubbles:!0}))}#We(e){let t=e.getAttribute?.("data-reactive-nested-json-field");if(!t)return null;let n=this.#e();return[...this.element.querySelectorAll(t)].find(n)??null}#me(e){let t={};for(let n of[...e.querySelectorAll?.("input, select, textarea")??[]]){let r=this.#pe(n.getAttribute?.("name"));if(r===null||r==="_destroy")continue;t[r]=this.#z(n)}return t}#pe(e){if(!e)return null;let t=e.match(/\[([^\][]+)\]$/);return t?t[1]:e}#z(e){if(e.type==="checkbox")return e.checked?e.value||"on":"";return e.value??""}#He(e){let t=this.element.getAttribute(e);if(!t)return[];try{let n=JSON.parse(t);return Array.isArray(n)?n:[]}catch{return[]}}#Ve(){let e=this.element.getAttribute("data-reactive-compute-inputs-param");if(!e)return[];try{let t=JSON.parse(e);if(Array.isArray(t))return t.map((n)=>[n,"number"]);if(t&&typeof t==="object")return Object.entries(t);return[]}catch{return[]}}#ze(e,t,n){let r=e?.target;if(!r?.name||typeof r.closest!=="function")return null;let i=this.#Ge(r.name,n);if(!t.includes(i))return null;return this.#n(r)?i:null}#Ge(e,t){if(!t)return e;let n=`${t}[`;return e.startsWith(n)&&e.endsWith("]")?e.slice(n.length,-1):e}#ve(e,t){let n=String(t);for(let r of this.#Ke(e)){if(r.textContent===n)continue;r.textContent=n}}#Ke(e){let t=this.element.querySelectorAll(`[data-reactive-text="${e}"]`);return Array.from(t).filter((n)=>this.#n(n))}#ge(e,t){let n=this.#Ue();for(let[r,i]of Object.entries(n)){let s=r in e?e[r]:t(r)?.value;if(s===void 0||s===null)continue;let o=String(s);for(let c of Array.isArray(i)?i:[i]){if(!Ot(c))continue;for(let a of document.querySelectorAll(c)){if(a.textContent===o)continue;a.textContent=o}}}}#Ue(){let e=this.element.getAttribute("data-reactive-compute-mirror-param");if(!e)return{};try{let t=JSON.parse(e);return t&&typeof t==="object"&&!Array.isArray(t)?t:{}}catch{return{}}}#ye(e,t,n,r,i,s,o){if(this.#x("reactive:before-dispatch",{action:t,params:this.#Ce(n),element:this.element},{cancelable:!0}).defaultPrevented)return;let a=Number(r)||0;if(a>0)return this.#Xe(e,a,t,n,s,o);let u=Number(i)||0;if(u>0)return this.#Qe(e,u,t,n,s,o);return this.#q(t,n,s,e,o)}#q(e,t,n,r,i){let s=this.#Bt(n,r),o=this.#Wt(e,r,i),c=this.#K()?this.#Ye(n,r):null;return this.queue=(this.queue??Promise.resolve()).then(()=>this.#ot(e,t,s,o,c)),this.queue}#Ye(e,t){if(!e?.hide)return null;let n=this.#$e(e,t);if(!n.length)return null;return()=>{let r=n.filter((i)=>i.isConnected&&!i.hidden);if(!r.length)return;console.warn("[phlex-reactive] optimistic: { hide: true } was undone by the reply's re-render — "+"the element is visible again. For an instant delete, return reply.remove so the server removes it; otherwise the hide only flashes.",r)}}#Xe(e,t,n,r,i,s){this.#G(e);let o=()=>{this.#G(e),this.#q(n,r,i,e,s)},c=setTimeout(o,t);e?.addEventListener?.("blur",o,{once:!0}),this.#T.set(e,{timer:c,flush:o})}#G(e){let t=this.#T.get(e);if(!t)return;clearTimeout(t.timer),e?.removeEventListener?.("blur",t.flush),this.#T.delete(e)}#Ze(){for(let e of[...this.#T.keys()])this.#G(e)}#Qe(e,t,n,r,i,s){let o=this.#m.get(e)??new Map;if(o.has(n))return;let c=setTimeout(()=>{if(o.delete(n),o.size===0)this.#m.delete(e)},t);return o.set(n,c),this.#m.set(e,o),this.#q(n,r,i,e,s)}#et(){for(let e of this.#m.values())for(let t of e.values())clearTimeout(t);this.#m.clear()}#x(e,t,{cancelable:n=!1}={}){let r=new CustomEvent(e,{bubbles:!0,composed:!0,cancelable:n,detail:t});return(this.element.isConnected?this.element:document).dispatchEvent(r),r}#u(e,t,n,r){let i=()=>{if(!this.element.isConnected){console.warn("[phlex-reactive] retry() ignored — the reactive root left the DOM");return}return this.#q(e,t)};this.#x("reactive:error",{action:e,params:n,...r,retry:i})}#l(e){if(this.element?.isConnected===!1)return;this.element?.setAttribute?.("data-reactive-error",e)}#tt(){this.element?.removeAttribute?.("data-reactive-error")}#nt(){let e=document.querySelector("[data-reactive-error-flash]");if(!e?.content)return;let t=e.getAttribute("data-reactive-error-flash")||"flash",n=document.getElementById(t);if(!n)return;n.appendChild(e.content.cloneNode(!0))}#rt(){if(typeof sessionStorage>"u")return Promise.resolve();let e=Number(sessionStorage.getItem(H));if(!Number.isFinite(e)||e<=0)return Promise.resolve();if(!x)x=!0,console.warn(`[phlex-reactive] latency simulator ACTIVE — every action is delayed by ${e}ms. Call PhlexReactive.disableLatencySim() (or clear sessionStorage) to turn it off.`);return new Promise((t)=>setTimeout(t,e))}#K(){return this.element?.getAttribute?.("data-reactive-debug")==="true"}#be(){return typeof performance<"u"&&typeof performance.now==="function"?performance.now():Date.now()}#it(e){if(!e)return[];let t=[],n=/]*)>/g,r;while((r=n.exec(e))!==null){let i=r[1],s=i.match(/\baction="([^"]*)"/)?.[1]??"?",o=i.match(/\btarget="([^"]*)"/)?.[1];t.push(o?`${s} → #${o}`:s)}return t}#st(e){let{action:t,status:n,ms:r}=e,s=`reactive ${this.element?.id?`#${this.element.id} `:""}${t} → ${n??"—"} (${Math.round(r)}ms)`;if(console.groupCollapsed(s),console.log(`params: [${e.paramNames.join(", ")}] + collected: [${e.fieldNames.join(", ")}]`),console.log(`encoding: ${e.encoding}`),e.streams.length)console.log(`streams: ${e.streams.join(" ")}`);console.log(`token: ${e.tokenRefreshed?"refreshed ✓":"unchanged"}`),console.groupEnd()}async#ot(e,t,n,r,i){let{fields:s,files:o}=this.#Se(),c=this.#Ce(t),a={...s,...c},u=this.#f,l=o.length>0,v=l?this.#Pt(u,e,a,o):JSON.stringify({token:u,act:e,params:a}),d=this.#K()?{action:e,paramNames:Object.keys(c),fieldNames:Object.keys(s),encoding:l?"multipart":"json",status:null,streams:[],tokenRefreshed:!1,started:this.#be()}:null;await this.#rt();try{if(navigator.onLine===!1){this.#d(n),this.#l("offline"),this.#u(e,t,a,{kind:"offline"});return}let p;try{let m={Accept:"text/vnd.turbo-stream.html","X-CSRF-Token":this.#Xt()};if(!l)m["Content-Type"]="application/json";let b=this.#Zt();if(b)m["X-Pgbus-Connection"]=b;p=await fetch(this.#Ut(),{method:"POST",headers:m,body:v,credentials:"same-origin",signal:AbortSignal.timeout(this.#Yt())})}catch(m){if(console.error("[phlex-reactive] action error",m),this.#d(n),m?.name==="TimeoutError"||m?.name==="AbortError"){this.#l("timeout"),this.#u(e,t,a,{kind:"timeout"});return}this.#nt(),this.#l("network"),this.#u(e,t,a,{kind:"network"});return}if(d)d.status=p.status;if(p.redirected){console.error("[phlex-reactive] action was redirected (auth/CSRF?) — no update applied"),this.#d(n),this.#l("redirected"),this.#u(e,t,a,{kind:"redirected",status:p.status});return}if(!p.ok){let m=await p.text();if(console.error(`[phlex-reactive] action failed: HTTP ${p.status}`,m),this.#d(n),(p.headers.get("Content-Type")||"").includes("turbo-stream")){let b=this.#we(m);if(this.#f=b??this.#f,d)this.#Ae(d,m,b);window.Turbo.renderStreamMessage(m)}this.#l("http"),this.#u(e,t,a,{kind:"http",status:p.status,body:m});return}let A=p.headers.get("Content-Type")||"";if(!A.includes("turbo-stream")){console.error(`[phlex-reactive] expected a turbo-stream, got "${A}" — no update applied`),this.#d(n),this.#l("content-type"),this.#u(e,t,a,{kind:"content-type",status:p.status});return}let f=await p.text(),h=this.#we(f);if(this.#f=h??this.#f,d)this.#Ae(d,f,h);if(window.Turbo.renderStreamMessage(f),i)queueMicrotask(i);this.#tt(),this.#x("reactive:applied",{action:e,params:a,html:f})}catch(p){console.error("[phlex-reactive] action error",p),this.#d(n),this.#x("reactive:error",{action:e,params:a,kind:"apply"})}finally{if(r?.(),d)this.#st({...d,ms:this.#be()-d.started})}}#Ae(e,t,n){e.streams=this.#it(t),e.tokenRefreshed=n!=null}get#f(){return this.#re??this.tokenValue}set#f(e){this.#re=e}#we(e){let t=this.element.id;if(!t)return e.match(/data-reactive-token-value="([^"]+)"/)?.[1];let{token:n,self:r}=this.#at(t),i=e.match(n);if(i)return i[1];let s=e.match(r);if(s)return s[1].match(/data-reactive-token-value="([^"]+)"/)?.[1];return}#at(e){let t=this.#ie;if(t&&t.id===e)return t;let n=ft(e);return this.#ie={id:e,token:new RegExp(`]*\\baction="reactive:token"[^>]*\\btarget="${n}"[^>]*\\bdata-reactive-token-value="([^"]+)"`),self:new RegExp(`]*\\baction="(?:replace|update)"[^>]*\\btarget="${n}"[^>]*>([\\s\\S]*?)`)}}#n(e){return e.closest('[data-controller~="reactive"]')===this.element}#e(){if(this.element.querySelectorAll('[data-controller~="reactive"]').length===0)return()=>!0;return(t)=>this.#n(t)}#U(e,t){if(e)return e;if(!t)return null;let n=t;if(typeof t==="string")try{n=JSON.parse(t)}catch{return console.warn(`[phlex-reactive] malformed conditional confirm payload ${JSON.stringify(t)} — skipped`),null}if(!n||typeof n!=="object")return null;let{fields:r}=this.#Se(),i=(o)=>r[o],s;if(typeof n.predicate==="string"){let o=Me(n.predicate);if(!o)return console.warn(`[phlex-reactive] confirm predicate "${n.predicate}" is not registered — proceeding without a dialog (register it with setConfirmPredicate)`),null;s=!!o(r)}else s=w(n.groups?.any,i)===!0;return s?n.message:null}#Se(){let e={},t=[],n=this.#e();return this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((r)=>{if(!n(r))return;if(r.type==="file")for(let i of r.files??[])t.push({name:r.name,file:i,multiple:r.multiple});else if(r.type==="checkbox")e[r.name]=r.checked;else if(r.type==="radio"){if(r.checked)e[r.name]=r.value}else e[r.name]=r.value}),this.element.querySelectorAll("[name]:is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])").forEach((r)=>{if(!n(r))return;let i=r.getAttribute("name");if(!i)return;let s=e[i];if(s==null||s==="")e[i]=r.value??r.textContent??r.innerHTML??""}),{fields:e,files:t}}#Y(){if(typeof this.element?.querySelectorAll!=="function")return;let e=0;if(this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((t)=>{if(!this.#n(t))return;if(t.type==="file")return;if(this.#ct(t))t.setAttribute("data-reactive-dirty","true"),e++;else t.removeAttribute("data-reactive-dirty")}),e>0)this.element.setAttribute("data-reactive-dirty",String(e));else this.element.removeAttribute("data-reactive-dirty")}#ct(e){if(e.type==="checkbox"||e.type==="radio")return e.checked!==e.defaultChecked;if(e.tag==="select"||e.options)return Array.from(e.options??[]).some((t)=>t.selected!==t.defaultSelected);return e.value!==e.defaultValue}#Ee(){let e=this.element.getAttribute?.("data-reactive-dirty"),t=Number(e);return Number.isFinite(t)&&t>0?t:0}#ut(){if(typeof window>"u"||typeof window.addEventListener!=="function")return;this.#g=(e)=>{if(this.#Ee()===0)return;return e.preventDefault(),e.returnValue="You have unsaved changes.",e.returnValue},this.#y=(e)=>{if(this.#Ee()===0)return;if(!(typeof window.confirm==="function"?window.confirm("You have unsaved changes. Leave anyway?"):!0))e.preventDefault?.()},window.addEventListener("beforeunload",this.#g),window.addEventListener("turbo:before-visit",this.#y)}#lt(){if(this.#v)this.element.removeEventListener?.("turbo:morph-element",this.#v),this.#v=void 0;if(typeof window<"u"&&typeof window.removeEventListener==="function"){if(this.#g)window.removeEventListener("beforeunload",this.#g);if(this.#y)window.removeEventListener("turbo:before-visit",this.#y)}this.#g=void 0,this.#y=void 0}#ft(){if(this.element.getAttribute?.("data-reactive-show-targets"))return!0;let e=this.element.querySelectorAll?.(ce)??[];for(let t of e)if(this.#n(t))return!0;return!1}#dt(){return!!this.element.getAttribute?.("data-reactive-on-complete")}#ht(){if(this.element.getAttribute?.("data-reactive-clipboard"))return!0;let e=this.element.querySelectorAll?.("[data-reactive-clipboard]")??[];for(let t of e)if(this.#n(t))return!0;return!1}#xe(){let e=typeof globalThis.navigator?.clipboard?.readText==="function";if(this.element.getAttribute?.("data-reactive-clipboard"))this.element.hidden=!e;for(let t of this.element.querySelectorAll?.("[data-reactive-clipboard]")??[])if(this.#n(t))t.hidden=!e}#mt(){if(!this.#S)return;this.element.removeEventListener?.("turbo:morph-element",this.#S),this.#S=void 0}#pt(){let e=this.element.getAttribute?.("data-reactive-on-complete")??null;if(e!==this.#ae)this.#ae=e,this.#W=e==null?[]:Ft(e),this.#H=this.#W.map(()=>!1);return this.#W}#X(e){let t=this.#pt();if(!t.length)return;let n=this.#e(),r=this.element.getAttribute?.("data-reactive-scope")||null,i=new Map,s=(o)=>{if(!i.has(o))i.set(o,this.#Le(o,n,r));return i.get(o)};t.forEach((o,c)=>{let a=w(o.any,s);if(a===null)return;let u=a&&!this.#H[c]&&Boolean(e);if(this.#H[c]=a,u)S(o.ops,(l)=>this.#P(l.to==null?{...l,to:"@root"}:l),(l,v)=>this.#M(`client op "${l}"`,v))})}#vt(){if(!this.#a)return;this.element.removeEventListener?.("input",this.#a),this.element.removeEventListener?.("change",this.#a),this.element.removeEventListener?.("turbo:morph-element",this.#B)}#ke(){if(typeof this.element?.querySelectorAll!=="function")return;let e=this.#e(),t=this.element.getAttribute?.("data-reactive-scope")||null,n=new Map,r=(i)=>{if(!n.has(i))n.set(i,this.#Le(i,e,t));return n.get(i)};for(let i of this.element.querySelectorAll(ce)){if(!e(i))continue;let s=i.getAttribute("data-reactive-show");if(s!==null){let u=Pt(Dt(s),r);if(u!==null)this.#Te(i,u,e,t);continue}let o=i.getAttribute("data-reactive-show-field");if(!o)continue;let c=r(o);if(c===null)continue;let a=Rt(i,c);if(a===null)continue;this.#Te(i,a,e,t)}this.#gt(r)}#Te(e,t,n,r){if(e.hidden=!t,e.getAttribute("data-reactive-show-disable")!=="true")return;if(typeof e.querySelectorAll!=="function")return;for(let i of e.querySelectorAll("input[name], select[name], textarea[name]"))if(n(i))i.disabled=!t;if(e.name&&n(e))e.disabled=!t}#gt(e){let t=this.#bt();for(let[n,r]of Object.entries(t)){if(n.startsWith("#")){this.#yt(n,r,e);continue}if(!r||typeof r!=="object"||Array.isArray(r))continue;let i=e(n);if(i===null)continue;let s=()=>i;for(let[o,c]of Object.entries(r)){if(!ue(o))continue;let a;if(Array.isArray(c)){if(c.length===0){console.warn(`[phlex-reactive] malformed reactive_show_targets group for ${o} — skipped`);continue}a=c.every((u)=>Q(u,s))}else{let u=Oe(c,i);if(u===null){console.warn(`[phlex-reactive] malformed reactive_show_targets predicate for ${o} — skipped`);continue}a=u}for(let u of document.querySelectorAll(o))u.hidden=!a}}}#yt(e,t,n){if(!ue(e))return;let r=t&&typeof t==="object"&&!Array.isArray(t)?t.any:null,i=Mt(r);if(i===null){console.warn(`[phlex-reactive] malformed reactive_show_targets conditions for ${e} — skipped`);return}if(i.every((o)=>n(o)===null))return;let s=w(r,n);if(s===null)return;for(let o of document.querySelectorAll(e))o.hidden=!s}#bt(){let e=this.element.getAttribute?.("data-reactive-show-targets");if(!e)return{};try{let t=JSON.parse(e);if(t&&typeof t==="object"&&!Array.isArray(t))return t}catch{}return console.warn("[phlex-reactive] malformed data-reactive-show-targets — ignored. "+"Did two reactive_show_targets calls collide on one root? Declare every field in ONE call: reactive_show_targets(mode: { ... }, kind: { ... })"),{}}#Le(e,t,n){let r=n&&!e.includes("[")?`${n}[${e}]`:e,i=!1,s=null;for(let o of this.element.querySelectorAll(`[name="${r}"]`)){if(!t(o))continue;if(o.type==="checkbox")return o.checked?"true":"false";if(o.type==="radio"){if(o.checked)return o.value??"";i=!0;continue}s??=o}if(s)return s.value??"";return i?"":null}#At(){let e=this.#i;this.#E=!1;let t=J(this.element,e);if(t){if(kt(this.element,e,t.fields),t.state)this.element.setAttribute?.(V,JSON.stringify(t.state));this.#x("reactive:persist-restored",{key:e.key,fields:t.fields,state:t.state??{}})}this.#E=!0,this.#c=()=>this.#wt(),this.#N=()=>this.#$(),this.#C=(n)=>this.#St(n),this.element.addEventListener?.("input",this.#c),this.element.addEventListener?.("change",this.#N);for(let n of re)this.element.addEventListener?.(n,this.#c);document.addEventListener?.("turbo:submit-end",this.#C)}#wt(){if(!this.#E)return;let e=Number(this.#i?.debounce)||0;if(e<=0)return this.#$();if(this.#t!==null)clearTimeout(this.#t);this.#t=setTimeout(()=>{this.#t=null,this.#$()},e)}#$(){if(!this.#E||!this.#i)return;if(this.#t!==null)clearTimeout(this.#t),this.#t=null;let e=this.#i,t=J(this.element,e);xe(this.element,e,{fields:Te(this.element,e),state:t?.state??null})}#St(e){if(!e?.detail?.success)return;let t=e.target;if(t?.tagName!=="FORM"||typeof t.contains!=="function")return;if(!t.contains(this.element))return;if(this.#t!==null)clearTimeout(this.#t),this.#t=null;Y(this.element,this.#i)}#Et(){if(!this.#i)return;if(this.#t!==null)this.#$();this.element.removeEventListener?.("input",this.#c),this.element.removeEventListener?.("change",this.#N);for(let e of re)this.element.removeEventListener?.(e,this.#c);document.removeEventListener?.("turbo:submit-end",this.#C),this.#c=this.#N=this.#C=void 0,this.#i=null,this.#E=!1}#xt(){if(!this.#r)return;this.element.removeEventListener?.("input",this.#r),this.element.removeEventListener?.("change",this.#r),this.element.removeEventListener?.("turbo:morph-element",this.#r),this.#r=void 0}#D(){return!!(this.element.getAttribute?.("data-reactive-filter-input")&&this.element.getAttribute?.("data-reactive-filter-option"))}#kt(){return this.element.getAttribute?.("data-reactive-compute-seed")==="true"}#Tt(e){let t=this.element.getAttribute("data-reactive-filter-input");return!!t&&typeof e.target?.matches==="function"&&e.target.matches(t)}#k(){if(typeof this.element?.querySelectorAll!=="function")return;let e=this.element.getAttribute("data-reactive-filter-input"),t=this.element.getAttribute("data-reactive-filter-option");if(!e||!t)return;let n=this.#e(),r=[...this.element.querySelectorAll(e)].find(n);if(!r)return;let i=(r.value??"").trim().toLowerCase(),s=0;for(let a of this.element.querySelectorAll(t)){if(!n(a))continue;let u=(a.getAttribute("data-reactive-filter-text")??a.textContent??"").toLowerCase(),l=a.hasAttribute?.("data-reactive-tags-selected")||i!==""&&!u.includes(i);if(a.hidden=l,l)a.removeAttribute("data-reactive-highlighted");else s++}let o=this.element.getAttribute("data-reactive-filter-group");if(o)for(let a of this.element.querySelectorAll(o)){if(!n(a))continue;let u=[...a.querySelectorAll(t)].filter(n);if(u.length===0)continue;a.hidden=u.every((l)=>l.hidden)}let c=this.element.getAttribute("data-reactive-filter-empty");if(c){for(let a of this.element.querySelectorAll(c))if(n(a))a.hidden=s>0}}#Lt(){if(!this.#s)return;this.element.removeEventListener?.("input",this.#s),this.element.removeEventListener?.("turbo:morph-element",this.#s),this.#s=void 0}#F(){return!!this.element.getAttribute?.("data-reactive-tags-field")}#_t(){if(typeof this.element?.querySelector!=="function")return!1;return!!this.element.querySelector("[data-reactive-nested-json]")}#Z(){if(typeof this.element?.querySelectorAll!=="function")return null;let e=this.element.getAttribute("data-reactive-tags-field");if(!e)return null;let t=this.#e();return[...this.element.querySelectorAll(e)].find(t)??null}#Q(e){let t=new Set,n=[];for(let r of String(e.value??"").split(",")){let i=r.trim();if(i===""||t.has(i.toLowerCase()))continue;t.add(i.toLowerCase()),n.push(i)}return n}#_e(e){let t=this.#Z();if(!t)return!1;let n=this.#Q(t),r=new Set(n.map((s)=>s.toLowerCase())),i=!1;for(let s of e){let o=String(s??"").trim();if(o===""||r.has(o.toLowerCase()))continue;r.add(o.toLowerCase()),n.push(o),i=!0}if(i)this.#Ne(t,n);return i}#Ne(e,t){if(e.value=t.join(","),typeof e.dispatchEvent==="function")e.dispatchEvent(new Event("input",{bubbles:!0}));this.#ee()}#Nt(){if(typeof this.element?.querySelectorAll!=="function")return null;let e=this.element.getAttribute("data-reactive-filter-input");if(!e)return null;let t=this.#e();return[...this.element.querySelectorAll(e)].find(t)??null}#ee(){let e=this.#Z();if(!e)return;let t=this.#Q(e),n=this.#e();this.#Ct(t,n),this.#Ot(t,n)}#Ct(e,t){let n=[...this.element.querySelectorAll("[data-reactive-tags-list]")].find(t);if(!n)return;let i=[...this.element.querySelectorAll("[data-reactive-tags-template]")].find(t)?.content?.firstElementChild;if(!i){if(!this.#ce)console.warn("[phlex-reactive] reactive_tags: no chip