diff --git a/CHANGELOG.md b/CHANGELOG.md index aba7e18c..08655ded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] - ### Added - **`bin/release` — the release front door, ported from pgbus.** Works out the @@ -441,6 +440,145 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **A checkbox group collapsed to one boolean, and the chosen values never left + the browser (#258).** `#collectFields` wrote `fields[name] = field.checked` for + every checkbox, so several boxes sharing a `features[]` name overwrote each + other and the action received the LAST box's checked state — `{}` under a + `[:string]` schema, `"false"` under a flat `:string` one, silent either way, + while a native submission of the same 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"]`. An editor sharing its `[]` name with another control adds its + value to the group instead of standing down behind it, so the entry count + changes there too. It stands down beside a radio that is the only native + control under that name, because a radio keeps its single value with or + without the suffix and nothing has made the name a group; with a second + native control present the name is a group and the editor appends there too. + A ready but empty editor contributes an empty string, the way an empty text + field in the same group does. In an otherwise empty group that matters over a + form body: the group then carries [""] rather than [], so it is not announced + as cleared, exactly as an empty text field in the same group already behaved. + An unticked box still contributes nothing. A hidden input under the same name + keeps contributing: nothing in the DOM tells a hidden that mirrors an editor + from one that is a list JS maintains, and a value posted twice is visible + where a swallowed one is not. Against a flat `params: { tags: :string }` the + array 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, so a cleared group is ANNOUNCED: its + key stays absent from `params` and its name rides in a field of its own, + `empty_groups[]`, which the endpoint fills with `[]`. The field is additive — + a request without it behaves exactly as before — and values always win over an + announcement. A blank entry (`params[name][]=""`) was the alternative and is + ambiguous: Rails leaves `[""]` to the caller, and the schema reads it per + element type (`[:string]` keeps it, `[:integer]` coerces `[0]`, `[:date]` and + `[:file]` drop the key), so treating it as "cleared" would have changed all + four — for a `[:file]` param backing a `has_many_attached`, the difference + between "the field did not come in" and purging the attachments. + + `post_reactive_multipart` takes `empty_groups:` so a request spec can + reproduce a cleared group the way the client sends it; omitted, the body is + exactly what it was before the field existed. `ParamSchema.bracket_path(key)`, + `ParamSchema.row_index?(segment)` and `ParamSchema#declares_array?(path)` are + public for the same resolution — one parser for the wire format, one test for + what counts as a row, and one answer to "does this declaration name an array + here". + + An announcement resolves against the DECLARED shape and nothing else. A + string-keyed declaration (`params: { "features" => [:string] }`) fills like a + symbol one — `compile` keeps whichever form the author wrote, so a lookup + that tried only symbols refused half the valid declarations. A group inside + a collection resolves through its row index — `rows_attributes[0][features]` + for nested attributes, `matrix[0]` for an array of arrays — because a + declaration describes its element once while the wire names a row, so the + index has no counterpart to look up. Anything else — + a name the action never declared, a declared param that is not an array, + invented nesting, a row key that is not an index — is dropped rather than + written into the raw params. + + Where a row index sits in the announced name decides whether it may be + created. An index ON THE WAY to the group is followed and never created: for + `rows_attributes[0][features]`, bringing the row into being would let the + ANNOUNCEMENT hand the action `rows_attributes: [{ features: [] }]` — a child + record for `accepts_nested_attributes_for` to take at face value — out of a + request that carried nothing else. A row that is really there says so through + its other fields, and `fields_for` renders the hidden id, so following it is + enough. When such an index is refused, the container above it is refused with + it rather than left behind, because an empty collection there reads as "the + caller cleared every row". + + The limit of that rule, measured rather than assumed: it constrains what an + announcement may build, not what the endpoint accepts. A JSON body from the + same DOM carries `rows_attributes[0][features][]` as an empty array outright + and does produce `[{ features: [] }]`. So for the one shape where a row + carries NOTHING but an emptied group, the two encodings disagree — the form + body reads as "no rows", the JSON body as "one row with an empty group". + Every shape in which the row carries anything else, which is what `fields_for` + renders, agrees. + + An index as the LAST segment is created, because there the row IS the group: + `matrix: [[:string]]` announces a cleared row as `matrix[0]`, and the walk + admits a name only where the declaration names an ARRAY TYPE at that + position, while the endpoint writes `[]` at the leaf either way — so what + appears is an empty array and never a record. Refusing it would leave the last + emptied row of a matrix with no way to say so — the distinction the field + exists to carry. It is also no more than a value can do: `matrix[2][]` posted + beside row 0 produces the same shape, in both encodings. + + `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. A `` sharing the group's name lost its rendered + selection instead, because under `restore: "always"` the select branch skips + the "the server had a say" check and matches `Set{"true"}` against its + options, where nothing matches. The next snapshot replaces the key with the + list. Only that one key changed meaning, which is why `PERSIST_VERSION` stays + where it is: bumping it would also throw away the drafted prose of every form + that has no checkbox group at all. + - **`reply.pending` kept its settle handle under `enqueue_after_transaction_commit = true` (#254).** The handle was captured in `Phlex::Reactive::Settles#serialize`, which reads a thread-local that lives diff --git a/README.md b/README.md index 2abf1053..ca58dcb2 100644 --- a/README.md +++ b/README.md @@ -491,12 +491,48 @@ def view_template end ``` -> **One multipart caveat:** `FormData` can't carry an *empty* array or hash, so on -> the multipart (file-present) path an empty `[]`/`{}` param is **omitted** and the -> action's keyword default applies — it does **not** arrive as an explicit empty -> collection the way it does over JSON. If you rely on sending `tags: []` to clear -> a collection, send that action *without* a file (the JSON path). A non-empty -> nested/array param rides along fine next to a file. +> **One multipart caveat:** `FormData` can't carry an *empty* array or hash. A +> **checkbox group** (a name ending in `[]`) is covered: a cleared group is +> announced instead — its key stays out of `params` and its name rides in +> `empty_groups[]`, a field of its own beside `token`/`act`/`params`, which the +> endpoint fills with `[]`. So JSON and form bodies agree about a cleared group, +> with one measured exception noted below. The field is additive: a request +> without it behaves exactly as before, and values sent for a group always win +> over an announcement. An announcement only fills what the action DECLARED as +> an array — a group inside a collection resolves through its row index +> (`rows_attributes[0][features]` for nested attributes, `matrix[0]` for an +> array of arrays), a declaration written with string keys resolves like a +> symbol one, and any other name is ignored rather than written into the params. +> A row index on the way to the group is *followed*, never *created* — +> announcing `rows_attributes[0][features]` fills a row the request carried and +> never brings one into being. An index as the LAST segment is created, because +> there the row IS the group (`matrix[0]`), and the declaration has to name an +> array type at that position. The one shape where the two encodings disagree is +> a brand-new nested-attributes row whose only control is the cleared group: it +> carries no id to travel with, so the form body reads as "no rows" where the +> JSON body reads as one row with an empty group. Every **other** empty +> `[]`/`{}` param is still **omitted** on the multipart (file-present) path and +> the action's keyword default applies. If you rely on sending `tags: []` to +> clear a collection through a param that is not a `[]`-named group, send that +> action *without* a file (the JSON path). A non-empty nested/array param rides +> along fine next to a file. + +**Checkbox groups.** Several controls sharing a name that ends in `[]` are collected +as an **array of the chosen values** — a ticked box contributes its `value`, an +unticked one nothing, a ` +``` + +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. **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 @@ -3222,8 +3258,10 @@ endpoint maps it to 403). Matchers: `have_reactive_replace`, refresh so a reply that would silently break the next click fails your test. **HTTP helpers** — `post_reactive_action(component_or_class, act, params:, payload:)` -and `post_reactive_multipart(...)` POST a signed token to -`Phlex::Reactive.action_path` exactly as the client does. **Token minting** — +and `post_reactive_multipart(..., empty_groups: [])` POST a signed token to +`Phlex::Reactive.action_path` exactly as the client does; `empty_groups:` names +the `[]` groups the client cleared, which a form body announces rather than +carries. **Token minting** — `reactive_token_for(component_or_class, payload = {})`. > `verbose_errors` defaults ON in test (it changes only an error BODY, never a diff --git a/app/controllers/phlex/reactive/actions_controller.rb b/app/controllers/phlex/reactive/actions_controller.rb index 1d53d983..9d9249cf 100644 --- a/app/controllers/phlex/reactive/actions_controller.rb +++ b/app/controllers/phlex/reactive/actions_controller.rb @@ -555,7 +555,18 @@ def reactive_action_name # skipped — zero extra work on the production path. def coerce_params(action_def, component_class: nil, action_name: nil) dropped = Phlex::Reactive.verbose_errors ? [] : nil - raw = unwrap_scope(params.fetch(:params, {}), component_class) + raw = params.fetch(:params, {}) + # BEFORE unwrap_scope, not after: the client announces the raw DOM name, + # so a scoped component sends "todo[tags]" and the group has to be placed + # at that depth first — peeling the scope afterwards then finds it, the + # same way it finds a value the client actually sent. Applied the other + # way round, the announcement lands beside the peeled params and the flat + # schema never sees it. + if params[:empty_groups].present? + raw = apply_empty_groups(raw.deep_dup, params[:empty_groups], action_def.schema, + component_class) + end + raw = unwrap_scope(raw, component_class) coerced = action_def.schema.coerce(raw, dropped) log_dropped_params(dropped, action_def.params, component_class, action_name) @@ -580,6 +591,120 @@ def unwrap_scope(raw, component_class) nested.is_a?(Hash) || nested.is_a?(ActionController::Parameters) ? nested : raw end + # Issue #258: a form body cannot carry an empty array, so the client + # ANNOUNCES a cleared `[]` group instead — its key is absent from params + # and its name rides in `empty_groups[]`, a field of its own beside + # token/act/params. Here those names are written back as empty arrays, + # which is what the JSON path sends outright, so the same action clears + # the same group whichever encoding carried it. + # + # Why not a blank entry (`params[name][]=""`): `[""]` is ambiguous. Rails + # leaves it to the caller — `collection_check_boxes` ships exactly that + # marker and the app filters it — and the schema reads it per element + # type: `[:string]` keeps `[""]`, `[:integer]` coerces `[0]`, `[:date]` + # and `[:file]` drop the key so the keyword default stands. Reading it as + # "cleared" would silently change all four. + # + # Three properties this rule keeps: + # * ADDITIVE — a request without the field behaves exactly as before, + # so an old client against a new server and a new client against an + # old server both keep today's behaviour. + # * VALUES WIN — a group announced as empty that nonetheless carries + # values keeps the values. The announcement only fills an absence. + # * BRACKETS RESOLVE — `project[features]` lands at params[:project] + # [:features], the same nesting `params` itself gets. + def apply_empty_groups(raw, names, schema, component_class) + return raw unless raw.is_a?(Hash) || raw.is_a?(ActionController::Parameters) + return raw unless names.is_a?(Array) + + names.each do + next unless it.is_a?(String) || it.is_a?(Symbol) + + path = ParamSchema.bracket_path(it.to_s) + # Only a name the action DECLARED as an array can be announced empty. + # Without this the field would reach every array param the schema has, + # from anywhere params come from — including the query string — and + # an undeclared name would write a junk key into the raw params. It + # also bounds the nesting: an invented `a[b][c][d]…` resolves against + # the declared shape or not at all, rather than building depth the + # request parser was never asked to allow. + # The announced name is the raw DOM name, so a scoped component sends + # "todo[tags]" while its schema is flat — peel the same one level + # unwrap_scope peels before asking the schema whether it declared it. + next unless schema.declares_array?(unscoped_path(path, component_class)) + + *parents, leaf = path + node = announcement_node(raw, parents) + next if node.nil? + + node[leaf] = [] unless node.key?(leaf) + end + raw + end + + # The node an announced group should be written into, or nil when the + # announcement must not touch `raw` at all. + # + # An announced group may be the only thing its parent carried, in which + # case the parent is absent too, and creating it is the announcement + # rather than a fabrication — the client said the group is there and + # empty. A parent that exists but is not a hash (the caller sent a scalar + # under that name) is left alone. + # + # A ROW INDEX among the PARENTS is the exception: there it is followed and + # never created. A `[]` group inside nested attributes is announced as + # "rows_attributes[0][features]", and creating the missing row would hand + # the action `rows_attributes: [{features: []}]` — a child record built + # out of a request that carried no params at all. A row that really is + # there usually says so through its other fields — `fields_for` renders + # the hidden id — so following is enough for every row the request + # describes. The shape where it is not is a BRAND-NEW row whose only + # control is the cleared group: nothing is persisted, so there is no id to + # travel with, and the announcement is refused. The CHANGELOG names that + # as a known limit rather than pretending it away. The check covers every + # segment still to be created, not just the first: bailing at the index + # after the container above it was created would leave that container + # behind, which coerces to an empty collection and is the same fabrication + # one level up. + # An index as the LAST segment is a different thing and IS created. There + # the row is not the way to the group, it IS the group — + # `matrix: [[:string]]` announced as "matrix[0]" — and the walk lets a + # name through only where the declaration names an ARRAY TYPE at that + # position, while the endpoint writes `[]` at the leaf either way, so + # what gets created is an empty array and never a record. Refusing it + # would make the last emptied row of a matrix unannounceable, which is + # the distinction this whole field exists to carry. It is also no more + # than a value can do: posting matrix[2][] beside row 0 produces the same + # shape, in both encodings. + def announcement_node(raw, parents) + node = raw + parents.each_with_index do |segment, index| + child = node[segment] + if child.is_a?(Hash) || child.is_a?(ActionController::Parameters) + node = child + next + end + return nil if node.key?(segment) + return nil if parents[index..].any? { ParamSchema.row_index?(it) } + + node[segment] = {} + # Read it BACK: ActionController::Parameters converts a hash on + # assignment, so the object we just handed it is not the one it + # stored — writing into that copy would land nowhere. + node = node[segment] + end + node + end + + # The path as the FLAT schema sees it: one scope level off the front when + # the component declares one and the name carries it. + def unscoped_path(path, component_class) + scope = component_class.reactive_scope if component_class.respond_to?(:reactive_scope) + return path unless scope && path.length > 1 && path.first == scope.to_s + + path.drop(1) + end + # ---- verbose_errors dropped-param logging -------------------------- # ParamSchema collects the dropped entries; the controller formats the ONE # warn line (with the #16/#21 shape hints). Everything below runs ONLY when @@ -612,10 +737,13 @@ def dropped_reason(path, reason, schema) # a flat name the schema declares one level down. Deliberately simple: it # searches one nesting level (hash / array-of-hash), no deeper. def shape_hint(path, schema) - segments = bracket_path(path) + segments = ParamSchema.bracket_path(path) if segments.length > 1 leaf = segments.last - return unless schema.key?(leaf.to_sym) + # Both key forms: `compile` keeps what the declaration used, so a + # string-keyed schema is as valid as a symbol one — and a diagnostic + # that goes quiet for half the valid declarations is worse than none. + return unless declared_key?(schema, leaf) "schema declares :#{leaf} at top level; nested schemas look like " \ "{ #{segments.first}: { #{leaf}: :string } }" @@ -628,30 +756,21 @@ def shape_hint(path, schema) end end + # A schema declares `name` whether it was written with a symbol or a + # string key; ParamSchema keeps whichever the author used. + def declared_key?(schema, name) + schema.key?(name.to_sym) || schema.key?(name.to_s) + end + # The first schema key whose nested hash (or array-of-hash element # schema) declares `name` one level down. def nested_declaration_of(name, schema) schema.find do |_key, type| inner = type.is_a?(Array) ? type.first : type - inner.is_a?(Hash) && inner.key?(name.to_sym) + inner.is_a?(Hash) && declared_key?(inner, name) end&.first end - # Matches each bracket segment in "items_attributes][0][qty]" — the part - # after the first "[". Hoisted to a frozen constant so the shape-hint path - # (verbose only) doesn't recompile the pattern per call. - BRACKET_SEGMENT = /[^\[\]]+/ - private_constant :BRACKET_SEGMENT - - # "invoice[date]" => ["invoice", "date"]. A key with no brackets is a - # single-element path. Used only to shape the dropped-param hint. - def bracket_path(key) - return [key] unless key.include?("[") - - head, rest = key.split("[", 2) - [head, *rest.scan(BRACKET_SEGMENT)] - end - # ---- end verbose_errors logging ------------------------------------ # Only components that opt into Reactive may be resolved. The signature diff --git a/app/javascript/phlex/reactive/confirm_predicate.js b/app/javascript/phlex/reactive/confirm_predicate.js index a8c30d41..e1675d2c 100644 --- a/app/javascript/phlex/reactive/confirm_predicate.js +++ b/app/javascript/phlex/reactive/confirm_predicate.js @@ -19,7 +19,13 @@ // // 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. The key keeps its suffix, so it is `fields["tags[]"]`, +// and `[]` is truthy in JS — 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..3a1b545b 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. The key keeps its suffix, so it is `fields[\"tags[]\"]`,\n// and `[]` is truthy in JS — test `fields[\"tags[]\"].length`, never\n// `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": "AAwCA,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..6e10c7d0 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). @@ -1235,6 +1236,14 @@ function persistEditorReady(el) { return typeof el.value === "string" } +// The same question for #collectFields. A RICH editor (lexxy/trix) is only +// ready once its custom element upgraded — Trix defines its elements in a +// setTimeout after load — and reading it before that yields "", which is issue +// #8. A bare [contenteditable] is plain DOM and always ready. +function collectorEditorReady(el) { + return PERSIST_EDITOR_TAGS.has(el.localName) ? persistEditorReady(el) : true +} + // Ask the editor whether it is empty (Lexxy `isEmpty`; Trix // `editor.getDocument().isEmpty()`), else the exact-string fallback. An // attachment-only server body is therefore NON-blank and never overwritten. @@ -1258,6 +1267,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 +1301,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 +1362,61 @@ 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 + // A multi-select reads a list by matching option values, which is only + // sound when the list is ITS list. In a group with another contributor the + // entries are mixed, and a text value that happens to equal an option + // would select it — measured, a draft of ["blue","freitext"] from a select + // plus a text field selected both options. `?? 0` because a name without + // the suffix is not in the map at all, and a plain ` + // 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 @@ -4759,9 +4998,33 @@ export default class extends Controller { const fd = new FormData() fd.append("token", token) fd.append("act", action) + const emptyGroups = [] 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 an empty array in a form body, so it is + // ANNOUNCED instead: its key stays absent from params and its name goes + // into `empty_groups[]`, a field of its own beside token/act/params. A + // blank entry was the obvious alternative and is ambiguous — Rails leaves + // `[""]` to the caller, and a `[:date]` or `[:file]` element reads it as + // "did not come in", so treating it as "cleared" would change what those + // params mean. The field is additive: a server that ignores it behaves + // exactly as it does today, and so does a client that never sends it. + if (Array.isArray(value) && String(key).endsWith("[]")) { + if (value.length === 0) emptyGroups.push(String(key).slice(0, -2)) + else { + const wire = `${this.#wireKey(key)}[]` + for (const element of value) fd.append(wire, String(element)) + } + } else { + this.#appendField(fd, this.#wireKey(key), value) + } } + for (const name of emptyGroups) fd.append("empty_groups[]", name) const multiNames = this.#multiFileNames(files) for (const { name, file, multiple } of files) { // params[name][] when the input is `multiple` (array shape even for one diff --git a/app/javascript/phlex/reactive/reactive_controller.min.js b/app/javascript/phlex/reactive/reactive_controller.min.js index 48b0cc82..69437b9b 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