phlex-reactive 0.12.2 → 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: 753ece18695afa9629ee3bec0fd51722f83618adcb2488265a873ff93bf89dd0
4
- data.tar.gz: 55fd2590040f0a9c0831e622981d50b71145fbcb289157237787c59b13ec6426
3
+ metadata.gz: 735a21537bb98a3b89ba46f21fe40630ee413191e5509db2fe7895172e6b92b7
4
+ data.tar.gz: 2110972a68b3ff0c336255afe15674167d9012201ebe80479a5c84fff922e682
5
5
  SHA512:
6
- metadata.gz: 8ba6bd8917f55a95d2039f8f86ac393c23c564da694575bb1978b0eeb5f1d5667a0531b0ac0c2a4f8f8e23e03199461eeba0d2dc3decb866b377b404ddb44665
7
- data.tar.gz: 4f46e011c63a7500e6262fb3b24fceaf4c13c64b99d1555c0d7fe130326791ba388245c4493a8716231f5510f6d336e91492985a193bfaa5851c9475f3b4b173
6
+ metadata.gz: 7f6c98822ac5eabbcfdd39b6bdbbbd50176e6d4d5735b5eb0189321ac26e0b559ccc7f8ec60c9394492e23e53eb7e77b79c89c0298f1c962b2115961f8cbfc48
7
+ data.tar.gz: 4b7dc6d779d5f250592387b3cb9615b4d9d37a5994c0b03e3b83ad1e740251e9875b06735b79a02e304ba0fe53b659220950c47906d0805d0128e81b84231966
data/CHANGELOG.md CHANGED
@@ -8,6 +8,81 @@ 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
+
42
+ - **Reducer-emitted client ops (`$ops`), a `submit` op, and declarative
43
+ `reactive_on_complete` (#226).** The "normalize on input, commit when
44
+ complete" field (a one-time-code entry being the canonical case) is now
45
+ declarable end to end, single- or multi-input, with no bespoke controller:
46
+ - **`js.submit(to = :root)`** joins the op vocabulary: requestSubmit the
47
+ target's own form (the target itself when it *is* a form, `input.form`
48
+ for a control, else `closest("form")`). A real cancelable `submit` event
49
+ fires, so it composes with native/Turbo forms AND
50
+ `on(:save, event: "submit")` interception —
51
+ `select(**on_client(:change, js.submit("form")))` is the one-line general
52
+ autosubmit. **Actor-only like focus**: refused in `broadcast_to(js:)`
53
+ (allowed in `reply.js`); `on_client(:submit, js.submit)` raises (it would
54
+ re-fire itself).
55
+ - **The reserved `$ops` reducer output**: a `reactive_compute` reducer may
56
+ return `$ops` holding an op chain — built with the new immutable `ops`
57
+ builder exported from `phlex/reactive/compute` (verbs mirror the wire op
58
+ names) or a raw `[[op, args], …]` list — run through the same frozen
59
+ client-op whitelist as a final phase after the write set settles.
60
+ **Rising-edge, keyed on chain content, event-gated**: an identical chain
61
+ never re-fires (a capped 7th keystroke can't re-submit), a changed chain
62
+ fires again (per-digit focus advance across split boxes), and the
63
+ connect/morph seed pass arms without firing (a validation-error re-render
64
+ with a complete value never self-submits).
65
+ - **`{ length: … }` in the ONE conditions language**: exact
66
+ (`{ length: 6 }`) or Integer-Range length predicates for
67
+ `reactive_show` / `reactive_show_targets` / `reactive_on_complete`,
68
+ counted in **codepoints** on both sides (Ruby `String#length`, client
69
+ `[...value].length`) — the shared parity fixture gains a multibyte proof
70
+ vector.
71
+ - **`reactive_on_complete`** — the zero-JavaScript declarative twin: the
72
+ same `if:`/`if_any:`/`unless:` kwargs as `reactive_show` plus `run:` (a
73
+ `js` chain, now buildable at class level, or an allowlist-checked raw
74
+ list), emitted as one root attr and evaluated by the generic controller
75
+ with the same rising-edge/arming semantics as `$ops`.
76
+ `reactive_on_complete if: { code: { length: 6 } },
77
+ run: js.dispatch("code:complete")` + `on(:verify, event: "code:complete")`
78
+ is a complete auto-committing code field with no reducer at all.
79
+ - Dummy flagships: `VerificationCodeComponent` (single input + `$ops`
80
+ submit), `SplitCodeComponent` (six boxes: paste redistribution,
81
+ reducer-driven focus advance, hidden joined-code output),
82
+ `CodeCompleteComponent` (declarative, zero JS), and the
83
+ `AutosubmitFilterComponent` select-driven GET filter — each with a
84
+ real-browser system spec.
85
+
11
86
  - **Instance-dynamic wire names — keyword escape hatches on the
12
87
  field-compiling helpers (#224).** A form builder's wire name is computed per
13
88
  instance (`user[tags]`), which the class-level `reactive_scope` compile can't
@@ -240,6 +315,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
240
315
 
241
316
  ### Fixed
242
317
 
318
+ - **`rake bench` ran the removed `broadcast_replace_to` API and exited 1.**
319
+ `benchmark/micro/broadcast.rb` migrated to the #185 spellings
320
+ (`broadcast_to(*key, replace:)` / `broadcast_to(each:, replace:)`); the
321
+ transport double is unchanged.
322
+
243
323
  - **`reactive_nested_remove(confirm:)` now interpolates `%{field}` on client-added
244
324
  rows (#222).** A row added in the browser via `reactive_nested_add` is a
245
325
  `cloneNode` of the `<template>`, and the clone path (`#renumberNestedRow` /
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). |
@@ -853,6 +853,35 @@ 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.
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.
856
885
  - **`transition: { during:, from:, to: }`** on `show`/`hide`/`toggle` animates the
857
886
  visibility flip: `during`+`from` are applied, then `from`→`to` swaps on the next
858
887
  frame, and the helper classes are cleaned up on `animationend` (with a timeout
@@ -863,6 +892,27 @@ button(**on_client(:click, js
863
892
  modifiers: the dropdown above closes on any click outside the component, and
864
893
  window-bound triggers never `preventDefault`, so links elsewhere keep working.
865
894
 
895
+ **The general autosubmit story.** With `submit` in the vocabulary, the classic
896
+ `onchange="this.form.requestSubmit()"` filter form is one declared line — no
897
+ bespoke controller, no reactive action, and Turbo Drive turns the resulting
898
+ submit into a normal visit:
899
+
900
+ ```ruby
901
+ form(action: "/products", method: "get") do
902
+ select(name: "sort", **mix(on_client(:change, js.submit("form")), data: { testid: "sort" })) do
903
+ option(value: "name") { "Name" }
904
+ option(value: "price") { "Price" }
905
+ end
906
+ end
907
+ ```
908
+
909
+ Use `change`-bound autosubmit for discrete controls (selects, radios,
910
+ checkboxes). For a **text** field that should commit "when the value is
911
+ complete," an unconditional `on_client(:input, js.submit)` would fire on every
912
+ keystroke — that conditional case is exactly what a reducer's
913
+ [`$ops`](#client-side-computes-reactive_compute--reactive_text) and
914
+ [`reactive_on_complete`](#declarative-completion-reactive_on_complete) are for.
915
+
866
916
  **Client ops are ephemeral UI — the one contract to internalize.** Any server
867
917
  re-render of the component (an action reply, a broadcast, a morph) rebuilds
868
918
  from server state and resets whatever the ops toggled: the menu closes, the tab
@@ -958,7 +1008,11 @@ end
958
1008
  - **The value language**: a **Hash is an AND** (multiple keys ANDed), an
959
1009
  **Array is membership**, a **Range is a threshold** (`10..` ≥ 10, `..10` ≤ 10,
960
1010
  `...10` < 10, `10..20` between), `true`/`false` compare a checkbox's checked
961
- state, `nil` matches blank. `unless:` **negates** and composes with `if:`.
1011
+ state, `nil` matches blank, and **`{ length: }` compares the value's
1012
+ length** — exact (`{ length: 6 }`) or an Integer Range (`{ length: 6.. }`,
1013
+ `{ length: 4..8 }`). Length counts **codepoints** on both sides (Ruby
1014
+ `String#length`, client `[...value].length`), so multibyte input agrees; a
1015
+ blank field has length 0. `unless:` **negates** and composes with `if:`.
962
1016
  Never an expression — every term is a declared literal, so there is no eval
963
1017
  surface. A blank/non-numeric value fails a numeric term **closed** (hidden).
964
1018
  - **OR-of-AND** — `if_any:` takes an array of AND-hashes (`if_any: [{ director:
@@ -1097,6 +1151,107 @@ setComputeReducer("preview", ({ title }) => ({
1097
1151
  a later morph repaints stale text — the same reconcile contract the whole
1098
1152
  new-vs-persisted split relies on.
1099
1153
 
1154
+ **Reducer-emitted ops (`$ops`) — commit when complete.** A reducer's outputs
1155
+ write fields and text; the reserved **`$ops`** key lets it emit a **conditional
1156
+ side effect** — the missing piece that used to force a bespoke controller next
1157
+ to an otherwise-declarative compute. Return an op chain (the `ops` builder
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
1161
+ controller runs it through the **same frozen op whitelist** `on_client` uses,
1162
+ as a final phase **after** the field writes, text sinks, and their dispatched
1163
+ `input` events settle. The canonical one-time-code field:
1164
+
1165
+ ```js
1166
+ import { setComputeReducer, ops } from "phlex/reactive/compute"
1167
+
1168
+ setComputeReducer("otp", ({ code }) => {
1169
+ const digits = code.replace(/\D/g, "").slice(0, 6)
1170
+ return { code: digits, $ops: digits.length === 6 ? ops.submit() : null }
1171
+ })
1172
+ ```
1173
+
1174
+ ```ruby
1175
+ form(action: "/verify", method: "post",
1176
+ **mix(reactive_root(compute: :otp), on(:verify, event: "submit"))) do
1177
+ input(name: "code", inputmode: "numeric", autocomplete: "one-time-code")
1178
+ end
1179
+ ```
1180
+
1181
+ Typing, pasting `123-456`, or platform SMS autofill all arrive as `input`
1182
+ events → the reducer normalizes, and at six digits `submit` requestSubmits the
1183
+ form — which `on(:verify, event: "submit")` intercepts into **one signed action
1184
+ POST**. The contract that makes this safe:
1185
+
1186
+ - **Rising edge, keyed on content.** The chain runs only when it **differs**
1187
+ from the previous pass's chain (including from "absent"). Returning the same
1188
+ chain again is settled — a 7th keystroke capped back to the same six digits
1189
+ can't re-submit — while a **different** chain fires again (a multi-box
1190
+ reducer advancing focus emits a new `ops.focus` target per digit). A pass
1191
+ returning `null`/no `$ops` re-arms.
1192
+ - **Event-gated.** The connect/morph **seed pass arms without firing** — a form
1193
+ re-rendered with an already-complete value (a validation-error morph, a
1194
+ browser restore) never auto-fires, which is what breaks the
1195
+ submit → error re-render → re-seed → submit loop.
1196
+ - **Whitelisted.** `$ops` is consumed before the write phases (never painted as
1197
+ a field/text/mirror), and unknown ops warn-and-skip while siblings apply.
1198
+ - **Multi-input works with the same machinery**: declare all boxes as inputs,
1199
+ join + redistribute in the reducer (a paste into any box fans out one digit
1200
+ per box), mirror the joined value into a hidden field, advance focus with a
1201
+ per-digit `ops.focus`, and `ops.submit()` on completion. See
1202
+ `spec/dummy/app/components/split_code_component.rb` for the full six-box
1203
+ example.
1204
+
1205
+ When you *don't* want to auto-submit, the same slot dispatches a completion
1206
+ event (`ops.dispatch("code:complete")`) for a sibling to react to, or enables
1207
+ the submit button (`ops.remove_attr("[type=submit]", "disabled")`) and leaves
1208
+ the commit to the user.
1209
+
1210
+ ### Declarative completion (`reactive_on_complete`)
1211
+
1212
+ The `$ops` escape hatch puts the condition in the reducer; when the condition
1213
+ is expressible in the [conditions language](#value-conditional-visibility-reactive_show),
1214
+ `reactive_on_complete` declares the whole binding in Ruby — **zero JavaScript,
1215
+ no reducer**:
1216
+
1217
+ ```ruby
1218
+ class CodeCompleteComponent < ApplicationComponent
1219
+ include Phlex::Reactive::Streamable
1220
+ include Phlex::Reactive::Component
1221
+
1222
+ reactive_state :code
1223
+ action :verify, params: { code: :string }
1224
+
1225
+ reactive_on_complete if: { code: { length: 6 } }, run: js.dispatch("code:complete")
1226
+
1227
+ def view_template
1228
+ div(**mix(reactive_root, on(:verify, event: "code:complete"))) do
1229
+ input(name: "code")
1230
+ end
1231
+ end
1232
+ end
1233
+ ```
1234
+
1235
+ The generic controller evaluates the conditions over the owned fields on every
1236
+ `input`/`change` (scope-aware, same resolver as `reactive_show`) and runs the
1237
+ declared ops on the **rising edge** — once, when the conditions first become
1238
+ true; going false re-arms; the connect/morph pass arms **without** firing, so a
1239
+ re-render with already-satisfied conditions never self-fires. The pieces:
1240
+
1241
+ - **Conditions** are the same `if:` / `if_any:` / `unless:` kwargs
1242
+ `reactive_show` takes — including the `length:` form above, which is what
1243
+ makes "exactly six characters" declarable.
1244
+ - **`run:`** is a `js` chain (available at class level) or a raw op list
1245
+ (re-checked through the attribute allowlist). `run: js.submit` auto-commits;
1246
+ `run: js.dispatch(...)` lets a sibling `on(:action, event: "...")` turn
1247
+ completion into a signed action, as above.
1248
+ - **Several bindings** coexist under names:
1249
+ `reactive_on_complete :commit, if: …, run: js.submit` — each latches
1250
+ independently; redeclaring a name overrides it (normal registry inheritance).
1251
+ - Prefer `$ops` when completion needs **normalization first** (strip
1252
+ separators, cap length) — the reducer already knows the cleaned value;
1253
+ prefer `reactive_on_complete` when the raw field value is the truth.
1254
+
1100
1255
  ### Cross-root mirrors (`mirror:`) — painting a recap outside the root
1101
1256
 
1102
1257
  `reactive_text` is deliberately **root-isolated** (a nested component's nodes are
@@ -1843,10 +1998,12 @@ Notifications::Badge.broadcast_to(user, :alerts,
1843
1998
  js: js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
1844
1999
  ```
1845
2000
 
1846
- `broadcast_to` with `js:` **refuses focus-class ops** (`focus`/`focus_first` raise
1847
- `ArgumentError`): broadcasting focus would steal it in every subscriber's tab, so
1848
- focus is an actor-reply concern only. Everything else is a fair broadcast (class
1849
- 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
1850
2007
  whitelist-interpreted client-side — an unknown op warns and is skipped — and the
1851
2008
  ops attribute is HTML-escaped, so a value can't break out of it. `reactive:js`
1852
2009
  is not a self-render: it never counts toward the token refresh, so the reply's
@@ -71,6 +71,44 @@
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
+ // 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.
111
+
74
112
  const reducers = new Map()
75
113
 
76
114
  // Register (or replace) the reducer for `key`. `fn` is
@@ -91,3 +129,123 @@ export function computeReducer(key) {
91
129
  export function __resetComputeRegistryForTest() {
92
130
  reducers.clear()
93
131
  }
132
+
133
+ // --- The reducer-side op-chain builder (issue #226) --------------------------
134
+ //
135
+ // A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder (minus
136
+ // paste_into — see the actor-only note above): verbs carry
137
+ // the WIRE op names (snake_case) and append [name, args] pairs; every verb
138
+ // returns a NEW instance, so a chain held in a constant can never be mutated by
139
+ // later use. `.ops` exposes the raw [[name, args], ...] list the controller
140
+ // interprets (and toJSON serializes it, so a chain can also feed a hand-built
141
+ // ops attr). Targets: a CSS selector string, or omit for "@root" (the
142
+ // component's own root). No build-time attr validation here — the interpreter's
143
+ // allowlist (guardAttr) is the enforcement point; this builder only shapes the
144
+ // wire.
145
+ const ROOT_SENTINEL = "@root"
146
+
147
+ function targetArgs(to, { global, transition } = {}) {
148
+ const args = { to: to ?? ROOT_SENTINEL }
149
+ if (global) args.global = true
150
+ if (transition) args.transition = normalizeTransition(transition)
151
+ return args
152
+ }
153
+
154
+ // Named legs { during, from, to } → the [during, from, to] wire array (the
155
+ // issue #186 vocabulary). Loud at authoring time, like the Ruby builder.
156
+ // Frozen, like every nested payload — the chain's immutability contract must
157
+ // hold all the way down (the Ruby twin freezes its legs array too).
158
+ function normalizeTransition(transition) {
159
+ const named =
160
+ transition && typeof transition === "object" && !Array.isArray(transition) &&
161
+ ["during", "from", "to"].every((k) => k in transition)
162
+ if (!named) throw new Error("[phlex-reactive] ops transition takes named legs { during, from, to }")
163
+ return Object.freeze([String(transition.during), String(transition.from), String(transition.to)])
164
+ }
165
+
166
+ class OpsChain {
167
+ constructor(list = Object.freeze([])) {
168
+ this.ops = list
169
+ Object.freeze(this)
170
+ }
171
+
172
+ show(to, opts) {
173
+ return this.#append("show", targetArgs(to, opts))
174
+ }
175
+
176
+ hide(to, opts) {
177
+ return this.#append("hide", targetArgs(to, opts))
178
+ }
179
+
180
+ toggle(to, opts) {
181
+ return this.#append("toggle", targetArgs(to, opts))
182
+ }
183
+
184
+ add_class(to, classes, opts) {
185
+ return this.#append("add_class", classArgs(to, classes, opts))
186
+ }
187
+
188
+ remove_class(to, classes, opts) {
189
+ return this.#append("remove_class", classArgs(to, classes, opts))
190
+ }
191
+
192
+ toggle_class(to, classes, opts) {
193
+ return this.#append("toggle_class", classArgs(to, classes, opts))
194
+ }
195
+
196
+ set_attr(to, name, value, opts) {
197
+ return this.#append("set_attr", { ...targetArgs(to, opts), name: String(name), value: String(value) })
198
+ }
199
+
200
+ remove_attr(to, name, opts) {
201
+ return this.#append("remove_attr", { ...targetArgs(to, opts), name: String(name) })
202
+ }
203
+
204
+ toggle_attr(to, name, opts) {
205
+ return this.#append("toggle_attr", { ...targetArgs(to, opts), name: String(name) })
206
+ }
207
+
208
+ focus(to, opts) {
209
+ return this.#append("focus", targetArgs(to, opts))
210
+ }
211
+
212
+ focus_first(to, opts) {
213
+ return this.#append("focus_first", targetArgs(to, opts))
214
+ }
215
+
216
+ text(to, value, opts) {
217
+ return this.#append("text", { ...targetArgs(to, opts), value: String(value ?? "") })
218
+ }
219
+
220
+ dispatch(name, { to, detail, global } = {}) {
221
+ const args = { name: String(name), to: to ?? ROOT_SENTINEL, detail: detail ?? {} }
222
+ if (global) args.global = true
223
+ return this.#append("dispatch", args)
224
+ }
225
+
226
+ submit(to, opts) {
227
+ return this.#append("submit", targetArgs(to, opts))
228
+ }
229
+
230
+ toJSON() {
231
+ return this.ops
232
+ }
233
+
234
+ #append(name, args) {
235
+ return new OpsChain(Object.freeze([...this.ops, Object.freeze([name, Object.freeze(args)])]))
236
+ }
237
+ }
238
+
239
+ // classes: one class string or an array of them (never whitespace-split — a
240
+ // classList token can't contain spaces, so splitting would only mask a bug).
241
+ // Loud on an empty/missing list, and frozen (the Ruby twin freezes its class
242
+ // list too) — a chain held in a constant must stay immutable all the way down.
243
+ function classArgs(to, classes, opts) {
244
+ const list = classes == null ? [] : (Array.isArray(classes) ? classes : [classes]).map(String)
245
+ if (list.length === 0) throw new Error("[phlex-reactive] a class op needs at least one class")
246
+ return { ...targetArgs(to, opts), classes: Object.freeze(list) }
247
+ }
248
+
249
+ // The shared empty chain — start every reducer effect from here:
250
+ // $ops: done ? ops.dispatch("code:complete").submit() : null
251
+ 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// 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": "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": "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",
8
+ "debugId": "3CB48262B60C70DD64756E2164756E21",
9
9
  "names": []
10
10
  }