phlex-reactive 0.12.1 → 0.12.3

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: f7be3334f1e2939d4307bb9ac8e490cf81d0d5796abf830a98ab409a5b36c7a7
4
- data.tar.gz: ed1bb9d32d29f10a878cfb0a9762991ee057127c66206117e5207ef4a36f5890
3
+ metadata.gz: 8d6ffd3dcf058d459bb58de296bf1d5cb9dd0808164d35c71ffbf60f437e0301
4
+ data.tar.gz: 11fc37b314bb89a87fa45db4c34328e2976dc82e00106383328b0fb18dd16097
5
5
  SHA512:
6
- metadata.gz: 15504e9fbbb4b77cfbe445cc4e20577975358e4ec9f5dfe338f1e28bdf1b280606d877235cd2c2780f3345afd0e553936d325bd4c8bf8a993052de75c3c0ac79
7
- data.tar.gz: 7d224d44d5165252e626ab8515c881f0cc2e0f992ff459c710cd31758272d4c1d486d2ed217f1343ecc4e7c9bd201bdb737fdd53d674e48b1e3ada366141da1d
6
+ metadata.gz: 1625e8fb285dc9ca91d73609896452ff5998a6fcb90eca3e3f7ef98a1397db764a966a161f81db9795b0489afd193035a62255bf7223c72271515573e10ecd32
7
+ data.tar.gz: 6c1448c90d1aad7e1814938b363c28108b924a20ef7bb7a8a4f67b1cfaa2706f62cc3724a43f72aa7aedeb28da2aaf177924268972dd965b261a305381244529
data/CHANGELOG.md CHANGED
@@ -8,6 +8,70 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **Reducer-emitted client ops (`$ops`), a `submit` op, and declarative
12
+ `reactive_on_complete` (#226).** The "normalize on input, commit when
13
+ complete" field (a one-time-code entry being the canonical case) is now
14
+ declarable end to end, single- or multi-input, with no bespoke controller:
15
+ - **`js.submit(to = :root)`** joins the op vocabulary: requestSubmit the
16
+ target's own form (the target itself when it *is* a form, `input.form`
17
+ for a control, else `closest("form")`). A real cancelable `submit` event
18
+ fires, so it composes with native/Turbo forms AND
19
+ `on(:save, event: "submit")` interception —
20
+ `select(**on_client(:change, js.submit("form")))` is the one-line general
21
+ autosubmit. **Actor-only like focus**: refused in `broadcast_to(js:)`
22
+ (allowed in `reply.js`); `on_client(:submit, js.submit)` raises (it would
23
+ re-fire itself).
24
+ - **The reserved `$ops` reducer output**: a `reactive_compute` reducer may
25
+ return `$ops` holding an op chain — built with the new immutable `ops`
26
+ builder exported from `phlex/reactive/compute` (verbs mirror the wire op
27
+ names) or a raw `[[op, args], …]` list — run through the same frozen
28
+ client-op whitelist as a final phase after the write set settles.
29
+ **Rising-edge, keyed on chain content, event-gated**: an identical chain
30
+ never re-fires (a capped 7th keystroke can't re-submit), a changed chain
31
+ fires again (per-digit focus advance across split boxes), and the
32
+ connect/morph seed pass arms without firing (a validation-error re-render
33
+ with a complete value never self-submits).
34
+ - **`{ length: … }` in the ONE conditions language**: exact
35
+ (`{ length: 6 }`) or Integer-Range length predicates for
36
+ `reactive_show` / `reactive_show_targets` / `reactive_on_complete`,
37
+ counted in **codepoints** on both sides (Ruby `String#length`, client
38
+ `[...value].length`) — the shared parity fixture gains a multibyte proof
39
+ vector.
40
+ - **`reactive_on_complete`** — the zero-JavaScript declarative twin: the
41
+ same `if:`/`if_any:`/`unless:` kwargs as `reactive_show` plus `run:` (a
42
+ `js` chain, now buildable at class level, or an allowlist-checked raw
43
+ list), emitted as one root attr and evaluated by the generic controller
44
+ with the same rising-edge/arming semantics as `$ops`.
45
+ `reactive_on_complete if: { code: { length: 6 } },
46
+ run: js.dispatch("code:complete")` + `on(:verify, event: "code:complete")`
47
+ is a complete auto-committing code field with no reducer at all.
48
+ - Dummy flagships: `VerificationCodeComponent` (single input + `$ops`
49
+ submit), `SplitCodeComponent` (six boxes: paste redistribution,
50
+ reducer-driven focus advance, hidden joined-code output),
51
+ `CodeCompleteComponent` (declarative, zero JS), and the
52
+ `AutosubmitFilterComponent` select-driven GET filter — each with a
53
+ real-browser system spec.
54
+
55
+ - **Instance-dynamic wire names — keyword escape hatches on the
56
+ field-compiling helpers (#224).** A form builder's wire name is computed per
57
+ instance (`user[tags]`), which the class-level `reactive_scope` compile can't
58
+ express — the gap that forced phlex-forms' `tag_field` draft
59
+ (mhenrixon/phlex-forms#6) onto raw data attrs. Every helper that compiles a
60
+ field name now takes a verbatim keyword escape hatch (never re-scoped,
61
+ validated at render, mutually exclusive with the blessed field form):
62
+ `reactive_tags(name: "user[tags]")`, `reactive_filter(input: "#tags_query")`
63
+ (a raw CSS selector — the deliberately **name-less** query input inside a
64
+ real form, targeted by id, so it can never submit a stray param),
65
+ `nested_field_name(:items, :qty, scope: "order")` (a per-call parent prefix
66
+ that wins over `reactive_scope`), and
67
+ `reactive_nested_list(:items, as: :json, name: "order[items]")` (the hidden
68
+ JSON sync field, JSON mode only). Server-side sugar only — the client already
69
+ resolves these attributes as arbitrary root-scoped selectors; the shipped
70
+ wire for existing calls is byte-identical. Note: `reactive_filter(input:)`
71
+ deliberately re-blesses the kwarg removed in #186 — the field form stays the
72
+ blessed default, and a pre-0.10 `input:`/`option:` call shape is valid again
73
+ with identical semantics instead of raising the removal error.
74
+
11
75
  - **Project board — the kanban flagship demo (#216).** A live three-lane board
12
76
  on the docs site (`/docs/example-project-board`, `/demos/project-board`)
13
77
  composing the toolkit end to end: each card is its own nested reactive root
@@ -220,6 +284,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
220
284
 
221
285
  ### Fixed
222
286
 
287
+ - **`rake bench` ran the removed `broadcast_replace_to` API and exited 1.**
288
+ `benchmark/micro/broadcast.rb` migrated to the #185 spellings
289
+ (`broadcast_to(*key, replace:)` / `broadcast_to(each:, replace:)`); the
290
+ transport double is unchanged.
291
+
223
292
  - **`reactive_nested_remove(confirm:)` now interpolates `%{field}` on client-added
224
293
  rows (#222).** A row added in the browser via `reactive_nested_add` is a
225
294
  `cloneNode` of the `<template>`, and the clone path (`#renumberNestedRow` /
data/README.md CHANGED
@@ -390,17 +390,17 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
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). |
392
392
  | `reactive_show_targets(:field, "#id" => value)` | **Cross-root visibility**: the component that owns the field declares which **outside**, id-allowlisted elements it governs (a nav tab, a panel in another pane) — the visibility parallel of `mirror:`. Spread on the **root** via `mix(reactive_root, …)`, **once per root** — several fields go in one call via the hash form. The value uses the same `where`-style vocabulary (`"advanced"`, `%w[a b]`, `10..`); a `"#id"` **key** takes a full conditions Hash for a **multi-field** predicate (`"#warn" => { if: { type: "trade", price: ..0 } }`). Id selectors only (raise at render + client warn-skip); toggles `hidden` only. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
393
- | `reactive_filter(:field, option: nil, group: nil, empty: nil)` | **Client-side option filtering** for a preloaded combobox: spread onto the root and name the **field** that drives it — `reactive_filter(:q)` compiles `:q` to `[name="q"]` (scope-aware) and typing shows/hides the options by their `data-reactive-filter-text` haystack, **zero round trips**. `option:` defaults to `[role=option]`; optional `group:` collapses an all-hidden group header; `empty:` reveals a no-matches node. See [Client-side option filtering](#client-side-option-filtering-reactive_filter). |
393
+ | `reactive_filter(:field, option: nil, group: nil, empty: nil)` | **Client-side option filtering** for a preloaded combobox: spread onto the root and name the **field** that drives it — `reactive_filter(:q)` compiles `:q` to `[name="q"]` (scope-aware) and typing shows/hides the options by their `data-reactive-filter-text` haystack, **zero round trips**. `option:` defaults to `[role=option]`; optional `group:` collapses an all-hidden group header; `empty:` reveals a no-matches node. `input:` is the escape hatch — a raw CSS selector for a **name-less** driving input (`input: "#tags_query"`), the form-builder case. See [Client-side option filtering](#client-side-option-filtering-reactive_filter). |
394
394
  | `reactive_listnav("[role=option]")` | The **standalone** combobox keyboard wiring (Arrow/Enter/Escape) for an input that fires **no action** — the preload-and-filter case. Same behavior as `on(…, listnav:)`, minus the POST. |
395
- | `reactive_tags(:tags)` | **Tag-chip input** (the combobox/tags widget): spread onto the root and name the hidden field that stores the **comma-joined** value — the client maintains that field + the chip list entirely client-side (form state, zero round trips), rebuilding chips from your server-owned `<template>`. Composes with `reactive_filter` (type to narrow) and `reactive_listnav` (Enter picks the highlighted option). See [Tag-chip input](#tag-chip-input-reactive_tags). |
395
+ | `reactive_tags(:tags)` | **Tag-chip input** (the combobox/tags widget): spread onto the root and name the hidden field that stores the **comma-joined** value — the client maintains that field + the chip list entirely client-side (form state, zero round trips), rebuilding chips from your server-owned `<template>`. Composes with `reactive_filter` (type to narrow) and `reactive_listnav` (Enter picks the highlighted option). `name:` is the escape hatch — a **verbatim** wire name (`name: "user[tags]"`, never re-scoped), the form-builder case. See [Tag-chip input](#tag-chip-input-reactive_tags). |
396
396
  | `reactive_tags_add` / `reactive_tags_option(tag)` / `reactive_tags_remove(tag)` | The tags triggers, all **client-only**: `reactive_tags_add` on the query input adds the typed text on Enter (mix it **after** `reactive_listnav`; Enter never submits the enclosing form); `reactive_tags_option` makes a preloaded suggestion add its declared tag on click; `reactive_tags_remove` is a chip's remove button (no arg inside the template — the client fills the tag per chip). |
397
397
  | `reactive_compute :name, inputs: { title: :string, qty: :number }, outputs:` | **Typed** inputs: a `:string` reaches the JS reducer raw, a `:number` is coerced through `Number`. The array form (`inputs: %i[a b]`) stays all-numeric; the **permit-style** form (`inputs: [:qty, title: :string]`) mixes both — bare symbols default `:number`, a trailing hash types the exceptions. `outputs:` is the field allowlist; a reducer-result key also paints any owned `reactive_text` node by presence and any `mirror:` id, so an `outputs:` entry that exists only to reach a text node is redundant (harmless — a widening). |
398
398
  | `reactive_compute :name, ..., mirror: { sum: "#summary-sum" }` | **Cross-root text mirrors**: paint a compute value into declared, id-allowlisted nodes **outside** the reactive root (a recap in another tab pane) via `textContent` — no bespoke listener. See [Cross-root mirrors](#cross-root-mirrors-mirror--painting-a-recap-outside-the-root). |
399
399
  | `reactive_dirty` / `reactive_dirty warn_unsaved: true` / `reactive_dirty only: %i[...]` | **Dirty tracking**, declared once at the class level, against the DOM's own `defaultValue`/`defaultChecked`/`defaultSelected` — no client state. Marks changed fields + the root `data-reactive-dirty`; `warn_unsaved:` arms a `beforeunload`/`turbo:before-visit` guard; `only:` scopes tracking to named fields. Style with `[data-reactive-dirty]`. See [Dirty-field tracking](#dirty-field-tracking-reactive_dirty). |
400
400
  | `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
401
- | `reactive_nested_list(:assoc, as: :attributes \| :json)` / `reactive_nested_template(:assoc)` / `reactive_nested_row` | **Draft nested-attribute rows** (the "new parent + child rows" form): the container rows land in, the `<template>` holding ONE row's markup, and the row wrapper marker — all **client-only** form state, keyed by association (several collections per root). `as: :json` (default `:attributes`) serializes the rows into ONE hidden JSON field instead of posting `accepts_nested_attributes_for` names — for an app whose controller `JSON.parse`s a serialized param. See [Draft rows for a new parent](#draft-rows-for-a-new-parent-reactive_nested_). |
401
+ | `reactive_nested_list(:assoc, as: :attributes \| :json)` / `reactive_nested_template(:assoc)` / `reactive_nested_row` | **Draft nested-attribute rows** (the "new parent + child rows" form): the container rows land in, the `<template>` holding ONE row's markup, and the row wrapper marker — all **client-only** form state, keyed by association (several collections per root). `as: :json` (default `:attributes`) serializes the rows into ONE hidden JSON field instead of posting `accepts_nested_attributes_for` names — for an app whose controller `JSON.parse`s a serialized param; `name:` names that hidden field **verbatim** (`name: "order[todos]"`, never re-scoped — the form-builder escape hatch, JSON mode only). See [Draft rows for a new parent](#draft-rows-for-a-new-parent-reactive_nested_). |
402
402
  | `reactive_nested_add(:assoc, from:, clear:)` / `reactive_nested_remove(confirm:)` | The row triggers, client-only (zero round trips): add clones the template and renumbers its placeholder index; remove deletes a draft row from the DOM, or `_destroy`-marks + hides a persisted row (a hidden `[_destroy]` input present). **Fill-then-add**: `from: { row_field => "#source-selector" }` seeds the new row from add controls that live OUTSIDE it (a preset select, a typeahead), and `clear: true` resets them — composes with `as: :attributes` AND `as: :json`. **Confirm on remove**: `reactive_nested_remove(confirm: "Really delete this row?")` gates the remove behind the same overridable `confirmResolver` as `on`/`on_client` (a per-row string, or the conditional `{ when:, message: }` Hash). See [Draft rows for a new parent](#draft-rows-for-a-new-parent-reactive_nested_). |
403
- | `nested_field_name(:assoc, :field, index: nil)` | The Rails `accepts_nested_attributes_for` wire name for one row field — `order[line_items_attributes][NEW_ROW][quantity]` (the template placeholder) by default, a real index when given. Scope-aware under `reactive_scope`. |
403
+ | `nested_field_name(:assoc, :field, index: nil)` | The Rails `accepts_nested_attributes_for` wire name for one row field — `order[line_items_attributes][NEW_ROW][quantity]` (the template placeholder) by default, a real index when given. Scope-aware under `reactive_scope`; `scope:` overrides it **per call** (`scope: "order"`, verbatim — the form-builder escape hatch). |
404
404
  | `reactive_collection :name, item:, container:, count:, empty:, size:` | Declare an add/remove-row list once; actions call `reply.append`/`prepend`/`remove`. See [Reactive collections](#reactive-collections-addremove-rows--count--empty-state). |
405
405
  | `reply.replace` / `.morph` / `.update` / `.remove` / `.redirect(url)` / `.with(*)` / `.js(ops)` | Return from an action to control the reply (flash, remove, redirect, multi-stream, server-pushed client ops). See [Controlling the action's reply](#reply--controlling-the-actions-reply). |
406
406
  | `reply.append(name, model)` / `.prepend(...)` / `.remove(name, model)` | Add/remove a row in a declared `reactive_collection` (row + count + empty-state in one reply). |
@@ -853,6 +853,16 @@ button(**on_client(:click, js
853
853
  another component or a plain Stimulus controller can react to a client-only
854
854
  interaction — `to:` picks the element (default: the component root), `detail:`
855
855
  is the payload.
856
+ - **`submit(to = :root)`** commits the **target's own form** via
857
+ `requestSubmit()`: the target itself when it *is* a form, its form owner for a
858
+ control (`input.form`, honoring a `form=` attribute), else the nearest ancestor
859
+ form. Constraint validation runs and a **real cancelable `submit` event**
860
+ fires, so it composes with both a native/Turbo form *and* an
861
+ `on(:save, event: "submit")` interception. **Actor-only like focus**: allowed
862
+ from `on_client` / `reply.js` / a reducer's `$ops`, refused in
863
+ `broadcast_to(js:)` (a broadcast would force-submit every subscriber's form).
864
+ Binding a submit op to the `submit` event itself raises at render — it would
865
+ re-fire itself forever.
856
866
  - **`transition: { during:, from:, to: }`** on `show`/`hide`/`toggle` animates the
857
867
  visibility flip: `during`+`from` are applied, then `from`→`to` swaps on the next
858
868
  frame, and the helper classes are cleaned up on `animationend` (with a timeout
@@ -863,6 +873,27 @@ button(**on_client(:click, js
863
873
  modifiers: the dropdown above closes on any click outside the component, and
864
874
  window-bound triggers never `preventDefault`, so links elsewhere keep working.
865
875
 
876
+ **The general autosubmit story.** With `submit` in the vocabulary, the classic
877
+ `onchange="this.form.requestSubmit()"` filter form is one declared line — no
878
+ bespoke controller, no reactive action, and Turbo Drive turns the resulting
879
+ submit into a normal visit:
880
+
881
+ ```ruby
882
+ form(action: "/products", method: "get") do
883
+ select(name: "sort", **mix(on_client(:change, js.submit("form")), data: { testid: "sort" })) do
884
+ option(value: "name") { "Name" }
885
+ option(value: "price") { "Price" }
886
+ end
887
+ end
888
+ ```
889
+
890
+ Use `change`-bound autosubmit for discrete controls (selects, radios,
891
+ checkboxes). For a **text** field that should commit "when the value is
892
+ complete," an unconditional `on_client(:input, js.submit)` would fire on every
893
+ keystroke — that conditional case is exactly what a reducer's
894
+ [`$ops`](#client-side-computes-reactive_compute--reactive_text) and
895
+ [`reactive_on_complete`](#declarative-completion-reactive_on_complete) are for.
896
+
866
897
  **Client ops are ephemeral UI — the one contract to internalize.** Any server
867
898
  re-render of the component (an action reply, a broadcast, a morph) rebuilds
868
899
  from server state and resets whatever the ops toggled: the menu closes, the tab
@@ -958,7 +989,11 @@ end
958
989
  - **The value language**: a **Hash is an AND** (multiple keys ANDed), an
959
990
  **Array is membership**, a **Range is a threshold** (`10..` ≥ 10, `..10` ≤ 10,
960
991
  `...10` < 10, `10..20` between), `true`/`false` compare a checkbox's checked
961
- state, `nil` matches blank. `unless:` **negates** and composes with `if:`.
992
+ state, `nil` matches blank, and **`{ length: }` compares the value's
993
+ length** — exact (`{ length: 6 }`) or an Integer Range (`{ length: 6.. }`,
994
+ `{ length: 4..8 }`). Length counts **codepoints** on both sides (Ruby
995
+ `String#length`, client `[...value].length`), so multibyte input agrees; a
996
+ blank field has length 0. `unless:` **negates** and composes with `if:`.
962
997
  Never an expression — every term is a declared literal, so there is no eval
963
998
  surface. A blank/non-numeric value fails a numeric term **closed** (hidden).
964
999
  - **OR-of-AND** — `if_any:` takes an array of AND-hashes (`if_any: [{ director:
@@ -1097,6 +1132,105 @@ setComputeReducer("preview", ({ title }) => ({
1097
1132
  a later morph repaints stale text — the same reconcile contract the whole
1098
1133
  new-vs-persisted split relies on.
1099
1134
 
1135
+ **Reducer-emitted ops (`$ops`) — commit when complete.** A reducer's outputs
1136
+ write fields and text; the reserved **`$ops`** key lets it emit a **conditional
1137
+ side effect** — the missing piece that used to force a bespoke controller next
1138
+ 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
1140
+ controller runs it through the **same frozen op whitelist** `on_client` uses,
1141
+ as a final phase **after** the field writes, text sinks, and their dispatched
1142
+ `input` events settle. The canonical one-time-code field:
1143
+
1144
+ ```js
1145
+ import { setComputeReducer, ops } from "phlex/reactive/compute"
1146
+
1147
+ setComputeReducer("otp", ({ code }) => {
1148
+ const digits = code.replace(/\D/g, "").slice(0, 6)
1149
+ return { code: digits, $ops: digits.length === 6 ? ops.submit() : null }
1150
+ })
1151
+ ```
1152
+
1153
+ ```ruby
1154
+ form(action: "/verify", method: "post",
1155
+ **mix(reactive_root(compute: :otp), on(:verify, event: "submit"))) do
1156
+ input(name: "code", inputmode: "numeric", autocomplete: "one-time-code")
1157
+ end
1158
+ ```
1159
+
1160
+ Typing, pasting `123-456`, or platform SMS autofill all arrive as `input`
1161
+ events → the reducer normalizes, and at six digits `submit` requestSubmits the
1162
+ form — which `on(:verify, event: "submit")` intercepts into **one signed action
1163
+ POST**. The contract that makes this safe:
1164
+
1165
+ - **Rising edge, keyed on content.** The chain runs only when it **differs**
1166
+ from the previous pass's chain (including from "absent"). Returning the same
1167
+ chain again is settled — a 7th keystroke capped back to the same six digits
1168
+ can't re-submit — while a **different** chain fires again (a multi-box
1169
+ reducer advancing focus emits a new `ops.focus` target per digit). A pass
1170
+ returning `null`/no `$ops` re-arms.
1171
+ - **Event-gated.** The connect/morph **seed pass arms without firing** — a form
1172
+ re-rendered with an already-complete value (a validation-error morph, a
1173
+ browser restore) never auto-fires, which is what breaks the
1174
+ submit → error re-render → re-seed → submit loop.
1175
+ - **Whitelisted.** `$ops` is consumed before the write phases (never painted as
1176
+ a field/text/mirror), and unknown ops warn-and-skip while siblings apply.
1177
+ - **Multi-input works with the same machinery**: declare all boxes as inputs,
1178
+ join + redistribute in the reducer (a paste into any box fans out one digit
1179
+ per box), mirror the joined value into a hidden field, advance focus with a
1180
+ per-digit `ops.focus`, and `ops.submit()` on completion. See
1181
+ `spec/dummy/app/components/split_code_component.rb` for the full six-box
1182
+ example.
1183
+
1184
+ When you *don't* want to auto-submit, the same slot dispatches a completion
1185
+ event (`ops.dispatch("code:complete")`) for a sibling to react to, or enables
1186
+ the submit button (`ops.remove_attr("[type=submit]", "disabled")`) and leaves
1187
+ the commit to the user.
1188
+
1189
+ ### Declarative completion (`reactive_on_complete`)
1190
+
1191
+ The `$ops` escape hatch puts the condition in the reducer; when the condition
1192
+ is expressible in the [conditions language](#value-conditional-visibility-reactive_show),
1193
+ `reactive_on_complete` declares the whole binding in Ruby — **zero JavaScript,
1194
+ no reducer**:
1195
+
1196
+ ```ruby
1197
+ class CodeCompleteComponent < ApplicationComponent
1198
+ include Phlex::Reactive::Streamable
1199
+ include Phlex::Reactive::Component
1200
+
1201
+ reactive_state :code
1202
+ action :verify, params: { code: :string }
1203
+
1204
+ reactive_on_complete if: { code: { length: 6 } }, run: js.dispatch("code:complete")
1205
+
1206
+ def view_template
1207
+ div(**mix(reactive_root, on(:verify, event: "code:complete"))) do
1208
+ input(name: "code")
1209
+ end
1210
+ end
1211
+ end
1212
+ ```
1213
+
1214
+ The generic controller evaluates the conditions over the owned fields on every
1215
+ `input`/`change` (scope-aware, same resolver as `reactive_show`) and runs the
1216
+ declared ops on the **rising edge** — once, when the conditions first become
1217
+ true; going false re-arms; the connect/morph pass arms **without** firing, so a
1218
+ re-render with already-satisfied conditions never self-fires. The pieces:
1219
+
1220
+ - **Conditions** are the same `if:` / `if_any:` / `unless:` kwargs
1221
+ `reactive_show` takes — including the `length:` form above, which is what
1222
+ makes "exactly six characters" declarable.
1223
+ - **`run:`** is a `js` chain (available at class level) or a raw op list
1224
+ (re-checked through the attribute allowlist). `run: js.submit` auto-commits;
1225
+ `run: js.dispatch(...)` lets a sibling `on(:action, event: "...")` turn
1226
+ completion into a signed action, as above.
1227
+ - **Several bindings** coexist under names:
1228
+ `reactive_on_complete :commit, if: …, run: js.submit` — each latches
1229
+ independently; redeclaring a name overrides it (normal registry inheritance).
1230
+ - Prefer `$ops` when completion needs **normalization first** (strip
1231
+ separators, cap length) — the reducer already knows the cleaned value;
1232
+ prefer `reactive_on_complete` when the raw field value is the truth.
1233
+
1100
1234
  ### Cross-root mirrors (`mirror:`) — painting a recap outside the root
1101
1235
 
1102
1236
  `reactive_text` is deliberately **root-isolated** (a nested component's nodes are
@@ -1218,6 +1352,17 @@ stays its own signed `on(:select)` trigger. Only *filtering* is client-side —
1218
1352
  selection still round-trips as a real signed action. Blank selectors raise at
1219
1353
  render: a dead binding must fail loudly, not no-op in the browser.
1220
1354
 
1355
+ **The escape hatch — a name-less input, targeted by id.** Inside a real
1356
+ POST/GET form, a *named* query input submits a stray param alongside your
1357
+ value. A form builder (phlex-forms' `tag_field`) therefore renders the query
1358
+ input with an **id and no `name`** — which the field form can't express. Pass
1359
+ `input:` (a raw CSS selector, verbatim — never re-scoped) instead of the field;
1360
+ exactly one of the two per call:
1361
+
1362
+ ```ruby
1363
+ reactive_filter(input: "#user_tags_query") # the input carries id, NO name
1364
+ ```
1365
+
1221
1366
  ### Tag-chip input (`reactive_tags`)
1222
1367
 
1223
1368
  The composed combobox/tags widget: preload suggestions, type to narrow, Enter or
@@ -1272,7 +1417,23 @@ dispatches a real `input` event on the hidden field, so `reactive_dirty`,
1272
1417
 
1273
1418
  The styled, form-builder-integrated version of this widget (label/errors/
1274
1419
  theming) belongs in your form layer — these helpers are deliberately the
1275
- unstyled primitives, like `reactive_filter` before them.
1420
+ unstyled primitives, like `reactive_filter` before them. That form layer is
1421
+ exactly who needs the **escape hatches**: a form builder's wire name is
1422
+ computed **per instance** (`user[tags]`, from the builder's object name), which
1423
+ the class-level `reactive_scope` compile can't express. `name:` takes the wire
1424
+ name **verbatim** (never re-scoped), and the query input goes name-less,
1425
+ targeted by id via `reactive_filter(input:)` — so a real form submit carries
1426
+ `user[tags]` and nothing else:
1427
+
1428
+ ```ruby
1429
+ div(**mix(reactive_root(id: "user_tags_widget"),
1430
+ reactive_tags(name: "user[tags]"), # verbatim — never re-scoped
1431
+ reactive_filter(input: "#user_tags_query"))) do # id-targeted, name-less input
1432
+ input(type: :hidden, name: "user[tags]", id: "user_tags", value: @tags.join(","))
1433
+ # …chips/template/suggestions as above…
1434
+ input(id: "user_tags_query", type: "search", **mix(reactive_listnav, reactive_tags_add))
1435
+ end
1436
+ ```
1276
1437
 
1277
1438
  ### Draft rows for a new parent (`reactive_nested_*`)
1278
1439
 
@@ -1346,6 +1507,12 @@ it on save. Several collections can share one root (everything is keyed by the
1346
1507
  association name); nesting a collection inside another's template is not
1347
1508
  supported.
1348
1509
 
1510
+ `nested_field_name` is scope-aware (`reactive_scope :order` → the
1511
+ `order[…]` wrap above). When the parent prefix is **per-instance** — a form
1512
+ builder's object name, possibly itself bracketed (`"user[profile]"`) — pass
1513
+ `scope:` per call instead; it's used verbatim and wins over the class-level
1514
+ scope: `nested_field_name(:line_items, :quantity, scope: "order")`.
1515
+
1349
1516
  Two boundaries to respect: the DOM is the single source of truth for unsent
1350
1517
  draft rows, so a **server re-render of the root replaces them** — keep
1351
1518
  replace-shaped actions out of a root holding unsent rows. And once the parent
@@ -1363,6 +1530,9 @@ path exactly as it is:
1363
1530
  div(**reactive_nested_list(:line_items, as: :json)) { } # + a hidden field to sync
1364
1531
  # the hidden field the client keeps in sync (seed "[]" so an empty submit posts one):
1365
1532
  input(type: "hidden", **reactive_field(:line_items), value: "[]")
1533
+
1534
+ # Form-builder escape hatch: name the hidden field VERBATIM (never re-scoped):
1535
+ div(**reactive_nested_list(:line_items, as: :json, name: "order[line_items]")) { }
1366
1536
  ```
1367
1537
 
1368
1538
  ```ruby
@@ -71,6 +71,39 @@
71
71
  // the re-entrant `changed === "field_c"` pass derives field_a back to the
72
72
  // value it already holds — no write, no event, settled in one bounce.
73
73
 
74
+ // THE RESERVED `$ops` OUTPUT (issue #226). Besides field/text outputs, a
75
+ // reducer may return the reserved key `$ops` holding a chain of client DOM
76
+ // ops — built with the `ops` builder below, or a raw [[name, args], ...]
77
+ // array. The controller consumes it as a PHASE 4 of the single-pass write set:
78
+ // the ops run AFTER the field writes, text sinks, and phase-3 input dispatches
79
+ // settle, through the SAME frozen CLIENT_OPS whitelist on_client uses (an
80
+ // unknown op warns + is skipped). `null`/`undefined`/absent = no effect.
81
+ //
82
+ // RISING-EDGE semantics, keyed on CONTENT: the chain runs only when it
83
+ // DIFFERS from the previous pass's chain (including from "absent"), and only
84
+ // on EVENT-DRIVEN passes. Returning the SAME chain again is settled — no
85
+ // re-fire (a 7th keystroke capped back to the same complete value can't
86
+ // re-submit), mirroring the change-guarded field writes. Returning a
87
+ // DIFFERENT chain fires again — a multi-box reducer advancing focus
88
+ // box-by-box emits a new focus target per digit, each a new intent. The
89
+ // connect/morph SEED pass (issue #199 — recompute with no event) ARMS the
90
+ // latch but never fires — a form re-rendered with an already-complete value
91
+ // (a validation-error morph, a browser restore) must not auto-fire; that is
92
+ // what breaks the submit → error re-render → re-seed → submit loop. A later
93
+ // pass returning no $ops re-arms. The canonical use — a one-time-code field
94
+ // that normalizes on input and commits when complete:
95
+ //
96
+ // import { setComputeReducer, ops } from "phlex/reactive/compute"
97
+ // setComputeReducer("otp", ({ code }) => {
98
+ // const digits = code.replace(/\D/g, "").slice(0, 6)
99
+ // return { code: digits, $ops: digits.length === 6 ? ops.submit() : null }
100
+ // })
101
+ //
102
+ // `submit` commits the target's own form via requestSubmit() — the real submit
103
+ // event fires, so an on(:verify, event: "submit") interception (or a native/
104
+ // Turbo form) handles it exactly like a user submit. submit/focus are
105
+ // ACTOR-ONLY: usable here and in on_client/reply.js, refused in broadcasts.
106
+
74
107
  const reducers = new Map()
75
108
 
76
109
  // Register (or replace) the reducer for `key`. `fn` is
@@ -91,3 +124,122 @@ export function computeReducer(key) {
91
124
  export function __resetComputeRegistryForTest() {
92
125
  reducers.clear()
93
126
  }
127
+
128
+ // --- The reducer-side op-chain builder (issue #226) --------------------------
129
+ //
130
+ // A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder: verbs carry
131
+ // the WIRE op names (snake_case) and append [name, args] pairs; every verb
132
+ // returns a NEW instance, so a chain held in a constant can never be mutated by
133
+ // later use. `.ops` exposes the raw [[name, args], ...] list the controller
134
+ // interprets (and toJSON serializes it, so a chain can also feed a hand-built
135
+ // ops attr). Targets: a CSS selector string, or omit for "@root" (the
136
+ // component's own root). No build-time attr validation here — the interpreter's
137
+ // allowlist (guardAttr) is the enforcement point; this builder only shapes the
138
+ // wire.
139
+ const ROOT_SENTINEL = "@root"
140
+
141
+ function targetArgs(to, { global, transition } = {}) {
142
+ const args = { to: to ?? ROOT_SENTINEL }
143
+ if (global) args.global = true
144
+ if (transition) args.transition = normalizeTransition(transition)
145
+ return args
146
+ }
147
+
148
+ // Named legs { during, from, to } → the [during, from, to] wire array (the
149
+ // issue #186 vocabulary). Loud at authoring time, like the Ruby builder.
150
+ // Frozen, like every nested payload — the chain's immutability contract must
151
+ // hold all the way down (the Ruby twin freezes its legs array too).
152
+ function normalizeTransition(transition) {
153
+ const named =
154
+ transition && typeof transition === "object" && !Array.isArray(transition) &&
155
+ ["during", "from", "to"].every((k) => k in transition)
156
+ if (!named) throw new Error("[phlex-reactive] ops transition takes named legs { during, from, to }")
157
+ return Object.freeze([String(transition.during), String(transition.from), String(transition.to)])
158
+ }
159
+
160
+ class OpsChain {
161
+ constructor(list = Object.freeze([])) {
162
+ this.ops = list
163
+ Object.freeze(this)
164
+ }
165
+
166
+ show(to, opts) {
167
+ return this.#append("show", targetArgs(to, opts))
168
+ }
169
+
170
+ hide(to, opts) {
171
+ return this.#append("hide", targetArgs(to, opts))
172
+ }
173
+
174
+ toggle(to, opts) {
175
+ return this.#append("toggle", targetArgs(to, opts))
176
+ }
177
+
178
+ add_class(to, classes, opts) {
179
+ return this.#append("add_class", classArgs(to, classes, opts))
180
+ }
181
+
182
+ remove_class(to, classes, opts) {
183
+ return this.#append("remove_class", classArgs(to, classes, opts))
184
+ }
185
+
186
+ toggle_class(to, classes, opts) {
187
+ return this.#append("toggle_class", classArgs(to, classes, opts))
188
+ }
189
+
190
+ set_attr(to, name, value, opts) {
191
+ return this.#append("set_attr", { ...targetArgs(to, opts), name: String(name), value: String(value) })
192
+ }
193
+
194
+ remove_attr(to, name, opts) {
195
+ return this.#append("remove_attr", { ...targetArgs(to, opts), name: String(name) })
196
+ }
197
+
198
+ toggle_attr(to, name, opts) {
199
+ return this.#append("toggle_attr", { ...targetArgs(to, opts), name: String(name) })
200
+ }
201
+
202
+ focus(to, opts) {
203
+ return this.#append("focus", targetArgs(to, opts))
204
+ }
205
+
206
+ focus_first(to, opts) {
207
+ return this.#append("focus_first", targetArgs(to, opts))
208
+ }
209
+
210
+ text(to, value, opts) {
211
+ return this.#append("text", { ...targetArgs(to, opts), value: String(value ?? "") })
212
+ }
213
+
214
+ dispatch(name, { to, detail, global } = {}) {
215
+ const args = { name: String(name), to: to ?? ROOT_SENTINEL, detail: detail ?? {} }
216
+ if (global) args.global = true
217
+ return this.#append("dispatch", args)
218
+ }
219
+
220
+ submit(to, opts) {
221
+ return this.#append("submit", targetArgs(to, opts))
222
+ }
223
+
224
+ toJSON() {
225
+ return this.ops
226
+ }
227
+
228
+ #append(name, args) {
229
+ return new OpsChain(Object.freeze([...this.ops, Object.freeze([name, Object.freeze(args)])]))
230
+ }
231
+ }
232
+
233
+ // classes: one class string or an array of them (never whitespace-split — a
234
+ // classList token can't contain spaces, so splitting would only mask a bug).
235
+ // Loud on an empty/missing list, and frozen (the Ruby twin freezes its class
236
+ // list too) — a chain held in a constant must stay immutable all the way down.
237
+ function classArgs(to, classes, opts) {
238
+ const list = classes == null ? [] : (Array.isArray(classes) ? classes : [classes]).map(String)
239
+ if (list.length === 0) throw new Error("[phlex-reactive] a class op needs at least one class")
240
+ return { ...targetArgs(to, opts), classes: Object.freeze(list) }
241
+ }
242
+
243
+ // The shared empty chain — start every reducer effect from here:
244
+ // $ops: done ? ops.dispatch("code:complete").submit() : null
245
+ export const ops = new OpsChain()
@@ -1,4 +1,4 @@
1
- var j=new Map;function q(b,p){j.set(b,p)}function v(b){return j.get(b)}function w(){j.clear()}export{q as setComputeReducer,v as computeReducer,w as __resetComputeRegistryForTest};
1
+ var F=new Map;function M(j,q){F.set(j,q)}function P(j){return F.get(j)}function Q(){F.clear()}var J="@root";function x(j,{global:q,transition:w}={}){let B={to:j??J};if(q)B.global=!0;if(w)B.transition=K(w);return B}function K(j){if(!(j&&typeof j==="object"&&!Array.isArray(j)&&["during","from","to"].every((w)=>(w in j))))throw Error("[phlex-reactive] ops transition takes named legs { during, from, to }");return Object.freeze([String(j.during),String(j.from),String(j.to)])}class G{constructor(j=Object.freeze([])){this.ops=j,Object.freeze(this)}show(j,q){return this.#j("show",x(j,q))}hide(j,q){return this.#j("hide",x(j,q))}toggle(j,q){return this.#j("toggle",x(j,q))}add_class(j,q,w){return this.#j("add_class",D(j,q,w))}remove_class(j,q,w){return this.#j("remove_class",D(j,q,w))}toggle_class(j,q,w){return this.#j("toggle_class",D(j,q,w))}set_attr(j,q,w,B){return this.#j("set_attr",{...x(j,B),name:String(q),value:String(w)})}remove_attr(j,q,w){return this.#j("remove_attr",{...x(j,w),name:String(q)})}toggle_attr(j,q,w){return this.#j("toggle_attr",{...x(j,w),name:String(q)})}focus(j,q){return this.#j("focus",x(j,q))}focus_first(j,q){return this.#j("focus_first",x(j,q))}text(j,q,w){return this.#j("text",{...x(j,w),value:String(q??"")})}dispatch(j,{to:q,detail:w,global:B}={}){let H={name:String(j),to:q??J,detail:w??{}};if(B)H.global=!0;return this.#j("dispatch",H)}submit(j,q){return this.#j("submit",x(j,q))}toJSON(){return this.ops}#j(j,q){return new G(Object.freeze([...this.ops,Object.freeze([j,Object.freeze(q)])]))}}function D(j,q,w){let B=q==null?[]:(Array.isArray(q)?q:[q]).map(String);if(B.length===0)throw Error("[phlex-reactive] a class op needs at least one class");return{...x(j,w),classes:Object.freeze(B)}}var U=new G;export{M as setComputeReducer,U as ops,P as computeReducer,Q as __resetComputeRegistryForTest};
2
2
 
3
- //# debugId=5EACBA6D0928063864756E2164756E21
3
+ //# debugId=3CB48262B60C70DD64756E2164756E21
4
4
  //# sourceMappingURL=compute.min.js.map
@@ -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\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"
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"
6
6
  ],
7
- "mappings": "AAyEA,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",
8
- "debugId": "5EACBA6D0928063864756E2164756E21",
7
+ "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",
8
+ "debugId": "3CB48262B60C70DD64756E2164756E21",
9
9
  "names": []
10
10
  }