phlex-reactive 0.12.3 → 0.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d6ffd3dcf058d459bb58de296bf1d5cb9dd0808164d35c71ffbf60f437e0301
4
- data.tar.gz: 11fc37b314bb89a87fa45db4c34328e2976dc82e00106383328b0fb18dd16097
3
+ metadata.gz: 735a21537bb98a3b89ba46f21fe40630ee413191e5509db2fe7895172e6b92b7
4
+ data.tar.gz: 2110972a68b3ff0c336255afe15674167d9012201ebe80479a5c84fff922e682
5
5
  SHA512:
6
- metadata.gz: 1625e8fb285dc9ca91d73609896452ff5998a6fcb90eca3e3f7ef98a1397db764a966a161f81db9795b0489afd193035a62255bf7223c72271515573e10ecd32
7
- data.tar.gz: 6c1448c90d1aad7e1814938b363c28108b924a20ef7bb7a8a4f67b1cfaa2706f62cc3724a43f72aa7aedeb28da2aaf177924268972dd965b261a305381244529
6
+ metadata.gz: 7f6c98822ac5eabbcfdd39b6bdbbbd50176e6d4d5735b5eb0189321ac26e0b559ccc7f8ec60c9394492e23e53eb7e77b79c89c0298f1c962b2115961f8cbfc48
7
+ data.tar.gz: 4b7dc6d779d5f250592387b3cb9615b4d9d37a5994c0b03e3b83ad1e740251e9875b06735b79a02e304ba0fe53b659220950c47906d0805d0128e81b84231966
data/CHANGELOG.md CHANGED
@@ -8,6 +8,37 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **`js.paste_into(selector)` — the clipboard-source trigger (#228).** A field
12
+ whose real `<input>` is visually hidden (an OTP cell UI painted by a
13
+ `reactive_compute` reducer) has no mouse-reachable paste path: right-click →
14
+ Paste targets the decorative cells, never the off-screen input. One declared
15
+ line — `button(hidden: true, **on_client(:click, js.paste_into("[name=code]")))
16
+ { "Paste code" }` — replaces the bespoke Stimulus controller:
17
+ - **The op** (the one async, value-reading member of the vocabulary): on the
18
+ user's gesture it starts `navigator.clipboard.readText()` fire-and-forget
19
+ (the browser's own permission UX — Chromium prompts, Safari shows its
20
+ paste pill) and, when the read resolves, feeds the text through the
21
+ **normal `input` pipeline** — set `.value`,
22
+ dispatch a bubbling `input` (reducers / `reactive_show` /
23
+ `reactive_on_complete` run exactly as if typed), then focus the field so a
24
+ partial paste continues from the caret. A denied/dismissed read, empty
25
+ text, or a missing API is a **silent no-op**; the op is fire-and-forget,
26
+ so chained siblings never wait.
27
+ - **Availability gating**: `on_client` marks a paste trigger with
28
+ `data-reactive-clipboard`; on connect (and every `turbo:morph-element`)
29
+ the controller sets `hidden = !available` on owned markers. Author the
30
+ trigger `hidden` and it is revealed only where the Async Clipboard API
31
+ exists — a dead button never shows in insecure contexts or webviews.
32
+ - **Actor-only, default-deny**: `paste_into` joins `focus`/`focus_first`/
33
+ `submit` in `BROADCAST_REFUSED_OPS` — `broadcast_to(js:)` raises
34
+ (a broadcast that reads every subscriber's clipboard would be hostile);
35
+ `reply.js` and gesture paths remain allowed.
36
+ - Dummy flagship: the `VerificationCodeComponent` OTP field gains the
37
+ hidden "Paste code" trigger — paste drives the otp reducer's normalize +
38
+ `$ops` auto-submit end to end, with a real-browser system spec covering
39
+ reveal-on-connect, dirty-paste auto-commit, partial-paste focus, and the
40
+ denied-read no-op.
41
+
11
42
  - **Reducer-emitted client ops (`$ops`), a `submit` op, and declarative
12
43
  `reactive_on_complete` (#226).** The "normalize on input, commit when
13
44
  complete" field (a one-time-code entry being the canonical case) is now
data/README.md CHANGED
@@ -385,7 +385,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
385
385
  | `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). |
386
386
  | `on(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). |
387
387
  | `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). |
388
- | `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. |
388
+ | `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. |
389
389
  | `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)) { … }`. |
390
390
  | `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). |
391
391
  | `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
863
863
  `broadcast_to(js:)` (a broadcast would force-submit every subscriber's form).
864
864
  Binding a submit op to the `submit` event itself raises at render — it would
865
865
  re-fire itself forever.
866
+ - **`paste_into(to)`** reads the clipboard into a field on a **user gesture**
867
+ (issue #228): `navigator.clipboard.readText()`, then the field gets the text
868
+ through the **normal `input` pipeline** — `.value` is set, a bubbling `input`
869
+ event fires (compute reducers, `reactive_show`, `reactive_on_complete` all run
870
+ exactly as if the user had typed), and the field is focused so a partial paste
871
+ continues from the caret. Built for fields whose real `<input>` is visually
872
+ hidden (an OTP cell UI), where right-click → Paste can't reach the editable
873
+ input. The permission UX is the **browser's own** (Chromium prompts, Safari
874
+ shows its paste pill); a denied/dismissed read, empty text, or a missing API
875
+ is a **silent no-op**. `on_client` marks the trigger with
876
+ `data-reactive-clipboard` and the controller sets `hidden = !available` on
877
+ connect — author the trigger `hidden` and it is revealed only where the
878
+ clipboard API exists, so a dead button never shows. The gate **owns** the
879
+ trigger's `hidden` flag: render the trigger unconditionally and don't also
880
+ bind `reactive_show` to it (the two passes would fight over the same
881
+ attribute). **Actor-only like focus/submit**: refused in `broadcast_to(js:)`
882
+ (a broadcast that reads every subscriber's clipboard would be hostile). The
883
+ op is async fire-and-forget — chained siblings apply immediately, never
884
+ waiting for the read.
866
885
  - **`transition: { during:, from:, to: }`** on `show`/`hide`/`toggle` animates the
867
886
  visibility flip: `during`+`from` are applied, then `from`→`to` swaps on the next
868
887
  frame, and the helper classes are cleaned up on `animationend` (with a timeout
@@ -1136,7 +1155,9 @@ setComputeReducer("preview", ({ title }) => ({
1136
1155
  write fields and text; the reserved **`$ops`** key lets it emit a **conditional
1137
1156
  side effect** — the missing piece that used to force a bespoke controller next
1138
1157
  to an otherwise-declarative compute. Return an op chain (the `ops` builder
1139
- mirrors the Ruby `js` verbs, or use a raw `[[op, args], …]` array) and the
1158
+ mirrors the Ruby `js` verbs minus `paste_into`, deliberately: a reducer runs
1159
+ on every input event, and a clipboard read per keystroke would spam permission
1160
+ prompts — or use a raw `[[op, args], …]` array) and the
1140
1161
  controller runs it through the **same frozen op whitelist** `on_client` uses,
1141
1162
  as a final phase **after** the field writes, text sinks, and their dispatched
1142
1163
  `input` events settle. The canonical one-time-code field:
@@ -1977,10 +1998,12 @@ Notifications::Badge.broadcast_to(user, :alerts,
1977
1998
  js: js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
1978
1999
  ```
1979
2000
 
1980
- `broadcast_to` with `js:` **refuses focus-class ops** (`focus`/`focus_first` raise
1981
- `ArgumentError`): broadcasting focus would steal it in every subscriber's tab, so
1982
- focus is an actor-reply concern only. Everything else is a fair broadcast (class
1983
- and attribute toggles, `dispatch`). As with `on_client`, the ops are
2001
+ `broadcast_to` with `js:` **refuses the actor-only ops** (`focus`/`focus_first`/
2002
+ `submit`/`paste_into` raise `ArgumentError`): broadcasting focus would steal it
2003
+ in every subscriber's tab, a broadcast submit would force-submit every
2004
+ subscriber's form, and a broadcast clipboard read would be hostile these
2005
+ belong to the actor's own reply or gesture. Everything else is a fair broadcast
2006
+ (class and attribute toggles, `text`, `dispatch`). As with `on_client`, the ops are
1984
2007
  whitelist-interpreted client-side — an unknown op warns and is skipped — and the
1985
2008
  ops attribute is HTML-escaped, so a value can't break out of it. `reactive:js`
1986
2009
  is not a self-render: it never counts toward the token refresh, so the reply's
@@ -103,6 +103,11 @@
103
103
  // event fires, so an on(:verify, event: "submit") interception (or a native/
104
104
  // Turbo form) handles it exactly like a user submit. submit/focus are
105
105
  // ACTOR-ONLY: usable here and in on_client/reply.js, refused in broadcasts.
106
+ // paste_into (issue #228) is actor-only too but DELIBERATELY absent from this
107
+ // builder: a reducer runs on every input event, and a clipboard read per
108
+ // keystroke (each changed chain fires) would spam permission prompts. Use it
109
+ // from on_client / reply.js / reactive_on_complete instead; a raw
110
+ // [["paste_into", …]] pair still interprets if you truly need it.
106
111
 
107
112
  const reducers = new Map()
108
113
 
@@ -127,7 +132,8 @@ export function __resetComputeRegistryForTest() {
127
132
 
128
133
  // --- The reducer-side op-chain builder (issue #226) --------------------------
129
134
  //
130
- // A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder: verbs carry
135
+ // A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder (minus
136
+ // paste_into — see the actor-only note above): verbs carry
131
137
  // the WIRE op names (snake_case) and append [name, args] pairs; every verb
132
138
  // returns a NEW instance, so a chain held in a constant can never be mutated by
133
139
  // later use. `.ops` exposes the raw [[name, args], ...] list the controller
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["compute.js"],
4
4
  "sourcesContent": [
5
- "// 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=\"<name>\"] 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<string, number>, meta: { changed: string | null })\n// => Record<string, unknown>.\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"
5
+ "// 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=\"<name>\"] 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<string, number>, meta: { changed: string | null })\n// => Record<string, unknown>.\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"
6
6
  ],
7
- "mappings": "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",
7
+ "mappings": "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",
8
8
  "debugId": "3CB48262B60C70DD64756E2164756E21",
9
9
  "names": []
10
10
  }
@@ -1047,9 +1047,11 @@ function runTransition(el, transition, flip) {
1047
1047
  // Phlex::Reactive::JS's vocabulary; an op name not in this map is
1048
1048
  // warn-and-skipped by #applyOps (client-side default-deny — a stale or newer
1049
1049
  // ops attr must never break the page). Each op is a pure, local DOM mutation:
1050
- // nothing is read back, nothing is sent anywhere. Frozen so nothing can be
1051
- // registered into it at runtime extending the vocabulary is a gem change,
1052
- // not an app hook.
1050
+ // nothing is sent anywhere, and nothing is read back with ONE deliberate
1051
+ // exception, paste_into (issue #228), which reads the clipboard behind the
1052
+ // browser's own gesture + permission gates and still only writes locally.
1053
+ // Frozen so nothing can be registered into it at runtime — extending the
1054
+ // vocabulary is a gem change, not an app hook.
1053
1055
  const CLIENT_OPS = Object.freeze({
1054
1056
  show: (el, args) => setHidden(el, false, args),
1055
1057
  hide: (el, args) => setHidden(el, true, args),
@@ -1099,6 +1101,24 @@ const CLIENT_OPS = Object.freeze({
1099
1101
  // exactly like a user submit. No form → no-op. ACTOR-ONLY like focus: the
1100
1102
  // broadcast builder refuses it server-side (BROADCAST_REFUSED_OPS).
1101
1103
  submit: (el) => submitFormFor(el)?.requestSubmit?.(),
1104
+
1105
+ // Clipboard-source paste (issue #228): on a user gesture, read
1106
+ // navigator.clipboard.readText() and feed the text into the target field
1107
+ // through the normal input pipeline — exactly what a native Cmd/Ctrl+V does.
1108
+ // The ONE op that reads a browser API and is async: fire-and-forget, so
1109
+ // applyOps stays sync and chain siblings never wait (the runTransition
1110
+ // posture). A rejected/dismissed read, empty text, or a missing API is a
1111
+ // SILENT no-op — page state must not change. ACTOR-ONLY like focus/submit:
1112
+ // the broadcast builder refuses it server-side (BROADCAST_REFUSED_OPS) —
1113
+ // and that server gate is the REAL one. The browser only partially backs it
1114
+ // up: Safari gates every read on a fresh gesture and Firefox shows its
1115
+ // paste picker per read, but Chromium's clipboard-read is a PERSISTENT
1116
+ // per-origin permission — once granted (the legit paste button itself
1117
+ // induces that), readText() succeeds with no gesture. No client-side
1118
+ // refusal is possible here: the reactive:js interpreter cannot distinguish
1119
+ // an actor reply's stream from a broadcast's, and reply.js legitimately
1120
+ // carries this op.
1121
+ paste_into: (el) => pasteClipboardInto(el),
1102
1122
  })
1103
1123
 
1104
1124
  // The form a submit op commits (issue #226), in order: the target itself when
@@ -1111,6 +1131,32 @@ function submitFormFor(el) {
1111
1131
  return el?.form ?? el?.closest?.("form") ?? null
1112
1132
  }
1113
1133
 
1134
+ // Read the clipboard into a field (issue #228) — the body of the paste_into
1135
+ // op. The write mirrors a native paste: set .value, dispatch a bubbling
1136
+ // `input` event (the set-value + dispatch contract, issue #183 — compute
1137
+ // reducers, show bindings, and on_complete all run exactly as if the user
1138
+ // had typed), then focus (the caret lands where the user continues typing on
1139
+ // a partial paste). Availability-guarded: insecure contexts and some
1140
+ // webviews have no navigator.clipboard — the connect()-time gate hides
1141
+ // marked triggers there, so this guard is belt-and-braces. Empty text is a
1142
+ // no-op: "paste nothing" must not clear a half-typed field.
1143
+ function pasteClipboardInto(field) {
1144
+ const clipboard = globalThis.navigator?.clipboard
1145
+ if (typeof clipboard?.readText !== "function") return
1146
+ clipboard
1147
+ .readText()
1148
+ .then((text) => {
1149
+ if (!text) return
1150
+ field.value = text
1151
+ if (typeof field.dispatchEvent === "function") field.dispatchEvent(new Event("input", { bubbles: true }))
1152
+ field.focus?.()
1153
+ })
1154
+ .catch(() => {
1155
+ // Permission denied or the prompt dismissed — the browser's own UX said
1156
+ // no. The issue-#228 contract: a silent no-op, never an error.
1157
+ })
1158
+ }
1159
+
1114
1160
  // Apply a hidden-flag change, optionally animated by a [during, from, to]
1115
1161
  // transition (issue #96). Split out so show/hide/toggle share it.
1116
1162
  function setHidden(el, hidden, args) {
@@ -1554,6 +1600,9 @@ export default class extends Controller {
1554
1600
  // Lazy initial mount (issue #165): the bound re-probe attached to
1555
1601
  // turbo:morph-element so a Turbo page-refresh morph re-fires the defer fetch.
1556
1602
  #boundProbeLazyDefer
1603
+ // Clipboard-trigger availability gate (issue #228): the bound morph re-sync,
1604
+ // held for teardown.
1605
+ #boundSyncClipboard
1557
1606
 
1558
1607
  // Mark that a reactive controller actually connected, so the registration
1559
1608
  // guard above knows the controller was registered (issue #26 part 2).
@@ -1735,6 +1784,24 @@ export default class extends Controller {
1735
1784
  this.element.addEventListener?.("turbo:morph-element", this.#boundSeedCompute)
1736
1785
  this.recompute()
1737
1786
  }
1787
+
1788
+ // Clipboard-trigger availability gate (issue #228) — ONLY when this root
1789
+ // owns a paste trigger (on_client marks one with data-reactive-clipboard),
1790
+ // so every other component pays a single probe (the show/filter/tags gate
1791
+ // precedent). The Async Clipboard API is absent in insecure contexts and
1792
+ // some webviews; a paste button that can never work must not show. The
1793
+ // gate OWNS a marked trigger's `hidden` flag: author the trigger `hidden`
1794
+ // and this pass reveals it where the API exists (a dead button never
1795
+ // paints); turbo:morph-element re-syncs because a morph rewrites the
1796
+ // trigger back to its authored hidden state. Like every sibling gate the
1797
+ // decision is made ONCE at connect — render the paste trigger
1798
+ // unconditionally: a trigger first INTRODUCED by a later morph stays
1799
+ // ungated (hidden) until a full replace re-connects the controller.
1800
+ if (this.#clipboardGateEnabled()) {
1801
+ this.#boundSyncClipboard = () => this.#syncClipboardTriggers()
1802
+ this.element.addEventListener?.("turbo:morph-element", this.#boundSyncClipboard)
1803
+ this.#syncClipboardTriggers()
1804
+ }
1738
1805
  }
1739
1806
 
1740
1807
  // Whether this root opts into dirty tracking (issue #103): track_dirty: puts the
@@ -1765,6 +1832,7 @@ export default class extends Controller {
1765
1832
  this.#teardownTagsSync()
1766
1833
  this.#teardownNestedJsonSync()
1767
1834
  this.#teardownComputeSeed()
1835
+ this.#teardownClipboardGate()
1768
1836
  if (this.#boundProbeLazyDefer) {
1769
1837
  this.element.removeEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
1770
1838
  }
@@ -3551,6 +3619,40 @@ export default class extends Controller {
3551
3619
  return !!this.element.getAttribute?.("data-reactive-on-complete")
3552
3620
  }
3553
3621
 
3622
+ // Whether this root owns a clipboard-marked paste trigger (issue #228) —
3623
+ // the connect() gate. The ROOT itself counts (a button-only component that
3624
+ // mixes on_client(paste_into) onto reactive_root — the #dirtyTrackingEnabled
3625
+ // root-then-descendants precedent), then one scoped query; a NESTED root's
3626
+ // triggers are its own controller's to gate (issue #15 ownership).
3627
+ #clipboardGateEnabled() {
3628
+ if (this.element.getAttribute?.("data-reactive-clipboard")) return true
3629
+ const nodes = this.element.querySelectorAll?.("[data-reactive-clipboard]") ?? []
3630
+ for (const el of nodes) if (this.#ownsField(el)) return true
3631
+ return false
3632
+ }
3633
+
3634
+ // Set every owned paste trigger's `hidden` from clipboard availability
3635
+ // (issue #228): available → revealed (the authored `hidden` was only the
3636
+ // no-dead-button first paint), missing → hidden (insecure context /
3637
+ // webview). The gate owns the flag on MARKED elements only — nothing else
3638
+ // is ever touched. A marked ROOT is gated too: when the component IS the
3639
+ // paste button, hiding the root is exactly "the dead button never shows".
3640
+ #syncClipboardTriggers() {
3641
+ const available = typeof globalThis.navigator?.clipboard?.readText === "function"
3642
+ if (this.element.getAttribute?.("data-reactive-clipboard")) this.element.hidden = !available
3643
+ for (const el of this.element.querySelectorAll?.("[data-reactive-clipboard]") ?? []) {
3644
+ if (this.#ownsField(el)) el.hidden = !available
3645
+ }
3646
+ }
3647
+
3648
+ // Remove the clipboard gate's morph listener on disconnect, so a stray
3649
+ // morph event after a Turbo navigation never re-syncs a detached root.
3650
+ #teardownClipboardGate() {
3651
+ if (!this.#boundSyncClipboard) return
3652
+ this.element.removeEventListener?.("turbo:morph-element", this.#boundSyncClipboard)
3653
+ this.#boundSyncClipboard = undefined
3654
+ }
3655
+
3554
3656
  // Parse-and-memoize the completion bindings, keyed on the RAW attr string:
3555
3657
  // a morph that rewrote the payload re-parses and RESETS the latches (the
3556
3658
  // morph listener's own arm pass then re-arms without firing). A removed