From 9cfd4d939dee5a466a1bd06c4bb2c1c93911414f Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Thu, 16 Jul 2026 15:25:33 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(client+component):=20clipboard-source?= =?UTF-8?q?=20paste=5Finto=20op=20=E2=80=94=20read=20the=20clipboard=20int?= =?UTF-8?q?o=20a=20bound=20field=20(#228)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary A field whose real input is visually hidden (OTP cell UIs) has no mouse-reachable paste path. js.paste_into(selector) adds the explicit affordance: on a user gesture, navigator.clipboard.readText() feeds the field through the NORMAL input pipeline (set .value, bubbling input, focus) — reducers/show/on_complete run exactly as if typed. A denied read, empty text, or missing API is a silent no-op; on_client marks the trigger data-reactive-clipboard and the controller sets hidden = !available on connect + morph, so a dead button never shows. Actor-only: paste_into joins BROADCAST_REFUSED_OPS. Also moves the ops serializer to the module singleton so the MODULE-LEVEL broadcast door reaches the same refusal (it crashed NoMethodError before, unpinned). ## Test Coverage - js_spec: wire shape, global:, loud :root/field-only refusal - component_spec: on_client marker emission (string "true", chain-buried, byte-stable non-paste wire) - js_broadcast_spec: chain + raw-array refusal, BOTH broadcast doors (class + module level) - response_spec: reply.js allows paste_into (actor-scoped) - reactive_paste_op.test.js (bun): ordered value→input→focus contract, empty/rejected/missing no-ops, chain composition, connect/morph gate, ownership, teardown - paste_into_spec.rb (Playwright): reveal-on-connect + post-replace re-reveal, dirty-paste auto-commit (1 POST), partial-paste focus, denied-read no-op, API-missing hide ## Verification - [x] bundle exec rubocop (gem + docs app) passes - [x] bundle exec rspec spec/phlex spec/requests — 1442 passed - [x] bun test spec/javascript — 584 passed - [x] rake spec:system_servers — 118 examples × puma AND falcon, 0 failures - [x] rake build:js + vendored copies re-synced (byte-identity guards green) --- CHANGELOG.md | 30 ++ README.md | 35 +- app/javascript/phlex/reactive/compute.js | 8 +- .../phlex/reactive/compute.min.js.map | 4 +- .../phlex/reactive/reactive_controller.js | 103 ++++- .../phlex/reactive/reactive_controller.min.js | 4 +- .../reactive/reactive_controller.min.js.map | 6 +- docs/app/views/docs/pages/actions_events.rb | 7 + docs/app/views/docs/pages/broadcasting.rb | 14 +- .../views/docs/pages/example_client_ops.rb | 38 +- .../views/docs/pages/example_notifications.rb | 8 +- .../views/docs/pages/example_payment_split.rb | 9 +- .../app/views/docs/pages/examples_overview.rb | 2 +- docs/app/views/docs/pages/security.rb | 11 +- lib/phlex/reactive/component/helpers.rb | 5 + lib/phlex/reactive/js.rb | 29 ++ lib/phlex/reactive/streamable.rb | 55 +-- .../components/verification_code_component.rb | 8 + .../public/vendor/reactive_controller.js | 4 +- spec/javascript/reactive_paste_op.test.js | 370 ++++++++++++++++++ spec/phlex/reactive/component_spec.rb | 23 ++ spec/phlex/reactive/js_spec.rb | 27 ++ spec/phlex/reactive/response_spec.rb | 7 + spec/requests/js_broadcast_spec.rb | 40 ++ spec/system/paste_into_spec.rb | 129 ++++++ 25 files changed, 912 insertions(+), 64 deletions(-) create mode 100644 spec/javascript/reactive_paste_op.test.js create mode 100644 spec/system/paste_into_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 042aec7..30c5342 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,36 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **`js.paste_into(selector)` — the clipboard-source trigger (#228).** A field + whose real `` is visually hidden (an OTP cell UI painted by a + `reactive_compute` reducer) has no mouse-reachable paste path: right-click → + Paste targets the decorative cells, never the off-screen input. One declared + line — `button(hidden: true, **on_client(:click, js.paste_into("[name=code]"))) + { "Paste code" }` — replaces the bespoke Stimulus controller: + - **The op** (the one async, value-reading member of the vocabulary): on the + user's gesture it awaits `navigator.clipboard.readText()` (the browser's + own permission UX — Chromium prompts, Safari shows its paste pill) and + feeds the text through the **normal `input` pipeline** — set `.value`, + dispatch a bubbling `input` (reducers / `reactive_show` / + `reactive_on_complete` run exactly as if typed), then focus the field so a + partial paste continues from the caret. A denied/dismissed read, empty + text, or a missing API is a **silent no-op**; the op is fire-and-forget, + so chained siblings never wait. + - **Availability gating**: `on_client` marks a paste trigger with + `data-reactive-clipboard`; on connect (and every `turbo:morph-element`) + the controller sets `hidden = !available` on owned markers. Author the + trigger `hidden` and it is revealed only where the Async Clipboard API + exists — a dead button never shows in insecure contexts or webviews. + - **Actor-only, default-deny**: `paste_into` joins `focus`/`focus_first`/ + `submit` in `BROADCAST_REFUSED_OPS` — `broadcast_to(js:)` raises + (a broadcast that reads every subscriber's clipboard would be hostile); + `reply.js` and gesture paths remain allowed. + - Dummy flagship: the `VerificationCodeComponent` OTP field gains the + hidden "Paste code" trigger — paste drives the otp reducer's normalize + + `$ops` auto-submit end to end, with a real-browser system spec covering + reveal-on-connect, dirty-paste auto-commit, partial-paste focus, and the + denied-read no-op. + - **Reducer-emitted client ops (`$ops`), a `submit` op, and declarative `reactive_on_complete` (#226).** The "normalize on input, commit when complete" field (a one-time-code entry being the canonical case) is now diff --git a/README.md b/README.md index 5ec821a..5de9ac1 100644 --- a/README.md +++ b/README.md @@ -385,7 +385,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`. | `busy_on(:save)` | Mark any element so it carries `data-reactive-busy` **only while `save` is in flight** — a spinner styled with pure CSS, zero Ruby. See [Loading states](#declarative-loading-states-loading--disable_with). | | `on(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). | | `on_client(:click, js.toggle("#menu"))` | **Client-only** trigger: applies declared DOM ops with ZERO round trip — no token, no POST, ever. Takes the same `window:`/`once:`/`outside:` modifiers. See [Client-only ops](#client-only-ops-on_client--js--zero-round-trips). | -| `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names), `focus`/`focus_first`, `text` (set `textContent` — XSS-safe), and `dispatch` — chainable. | +| `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names), `focus`/`focus_first`, `text` (set `textContent` — XSS-safe), `dispatch`, `submit` (requestSubmit the target's own form), and `paste_into` (read the clipboard into a field, gesture-gated) — chainable. | | `reactive_field(:param, **attrs)` | The attribute hash that binds a control to an action param (no magic `name:`) — spread onto any control: `input(**reactive_field(:value, value: @record.name))`, `select(**reactive_field(:status)) { … }`. | | `reactive_text(:name, initial)` | Mirror a compute output (or a declared input) into a **text node** — a live preview heading, a character counter, `"Hello, {name}"` — via `textContent` (XSS-safe). The text sibling of `reactive_field`; carries no `name`, so it's never POSTed. See [Client-side computes](#client-side-computes-reactive_compute--reactive_text). | | `reactive_show(if:/if_any:/unless:)` | **Value-conditional visibility** (the `x-show`/`data-show` case): spread onto the element to show/hide — it toggles `hidden` from the fields' **current values**, client-only, zero round trip. One conditions language: a **Hash is an AND**, an **Array is membership**, a **Range is a threshold**, `if_any:` is OR-of-AND, `unless:` negates. `reactive_values` computes first paint; `disable:` disables a hidden section's controls. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). | @@ -863,6 +863,25 @@ button(**on_client(:click, js `broadcast_to(js:)` (a broadcast would force-submit every subscriber's form). Binding a submit op to the `submit` event itself raises at render — it would re-fire itself forever. +- **`paste_into(to)`** reads the clipboard into a field on a **user gesture** + (issue #228): `navigator.clipboard.readText()`, then the field gets the text + through the **normal `input` pipeline** — `.value` is set, a bubbling `input` + event fires (compute reducers, `reactive_show`, `reactive_on_complete` all run + exactly as if the user had typed), and the field is focused so a partial paste + continues from the caret. Built for fields whose real `` is visually + hidden (an OTP cell UI), where right-click → Paste can't reach the editable + input. The permission UX is the **browser's own** (Chromium prompts, Safari + shows its paste pill); a denied/dismissed read, empty text, or a missing API + is a **silent no-op**. `on_client` marks the trigger with + `data-reactive-clipboard` and the controller sets `hidden = !available` on + connect — author the trigger `hidden` and it is revealed only where the + clipboard API exists, so a dead button never shows. The gate **owns** the + trigger's `hidden` flag: render the trigger unconditionally and don't also + bind `reactive_show` to it (the two passes would fight over the same + attribute). **Actor-only like focus/submit**: refused in `broadcast_to(js:)` + (a broadcast that reads every subscriber's clipboard would be hostile). The + op is async fire-and-forget — chained siblings apply immediately, never + waiting for the read. - **`transition: { during:, from:, to: }`** on `show`/`hide`/`toggle` animates the visibility flip: `during`+`from` are applied, then `from`→`to` swaps on the next frame, and the helper classes are cleaned up on `animationend` (with a timeout @@ -1136,7 +1155,9 @@ setComputeReducer("preview", ({ title }) => ({ write fields and text; the reserved **`$ops`** key lets it emit a **conditional side effect** — the missing piece that used to force a bespoke controller next to an otherwise-declarative compute. Return an op chain (the `ops` builder -mirrors the Ruby `js` verbs, or use a raw `[[op, args], …]` array) and the +mirrors the Ruby `js` verbs — minus `paste_into`, deliberately: a reducer runs +on every input event, and a clipboard read per keystroke would spam permission +prompts — or use a raw `[[op, args], …]` array) and the controller runs it through the **same frozen op whitelist** `on_client` uses, as a final phase **after** the field writes, text sinks, and their dispatched `input` events settle. The canonical one-time-code field: @@ -1977,10 +1998,12 @@ Notifications::Badge.broadcast_to(user, :alerts, js: js.add_class("#bell", "has-unread"), exclude: reactive_connection_id) ``` -`broadcast_to` with `js:` **refuses focus-class ops** (`focus`/`focus_first` raise -`ArgumentError`): broadcasting focus would steal it in every subscriber's tab, so -focus is an actor-reply concern only. Everything else is a fair broadcast (class -and attribute toggles, `dispatch`). As with `on_client`, the ops are +`broadcast_to` with `js:` **refuses the actor-only ops** (`focus`/`focus_first`/ +`submit`/`paste_into` raise `ArgumentError`): broadcasting focus would steal it +in every subscriber's tab, a broadcast submit would force-submit every +subscriber's form, and a broadcast clipboard read would be hostile — these +belong to the actor's own reply or gesture. Everything else is a fair broadcast +(class and attribute toggles, `text`, `dispatch`). As with `on_client`, the ops are whitelist-interpreted client-side — an unknown op warns and is skipped — and the ops attribute is HTML-escaped, so a value can't break out of it. `reactive:js` is not a self-render: it never counts toward the token refresh, so the reply's diff --git a/app/javascript/phlex/reactive/compute.js b/app/javascript/phlex/reactive/compute.js index 0be1d75..fbf0995 100644 --- a/app/javascript/phlex/reactive/compute.js +++ b/app/javascript/phlex/reactive/compute.js @@ -103,6 +103,11 @@ // event fires, so an on(:verify, event: "submit") interception (or a native/ // Turbo form) handles it exactly like a user submit. submit/focus are // ACTOR-ONLY: usable here and in on_client/reply.js, refused in broadcasts. +// paste_into (issue #228) is actor-only too but DELIBERATELY absent from this +// builder: a reducer runs on every input event, and a clipboard read per +// keystroke (each changed chain fires) would spam permission prompts. Use it +// from on_client / reply.js / reactive_on_complete instead; a raw +// [["paste_into", …]] pair still interprets if you truly need it. const reducers = new Map() @@ -127,7 +132,8 @@ export function __resetComputeRegistryForTest() { // --- The reducer-side op-chain builder (issue #226) -------------------------- // -// A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder: verbs carry +// A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder (minus +// paste_into — see the actor-only note above): verbs carry // the WIRE op names (snake_case) and append [name, args] pairs; every verb // returns a NEW instance, so a chain held in a constant can never be mutated by // later use. `.ops` exposes the raw [[name, args], ...] list the controller diff --git a/app/javascript/phlex/reactive/compute.min.js.map b/app/javascript/phlex/reactive/compute.min.js.map index d22a2a4..c42258d 100644 --- a/app/javascript/phlex/reactive/compute.min.js.map +++ b/app/javascript/phlex/reactive/compute.min.js.map @@ -2,9 +2,9 @@ "version": 3, "sources": ["compute.js"], "sourcesContent": [ - "// The client-side compute (data-binding) registry — the \"instant\" half of the\n// new/unpersisted-record UX.\n//\n// A record-backed reactive component round-trips every change to the server\n// (the signed identity re-finds the record; the server re-renders). A NEW,\n// unpersisted record has no such server truth to re-render against on every\n// keystroke — the classic answer is a bespoke Stimulus controller doing the math\n// in the browser (carlqvist's new_order_controller.js). This registry lets that\n// math be a DECLARED part of the component instead: `reactive_compute :name,\n// inputs:, outputs:` (Ruby) names a reducer registered here, and the generic\n// reactive controller runs it on `input` — writing the outputs with NO round\n// trip. When the component ALSO carries on(...) (a persisted record, or a draft\n// you sync), the debounced POST reconciles from the authoritative server reply.\n//\n// The seam mirrors confirm.js: a settable registry with a lookup the controller\n// calls. Register once at boot:\n//\n// import { setComputeReducer } from \"phlex/reactive/compute\"\n// setComputeReducer(\"payment_split\", ({ allowance, cash, leasing, total }) => ({\n// allowance, leasing, cash: total - allowance - leasing,\n// }))\n//\n// The reducer's signature is (values, meta):\n//\n// values — a plain object of { inputName: value } over the declared inputs.\n// Untyped inputs (the array form) AND :number-typed inputs arrive as\n// Numbers (blank/NaN → 0); :string-typed inputs (the hash form,\n// issue #104) arrive as the RAW string. `reactive_compute :x, inputs:\n// { title: :string, qty: :number }` is what selects per-input types;\n// `inputs: %i[a b]` stays all-numeric (backward compatible).\n// meta — { changed }: the name (string) of the declared input the\n// triggering event edited, or null (a direct recompute() call, or a\n// target this root doesn't own / didn't declare as an input).\n//\n// OUTPUTS may be a form FIELD or a TEXT NODE (issue #104). An output whose name\n// matches an owned control writes its .value (+ the change-guarded input\n// dispatch below). An output with NO matching field writes textContent to every\n// owned [data-reactive-text=\"\"] node (reactive_text(:name)) — XSS-safe by\n// construction, change-guarded, NO input dispatch (a text node has no listener\n// contract). A declared INPUT also mirrors into its own text node on every\n// input via an always-run pass — so reactive_text(:title) is a live field\n// preview with NO registered reducer at all.\n//\n// It returns a plain object of { outputName: value } — only the outputs it\n// names are written, so it can leave the edited field (and its caret)\n// untouched. A one-argument reducer keeps working unchanged (it just ignores\n// meta). `changed` is what makes a MULTI-WAY / MUTUAL rebalance expressible as\n// one reducer (issue #75) — branch on which field the user edited:\n//\n// setComputeReducer(\"three_way_split\", ({ field_a, field_b, field_c, total }, { changed }) => {\n// if (changed === \"field_c\") return { field_a: total - field_c - field_b }\n// return { field_c: total - field_a - field_b }\n// })\n//\n// Output writes are CHANGE-GUARDED: the controller writes a field and\n// dispatches a bubbling `input` event on it ONLY when the new value differs\n// from the field's current value (real browsers never fire `input` on a\n// programmatic .value write, so the controller dispatches explicitly — that's\n// what drives a chained summary repaint, matching the server's set_value +\n// dispatch(\"input\") contract). Returning the SAME value a field already holds\n// is skipped entirely — no write, no event — which is why a reducer with\n// overlapping inputs/outputs (like payment_split above) settles instead of\n// re-entering itself forever.\n//\n// CONVERGENCE REQUIREMENT: because an output write dispatches a REAL input\n// event (issue #76), recompute re-enters with changed = that OUTPUT field's\n// name (when it's also a declared input). A branching reducer must therefore\n// be convergent: the re-entrant pass must compute values EQUAL to what the\n// first pass already wrote to the DOM, so the change guard settles the chain.\n// The three_way_split above is: after `changed === \"field_a\"` writes field_c,\n// the re-entrant `changed === \"field_c\"` pass derives field_a back to the\n// value it already holds — no write, no event, settled in one bounce.\n\n// THE RESERVED `$ops` OUTPUT (issue #226). Besides field/text outputs, a\n// reducer may return the reserved key `$ops` holding a chain of client DOM\n// ops — built with the `ops` builder below, or a raw [[name, args], ...]\n// array. The controller consumes it as a PHASE 4 of the single-pass write set:\n// the ops run AFTER the field writes, text sinks, and phase-3 input dispatches\n// settle, through the SAME frozen CLIENT_OPS whitelist on_client uses (an\n// unknown op warns + is skipped). `null`/`undefined`/absent = no effect.\n//\n// RISING-EDGE semantics, keyed on CONTENT: the chain runs only when it\n// DIFFERS from the previous pass's chain (including from \"absent\"), and only\n// on EVENT-DRIVEN passes. Returning the SAME chain again is settled — no\n// re-fire (a 7th keystroke capped back to the same complete value can't\n// re-submit), mirroring the change-guarded field writes. Returning a\n// DIFFERENT chain fires again — a multi-box reducer advancing focus\n// box-by-box emits a new focus target per digit, each a new intent. The\n// connect/morph SEED pass (issue #199 — recompute with no event) ARMS the\n// latch but never fires — a form re-rendered with an already-complete value\n// (a validation-error morph, a browser restore) must not auto-fire; that is\n// what breaks the submit → error re-render → re-seed → submit loop. A later\n// pass returning no $ops re-arms. The canonical use — a one-time-code field\n// that normalizes on input and commits when complete:\n//\n// import { setComputeReducer, ops } from \"phlex/reactive/compute\"\n// setComputeReducer(\"otp\", ({ code }) => {\n// const digits = code.replace(/\\D/g, \"\").slice(0, 6)\n// return { code: digits, $ops: digits.length === 6 ? ops.submit() : null }\n// })\n//\n// `submit` commits the target's own form via requestSubmit() — the real submit\n// event fires, so an on(:verify, event: \"submit\") interception (or a native/\n// Turbo form) handles it exactly like a user submit. submit/focus are\n// ACTOR-ONLY: usable here and in on_client/reply.js, refused in broadcasts.\n\nconst reducers = new Map()\n\n// Register (or replace) the reducer for `key`. `fn` is\n// (values: Record, meta: { changed: string | null })\n// => Record.\nexport function setComputeReducer(key, fn) {\n reducers.set(key, fn)\n}\n\n// Look up a registered reducer; undefined when none — the controller then makes\n// #recompute a no-op rather than throwing (a missing reducer must not break the\n// page; it just means no client-side binding for that root).\nexport function computeReducer(key) {\n return reducers.get(key)\n}\n\n// Test seam: clear the registry so a reducer registered in one test can't leak.\nexport function __resetComputeRegistryForTest() {\n reducers.clear()\n}\n\n// --- The reducer-side op-chain builder (issue #226) --------------------------\n//\n// A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder: verbs carry\n// the WIRE op names (snake_case) and append [name, args] pairs; every verb\n// returns a NEW instance, so a chain held in a constant can never be mutated by\n// later use. `.ops` exposes the raw [[name, args], ...] list the controller\n// interprets (and toJSON serializes it, so a chain can also feed a hand-built\n// ops attr). Targets: a CSS selector string, or omit for \"@root\" (the\n// component's own root). No build-time attr validation here — the interpreter's\n// allowlist (guardAttr) is the enforcement point; this builder only shapes the\n// wire.\nconst ROOT_SENTINEL = \"@root\"\n\nfunction targetArgs(to, { global, transition } = {}) {\n const args = { to: to ?? ROOT_SENTINEL }\n if (global) args.global = true\n if (transition) args.transition = normalizeTransition(transition)\n return args\n}\n\n// Named legs { during, from, to } → the [during, from, to] wire array (the\n// issue #186 vocabulary). Loud at authoring time, like the Ruby builder.\n// Frozen, like every nested payload — the chain's immutability contract must\n// hold all the way down (the Ruby twin freezes its legs array too).\nfunction normalizeTransition(transition) {\n const named =\n transition && typeof transition === \"object\" && !Array.isArray(transition) &&\n [\"during\", \"from\", \"to\"].every((k) => k in transition)\n if (!named) throw new Error(\"[phlex-reactive] ops transition takes named legs { during, from, to }\")\n return Object.freeze([String(transition.during), String(transition.from), String(transition.to)])\n}\n\nclass OpsChain {\n constructor(list = Object.freeze([])) {\n this.ops = list\n Object.freeze(this)\n }\n\n show(to, opts) {\n return this.#append(\"show\", targetArgs(to, opts))\n }\n\n hide(to, opts) {\n return this.#append(\"hide\", targetArgs(to, opts))\n }\n\n toggle(to, opts) {\n return this.#append(\"toggle\", targetArgs(to, opts))\n }\n\n add_class(to, classes, opts) {\n return this.#append(\"add_class\", classArgs(to, classes, opts))\n }\n\n remove_class(to, classes, opts) {\n return this.#append(\"remove_class\", classArgs(to, classes, opts))\n }\n\n toggle_class(to, classes, opts) {\n return this.#append(\"toggle_class\", classArgs(to, classes, opts))\n }\n\n set_attr(to, name, value, opts) {\n return this.#append(\"set_attr\", { ...targetArgs(to, opts), name: String(name), value: String(value) })\n }\n\n remove_attr(to, name, opts) {\n return this.#append(\"remove_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n toggle_attr(to, name, opts) {\n return this.#append(\"toggle_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n focus(to, opts) {\n return this.#append(\"focus\", targetArgs(to, opts))\n }\n\n focus_first(to, opts) {\n return this.#append(\"focus_first\", targetArgs(to, opts))\n }\n\n text(to, value, opts) {\n return this.#append(\"text\", { ...targetArgs(to, opts), value: String(value ?? \"\") })\n }\n\n dispatch(name, { to, detail, global } = {}) {\n const args = { name: String(name), to: to ?? ROOT_SENTINEL, detail: detail ?? {} }\n if (global) args.global = true\n return this.#append(\"dispatch\", args)\n }\n\n submit(to, opts) {\n return this.#append(\"submit\", targetArgs(to, opts))\n }\n\n toJSON() {\n return this.ops\n }\n\n #append(name, args) {\n return new OpsChain(Object.freeze([...this.ops, Object.freeze([name, Object.freeze(args)])]))\n }\n}\n\n// classes: one class string or an array of them (never whitespace-split — a\n// classList token can't contain spaces, so splitting would only mask a bug).\n// Loud on an empty/missing list, and frozen (the Ruby twin freezes its class\n// list too) — a chain held in a constant must stay immutable all the way down.\nfunction classArgs(to, classes, opts) {\n const list = classes == null ? [] : (Array.isArray(classes) ? classes : [classes]).map(String)\n if (list.length === 0) throw new Error(\"[phlex-reactive] a class op needs at least one class\")\n return { ...targetArgs(to, opts), classes: Object.freeze(list) }\n}\n\n// The shared empty chain — start every reducer effect from here:\n// $ops: done ? ops.dispatch(\"code:complete\").submit() : null\nexport const ops = new OpsChain()\n" + "// The client-side compute (data-binding) registry — the \"instant\" half of the\n// new/unpersisted-record UX.\n//\n// A record-backed reactive component round-trips every change to the server\n// (the signed identity re-finds the record; the server re-renders). A NEW,\n// unpersisted record has no such server truth to re-render against on every\n// keystroke — the classic answer is a bespoke Stimulus controller doing the math\n// in the browser (carlqvist's new_order_controller.js). This registry lets that\n// math be a DECLARED part of the component instead: `reactive_compute :name,\n// inputs:, outputs:` (Ruby) names a reducer registered here, and the generic\n// reactive controller runs it on `input` — writing the outputs with NO round\n// trip. When the component ALSO carries on(...) (a persisted record, or a draft\n// you sync), the debounced POST reconciles from the authoritative server reply.\n//\n// The seam mirrors confirm.js: a settable registry with a lookup the controller\n// calls. Register once at boot:\n//\n// import { setComputeReducer } from \"phlex/reactive/compute\"\n// setComputeReducer(\"payment_split\", ({ allowance, cash, leasing, total }) => ({\n// allowance, leasing, cash: total - allowance - leasing,\n// }))\n//\n// The reducer's signature is (values, meta):\n//\n// values — a plain object of { inputName: value } over the declared inputs.\n// Untyped inputs (the array form) AND :number-typed inputs arrive as\n// Numbers (blank/NaN → 0); :string-typed inputs (the hash form,\n// issue #104) arrive as the RAW string. `reactive_compute :x, inputs:\n// { title: :string, qty: :number }` is what selects per-input types;\n// `inputs: %i[a b]` stays all-numeric (backward compatible).\n// meta — { changed }: the name (string) of the declared input the\n// triggering event edited, or null (a direct recompute() call, or a\n// target this root doesn't own / didn't declare as an input).\n//\n// OUTPUTS may be a form FIELD or a TEXT NODE (issue #104). An output whose name\n// matches an owned control writes its .value (+ the change-guarded input\n// dispatch below). An output with NO matching field writes textContent to every\n// owned [data-reactive-text=\"\"] node (reactive_text(:name)) — XSS-safe by\n// construction, change-guarded, NO input dispatch (a text node has no listener\n// contract). A declared INPUT also mirrors into its own text node on every\n// input via an always-run pass — so reactive_text(:title) is a live field\n// preview with NO registered reducer at all.\n//\n// It returns a plain object of { outputName: value } — only the outputs it\n// names are written, so it can leave the edited field (and its caret)\n// untouched. A one-argument reducer keeps working unchanged (it just ignores\n// meta). `changed` is what makes a MULTI-WAY / MUTUAL rebalance expressible as\n// one reducer (issue #75) — branch on which field the user edited:\n//\n// setComputeReducer(\"three_way_split\", ({ field_a, field_b, field_c, total }, { changed }) => {\n// if (changed === \"field_c\") return { field_a: total - field_c - field_b }\n// return { field_c: total - field_a - field_b }\n// })\n//\n// Output writes are CHANGE-GUARDED: the controller writes a field and\n// dispatches a bubbling `input` event on it ONLY when the new value differs\n// from the field's current value (real browsers never fire `input` on a\n// programmatic .value write, so the controller dispatches explicitly — that's\n// what drives a chained summary repaint, matching the server's set_value +\n// dispatch(\"input\") contract). Returning the SAME value a field already holds\n// is skipped entirely — no write, no event — which is why a reducer with\n// overlapping inputs/outputs (like payment_split above) settles instead of\n// re-entering itself forever.\n//\n// CONVERGENCE REQUIREMENT: because an output write dispatches a REAL input\n// event (issue #76), recompute re-enters with changed = that OUTPUT field's\n// name (when it's also a declared input). A branching reducer must therefore\n// be convergent: the re-entrant pass must compute values EQUAL to what the\n// first pass already wrote to the DOM, so the change guard settles the chain.\n// The three_way_split above is: after `changed === \"field_a\"` writes field_c,\n// the re-entrant `changed === \"field_c\"` pass derives field_a back to the\n// value it already holds — no write, no event, settled in one bounce.\n\n// THE RESERVED `$ops` OUTPUT (issue #226). Besides field/text outputs, a\n// reducer may return the reserved key `$ops` holding a chain of client DOM\n// ops — built with the `ops` builder below, or a raw [[name, args], ...]\n// array. The controller consumes it as a PHASE 4 of the single-pass write set:\n// the ops run AFTER the field writes, text sinks, and phase-3 input dispatches\n// settle, through the SAME frozen CLIENT_OPS whitelist on_client uses (an\n// unknown op warns + is skipped). `null`/`undefined`/absent = no effect.\n//\n// RISING-EDGE semantics, keyed on CONTENT: the chain runs only when it\n// DIFFERS from the previous pass's chain (including from \"absent\"), and only\n// on EVENT-DRIVEN passes. Returning the SAME chain again is settled — no\n// re-fire (a 7th keystroke capped back to the same complete value can't\n// re-submit), mirroring the change-guarded field writes. Returning a\n// DIFFERENT chain fires again — a multi-box reducer advancing focus\n// box-by-box emits a new focus target per digit, each a new intent. The\n// connect/morph SEED pass (issue #199 — recompute with no event) ARMS the\n// latch but never fires — a form re-rendered with an already-complete value\n// (a validation-error morph, a browser restore) must not auto-fire; that is\n// what breaks the submit → error re-render → re-seed → submit loop. A later\n// pass returning no $ops re-arms. The canonical use — a one-time-code field\n// that normalizes on input and commits when complete:\n//\n// import { setComputeReducer, ops } from \"phlex/reactive/compute\"\n// setComputeReducer(\"otp\", ({ code }) => {\n// const digits = code.replace(/\\D/g, \"\").slice(0, 6)\n// return { code: digits, $ops: digits.length === 6 ? ops.submit() : null }\n// })\n//\n// `submit` commits the target's own form via requestSubmit() — the real submit\n// event fires, so an on(:verify, event: \"submit\") interception (or a native/\n// Turbo form) handles it exactly like a user submit. submit/focus are\n// ACTOR-ONLY: usable here and in on_client/reply.js, refused in broadcasts.\n// paste_into (issue #228) is actor-only too but DELIBERATELY absent from this\n// builder: a reducer runs on every input event, and a clipboard read per\n// keystroke (each changed chain fires) would spam permission prompts. Use it\n// from on_client / reply.js / reactive_on_complete instead; a raw\n// [[\"paste_into\", …]] pair still interprets if you truly need it.\n\nconst reducers = new Map()\n\n// Register (or replace) the reducer for `key`. `fn` is\n// (values: Record, meta: { changed: string | null })\n// => Record.\nexport function setComputeReducer(key, fn) {\n reducers.set(key, fn)\n}\n\n// Look up a registered reducer; undefined when none — the controller then makes\n// #recompute a no-op rather than throwing (a missing reducer must not break the\n// page; it just means no client-side binding for that root).\nexport function computeReducer(key) {\n return reducers.get(key)\n}\n\n// Test seam: clear the registry so a reducer registered in one test can't leak.\nexport function __resetComputeRegistryForTest() {\n reducers.clear()\n}\n\n// --- The reducer-side op-chain builder (issue #226) --------------------------\n//\n// A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder (minus\n// paste_into — see the actor-only note above): verbs carry\n// the WIRE op names (snake_case) and append [name, args] pairs; every verb\n// returns a NEW instance, so a chain held in a constant can never be mutated by\n// later use. `.ops` exposes the raw [[name, args], ...] list the controller\n// interprets (and toJSON serializes it, so a chain can also feed a hand-built\n// ops attr). Targets: a CSS selector string, or omit for \"@root\" (the\n// component's own root). No build-time attr validation here — the interpreter's\n// allowlist (guardAttr) is the enforcement point; this builder only shapes the\n// wire.\nconst ROOT_SENTINEL = \"@root\"\n\nfunction targetArgs(to, { global, transition } = {}) {\n const args = { to: to ?? ROOT_SENTINEL }\n if (global) args.global = true\n if (transition) args.transition = normalizeTransition(transition)\n return args\n}\n\n// Named legs { during, from, to } → the [during, from, to] wire array (the\n// issue #186 vocabulary). Loud at authoring time, like the Ruby builder.\n// Frozen, like every nested payload — the chain's immutability contract must\n// hold all the way down (the Ruby twin freezes its legs array too).\nfunction normalizeTransition(transition) {\n const named =\n transition && typeof transition === \"object\" && !Array.isArray(transition) &&\n [\"during\", \"from\", \"to\"].every((k) => k in transition)\n if (!named) throw new Error(\"[phlex-reactive] ops transition takes named legs { during, from, to }\")\n return Object.freeze([String(transition.during), String(transition.from), String(transition.to)])\n}\n\nclass OpsChain {\n constructor(list = Object.freeze([])) {\n this.ops = list\n Object.freeze(this)\n }\n\n show(to, opts) {\n return this.#append(\"show\", targetArgs(to, opts))\n }\n\n hide(to, opts) {\n return this.#append(\"hide\", targetArgs(to, opts))\n }\n\n toggle(to, opts) {\n return this.#append(\"toggle\", targetArgs(to, opts))\n }\n\n add_class(to, classes, opts) {\n return this.#append(\"add_class\", classArgs(to, classes, opts))\n }\n\n remove_class(to, classes, opts) {\n return this.#append(\"remove_class\", classArgs(to, classes, opts))\n }\n\n toggle_class(to, classes, opts) {\n return this.#append(\"toggle_class\", classArgs(to, classes, opts))\n }\n\n set_attr(to, name, value, opts) {\n return this.#append(\"set_attr\", { ...targetArgs(to, opts), name: String(name), value: String(value) })\n }\n\n remove_attr(to, name, opts) {\n return this.#append(\"remove_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n toggle_attr(to, name, opts) {\n return this.#append(\"toggle_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n focus(to, opts) {\n return this.#append(\"focus\", targetArgs(to, opts))\n }\n\n focus_first(to, opts) {\n return this.#append(\"focus_first\", targetArgs(to, opts))\n }\n\n text(to, value, opts) {\n return this.#append(\"text\", { ...targetArgs(to, opts), value: String(value ?? \"\") })\n }\n\n dispatch(name, { to, detail, global } = {}) {\n const args = { name: String(name), to: to ?? ROOT_SENTINEL, detail: detail ?? {} }\n if (global) args.global = true\n return this.#append(\"dispatch\", args)\n }\n\n submit(to, opts) {\n return this.#append(\"submit\", targetArgs(to, opts))\n }\n\n toJSON() {\n return this.ops\n }\n\n #append(name, args) {\n return new OpsChain(Object.freeze([...this.ops, Object.freeze([name, Object.freeze(args)])]))\n }\n}\n\n// classes: one class string or an array of them (never whitespace-split — a\n// classList token can't contain spaces, so splitting would only mask a bug).\n// Loud on an empty/missing list, and frozen (the Ruby twin freezes its class\n// list too) — a chain held in a constant must stay immutable all the way down.\nfunction classArgs(to, classes, opts) {\n const list = classes == null ? [] : (Array.isArray(classes) ? classes : [classes]).map(String)\n if (list.length === 0) throw new Error(\"[phlex-reactive] a class op needs at least one class\")\n return { ...targetArgs(to, opts), classes: Object.freeze(list) }\n}\n\n// The shared empty chain — start every reducer effect from here:\n// $ops: done ? ops.dispatch(\"code:complete\").submit() : null\nexport const ops = new OpsChain()\n" ], - "mappings": "AA0GA,IAAM,EAAW,IAAI,IAKd,SAAS,CAAiB,CAAC,EAAK,EAAI,CACzC,EAAS,IAAI,EAAK,CAAE,EAMf,SAAS,CAAc,CAAC,EAAK,CAClC,OAAO,EAAS,IAAI,CAAG,EAIlB,SAAS,CAA6B,EAAG,CAC9C,EAAS,MAAM,EAcjB,IAAM,EAAgB,QAEtB,SAAS,CAAU,CAAC,GAAM,SAAQ,cAAe,CAAC,EAAG,CACnD,IAAM,EAAO,CAAE,GAAI,GAAM,CAAc,EACvC,GAAI,EAAQ,EAAK,OAAS,GAC1B,GAAI,EAAY,EAAK,WAAa,EAAoB,CAAU,EAChE,OAAO,EAOT,SAAS,CAAmB,CAAC,EAAY,CAIvC,GAAI,EAFF,GAAc,OAAO,IAAe,UAAY,CAAC,MAAM,QAAQ,CAAU,GACzE,CAAC,SAAU,OAAQ,IAAI,EAAE,MAAM,CAAC,KAAM,KAAK,EAAU,GAC3C,MAAU,MAAM,uEAAuE,EACnG,OAAO,OAAO,OAAO,CAAC,OAAO,EAAW,MAAM,EAAG,OAAO,EAAW,IAAI,EAAG,OAAO,EAAW,EAAE,CAAC,CAAC,EAGlG,MAAM,CAAS,CACb,WAAW,CAAC,EAAO,OAAO,OAAO,CAAC,CAAC,EAAG,CACpC,KAAK,IAAM,EACX,OAAO,OAAO,IAAI,EAGpB,IAAI,CAAC,EAAI,EAAM,CACb,OAAO,KAAK,GAAQ,OAAQ,EAAW,EAAI,CAAI,CAAC,EAGlD,IAAI,CAAC,EAAI,EAAM,CACb,OAAO,KAAK,GAAQ,OAAQ,EAAW,EAAI,CAAI,CAAC,EAGlD,MAAM,CAAC,EAAI,EAAM,CACf,OAAO,KAAK,GAAQ,SAAU,EAAW,EAAI,CAAI,CAAC,EAGpD,SAAS,CAAC,EAAI,EAAS,EAAM,CAC3B,OAAO,KAAK,GAAQ,YAAa,EAAU,EAAI,EAAS,CAAI,CAAC,EAG/D,YAAY,CAAC,EAAI,EAAS,EAAM,CAC9B,OAAO,KAAK,GAAQ,eAAgB,EAAU,EAAI,EAAS,CAAI,CAAC,EAGlE,YAAY,CAAC,EAAI,EAAS,EAAM,CAC9B,OAAO,KAAK,GAAQ,eAAgB,EAAU,EAAI,EAAS,CAAI,CAAC,EAGlE,QAAQ,CAAC,EAAI,EAAM,EAAO,EAAM,CAC9B,OAAO,KAAK,GAAQ,WAAY,IAAK,EAAW,EAAI,CAAI,EAAG,KAAM,OAAO,CAAI,EAAG,MAAO,OAAO,CAAK,CAAE,CAAC,EAGvG,WAAW,CAAC,EAAI,EAAM,EAAM,CAC1B,OAAO,KAAK,GAAQ,cAAe,IAAK,EAAW,EAAI,CAAI,EAAG,KAAM,OAAO,CAAI,CAAE,CAAC,EAGpF,WAAW,CAAC,EAAI,EAAM,EAAM,CAC1B,OAAO,KAAK,GAAQ,cAAe,IAAK,EAAW,EAAI,CAAI,EAAG,KAAM,OAAO,CAAI,CAAE,CAAC,EAGpF,KAAK,CAAC,EAAI,EAAM,CACd,OAAO,KAAK,GAAQ,QAAS,EAAW,EAAI,CAAI,CAAC,EAGnD,WAAW,CAAC,EAAI,EAAM,CACpB,OAAO,KAAK,GAAQ,cAAe,EAAW,EAAI,CAAI,CAAC,EAGzD,IAAI,CAAC,EAAI,EAAO,EAAM,CACpB,OAAO,KAAK,GAAQ,OAAQ,IAAK,EAAW,EAAI,CAAI,EAAG,MAAO,OAAO,GAAS,EAAE,CAAE,CAAC,EAGrF,QAAQ,CAAC,GAAQ,KAAI,SAAQ,UAAW,CAAC,EAAG,CAC1C,IAAM,EAAO,CAAE,KAAM,OAAO,CAAI,EAAG,GAAI,GAAM,EAAe,OAAQ,GAAU,CAAC,CAAE,EACjF,GAAI,EAAQ,EAAK,OAAS,GAC1B,OAAO,KAAK,GAAQ,WAAY,CAAI,EAGtC,MAAM,CAAC,EAAI,EAAM,CACf,OAAO,KAAK,GAAQ,SAAU,EAAW,EAAI,CAAI,CAAC,EAGpD,MAAM,EAAG,CACP,OAAO,KAAK,IAGd,EAAO,CAAC,EAAM,EAAM,CAClB,OAAO,IAAI,EAAS,OAAO,OAAO,CAAC,GAAG,KAAK,IAAK,OAAO,OAAO,CAAC,EAAM,OAAO,OAAO,CAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAEhG,CAMA,SAAS,CAAS,CAAC,EAAI,EAAS,EAAM,CACpC,IAAM,EAAO,GAAW,KAAO,CAAC,GAAK,MAAM,QAAQ,CAAO,EAAI,EAAU,CAAC,CAAO,GAAG,IAAI,MAAM,EAC7F,GAAI,EAAK,SAAW,EAAG,MAAU,MAAM,sDAAsD,EAC7F,MAAO,IAAK,EAAW,EAAI,CAAI,EAAG,QAAS,OAAO,OAAO,CAAI,CAAE,EAK1D,IAAM,EAAM,IAAI", + "mappings": "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", "debugId": "3CB48262B60C70DD64756E2164756E21", "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 ab581e2..b732767 100644 --- a/app/javascript/phlex/reactive/reactive_controller.js +++ b/app/javascript/phlex/reactive/reactive_controller.js @@ -1047,9 +1047,11 @@ function runTransition(el, transition, flip) { // Phlex::Reactive::JS's vocabulary; an op name not in this map is // warn-and-skipped by #applyOps (client-side default-deny — a stale or newer // ops attr must never break the page). Each op is a pure, local DOM mutation: -// nothing is read back, nothing is sent anywhere. Frozen so nothing can be -// registered into it at runtime — extending the vocabulary is a gem change, -// not an app hook. +// nothing is sent anywhere, and nothing is read back — with ONE deliberate +// exception, paste_into (issue #228), which reads the clipboard behind the +// browser's own gesture + permission gates and still only writes locally. +// Frozen so nothing can be registered into it at runtime — extending the +// vocabulary is a gem change, not an app hook. const CLIENT_OPS = Object.freeze({ show: (el, args) => setHidden(el, false, args), hide: (el, args) => setHidden(el, true, args), @@ -1099,6 +1101,24 @@ const CLIENT_OPS = Object.freeze({ // exactly like a user submit. No form → no-op. ACTOR-ONLY like focus: the // broadcast builder refuses it server-side (BROADCAST_REFUSED_OPS). submit: (el) => submitFormFor(el)?.requestSubmit?.(), + + // Clipboard-source paste (issue #228): on a user gesture, read + // navigator.clipboard.readText() and feed the text into the target field + // through the normal input pipeline — exactly what a native Cmd/Ctrl+V does. + // The ONE op that reads a browser API and is async: fire-and-forget, so + // applyOps stays sync and chain siblings never wait (the runTransition + // posture). A rejected/dismissed read, empty text, or a missing API is a + // SILENT no-op — page state must not change. ACTOR-ONLY like focus/submit: + // the broadcast builder refuses it server-side (BROADCAST_REFUSED_OPS) — + // and that server gate is the REAL one. The browser only partially backs it + // up: Safari gates every read on a fresh gesture and Firefox shows its + // paste picker per read, but Chromium's clipboard-read is a PERSISTENT + // per-origin permission — once granted (the legit paste button itself + // induces that), readText() succeeds with no gesture. No client-side + // refusal is possible here: the reactive:js interpreter cannot distinguish + // an actor reply's stream from a broadcast's, and reply.js legitimately + // carries this op. + paste_into: (el) => pasteClipboardInto(el), }) // The form a submit op commits (issue #226), in order: the target itself when @@ -1111,6 +1131,32 @@ function submitFormFor(el) { return el?.form ?? el?.closest?.("form") ?? null } +// Read the clipboard into a field (issue #228) — the body of the paste_into +// op. The write mirrors a native paste: set .value, dispatch a bubbling +// `input` event (the set-value + dispatch contract, issue #183 — compute +// reducers, show bindings, and on_complete all run exactly as if the user +// had typed), then focus (the caret lands where the user continues typing on +// a partial paste). Availability-guarded: insecure contexts and some +// webviews have no navigator.clipboard — the connect()-time gate hides +// marked triggers there, so this guard is belt-and-braces. Empty text is a +// no-op: "paste nothing" must not clear a half-typed field. +function pasteClipboardInto(field) { + const clipboard = globalThis.navigator?.clipboard + if (typeof clipboard?.readText !== "function") return + clipboard + .readText() + .then((text) => { + if (!text) return + field.value = text + if (typeof field.dispatchEvent === "function") field.dispatchEvent(new Event("input", { bubbles: true })) + field.focus?.() + }) + .catch(() => { + // Permission denied or the prompt dismissed — the browser's own UX said + // no. The issue-#228 contract: a silent no-op, never an error. + }) +} + // Apply a hidden-flag change, optionally animated by a [during, from, to] // transition (issue #96). Split out so show/hide/toggle share it. function setHidden(el, hidden, args) { @@ -1554,6 +1600,9 @@ export default class extends Controller { // Lazy initial mount (issue #165): the bound re-probe attached to // turbo:morph-element so a Turbo page-refresh morph re-fires the defer fetch. #boundProbeLazyDefer + // Clipboard-trigger availability gate (issue #228): the bound morph re-sync, + // held for teardown. + #boundSyncClipboard // Mark that a reactive controller actually connected, so the registration // guard above knows the controller was registered (issue #26 part 2). @@ -1735,6 +1784,24 @@ export default class extends Controller { this.element.addEventListener?.("turbo:morph-element", this.#boundSeedCompute) this.recompute() } + + // Clipboard-trigger availability gate (issue #228) — ONLY when this root + // owns a paste trigger (on_client marks one with data-reactive-clipboard), + // so every other component pays a single probe (the show/filter/tags gate + // precedent). The Async Clipboard API is absent in insecure contexts and + // some webviews; a paste button that can never work must not show. The + // gate OWNS a marked trigger's `hidden` flag: author the trigger `hidden` + // and this pass reveals it where the API exists (a dead button never + // paints); turbo:morph-element re-syncs because a morph rewrites the + // trigger back to its authored hidden state. Like every sibling gate the + // decision is made ONCE at connect — render the paste trigger + // unconditionally: a trigger first INTRODUCED by a later morph stays + // ungated (hidden) until a full replace re-connects the controller. + if (this.#clipboardGateEnabled()) { + this.#boundSyncClipboard = () => this.#syncClipboardTriggers() + this.element.addEventListener?.("turbo:morph-element", this.#boundSyncClipboard) + this.#syncClipboardTriggers() + } } // Whether this root opts into dirty tracking (issue #103): track_dirty: puts the @@ -1765,6 +1832,7 @@ export default class extends Controller { this.#teardownTagsSync() this.#teardownNestedJsonSync() this.#teardownComputeSeed() + this.#teardownClipboardGate() if (this.#boundProbeLazyDefer) { this.element.removeEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer) } @@ -3551,6 +3619,35 @@ export default class extends Controller { return !!this.element.getAttribute?.("data-reactive-on-complete") } + // Whether this root owns a clipboard-marked paste trigger (issue #228) — + // the connect() gate. One scoped query; a NESTED root's triggers are its + // own controller's to gate (issue #15 ownership). + #clipboardGateEnabled() { + const nodes = this.element.querySelectorAll?.("[data-reactive-clipboard]") ?? [] + for (const el of nodes) if (this.#ownsField(el)) return true + return false + } + + // Set every owned paste trigger's `hidden` from clipboard availability + // (issue #228): available → revealed (the authored `hidden` was only the + // no-dead-button first paint), missing → hidden (insecure context / + // webview). The gate owns the flag on MARKED elements only — nothing else + // is ever touched. + #syncClipboardTriggers() { + const available = typeof globalThis.navigator?.clipboard?.readText === "function" + for (const el of this.element.querySelectorAll?.("[data-reactive-clipboard]") ?? []) { + if (this.#ownsField(el)) el.hidden = !available + } + } + + // Remove the clipboard gate's morph listener on disconnect, so a stray + // morph event after a Turbo navigation never re-syncs a detached root. + #teardownClipboardGate() { + if (!this.#boundSyncClipboard) return + this.element.removeEventListener?.("turbo:morph-element", this.#boundSyncClipboard) + this.#boundSyncClipboard = undefined + } + // Parse-and-memoize the completion bindings, keyed on the RAW attr string: // a morph that rewrote the payload re-parses and RESETS the latches (the // morph listener's own arm pass then re-arms without firing). A removed diff --git a/app/javascript/phlex/reactive/reactive_controller.min.js b/app/javascript/phlex/reactive/reactive_controller.min.js index e8a6200..0c169d9 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 WX}from"@hotwired/stimulus";import{confirmResolver as I}from"phlex/reactive/confirm";import{computeReducer as JX}from"phlex/reactive/compute";import{confirmPredicate as LX}from"phlex/reactive/confirm_predicate";function VX(){let X=window.Turbo?.StreamActions;if(!X||X["reactive:visit"])return;X["reactive:visit"]=function(){let Z=this.getAttribute("data-url");if(Z)window.Turbo.visit(Z,{action:"advance"})}}function BX(){let X=window.Turbo?.StreamActions;if(!X||X["reactive:token"])return;X["reactive:token"]=function(){let Z=this.getAttribute("data-reactive-token-value"),$=this.getAttribute("target");if(!Z||!$)return;let Q=document.getElementById($);if(Q)Q.setAttribute("data-reactive-token-value",Z)}}function _X(){let X=window.Turbo?.StreamActions;if(!X||X["reactive:js"])return;X["reactive:js"]=function(){let Z=UX(this.getAttribute("data-reactive-ops"));if(!Z.length)return;let $=this.getAttribute("target"),Q=$?document.getElementById($):null;if($&&!Q)return;N(Z,(j)=>qZ(j,Q))}}var _=new Map;function n(X,Z){let $=!_.has(X);if(_.set(X,Z),$)jX()}function R(X){if(_.delete(X))zX()}function UZ(){_.clear(),w=!1}function WZ(X){return _.get(X)?.via}var w=!1;function MX(){let X=window.Turbo?.StreamActions;if(!X||X["reactive:defer"])return;if(X["reactive:defer"]=function(){let Z=this.getAttribute("target");if(!Z)return;if(this.getAttribute("data-reactive-defer-via")==="stream"){xX(Z,this);return}let $=this.getAttribute("data-reactive-defer-token");if(!$)return;F(Z,$)},!w&&typeof document<"u"&&document.addEventListener)w=!0,document.addEventListener("turbo:before-stream-render",AX)}function AX(X){let $=X.target?.getAttribute?.("target");if(!$)return;let Q=$.startsWith("reactive-defer-src-")?$.slice(19):$;if(_.get(Q)?.via==="stream")R(Q)}function F(X,Z){let $=document.getElementById(X);if(!$){console.warn(`[phlex-reactive] reactive:defer target #${X} is not on the page — skipped`);return}r(X),t($);let Q={via:"fetch",abort:new AbortController,timedOut:!1};n(X,Q),PX(X,Q,Z)}function xX(X,Z){let $=document.getElementById(X);if(!$){console.warn(`[phlex-reactive] reactive:defer target #${X} is not on the page — skipped`);return}let Q=Z.getAttribute("data-reactive-defer-src");if(!Q)return;if(!globalThis.customElements?.get?.("pgbus-stream-source")){let z=Z.getAttribute("data-reactive-defer-token");if(z){F(X,z);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}r(X),t($);let j=document.createElement("pgbus-stream-source");j.id=a(X),j.setAttribute("src",Q),j.setAttribute("since-id",Z.getAttribute("data-reactive-defer-since-id")??"0"),j.setAttribute("hidden",""),document.body.appendChild(j),n(X,{via:"stream"})}function a(X){return`reactive-defer-src-${X}`}async function PX(X,Z,$){let Q=setTimeout(()=>{Z.timedOut=!0,Z.abort.abort()},DX()),j;try{j=await fetch(NX(),{method:"POST",headers:{Accept:"text/vnd.turbo-stream.html","Content-Type":"application/json","X-CSRF-Token":OX()},body:JSON.stringify({token:$}),credentials:"same-origin",signal:Z.abort.signal})}catch(q){if(clearTimeout(Q),_.get(X)!==Z)return;console.error("[phlex-reactive] deferred render failed",q),T(X,$);return}if(_.get(X)!==Z){clearTimeout(Q);return}if(j.status===204){clearTimeout(Q),c(X);return}if(!j.ok){clearTimeout(Q),console.error(`[phlex-reactive] deferred render failed: HTTP ${j.status}`),T(X,$,j.status);return}let z;try{z=await j.text()}catch(q){if(clearTimeout(Q),_.get(X)!==Z)return;console.error("[phlex-reactive] deferred render failed reading the body",q),T(X,$);return}if(clearTimeout(Q),_.get(X)!==Z)return;c(X),window.Turbo.renderStreamMessage(z)}function r(X){let Z=_.get(X);if(!Z)return;if(R(X),Z.via==="fetch")Z.abort.abort();else document.getElementById(a(X))?.remove?.()}function t(X){X.setAttribute("data-reactive-defer-pending","true"),X.setAttribute("aria-busy","true")}function e(X){X.removeAttribute("data-reactive-defer-pending"),X.removeAttribute("aria-busy")}function c(X){R(X);let Z=document.getElementById(X);if(!Z)return;e(Z),Z.removeAttribute("data-reactive-error")}function T(X,Z,$){R(X);let Q=document.getElementById(X);if(!Q)return;e(Q),Q.setAttribute("data-reactive-error","defer");let j=()=>{let z=document.getElementById(X);if(!z){console.warn("[phlex-reactive] defer retry() ignored — the target left the DOM");return}z.removeAttribute("data-reactive-error"),F(X,Z)};Q.dispatchEvent(new CustomEvent("reactive:error",{bubbles:!0,composed:!0,detail:{kind:"defer",target:X,status:$,retry:j}}))}function NX(){return document.querySelector('meta[name="phlex-reactive-defer-path"]')?.content||"/reactive/defer"}function OX(){return document.querySelector('meta[name="csrf-token"]')?.content??""}function DX(){let X=document.querySelector('meta[name="phlex-reactive-timeout"]')?.content,Z=Number(X);return Number.isFinite(Z)&&Z>0?Z:30000}var h=!1;function RX(){if(h)return;if(typeof document>"u"||!document.addEventListener)return;h=!0,document.addEventListener("turbo:before-stream-render",FX)}function FX(X){let Z=X.detail,$=Z?.render;if(typeof $!=="function"||$.__reactiveDismissWrapped){if(typeof requestAnimationFrame==="function")requestAnimationFrame(E);else setTimeout(E,0);return}let Q=async(j)=>{await $(j),E()};Q.__reactiveDismissWrapped=!0,Z.render=Q}function E(){let X=document.querySelectorAll("[data-reactive-dismiss-after]");for(let Z of X){if(Z.hasAttribute("data-reactive-dismiss-scheduled"))continue;let $=Number(Z.getAttribute("data-reactive-dismiss-after"));if(!Number.isFinite($)||$<=0)continue;Z.setAttribute("data-reactive-dismiss-scheduled",""),setTimeout(()=>Z.remove(),$)}}function JZ(){h=!1}var CX=Object.freeze({append:"enter",prepend:"enter",replace:"update",update:"update",remove:"exit"}),k=Object.freeze(["fade","slide","scale","highlight","shake"]),y="data-reactive-fx-pending",XX=1000,v=!1;function IX(){if(v)return;if(typeof document>"u"||typeof document.addEventListener!=="function")return;v=!0,document.addEventListener("turbo:before-stream-render",TX)}function LZ(){v=!1}function TX(X){let Z=X.detail,$=Z?.render;if(typeof $!=="function"||$.__reactiveEffectsWrapped)return;let Q=Z?.newStream??X.target,j=CX[Q?.getAttribute?.("action")];if(!j||SX())return;let z=EX(Q,j);if(!z)return;let q=j==="exit"?async(G)=>{await hX(O(Q),z),await $(G)}:async(G)=>{let Y=j==="enter"?bX(Q):null;if(await $(G),j==="enter")wX(Y,z);else ZX(O(Q),z)};q.__reactiveEffectsWrapped=!0,Z.render=q}function EX(X,Z){let $=X.getAttribute?.("data-reactive-effect");if($==="off")return null;if($)return d($,Z);let j=(Z==="enter"?kX(X):O(X))?.getAttribute?.(`data-reactive-effect-${Z}`);return j?d(j,Z):null}function O(X){let Z=X.getAttribute?.("target");return Z?document.getElementById?.(Z)??null:null}function kX(X){return X.querySelector?.("template")?.content?.firstElementChild??null}function d(X,Z){if(X.startsWith("[")){let Q=null;try{let j=JSON.parse(X);if(Array.isArray(j)&&j.length===3)Q=j.map(String)}catch{}if(Q)return{legs:Q};return console.warn(`[phlex-reactive] malformed effect legs ${JSON.stringify(X)} — skipped`),null}let $=X==="random"?k[Math.floor(Math.random()*k.length)]:X;if(!k.includes($))return console.warn(`[phlex-reactive] unknown effect ${JSON.stringify(X)} — skipped`),null;return{className:`reactive-fx--${$}-${Z}`}}function SX(){try{return typeof matchMedia==="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches}catch{return!1}}function bX(X){let Z=X.querySelector?.("template")?.content;if(!Z)return null;for(let $ of Array.from(Z.children??[]))$.setAttribute?.(y,"");return O(X)}function wX(X,Z){if(typeof X?.querySelectorAll!=="function")return;for(let $ of Array.from(X.querySelectorAll(`[${y}]`)))$.removeAttribute(y),ZX($,Z)}async function hX(X,Z){if(!X?.classList)return;if(Z.legs){await $X(X,Z.legs);return}X.classList.add(Z.className);let $=p(X);if($<=0){X.classList.remove(Z.className);return}await u(X,$),X.classList.remove(Z.className)}function ZX(X,Z){if(!X?.classList)return;if(Z.legs){$X(X,Z.legs);return}if(X.classList.contains(Z.className))X.classList.remove(Z.className),X.offsetWidth;X.classList.add(Z.className);let $=p(X);if($<=0){X.classList.remove(Z.className);return}let Q=X.__reactiveFxToken=(X.__reactiveFxToken??0)+1;u(X,$).then(()=>{if(X.__reactiveFxToken===Q)X.classList.remove(Z.className)})}async function $X(X,Z){let[$,Q,j]=Z.map(yX),z=X.__reactiveFxToken=(X.__reactiveFxToken??0)+1;if(X.classList.remove(...$,...Q,...j),X.classList.add(...$,...Q),await vX(),X.__reactiveFxToken!==z)return;X.classList.remove(...Q),X.classList.add(...j);let q=p(X);if(q>0)await u(X,q);if(X.__reactiveFxToken!==z)return;X.classList.remove(...$,...j)}function yX(X){return String(X??"").split(/\s+/).filter(Boolean)}function p(X){if(typeof getComputedStyle!=="function")return 0;try{let Z=getComputedStyle(X),$=(z)=>String(z??"").split(",").reduce((q,G)=>Math.max(q,parseFloat(G)||0),0),Q=$(Z.animationDuration)+$(Z.animationDelay),j=$(Z.transitionDuration)+$(Z.transitionDelay);return Math.min(Math.max(Q,j)*1000,XX)}catch{return 0}}function u(X,Z){return new Promise(($)=>{let Q=!1,j=()=>{if(Q)return;Q=!0,$()};X.addEventListener?.("animationend",j,{once:!0}),X.addEventListener?.("transitionend",j,{once:!0}),setTimeout(j,Math.min(Z+50,XX))})}function vX(){return new Promise((X)=>{if(typeof requestAnimationFrame==="function")requestAnimationFrame(()=>X());else setTimeout(X,16)})}var f=!1;function fX(){if(f)return;if(typeof window>"u"||typeof document>"u")return;if(typeof window.addEventListener!=="function")return;f=!0;let X=()=>{let Z=document.documentElement;if(typeof Z?.toggleAttribute!=="function")return;Z.toggleAttribute("data-reactive-offline",globalThis.navigator?.onLine===!1)};X(),window.addEventListener("online",X),window.addEventListener("offline",X)}function VZ(){f=!1}var m="phlex-reactive:latency",D=!1;function pX(X){if(typeof sessionStorage>"u")return;sessionStorage.setItem(m,String(X))}function uX(){if(typeof sessionStorage>"u")return;sessionStorage.removeItem(m),D=!1}function mX(){if(typeof window>"u"||typeof document>"u")return;if(document.querySelector?.('meta[name="phlex-reactive-env"]')?.content!=="development")return;window.PhlexReactive={enableLatencySim:pX,disableLatencySim:uX}}function BZ(){D=!1}var QX="data-reactive-active",M=0;function jX(){if(M++,M===1)qX("reactive:busy")}function zX(){if(M===0)return;if(M--,M===0)qX("reactive:idle")}function _Z(){return M}function MZ(){M=0,(typeof document<"u"?document.documentElement:null)?.removeAttribute?.(QX)}function qX(X){if(typeof document>"u")return;let Z=document.documentElement;if(typeof Z?.toggleAttribute==="function")Z.toggleAttribute(QX,M>0);if(typeof document.dispatchEvent==="function"&&typeof CustomEvent==="function")document.dispatchEvent(new CustomEvent(X,{detail:{count:M}}))}function l(){VX(),BX(),_X(),MX(),RX(),IX(),fX(),mX()}function gX(X){return X.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}if(typeof window<"u")if(window.Turbo)l();else document.addEventListener("turbo:load",l,{once:!0});var C=!1;function cX(){if(C)return;if(typeof document>"u")return;let X=document.querySelectorAll('[data-controller~="reactive"]');if(!X||X.length===0)return;console.warn("[phlex-reactive] found "+X.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 AZ(){C=!1}function xZ(){C=!0}if(typeof window<"u"&&typeof document<"u"){let X=()=>setTimeout(cX,0);if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",X,{once:!0});else X()}var dX=new Set(["href","src","srcdoc","action","formaction","xlink:href","style"]);function lX(X){let Z=String(X).toLowerCase();return Z.startsWith("on")||dX.has(Z)}function iX(X,Z,$){let[Q,j,z]=Z;X.classList.add(Q,j),$(),requestAnimationFrame(()=>{X.classList.remove(j),X.classList.add(z)});let q=!1,G=()=>{if(q)return;q=!0,X.classList.remove(Q,z)};X.addEventListener("animationend",G,{once:!0}),setTimeout(G,350)}var i=Object.freeze({show:(X,Z)=>S(X,!1,Z),hide:(X,Z)=>S(X,!0,Z),toggle:(X,Z)=>S(X,!X.hidden,Z),add_class:(X,Z)=>X.classList.add(...Z.classes??[]),remove_class:(X,Z)=>X.classList.remove(...Z.classes??[]),toggle_class:(X,Z)=>(Z.classes??[]).forEach(($)=>X.classList.toggle($)),set_attr:(X,Z)=>{if(b(Z.name))X.setAttribute(Z.name,Z.value??"")},remove_attr:(X,Z)=>{if(b(Z.name))X.removeAttribute(Z.name)},toggle_attr:(X,Z)=>{if(!b(Z.name))return;if(X.hasAttribute(Z.name))X.removeAttribute(Z.name);else X.setAttribute(Z.name,"")},focus:(X)=>X.focus?.(),focus_first:(X)=>jZ(X)?.focus?.(),text:(X,Z)=>{let $=String(Z.value??"");if(X.textContent!==$)X.textContent=$},dispatch:(X,Z)=>{X.dispatchEvent(new CustomEvent(Z.name,{bubbles:!0,composed:!0,detail:Z.detail??{}}))},submit:(X)=>oX(X)?.requestSubmit?.()});function oX(X){if(X?.tagName==="FORM")return X;return X?.form??X?.closest?.("form")??null}function S(X,Z,$){if($?.transition)iX(X,$.transition,()=>X.hidden=Z);else X.hidden=Z}function b(X){if(!lX(X))return!0;return console.warn(`[phlex-reactive] refused client attr op on ${JSON.stringify(X)} — skipped`),!1}var GX=/^#[A-Za-z_][\w-]*$/;function sX(X){if(typeof X==="string"&&GX.test(X))return!0;return console.warn(`[phlex-reactive] refused cross-root mirror target ${JSON.stringify(X)} — skipped`),!1}function nX(X,Z){let $=X.getAttribute("data-reactive-show-equals");if($!==null)return Z===$;let Q=X.getAttribute("data-reactive-show-not");if(Q!==null)return Z!==Q;let j=X.getAttribute("data-reactive-show-in");if(j!==null){try{let z=JSON.parse(j);if(Array.isArray(z))return z.includes(Z)}catch{}return console.warn(`[phlex-reactive] malformed reactive_show in: list ${JSON.stringify(j)} — skipped`),null}for(let z of YX){let q=X.getAttribute(`data-reactive-show-${z}`);if(q!==null)return KX(z,q,Z)}return console.warn("[phlex-reactive] a reactive_show binding declares no predicate — skipped"),null}var YX=["gte","gt","lte","lt"],aX=["len_eq","len_gte","len_gt","len_lte","len_lt"];function rX(X,Z,$){if(!Number.isInteger(Z))return console.warn(`[phlex-reactive] reactive_show ${X}: needs an integer literal, got ${JSON.stringify(Z)} — skipped`),null;let Q=[...String($??"")].length;switch(X){case"len_eq":return Q===Z;case"len_gte":return Q>=Z;case"len_gt":return Q>Z;case"len_lte":return Q<=Z;case"len_lt":return Q=Q;case"gt":return z>Q;case"lte":return z<=Q;case"lt":return z$&&typeof $==="object"&&Array.isArray($.any)&&Array.isArray($.ops)))return Z}catch{}return console.warn(`[phlex-reactive] malformed reactive_on_complete payload ${JSON.stringify(X)} — skipped`),[]}function g(X,Z){if(!X||typeof X!=="object"||typeof X.field!=="string")return!1;let $=Z(X.field)??"";return HX(X,$)===!0}function P(X,Z){if(!Array.isArray(X)||X.length===0)return null;return X.some(($)=>Array.isArray($)&&$.length>0&&$.every((Q)=>g(Q,Z)))}function XZ(X){if(!Array.isArray(X)||X.length===0)return null;let Z=new Set;for(let $ of X){if(!Array.isArray($))continue;for(let Q of $)if(Q&&typeof Q==="object"&&typeof Q.field==="string")Z.add(Q.field)}return Z.size>0?[...Z]:null}function ZZ(X,Z){if(!X||typeof X!=="object")return null;let $=X.any;if(Array.isArray($)&&($.length===0||Array.isArray($[0])))return P($,Z);return $Z(X,Z)}function $Z(X,Z){let $=Array.isArray(X.all)?"all":Array.isArray(X.any)?"any":null;if(!$)return null;let Q=X[$];if(Q.length===0)return null;let j=Q.map((z)=>g(z,Z));return $==="all"?j.every(Boolean):j.some(Boolean)}function s(X){if(typeof X==="string"&&GX.test(X))return!0;return console.warn(`[phlex-reactive] refused cross-root show target ${JSON.stringify(X)} — skipped`),!1}var QZ='a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';function jZ(X){return X.querySelectorAll?.(QZ)?.[0]??null}function UX(X){if(Array.isArray(X))return X;if(typeof X!=="string")return[];try{let Z=JSON.parse(X);return Array.isArray(Z)?Z:[]}catch{return[]}}function zZ(X){if(X==null)return null;let Z=Array.isArray(X)?X:X.ops;if(Array.isArray(Z))return Z.length>0?Z:null;return console.warn("[phlex-reactive] $ops must be an ops chain or a [[op, args], ...] list — skipped"),null}function N(X,Z){for(let $ of X){if(!Array.isArray($))continue;let[Q,j={}]=$;if(!Object.hasOwn(i,Q)){console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(Q)} — skipped`);continue}for(let z of Z(j))i[Q](z,j)}}function qZ(X,Z){let $=X.to;if(Z){if($==="@root")return[Z];if(typeof $!=="string"||$==="")return[];if(X.global)return[...document.querySelectorAll($)];return[...Z.querySelectorAll($)]}if(typeof $!=="string"||$===""||$==="@root")return[];return[...document.querySelectorAll($)]}class PZ extends WX{static values={token:String};#i;#x=new Map;#U=new Map;#RX;#E;#o;#k=0;#W=new Map;#S=new WeakMap;#P=new Map;#s=new WeakSet;#n=null;#J;#L;#V;#Z;#z;#b;#a;#w;#h;#Q;#B;#r=!1;#y=0;#t=!1;#j;#_;#M;#N;connect(){if(C=!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.#e(),this.#N=()=>this.#e(),this.element.addEventListener?.("turbo:morph-element",this.#N);if(this.#FX()){if(this.#J=()=>this.#u(),this.element.addEventListener?.("turbo:morph-element",this.#J),this.#u(),this.element.getAttribute?.("data-reactive-warn-unsaved")==="true")this.#tX()}if(this.#XZ())this.#Z=()=>this.#LX(),this.element.addEventListener?.("input",this.#Z),this.element.addEventListener?.("change",this.#Z),this.element.addEventListener?.("turbo:morph-element",this.#Z),this.#LX();if(this.#ZZ())this.#z=(X)=>this.#m(X),this.#b=()=>this.#m(null),this.element.addEventListener?.("input",this.#z),this.element.addEventListener?.("change",this.#z),this.element.addEventListener?.("turbo:morph-element",this.#b),this.#m(null);if(this.#C())this.#Q=(X)=>{if(X?.type==="input"&&!this.#KZ(X))return;this.#A()},this.element.addEventListener?.("input",this.#Q),this.element.addEventListener?.("turbo:morph-element",this.#Q),this.#A();if(this.#I())this.#B=()=>this.#d(),this.element.addEventListener?.("turbo:morph-element",this.#B),this.#d();if(this.#UZ())this.#j=(X)=>this.syncNestedJson(X),this.#_=()=>this.#D(),this.element.addEventListener?.("input",this.#j),this.element.addEventListener?.("change",this.#j),this.element.addEventListener?.("turbo:morph-element",this.#_),this.#D();if(this.#YZ())this.#M=()=>this.recompute(),this.element.addEventListener?.("turbo:morph-element",this.#M),this.recompute()}#FX(){if((this.element.getAttribute?.("data-action")??"").includes("reactive#trackDirty"))return!0;let X=this.element.querySelectorAll?.('[data-action*="reactive#trackDirty"]')??[];for(let Z of X)if(this.#$(Z))return!0;return!1}disconnect(){if(this.#mX(),this.#cX(),this.#eX(),this.#GZ(),this.#QZ(),this.#HZ(),this.#MZ(),this.#AZ(),this.#xZ(),this.#N)this.element.removeEventListener?.("turbo:morph-element",this.#N)}#e(){let X=this.element;if(!X?.id)return;let Z=X.getAttribute?.("data-reactive-defer-token");if(!Z)return;if(X.getAttribute?.("data-reactive-defer-pending")!=="true")return;F(X.id,Z)}dispatch(X){let{action:Z,params:$,debounce:Q,throttle:j,confirm:z,confirmWhen:q,outside:G,window:Y,optimistic:K}=X.params;if(!Z)return;let U=X.params.busy??this.#EZ(X.params.loading);if(G&&this.element.contains(X.target))return;let B=X.currentTarget??X.target;if(!Y&&!this.#OZ(K,B))X.preventDefault();let W=this.#p(z,q);if(!W)return this.#GX(B,Z,$,Q,j,K,U);Promise.resolve().then(()=>I(W,{el:B})).catch(()=>!1).then((V)=>{if(V)this.#GX(B,Z,$,Q,j,K,U)})}runOps(X){let{ops:Z,confirm:$,confirmWhen:Q,outside:j,window:z}=X.params,q=X.currentTarget??X.target;if(j&&this.element.contains(X.target))return;if(!z)X.preventDefault();let G=this.#p($,Q);if(!G)return this.#PX(this.#xX(Z));Promise.resolve().then(()=>I(G,{el:q})).catch(()=>!1).then((Y)=>{if(Y)this.#PX(this.#xX(Z))})}trackDirty(){this.#u()}recompute(X){if(X&&this.#s.has(X))return;let Z=this.#wX(),$=Z.map(([H])=>H),Q=this.element.getAttribute?.("data-reactive-scope")||null,j=(H)=>Q&&!H.includes("[")?`${Q}[${H}]`:H,z=this.#X(),q=new Map,G=(H)=>{if(q.has(H))return q.get(H);let J=null;for(let L of this.element.querySelectorAll(`[name="${j(H)}"]`))if(z(L)){J=L;break}return q.set(H,J),J};for(let H of $)this.#zX(H,G(H)?.value??"");let Y=this.element.getAttribute("data-reactive-compute-reducer-param"),K=Y?JX(Y):null;if(!K){this.#qX({},G);return}let U=this.#bX("data-reactive-compute-outputs-param"),B={};for(let[H,J]of Z){let L=G(H);if(J==="string")B[H]=L?.value??"";else{let A=Number(L?.value);B[H]=Number.isFinite(A)?A:0}}let W=K(B,{changed:this.#hX(X,$,Q)})||{},V=zZ(W.$ops),x=[];for(let H of U){if(H==="$ops"||!(H in W))continue;let J=G(H);if(!J)continue;if(String(W[H])===J.value)continue;J.value=W[H],x.push(J)}for(let H of Object.keys(W)){if(H==="$ops")continue;let J=W[H];if(J===void 0||J===null)continue;this.#zX(H,J)}this.#qX(W,G);for(let H of x){let J=new Event("input",{bubbles:!0});this.#s.add(J),H.dispatchEvent(J)}this.#CX(V,Boolean(X))}#CX(X,Z){let $=X===null?null:JSON.stringify(X),Q=$!==null&&$!==this.#n&&Z;if(this.#n=$,!Q)return;N(X,(j)=>this.#T(j.to==null?{...j,to:"@root"}:j))}listnavNext(X){this.#XX(X,1)}listnavPrev(X){this.#XX(X,-1)}listnavPick(X){let Z=this.#O(X),$=Z.findIndex((Q)=>Q.hasAttribute("data-reactive-highlighted"));if($<0)return;X.preventDefault(),Z[$].click()}listnavClose(X){for(let Z of this.#O(X))Z.removeAttribute("data-reactive-highlighted")}#XX(X,Z){let $=this.#O(X);if(!$.length)return;X.preventDefault();let Q=$.findIndex((q)=>q.hasAttribute("data-reactive-highlighted")),j=Q<0?Z>0?0:$.length-1:(Q+Z+$.length)%$.length;for(let q of $)q.removeAttribute("data-reactive-highlighted");let z=$[j];z.setAttribute("data-reactive-highlighted","true"),z.scrollIntoView?.({block:"nearest"})}#O(X){let $=(X?.currentTarget??X?.target??this.element).getAttribute?.("data-reactive-listnav-option-param")??this.element.getAttribute("data-reactive-listnav-option-param");if(!$)return[];let Q=this.#X();return Array.from(this.element.querySelectorAll($)).filter((j)=>!j.hidden&&Q(j))}tagsAdd(X){if(!this.#I())return;if(X?.defaultPrevented)return;if(this.#O(X).some((Q)=>Q.hasAttribute?.("data-reactive-highlighted")))return;X?.preventDefault?.();let Z=X?.currentTarget??X?.target;if(!Z)return;if(!this.#_X(String(Z.value??"").split(",")))return;if(Z.value="",this.#C())this.#A()}tagsPick(X){if(!this.#I())return;X?.preventDefault?.();let $=(X?.currentTarget??X?.target)?.getAttribute?.("data-reactive-tag-param");if(!$)return;if(!this.#_X([$]))return;let Q=this.#WZ();if(!Q)return;Q.value="",this.#A(),Q.focus?.()}tagsRemove(X){if(!this.#I())return;X?.preventDefault?.();let $=(X?.currentTarget??X?.target)?.getAttribute?.("data-reactive-tag-param");if(!$)return;let Q=this.#g();if(!Q)return;let j=this.#c(Q),z=j.filter((q)=>q.toLowerCase()!==$.toLowerCase());if(z.length===j.length)return;this.#MX(Q,z)}nestedAdd(X){X?.preventDefault?.();let Z=X?.currentTarget??X?.target,$=Z?.getAttribute?.("data-reactive-association-param");if(!$)return;if(typeof this.element?.querySelectorAll!=="function")return;let Q=this.#X(),j=[...this.element.querySelectorAll(`[data-reactive-nested-list="${$}"]`)].find(Q),q=[...this.element.querySelectorAll(`[data-reactive-nested-template="${$}"]`)].find(Q)?.content?.firstElementChild;if(!j||!q){this.#_Z($);return}let G=q.cloneNode(!0);this.#BZ(G,this.#VZ()),j.appendChild(G);let Y=Z?.getAttribute?.("data-reactive-nested-from-param"),K=Z?.getAttribute?.("data-reactive-nested-clear-param")==="true",U=this.#IX(G,Y,K);if(Y)U?.focus?.();else[...G.querySelectorAll?.("input, select, textarea")??[]][0]?.focus?.();if(j.getAttribute?.("data-reactive-nested-json")===$)this.#$X($)}#IX(X,Z,$){if(!Z)return null;let Q;try{Q=JSON.parse(Z)}catch{return null}if(!Q||typeof Q!=="object")return null;let j=this.#X(),z=[...X.querySelectorAll?.("input, select, textarea")??[]],q=[];for(let[G,Y]of Object.entries(Q)){let K=[...this.element.querySelectorAll?.(Y)??[]].find(j);if(!K)continue;let U=z.find((B)=>this.#jX(B.getAttribute?.("name"))===G);if(!U)continue;this.#TX(U,K),q.push(K)}if($)for(let G of q)this.#EX(G);return q[0]??null}#TX(X,Z){if(X.type==="checkbox")X.checked=Z.type==="checkbox"?!!Z.checked:this.#v(Z)!=="";else X.value=this.#v(Z);if(typeof X.dispatchEvent==="function")X.dispatchEvent(new Event("input",{bubbles:!0}))}#EX(X){if(X.type==="checkbox")X.checked=!1;else X.value="";if(typeof X.dispatchEvent==="function")X.dispatchEvent(new Event("input",{bubbles:!0}))}nestedRemove(X){X?.preventDefault?.();let Z=X?.currentTarget??X?.target,$=Z?.closest?.("[data-reactive-nested-row]");if(!$)return;if($.closest?.('[data-controller~="reactive"]')!==this.element)return;let Q=Z?.getAttribute?.("data-reactive-confirm-param"),j=Z?.getAttribute?.("data-reactive-confirm-when-param"),z=this.#p(Q,j);if(!z)return this.#ZX($);let q=this.#QX($),G=this.#kX(z,q);return Promise.resolve().then(()=>I(G,{el:Z,row:$,fields:q})).catch(()=>!1).then((Y)=>{if(Y)this.#ZX($)})}#kX(X,Z){if(!X.includes("%{"))return X;return X.replace(/%\{(\w+)\}/g,($,Q)=>Object.prototype.hasOwnProperty.call(Z,Q)?Z[Q]:$)}#ZX(X){let Z=[...X.querySelectorAll?.('input[name$="[_destroy]"]')??[]][0];if(Z){if(Z.value="1",typeof Z.dispatchEvent==="function")Z.dispatchEvent(new Event("input",{bubbles:!0}));X.hidden=!0}else X.parentNode?.removeChild?.(X);this.#D()}syncNestedJson(X){let Z=X?.target;if(!Z||!this.#$(Z))return;this.#D()}#D(){if(typeof this.element?.querySelectorAll!=="function")return;let X=this.#X();for(let Z of[...this.element.querySelectorAll("[data-reactive-nested-json]")].filter(X))this.#$X(Z.getAttribute("data-reactive-nested-json"))}#$X(X){let Z=this.#X(),$=[...this.element.querySelectorAll(`[data-reactive-nested-list="${X}"]`)].find(Z);if(!$)return;let Q=this.#SX($);if(!Q)return;let j=[];for(let q of[...$.querySelectorAll?.("[data-reactive-nested-row]")??[]]){if(!Z(q)||q.hidden)continue;j.push(this.#QX(q))}let z=JSON.stringify(j);if(Q.value===z)return;if(Q.value=z,typeof Q.dispatchEvent==="function")Q.dispatchEvent(new Event("input",{bubbles:!0}))}#SX(X){let Z=X.getAttribute?.("data-reactive-nested-json-field");if(!Z)return null;let $=this.#X();return[...this.element.querySelectorAll(Z)].find($)??null}#QX(X){let Z={};for(let $ of[...X.querySelectorAll?.("input, select, textarea")??[]]){let Q=this.#jX($.getAttribute?.("name"));if(Q===null||Q==="_destroy")continue;Z[Q]=this.#v($)}return Z}#jX(X){if(!X)return null;let Z=X.match(/\[([^\][]+)\]$/);return Z?Z[1]:X}#v(X){if(X.type==="checkbox")return X.checked?X.value||"on":"";return X.value??""}#bX(X){let Z=this.element.getAttribute(X);if(!Z)return[];try{let $=JSON.parse(Z);return Array.isArray($)?$:[]}catch{return[]}}#wX(){let X=this.element.getAttribute("data-reactive-compute-inputs-param");if(!X)return[];try{let Z=JSON.parse(X);if(Array.isArray(Z))return Z.map(($)=>[$,"number"]);if(Z&&typeof Z==="object")return Object.entries(Z);return[]}catch{return[]}}#hX(X,Z,$){let Q=X?.target;if(!Q?.name||typeof Q.closest!=="function")return null;let j=this.#yX(Q.name,$);if(!Z.includes(j))return null;return this.#$(Q)?j:null}#yX(X,Z){if(!Z)return X;let $=`${Z}[`;return X.startsWith($)&&X.endsWith("]")?X.slice($.length,-1):X}#zX(X,Z){let $=String(Z);for(let Q of this.#vX(X)){if(Q.textContent===$)continue;Q.textContent=$}}#vX(X){let Z=this.element.querySelectorAll(`[data-reactive-text="${X}"]`);return Array.from(Z).filter(($)=>this.#$($))}#qX(X,Z){let $=this.#fX();for(let[Q,j]of Object.entries($)){let z=Q in X?X[Q]:Z(Q)?.value;if(z===void 0||z===null)continue;let q=String(z);for(let G of Array.isArray(j)?j:[j]){if(!sX(G))continue;for(let Y of document.querySelectorAll(G)){if(Y.textContent===q)continue;Y.textContent=q}}}}#fX(){let X=this.element.getAttribute("data-reactive-compute-mirror-param");if(!X)return{};try{let Z=JSON.parse(X);return Z&&typeof Z==="object"&&!Array.isArray(Z)?Z:{}}catch{return{}}}#GX(X,Z,$,Q,j,z,q){if(this.#F("reactive:before-dispatch",{action:Z,params:this.#AX($),element:this.element},{cancelable:!0}).defaultPrevented)return;let Y=Number(Q)||0;if(Y>0)return this.#uX(X,Y,Z,$,z,q);let K=Number(j)||0;if(K>0)return this.#gX(X,K,Z,$,z,q);return this.#R(Z,$,z,X,q)}#R(X,Z,$,Q,j){let z=this.#DZ($,Q),q=this.#RZ(X,Q,j),G=this.#YX()?this.#pX($,Q):null;return this.queue=(this.queue??Promise.resolve()).then(()=>this.#nX(X,Z,z,q,G)),this.queue}#pX(X,Z){if(!X?.hide)return null;let $=this.#OX(X,Z);if(!$.length)return null;return()=>{let Q=$.filter((j)=>j.isConnected&&!j.hidden);if(!Q.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.",Q)}}#uX(X,Z,$,Q,j,z){this.#f(X);let q=()=>{this.#f(X),this.#R($,Q,j,X,z)},G=setTimeout(q,Z);X?.addEventListener?.("blur",q,{once:!0}),this.#x.set(X,{timer:G,flush:q})}#f(X){let Z=this.#x.get(X);if(!Z)return;clearTimeout(Z.timer),X?.removeEventListener?.("blur",Z.flush),this.#x.delete(X)}#mX(){for(let X of[...this.#x.keys()])this.#f(X)}#gX(X,Z,$,Q,j,z){let q=this.#U.get(X)??new Map;if(q.has($))return;let G=setTimeout(()=>{if(q.delete($),q.size===0)this.#U.delete(X)},Z);return q.set($,G),this.#U.set(X,q),this.#R($,Q,j,X,z)}#cX(){for(let X of this.#U.values())for(let Z of X.values())clearTimeout(Z);this.#U.clear()}#F(X,Z,{cancelable:$=!1}={}){let Q=new CustomEvent(X,{bubbles:!0,composed:!0,cancelable:$,detail:Z});return(this.element.isConnected?this.element:document).dispatchEvent(Q),Q}#q(X,Z,$,Q){let j=()=>{if(!this.element.isConnected){console.warn("[phlex-reactive] retry() ignored — the reactive root left the DOM");return}return this.#R(X,Z)};this.#F("reactive:error",{action:X,params:$,...Q,retry:j})}#G(X){if(this.element?.isConnected===!1)return;this.element?.setAttribute?.("data-reactive-error",X)}#dX(){this.element?.removeAttribute?.("data-reactive-error")}#lX(){let X=document.querySelector("[data-reactive-error-flash]");if(!X?.content)return;let Z=X.getAttribute("data-reactive-error-flash")||"flash",$=document.getElementById(Z);if(!$)return;$.appendChild(X.content.cloneNode(!0))}#iX(){if(typeof sessionStorage>"u")return Promise.resolve();let X=Number(sessionStorage.getItem(m));if(!Number.isFinite(X)||X<=0)return Promise.resolve();if(!D)D=!0,console.warn(`[phlex-reactive] latency simulator ACTIVE — every action is delayed by ${X}ms. Call PhlexReactive.disableLatencySim() (or clear sessionStorage) to turn it off.`);return new Promise((Z)=>setTimeout(Z,X))}#YX(){return this.element?.getAttribute?.("data-reactive-debug")==="true"}#KX(){return typeof performance<"u"&&typeof performance.now==="function"?performance.now():Date.now()}#oX(X){if(!X)return[];let Z=[],$=/]*)>/g,Q;while((Q=$.exec(X))!==null){let j=Q[1],z=j.match(/\baction="([^"]*)"/)?.[1]??"?",q=j.match(/\btarget="([^"]*)"/)?.[1];Z.push(q?`${z} → #${q}`:z)}return Z}#sX(X){let{action:Z,status:$,ms:Q}=X,z=`reactive ${this.element?.id?`#${this.element.id} `:""}${Z} → ${$??"—"} (${Math.round(Q)}ms)`;if(console.groupCollapsed(z),console.log(`params: [${X.paramNames.join(", ")}] + collected: [${X.fieldNames.join(", ")}]`),console.log(`encoding: ${X.encoding}`),X.streams.length)console.log(`streams: ${X.streams.join(" ")}`);console.log(`token: ${X.tokenRefreshed?"refreshed ✓":"unchanged"}`),console.groupEnd()}async#nX(X,Z,$,Q,j){let{fields:z,files:q}=this.#WX(),G=this.#AX(Z),Y={...z,...G},K=this.#Y,U=q.length>0,B=U?this.#PZ(K,X,Y,q):JSON.stringify({token:K,act:X,params:Y}),W=this.#YX()?{action:X,paramNames:Object.keys(G),fieldNames:Object.keys(z),encoding:U?"multipart":"json",status:null,streams:[],tokenRefreshed:!1,started:this.#KX()}:null;await this.#iX();try{if(navigator.onLine===!1){this.#K($),this.#G("offline"),this.#q(X,Z,Y,{kind:"offline"});return}let V;try{let L={Accept:"text/vnd.turbo-stream.html","X-CSRF-Token":this.#bZ()};if(!U)L["Content-Type"]="application/json";let A=this.#wZ();if(A)L["X-Pgbus-Connection"]=A;V=await fetch(this.#kZ(),{method:"POST",headers:L,body:B,credentials:"same-origin",signal:AbortSignal.timeout(this.#SZ())})}catch(L){if(console.error("[phlex-reactive] action error",L),this.#K($),L?.name==="TimeoutError"||L?.name==="AbortError"){this.#G("timeout"),this.#q(X,Z,Y,{kind:"timeout"});return}this.#lX(),this.#G("network"),this.#q(X,Z,Y,{kind:"network"});return}if(W)W.status=V.status;if(V.redirected){console.error("[phlex-reactive] action was redirected (auth/CSRF?) — no update applied"),this.#K($),this.#G("redirected"),this.#q(X,Z,Y,{kind:"redirected",status:V.status});return}if(!V.ok){let L=await V.text();if(console.error(`[phlex-reactive] action failed: HTTP ${V.status}`,L),this.#K($),(V.headers.get("Content-Type")||"").includes("turbo-stream")){let A=this.#UX(L);if(this.#Y=A??this.#Y,W)this.#HX(W,L,A);window.Turbo.renderStreamMessage(L)}this.#G("http"),this.#q(X,Z,Y,{kind:"http",status:V.status,body:L});return}let x=V.headers.get("Content-Type")||"";if(!x.includes("turbo-stream")){console.error(`[phlex-reactive] expected a turbo-stream, got "${x}" — no update applied`),this.#K($),this.#G("content-type"),this.#q(X,Z,Y,{kind:"content-type",status:V.status});return}let H=await V.text(),J=this.#UX(H);if(this.#Y=J??this.#Y,W)this.#HX(W,H,J);if(window.Turbo.renderStreamMessage(H),j)queueMicrotask(j);this.#dX(),this.#F("reactive:applied",{action:X,params:Y,html:H})}catch(V){console.error("[phlex-reactive] action error",V),this.#K($),this.#F("reactive:error",{action:X,params:Y,kind:"apply"})}finally{if(Q?.(),W)this.#sX({...W,ms:this.#KX()-W.started})}}#HX(X,Z,$){X.streams=this.#oX(Z),X.tokenRefreshed=$!=null}get#Y(){return this.#i??this.tokenValue}set#Y(X){this.#i=X}#UX(X){let Z=this.element.id;if(!Z)return X.match(/data-reactive-token-value="([^"]+)"/)?.[1];let{token:$,self:Q}=this.#aX(Z),j=X.match($);if(j)return j[1];let z=X.match(Q);if(z)return z[1].match(/data-reactive-token-value="([^"]+)"/)?.[1];return}#aX(X){let Z=this.#o;if(Z&&Z.id===X)return Z;let $=gX(X);return this.#o={id:X,token:new RegExp(`]*\\baction="reactive:token"[^>]*\\btarget="${$}"[^>]*\\bdata-reactive-token-value="([^"]+)"`),self:new RegExp(`]*\\baction="(?:replace|update)"[^>]*\\btarget="${$}"[^>]*>([\\s\\S]*?)`)}}#$(X){return X.closest('[data-controller~="reactive"]')===this.element}#X(){if(this.element.querySelectorAll('[data-controller~="reactive"]').length===0)return()=>!0;return(Z)=>this.#$(Z)}#p(X,Z){if(X)return X;if(!Z)return null;let $=Z;if(typeof Z==="string")try{$=JSON.parse(Z)}catch{return console.warn(`[phlex-reactive] malformed conditional confirm payload ${JSON.stringify(Z)} — skipped`),null}if(!$||typeof $!=="object")return null;let{fields:Q}=this.#WX(),j=(q)=>Q[q],z;if(typeof $.predicate==="string"){let q=LX($.predicate);if(!q)return console.warn(`[phlex-reactive] confirm predicate "${$.predicate}" is not registered — proceeding without a dialog (register it with setConfirmPredicate)`),null;z=!!q(Q)}else z=P($.groups?.any,j)===!0;return z?$.message:null}#WX(){let X={},Z=[],$=this.#X();return this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((Q)=>{if(!$(Q))return;if(Q.type==="file")for(let j of Q.files??[])Z.push({name:Q.name,file:j,multiple:Q.multiple});else if(Q.type==="checkbox")X[Q.name]=Q.checked;else if(Q.type==="radio"){if(Q.checked)X[Q.name]=Q.value}else X[Q.name]=Q.value}),this.element.querySelectorAll("[name]:is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])").forEach((Q)=>{if(!$(Q))return;let j=Q.getAttribute("name");if(!j)return;let z=X[j];if(z==null||z==="")X[j]=Q.value??Q.textContent??Q.innerHTML??""}),{fields:X,files:Z}}#u(){if(typeof this.element?.querySelectorAll!=="function")return;let X=0;if(this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((Z)=>{if(!this.#$(Z))return;if(Z.type==="file")return;if(this.#rX(Z))Z.setAttribute("data-reactive-dirty","true"),X++;else Z.removeAttribute("data-reactive-dirty")}),X>0)this.element.setAttribute("data-reactive-dirty",String(X));else this.element.removeAttribute("data-reactive-dirty")}#rX(X){if(X.type==="checkbox"||X.type==="radio")return X.checked!==X.defaultChecked;if(X.tag==="select"||X.options)return Array.from(X.options??[]).some((Z)=>Z.selected!==Z.defaultSelected);return X.value!==X.defaultValue}#JX(){let X=this.element.getAttribute?.("data-reactive-dirty"),Z=Number(X);return Number.isFinite(Z)&&Z>0?Z:0}#tX(){if(typeof window>"u"||typeof window.addEventListener!=="function")return;this.#L=(X)=>{if(this.#JX()===0)return;return X.preventDefault(),X.returnValue="You have unsaved changes.",X.returnValue},this.#V=(X)=>{if(this.#JX()===0)return;if(!(typeof window.confirm==="function"?window.confirm("You have unsaved changes. Leave anyway?"):!0))X.preventDefault?.()},window.addEventListener("beforeunload",this.#L),window.addEventListener("turbo:before-visit",this.#V)}#eX(){if(this.#J)this.element.removeEventListener?.("turbo:morph-element",this.#J),this.#J=void 0;if(typeof window<"u"&&typeof window.removeEventListener==="function"){if(this.#L)window.removeEventListener("beforeunload",this.#L);if(this.#V)window.removeEventListener("turbo:before-visit",this.#V)}this.#L=void 0,this.#V=void 0}#XZ(){if(this.element.getAttribute?.("data-reactive-show-targets"))return!0;let X=this.element.querySelectorAll?.(o)??[];for(let Z of X)if(this.#$(Z))return!0;return!1}#ZZ(){return!!this.element.getAttribute?.("data-reactive-on-complete")}#$Z(){let X=this.element.getAttribute?.("data-reactive-on-complete")??null;if(X!==this.#a)this.#a=X,this.#w=X==null?[]:eX(X),this.#h=this.#w.map(()=>!1);return this.#w}#m(X){let Z=this.#$Z();if(!Z.length)return;let $=this.#X(),Q=this.element.getAttribute?.("data-reactive-scope")||null,j=new Map,z=(q)=>{if(!j.has(q))j.set(q,this.#BX(q,$,Q));return j.get(q)};Z.forEach((q,G)=>{let Y=P(q.any,z);if(Y===null)return;let K=Y&&!this.#h[G]&&Boolean(X);if(this.#h[G]=Y,K)N(q.ops,(U)=>this.#T(U.to==null?{...U,to:"@root"}:U))})}#QZ(){if(!this.#z)return;this.element.removeEventListener?.("input",this.#z),this.element.removeEventListener?.("change",this.#z),this.element.removeEventListener?.("turbo:morph-element",this.#b)}#LX(){if(typeof this.element?.querySelectorAll!=="function")return;let X=this.#X(),Z=this.element.getAttribute?.("data-reactive-scope")||null,$=new Map,Q=(j)=>{if(!$.has(j))$.set(j,this.#BX(j,X,Z));return $.get(j)};for(let j of this.element.querySelectorAll(o)){if(!X(j))continue;let z=j.getAttribute("data-reactive-show");if(z!==null){let K=ZZ(tX(z),Q);if(K!==null)this.#VX(j,K,X,Z);continue}let q=j.getAttribute("data-reactive-show-field");if(!q)continue;let G=Q(q);if(G===null)continue;let Y=nX(j,G);if(Y===null)continue;this.#VX(j,Y,X,Z)}this.#jZ(Q)}#VX(X,Z,$,Q){if(X.hidden=!Z,X.getAttribute("data-reactive-show-disable")!=="true")return;if(typeof X.querySelectorAll!=="function")return;for(let j of X.querySelectorAll("input[name], select[name], textarea[name]"))if($(j))j.disabled=!Z;if(X.name&&$(X))X.disabled=!Z}#jZ(X){let Z=this.#qZ();for(let[$,Q]of Object.entries(Z)){if($.startsWith("#")){this.#zZ($,Q,X);continue}if(!Q||typeof Q!=="object"||Array.isArray(Q))continue;let j=X($);if(j===null)continue;let z=()=>j;for(let[q,G]of Object.entries(Q)){if(!s(q))continue;let Y;if(Array.isArray(G)){if(G.length===0){console.warn(`[phlex-reactive] malformed reactive_show_targets group for ${q} — skipped`);continue}Y=G.every((K)=>g(K,z))}else{let K=HX(G,j);if(K===null){console.warn(`[phlex-reactive] malformed reactive_show_targets predicate for ${q} — skipped`);continue}Y=K}for(let K of document.querySelectorAll(q))K.hidden=!Y}}}#zZ(X,Z,$){if(!s(X))return;let Q=Z&&typeof Z==="object"&&!Array.isArray(Z)?Z.any:null,j=XZ(Q);if(j===null){console.warn(`[phlex-reactive] malformed reactive_show_targets conditions for ${X} — skipped`);return}if(j.every((q)=>$(q)===null))return;let z=P(Q,$);if(z===null)return;for(let q of document.querySelectorAll(X))q.hidden=!z}#qZ(){let X=this.element.getAttribute?.("data-reactive-show-targets");if(!X)return{};try{let Z=JSON.parse(X);if(Z&&typeof Z==="object"&&!Array.isArray(Z))return Z}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: { ... })"),{}}#BX(X,Z,$){let Q=$&&!X.includes("[")?`${$}[${X}]`:X,j=!1,z=null;for(let q of this.element.querySelectorAll(`[name="${Q}"]`)){if(!Z(q))continue;if(q.type==="checkbox")return q.checked?"true":"false";if(q.type==="radio"){if(q.checked)return q.value??"";j=!0;continue}z??=q}if(z)return z.value??"";return j?"":null}#GZ(){if(!this.#Z)return;this.element.removeEventListener?.("input",this.#Z),this.element.removeEventListener?.("change",this.#Z),this.element.removeEventListener?.("turbo:morph-element",this.#Z),this.#Z=void 0}#C(){return!!(this.element.getAttribute?.("data-reactive-filter-input")&&this.element.getAttribute?.("data-reactive-filter-option"))}#YZ(){return this.element.getAttribute?.("data-reactive-compute-seed")==="true"}#KZ(X){let Z=this.element.getAttribute("data-reactive-filter-input");return!!Z&&typeof X.target?.matches==="function"&&X.target.matches(Z)}#A(){if(typeof this.element?.querySelectorAll!=="function")return;let X=this.element.getAttribute("data-reactive-filter-input"),Z=this.element.getAttribute("data-reactive-filter-option");if(!X||!Z)return;let $=this.#X(),Q=[...this.element.querySelectorAll(X)].find($);if(!Q)return;let j=(Q.value??"").trim().toLowerCase(),z=0;for(let Y of this.element.querySelectorAll(Z)){if(!$(Y))continue;let K=(Y.getAttribute("data-reactive-filter-text")??Y.textContent??"").toLowerCase(),U=Y.hasAttribute?.("data-reactive-tags-selected")||j!==""&&!K.includes(j);if(Y.hidden=U,U)Y.removeAttribute("data-reactive-highlighted");else z++}let q=this.element.getAttribute("data-reactive-filter-group");if(q)for(let Y of this.element.querySelectorAll(q)){if(!$(Y))continue;let K=[...Y.querySelectorAll(Z)].filter($);if(K.length===0)continue;Y.hidden=K.every((U)=>U.hidden)}let G=this.element.getAttribute("data-reactive-filter-empty");if(G){for(let Y of this.element.querySelectorAll(G))if($(Y))Y.hidden=z>0}}#HZ(){if(!this.#Q)return;this.element.removeEventListener?.("input",this.#Q),this.element.removeEventListener?.("turbo:morph-element",this.#Q),this.#Q=void 0}#I(){return!!this.element.getAttribute?.("data-reactive-tags-field")}#UZ(){if(typeof this.element?.querySelector!=="function")return!1;return!!this.element.querySelector("[data-reactive-nested-json]")}#g(){if(typeof this.element?.querySelectorAll!=="function")return null;let X=this.element.getAttribute("data-reactive-tags-field");if(!X)return null;let Z=this.#X();return[...this.element.querySelectorAll(X)].find(Z)??null}#c(X){let Z=new Set,$=[];for(let Q of String(X.value??"").split(",")){let j=Q.trim();if(j===""||Z.has(j.toLowerCase()))continue;Z.add(j.toLowerCase()),$.push(j)}return $}#_X(X){let Z=this.#g();if(!Z)return!1;let $=this.#c(Z),Q=new Set($.map((z)=>z.toLowerCase())),j=!1;for(let z of X){let q=String(z??"").trim();if(q===""||Q.has(q.toLowerCase()))continue;Q.add(q.toLowerCase()),$.push(q),j=!0}if(j)this.#MX(Z,$);return j}#MX(X,Z){if(X.value=Z.join(","),typeof X.dispatchEvent==="function")X.dispatchEvent(new Event("input",{bubbles:!0}));this.#d()}#WZ(){if(typeof this.element?.querySelectorAll!=="function")return null;let X=this.element.getAttribute("data-reactive-filter-input");if(!X)return null;let Z=this.#X();return[...this.element.querySelectorAll(X)].find(Z)??null}#d(){let X=this.#g();if(!X)return;let Z=this.#c(X),$=this.#X();this.#JZ(Z,$),this.#LZ(Z,$)}#JZ(X,Z){let $=[...this.element.querySelectorAll("[data-reactive-tags-list]")].find(Z);if(!$)return;let j=[...this.element.querySelectorAll("[data-reactive-tags-template]")].find(Z)?.content?.firstElementChild;if(!j){if(!this.#r)console.warn("[phlex-reactive] reactive_tags: no chip