phlex-reactive 0.10.0 → 0.11.0

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.
data/README.md CHANGED
@@ -233,7 +233,7 @@ rails g phlex:reactive:claude
233
233
  └──────── Turbo applies it in ◀──────────────────────────────┘
234
234
 
235
235
  ...and for OTHER tabs/users:
236
- model change → Component.broadcast_replace_to(stream) → pgbus SSE → same morph
236
+ model change → Component.broadcast_to(stream, replace: model) → pgbus SSE → same morph
237
237
  ```
238
238
 
239
239
  Client actions and server broadcasts **converge on one re-render unit**: the
@@ -326,7 +326,7 @@ The [inline edit example](https://phlex-reactive.zoolutions.llc/docs/example-inl
326
326
  | [Cross-tab chat](https://phlex-reactive.zoolutions.llc/docs/example-chat) | Record-backed action **+ pgbus broadcast** → live sync across tabs/browsers |
327
327
  | [Live todo list](https://phlex-reactive.zoolutions.llc/docs/example-todo-list) | Per-row components, add/toggle/rename/archive, optimistic toggle + delete, Enter-to-add, broadcast on change |
328
328
  | [Inline edit + dirty tracking](https://phlex-reactive.zoolutions.llc/docs/example-inline-edit) | Show ↔ edit toggle plus an "Unsaved" badge + leave-guard, with zero shipped state |
329
- | [Notifications / badges](https://phlex-reactive.zoolutions.llc/docs/example-notifications) | Pure broadcast — a background event pushes a re-render, plus a `broadcast_js_to` cross-tab pulse |
329
+ | [Notifications / badges](https://phlex-reactive.zoolutions.llc/docs/example-notifications) | Pure broadcast — a background event pushes a re-render, plus a `broadcast_to(js:)` cross-tab pulse |
330
330
  | [Reactive collections](https://phlex-reactive.zoolutions.llc/docs/example-collections) | Add/remove rows + a running count + an empty state, declared **once** with `reactive_collection`, optimistic dismiss |
331
331
  | [Loading states](https://phlex-reactive.zoolutions.llc/docs/example-loading-states) | `disable_with:` + `busy_on` + `aria-busy`, with a latency toggle to make the pending window visible |
332
332
  | [Client-only ops](https://phlex-reactive.zoolutions.llc/docs/example-client-ops) | `on_client` tabs / outside-close menu / accessible drawer — zero fetches, zero custom JS |
@@ -350,11 +350,11 @@ flagship — every feature composed into one believable UI.
350
350
  | `.replace(model = nil, morph: false, **opts)` | `<turbo-stream action=replace target=id>` of a freshly built component; `morph: true` adds `method="morph"` |
351
351
  | `.update(model = nil, morph: false, **opts)` | `<turbo-stream action=update target=id>` (inner-HTML update); `morph: true` adds `method="morph"` so Turbo morphs the inner HTML in place (issue #113) |
352
352
  | `.append(target:)` / `.prepend(target:)` / `.remove` | The other Turbo Stream actions |
353
- | `.broadcast_replace_to(*streamables, model:, morph: false)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable); `morph: true` morphs in place |
354
- | `.broadcast_update_to(*streamables, model:, morph: false)` | Broadcast an inner-HTML update; `morph: true` morphs in place, so a peer editing the component keeps its focus/caret (issue #113) |
355
- | `.broadcast_append_to(*streamables, target:, model:)` / `_prepend_` / `_remove_` | The other broadcast variants |
356
- | `.broadcast_replace_to_each(stream_keys, model:, morph: false, exclude:, visible_to:)` / `_update_` / `_append_` / `_prepend_` / `_remove_` | **Render once, fan out** the *same* payload to K different stream keys — a per-tenant loop. K renders + K HMACs collapse to 1 + K cheap channel calls (~9.5× at K=10). Each key is a `[record, :symbol]` pair (or a bare string). Transport opts + `morph:` forward per key. Per-viewer `visible_to:` content stays render-per-call. See [Broadcasting](https://phlex-reactive.zoolutions.llc/docs/broadcasting). |
357
- | `#to_stream_replace` / `#to_stream_morph` / `#to_stream_remove` | Stream the *already-built* instance (used internally after an action / by `reply`); `#to_stream_morph` morphs in place |
353
+ | `.broadcast_to(*streamables, replace: model, morph: false)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable); `morph: true` morphs in place |
354
+ | `.broadcast_to(*streamables, update: model, morph: false)` | Broadcast an inner-HTML update; `morph: true` morphs in place, so a peer editing the component keeps its focus/caret (issue #113) |
355
+ | `.broadcast_to(*streamables, append: model, target:)` / `prepend:` / `remove:` | The other broadcast verbs — the verb is a kwarg (exactly one of `replace:`/`update:`/`append:`/`prepend:`/`remove:`/`js:`) |
356
+ | `.broadcast_to(each: stream_keys, replace: model, morph: false, exclude:, visible_to:)` | **Render once, fan out** the *same* payload to K different stream keys — a per-tenant loop. Pass `each:` instead of `*streamables`. K renders + K HMACs collapse to 1 + K cheap channel calls (~9.5× at K=10). Each key is a `[record, :symbol]` pair (or a bare string). Transport opts + `morph:` forward per key. Per-viewer `visible_to:` content stays render-per-call. See [Broadcasting](https://phlex-reactive.zoolutions.llc/docs/broadcasting). |
357
+ | `#to_stream_replace(morph: false)` / `#to_stream_remove` | Stream the *already-built* instance (used internally after an action / by `reply`); `morph: true` morphs in place |
358
358
  | `#to_stream_update(morph: false)` | Inner-HTML update of the *already-built* instance; `morph: true` morphs in place (issue #113) |
359
359
 
360
360
  Use in controllers: `render turbo_stream: Counter.replace(counter)`.
@@ -368,7 +368,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
368
368
  | `action :name, params: { x: :integer }` | Declare a client-invokable action + its param schema. **Default-deny.** |
369
369
  | `mark_authorized!` | Inside an action: satisfy the `verify_authorized` guard after a bespoke check the interceptor can't see (a hand-rolled policy). Call it only after your check passes. |
370
370
  | `skip_verify_authorized [ :a, :b ]` | Opt a component (bare) or specific actions out of the default-ON `verify_authorized` guard — for a genuinely public component (a counter, a client-only filter). |
371
- | `reactive_root(**overrides)` | Spread onto the root element: emits the component `id` **and** `reactive_attrs` together, so the controller root always carries `#id`. Preferred over `id:` + `reactive_attrs`. `**overrides` (`class:`/`data:`) deep-merge. |
371
+ | `reactive_root(**overrides)` | Spread onto the root element: emits the component `id` **and** `reactive_attrs` together, so the controller root always carries `#id`. Preferred over `id:` + `reactive_attrs`. `**overrides` (`class:`/`data:`) deep-merge. `compute: :name` binds a client-side compute **at the root** — descriptors plus the `input->reactive#recompute` delegation, so no field needs its own wiring; `nil` collapses to no binding. See [Client-side computes](#client-side-computes-reactive_compute--reactive_text). |
372
372
  | `reactive_attrs` | Marks an element reactive + carries the signed token (no `id`). Spread alongside `id:` on the **same** element: `div(id:, **reactive_attrs)`. Prefer `reactive_root`, which can't split them. |
373
373
  | `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
374
374
  | `on(:action, event: "input", debounce: 300)` | Coalesce rapid events into one round trip after a quiet period (live-as-you-type). |
@@ -384,16 +384,15 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
384
384
  | `on(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). |
385
385
  | `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). |
386
386
  | `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. |
387
- | `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |
388
- | `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |
387
+ | `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)) { … }`. |
389
388
  | `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). |
390
389
  | `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). |
391
390
  | `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..`). Id selectors only (raise at render + client warn-skip); toggles `hidden` only. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
392
- | `reactive_filter(input:, option:, group: nil, empty: nil)` | **Client-side option filtering** for a preloaded combobox: spread onto the root — typing in the named input shows/hides the options by their `data-reactive-filter-text` haystack, **zero round trips**. 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). |
391
+ | `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
392
  | `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. |
394
- | `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. |
393
+ | `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). |
395
394
  | `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). |
396
- | `reactive_root(track_dirty: true, warn_unsaved: true)` / `reactive_field(:param, dirty: true)` | **Dirty tracking** 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. Style with `[data-reactive-dirty]`. See [Dirty-field tracking](#dirty-field-tracking-dirty--track_dirty--warn_unsaved). |
395
+ | `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). |
397
396
  | `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
398
397
  | `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). |
399
398
  | `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). |
@@ -428,6 +427,23 @@ Register during boot only: the registry is **frozen after initialization**, so a
428
427
  runtime `param_type` call raises. A schema referencing a registered type is
429
428
  validated at declaration like any built-in.
430
429
 
430
+ **Named param schemas (`Phlex::Reactive.param_schema`).** Register a reusable
431
+ schema once so sibling components stop duplicating a verbatim params Hash that
432
+ drifts — follows the same boot-only contract as `param_type`:
433
+
434
+ ```ruby
435
+ # config/initializers/phlex_reactive.rb
436
+ Phlex::Reactive.param_schema :todo, title: :string, done: :boolean
437
+
438
+ # then, in any component:
439
+ action :save, params: :todo
440
+
441
+ # or compose it into a bigger schema:
442
+ action :bulk, params: { **Phlex::Reactive.param_schema(:todo), note: :string }
443
+ ```
444
+
445
+ Reading an unregistered name raises, listing the ones that are registered.
446
+
431
447
  **File uploads (`:file`).** Declare `:file` (or `[:file]` for multiple) to accept
432
448
  an uploaded file in a reactive action — attach a document/receipt/image to the
433
449
  record without dropping out to a bespoke controller. When the reactive root holds
@@ -686,7 +702,7 @@ is not disabled mid-typing. On settle the text is restored only if it still
686
702
  matches what was swapped in; a morph that rendered a **new** server label is left
687
703
  alone.
688
704
 
689
- ### Dirty-field tracking (`dirty:` / `track_dirty:` + `warn_unsaved:`)
705
+ ### Dirty-field tracking (`reactive_dirty`)
690
706
 
691
707
  Show "unsaved changes", enable **Save** only when something changed, or warn
692
708
  before navigating away — Livewire's `wire:dirty` — **without shipping any client
@@ -696,17 +712,32 @@ zero extra bytes. `input.defaultValue` / `defaultChecked` / `option.defaultSelec
696
712
  phlex-reactive reads that baseline straight from the DOM — nothing to snapshot,
697
713
  nothing to sign.
698
714
 
715
+ `reactive_dirty` is declared **once, at the class level**, alongside your other
716
+ macros (`reactive_record`/`reactive_state`/`action`) — not as a `reactive_root` or
717
+ `reactive_field` keyword:
718
+
699
719
  ```ruby
700
- div(**reactive_root(track_dirty: true, warn_unsaved: true)) do
701
- input(**reactive_field(:title, value: @todo.title, dirty: true))
702
- span(class: "unsaved-badge") { "Unsaved" } # shown via [data-reactive-dirty] CSS
703
- button(**on(:save, disable_with: "Saving…")) { "Save" }
720
+ class TodoForm < ApplicationComponent
721
+ include Phlex::Reactive::Component
722
+
723
+ reactive_record :todo
724
+ reactive_dirty warn_unsaved: true
725
+ action :save, params: { title: :string }
726
+
727
+ def view_template
728
+ div(**reactive_root) do
729
+ input(**reactive_field(:title, value: @todo.title))
730
+ span(class: "unsaved-badge") { "Unsaved" } # shown via [data-reactive-dirty] CSS
731
+ button(**on(:save, disable_with: "Saving…")) { "Save" }
732
+ end
733
+ end
704
734
  end
705
735
  ```
706
736
 
707
- - **`track_dirty: true`** on the root wires every input under it to a full
708
- re-scan on change. **`dirty: true`** on a single `reactive_field` opts that one
709
- field in (use it when only some fields should count).
737
+ - **`reactive_dirty`** (no args) wires every input under the root to a full
738
+ re-scan on change. **`reactive_dirty only: %i[title]`** scopes tracking to the
739
+ named fields only each carries its own descriptor instead of the root
740
+ delegating for the whole subtree (use it when only some fields should count).
710
741
  - On each change the client re-scans **every field the root owns** in one pass and
711
742
  marks:
712
743
  - each changed field with **`data-reactive-dirty="true"`**, and
@@ -739,15 +770,16 @@ and the badge clears with no reload. (Turbo 8 morph preserves a focused field's
739
770
  in-progress value while writing the fresh defaults — the post-morph re-scan is
740
771
  what keeps the root count honest in that state.)
741
772
 
742
- **`warn_unsaved: true`** arms a navigate-away guard gated on the **live** dirty
743
- count: `beforeunload` (a real browser unload) and `turbo:before-visit` (a Turbo
744
- in-app navigation). A clean form never blocks. **Caveats:** browsers show their
745
- own generic `beforeunload` copy (your message string is legacy and ignored), and
746
- `turbo:before-visit` **does not fire on restoration visits** (Back/Forward) — a
747
- documented Turbo gap, not a phlex-reactive one.
773
+ **`reactive_dirty warn_unsaved: true`** arms a navigate-away guard gated on the
774
+ **live** dirty count: `beforeunload` (a real browser unload) and
775
+ `turbo:before-visit` (a Turbo in-app navigation). A clean form never blocks.
776
+ **Caveats:** browsers show their own generic `beforeunload` copy (your message
777
+ string is legacy and ignored), and `turbo:before-visit` **does not fire on
778
+ restoration visits** (Back/Forward) — a documented Turbo gap, not a
779
+ phlex-reactive one.
748
780
 
749
781
  **Out of scope (v1):** rich-text / `contenteditable` editors have no `default*`
750
- baseline and are **not** tracked. Combining `reactive_field(dirty:)` with your own
782
+ baseline and are **not** tracked. Combining `reactive_field` with your own
751
783
  `data:`/`on(...)` still needs `mix(...)` (a bare merge clobbers the `data-action`
752
784
  the descriptor rides on — the same rule as everywhere else).
753
785
 
@@ -788,7 +820,7 @@ the same chain covers the rest of the client-only vocabulary:
788
820
 
789
821
  ```ruby
790
822
  button(**on_client(:click, js
791
- .toggle("#menu", transition: %w[transition-opacity opacity-0 opacity-100])
823
+ .toggle("#menu", transition: { during: "transition-opacity", from: "opacity-0", to: "opacity-100" })
792
824
  .toggle_attr(:root, "aria-expanded") # accessible disclosure state
793
825
  .focus_first("#menu") # move focus into the opened menu
794
826
  .dispatch("app:menu-toggled", detail: { open: true }))) { "Menu" }
@@ -814,7 +846,7 @@ button(**on_client(:click, js
814
846
  another component or a plain Stimulus controller can react to a client-only
815
847
  interaction — `to:` picks the element (default: the component root), `detail:`
816
848
  is the payload.
817
- - **`transition: [during, from, to]`** on `show`/`hide`/`toggle` animates the
849
+ - **`transition: { during:, from:, to: }`** on `show`/`hide`/`toggle` animates the
818
850
  visibility flip: `during`+`from` are applied, then `from`→`to` swaps on the next
819
851
  frame, and the helper classes are cleaned up on `animationend` (with a timeout
820
852
  fallback, so an element with no animation never leaves them stuck). The op chain
@@ -996,9 +1028,8 @@ reactive_compute :preview,
996
1028
  inputs: { title: :string, qty: :number }, # typed: :string raw, :number → Number
997
1029
  outputs: %i[title_preview char_count] # written with no round trip
998
1030
 
999
- div(**mix(reactive_root, reactive_compute_attrs(:preview))) do
1000
- input(**mix(reactive_field(:title, value: @post.title),
1001
- data: { action: "input->reactive#recompute" }))
1031
+ div(**reactive_root(compute: :preview)) do
1032
+ input(**reactive_field(:title, value: @post.title))
1002
1033
  h2 { reactive_text(:title_preview, @post.title) } # a text-node output
1003
1034
  small { reactive_text(:char_count) } # another text-node output
1004
1035
  end
@@ -1012,18 +1043,31 @@ setComputeReducer("preview", ({ title }) => ({
1012
1043
  }))
1013
1044
  ```
1014
1045
 
1046
+ - **`reactive_root(compute: :name)` binds AND listens at the root.** It emits the
1047
+ compute descriptors plus the `input->reactive#recompute` delegation on the root
1048
+ element, so no field needs any per-field compute wiring. `nil` (e.g.
1049
+ `reactive_root(compute: (:preview unless @post.persisted?))`) collapses to no
1050
+ binding at all — one expression for conditional compute.
1015
1051
  - **Typed inputs.** `inputs:` takes a **hash** to type each input: a `:number` is
1016
1052
  coerced through `Number` (blank/NaN → 0 — the array-form default), a `:string`
1017
1053
  reaches the reducer **raw** so a live text preview reads real text. The **array
1018
- form** (`inputs: %i[a b]`) stays all-numeric and byte-identical on the wire.
1054
+ form** (`inputs: %i[a b]`) stays all-numeric and byte-identical on the wire. The
1055
+ **permit-style form** (`inputs: [:qty, title: :string]`) combines both in one
1056
+ declaration — bare symbols default to `:number`, a trailing hash types the
1057
+ exceptions.
1019
1058
  - **`reactive_text(:name, initial)`** mirrors a value into a **text node** via
1020
- `textContent` (XSS-safe by construction). An output whose name matches an owned
1021
- form field writes that field's `.value`; an output with **no** matching field
1022
- writes every owned `[data-reactive-text="<name>"]` node. It carries **no
1023
- `name`**, so it's never collected or POSTed as a param.
1024
- - **Reducer-less mirrors.** A declared **input** also mirrors into its own
1025
- `reactive_text(:same_name)` node on every keystroke with **no reducer at all** —
1026
- so `reactive_text(:title)` is a live field echo out of the box.
1059
+ `textContent` (XSS-safe by construction). Every reducer-result key paints any
1060
+ matching sink: an owned **field** if declared in `outputs:`, any owned
1061
+ **`reactive_text`** node by presence, or a declared **`mirror:`** id — so an
1062
+ `outputs:` entry that exists only to reach a text node is redundant (existing
1063
+ declarations keep working; it's a widening, not a breaking change). It carries
1064
+ **no `name`**, so it's never collected or POSTed as a param.
1065
+ - **Reducer-less mirrors, and reactive_values seeding.** A declared **input** also
1066
+ mirrors into its own `reactive_text(:same_name)` node on every keystroke with
1067
+ **no reducer at all** — so `reactive_text(:title)` is a live field echo out of
1068
+ the box. And `reactive_text(:name)` called with **no explicit `initial`** seeds
1069
+ its first paint from `reactive_values` when the component declares one and
1070
+ covers that name — the same no-flash first-paint contract `reactive_show` uses.
1027
1071
  - **Seed the server render.** Your `view_template` must seed each mirror with the
1028
1072
  same derived value the reducer would (`reactive_text(:char_count, "5/80")`), or
1029
1073
  a later morph repaints stale text — the same reconcile contract the whole
@@ -1109,13 +1153,13 @@ root instead:
1109
1153
 
1110
1154
  ```ruby
1111
1155
  div(**mix(reactive_root, reactive_filter(
1112
- input: "#exercise-search", # the input whose value drives the filter
1113
- option: "[role=option]", # the elements to show/hide
1114
- group: "[data-filter-group]", # optional: collapse a header when all its options hide
1115
- empty: "#no-matches" # optional: reveal when 0 options match
1156
+ :q, # the FIELD that drives the filter → [name="q"] (scope-aware)
1157
+ group: "[data-filter-group]", # optional: collapse a header when all its options hide
1158
+ empty: "#no-matches" # optional: reveal when 0 options match
1116
1159
  ))) do
1117
1160
  # STANDALONE keyboard nav — no action on the input, so typing never POSTs.
1118
- input(id: "exercise-search", type: "search", **reactive_listnav("[role=option]"))
1161
+ # name: "q" is the field reactive_filter(:q) binds to; option: defaults to [role=option].
1162
+ input(name: "q", type: "search", **reactive_listnav("[role=option]"))
1119
1163
 
1120
1164
  categories.each do |category, exercises|
1121
1165
  div(data: { filter_group: "" }) do
@@ -1178,17 +1222,17 @@ button(**on(:increment), data: { testid: "inc" }) { "+" }
1178
1222
  **Binding inputs to action params (drop the magic `name:`).** A field's value
1179
1223
  travels with an action only if its `name` equals the param. Hand-writing
1180
1224
  `name: "value"` on every input is easy to forget — the action then silently gets
1181
- nothing. `reactive_input`/`reactive_select` emit the binding for you (the trigger
1182
- stays on the button, so focusing the field doesn't dispatch and collapse edit
1183
- mode):
1225
+ nothing. `reactive_field` returns the attribute hash that carries the binding —
1226
+ spread it onto any control (the trigger stays on the button, so focusing the
1227
+ field doesn't dispatch and collapse edit mode):
1184
1228
 
1185
1229
  ```ruby
1186
1230
  action :save, params: { value: :string, status: :string }
1187
1231
 
1188
1232
  def view_template
1189
1233
  span(**reactive_root) do
1190
- reactive_input(:value, value: @record.name) # <input name="value" …>
1191
- reactive_select(:status) do # <select name="status">…</select>
1234
+ input(**reactive_field(:value, value: @record.name)) # <input name="value" …>
1235
+ select(**reactive_field(:status)) do # <select name="status">…</select>
1192
1236
  %w[open closed].each { |s| option(value: s, selected: s == @record.status) { s } }
1193
1237
  end
1194
1238
  button(**mix(on(:save), data: { testid: "save" })) { "Save" }
@@ -1252,7 +1296,7 @@ gains a seam.
1252
1296
  By default an action re-renders its component in place. To do more, **return**
1253
1297
  `reply.<verb>` — a subject-bound builder available in every component. It governs
1254
1298
  only the actor's HTTP reply (cross-tab updates still use
1255
- `broadcast_*_to(..., exclude: reactive_connection_id)`). Returning anything else
1299
+ `broadcast_to(..., exclude: reactive_connection_id)`). Returning anything else
1256
1300
  keeps the default, so existing actions are unaffected.
1257
1301
 
1258
1302
  `reply` reads cleanly: the component is the implicit subject (no `self` to
@@ -1276,7 +1320,7 @@ def add(item:) = reply.replace.stream(Totals.update(@order)) # mul
1276
1320
  def update(name:) = (@row.update!(name:); reply.morph)
1277
1321
 
1278
1322
  # Re-render a COMPANION element (a heading mirroring the edited name) alongside self:
1279
- def rename(value:) = (@account.update!(name: value); reply.replace.also_update("page_heading", html: @account.name))
1323
+ def rename(value:) = (@account.update!(name: value); reply.replace.also(page_heading: @account.name))
1280
1324
 
1281
1325
  # Update ONLY part of the component (issue #30): re-stream just the total cell,
1282
1326
  # NOT the whole row. reply.streams emits exactly your streams plus a tiny
@@ -1289,8 +1333,8 @@ def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams
1289
1333
  |---|---|
1290
1334
  | `reply.replace` / `reply.update(morph: false)` | re-render in place (default; `replace` swaps the whole element via outerHTML, `update` swaps only the inner HTML) |
1291
1335
  | `reply.morph` / `reply.replace(morph: true)` / `reply.update(morph: true)` | re-render in place via Idiomorph (`method="morph"`) — preserves the focused `<input>` + caret; for per-field reactive editing (`replace` #28; `update` #113) |
1292
- | `.also_update(target, html:)` | also re-render a companion element by DOM id; `html` is a plain string (escaped) or a Phlex component |
1293
- | `.also_replace(component, morph: false)` | also re-render another Streamable component, targeting its own `#id`; `morph: true` morphs it in place |
1336
+ | `.also(target => content, )` | **UPDATE** (inner HTML) companion elements by DOM id; `content` is a plain string (escaped) or a Phlex component. The argument *type* picks the action — pairs mean `update` |
1337
+ | `.also(component, morph: false)` | **REPLACE** another Streamable component at its own `#id` (`morph: true` morphs in place). A component argument means `replace` |
1294
1338
  | `.flash(level, content, target: …)` | append a flash; `content` is a plain string (escaped, wrapped in a level-carrying `<div>` — see [Flash levels](#flash-levels)) or a Phlex component (rendered verbatim; off-request — no Rails `flash`); target defaults to `Phlex::Reactive.flash_target` (`"flash"`) |
1295
1339
  | `reply.remove` | remove the element (backed by `Streamable#to_stream_remove`) |
1296
1340
  | `reply.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
@@ -1299,7 +1343,7 @@ def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams
1299
1343
  | `.defer(component, placeholder:, morph:)` | take an **expensive segment off the actor's critical path** (issue #165) — the reply returns immediately and the real render streams to the SAME actor when ready; see [Deferred segments](#deferred-segments-replydefer--reactive_lazy) |
1300
1344
  | `reply.with(*streams)` / `#stream(*more)` | multi-stream (self re-render still injected for the token) |
1301
1345
 
1302
- `.flash`/`.stream`/`.also_*` are additive on a self-replace, so the component's
1346
+ `.flash`/`.stream`/`.also` are additive on a self-replace, so the component's
1303
1347
  signed token always refreshes. **`reply.streams`** is the exception that proves
1304
1348
  the rule: it deliberately skips the full-self replace (so your hand-built streams
1305
1349
  update only the targets you name) and refreshes the token via a tiny inert
@@ -1363,7 +1407,7 @@ Semantics you can rely on:
1363
1407
  the action's transaction committed; a rollback or a denied action leaks no
1364
1408
  deferred render (and enqueues no job).
1365
1409
  - **Actor-scoped** — the deferred render reaches only the actor; peers keep
1366
- getting updates via `broadcast_*_to` (use both when both need the value).
1410
+ getting updates via `broadcast_to` (use both when both need the value).
1367
1411
  - **Superseding** — a newer action for the same target aborts the in-flight
1368
1412
  deferred render; a fast typist never gets stale totals painted over fresh ones.
1369
1413
  - **Interactive on arrival** — the streamed root carries a fresh action token.
@@ -1479,12 +1523,14 @@ Prefer your own markup? Two escape hatches:
1479
1523
  # (you own the markup entirely, including the level styling):
1480
1524
  reply.replace.flash(:error, Alert.new(level: :error, message: msg))
1481
1525
 
1482
- # 2. Or configure a flash component ONCE — string flashes render through it
1483
- # (instantiated new(level:, content:)); component content still bypasses it:
1484
- Phlex::Reactive.flash_component = MyFlash # default nil the built-in wrapper
1526
+ # 2. Or configure a flash component ONCE — string flashes render through it;
1527
+ # component content still bypasses it. flash_component is an app-owned
1528
+ # CALLABLE (issue #182): it maps (level, content) to your component —
1529
+ # the gem never guesses kwargs:
1530
+ Phlex::Reactive.flash_component = ->(level, content) { MyFlash.new(level:, content:) } # default nil → the built-in wrapper
1485
1531
  ```
1486
1532
 
1487
- #### Server-pushed client ops (`reply.js` + `broadcast_js_to`, issue #97)
1533
+ #### Server-pushed client ops (`reply.js` + `broadcast_to(js:)`, issue #97)
1488
1534
 
1489
1535
  Sometimes the server needs the client to do something other than swap HTML —
1490
1536
  focus the next field after a save, dispatch an app event to a toast host, add an
@@ -1514,11 +1560,11 @@ transport (Action Cable **or** pgbus) — a background nudge to all viewers:
1514
1560
 
1515
1561
  ```ruby
1516
1562
  # In a model/job: light up the bell in every viewer's tab, minus the actor's own.
1517
- Notifications::Badge.broadcast_js_to(user, :alerts,
1518
- js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
1563
+ Notifications::Badge.broadcast_to(user, :alerts,
1564
+ js: js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
1519
1565
  ```
1520
1566
 
1521
- `broadcast_js_to` **refuses focus-class ops** (`focus`/`focus_first` raise
1567
+ `broadcast_to` with `js:` **refuses focus-class ops** (`focus`/`focus_first` raise
1522
1568
  `ArgumentError`): broadcasting focus would steal it in every subscriber's tab, so
1523
1569
  focus is an actor-reply concern only. Everything else is a fair broadcast (class
1524
1570
  and attribute toggles, `dispatch`). As with `on_client`, the ops are
@@ -1538,7 +1584,7 @@ record can be there purely for **identity + authorization** while the action's
1538
1584
  real job is to recompute **live, unsaved form values** the user is mid-edit. The
1539
1585
  record is re-located and instantiated on each action (`from_identity`), never
1540
1586
  auto-saved and never auto-broadcast; persistence and cross-tab broadcast are both
1541
- opt-in (you call `record.update!` / `broadcast_*_to` yourself). Pair that with
1587
+ opt-in (you call `record.update!` / `broadcast_to` yourself). Pair that with
1542
1588
  `reply.streams` and you get a first-class "authorize via the row, compute over
1543
1589
  the params, stream a partial update, touch neither the DB nor peer tabs" action:
1544
1590
 
@@ -1565,9 +1611,10 @@ Broadcasting is deliberately omitted so peer tabs with their own in-flight edits
1565
1611
  aren't clobbered. Authorize the record as always — identity is never permission.
1566
1612
 
1567
1613
  > **Under the hood.** `reply.<verb>` returns a `Phlex::Reactive::Response` — the
1568
- > immutable value object the endpoint reads. You can build one directly
1569
- > (`Phlex::Reactive::Response.replace(self)`) and it still works, but `reply` is
1570
- > the preferred surface; treat `Response` as an internal detail.
1614
+ > immutable value object the endpoint reads. `reply` is the ONE documented door
1615
+ > (issue #182): the old `Phlex::Reactive::Response.replace(self)` class verbs
1616
+ > are removed and raise a guided `ArgumentError` naming the `reply.<verb>`
1617
+ > rewrite; treat `Response` as an internal detail.
1571
1618
  > **`html:`/`content` escaping.** A plain string is **HTML-escaped** by Turbo, so
1572
1619
  > `html: @account.name` is safe even for user-supplied values. To emit intentional
1573
1620
  > markup, pass a **Phlex component** (`html: Heading.new(name: @record.name)`) —
@@ -1825,12 +1872,13 @@ class NotificationsList < ApplicationComponent
1825
1872
 
1826
1873
  def add(title:)
1827
1874
  todo = Todo.create!(title:)
1828
- reply.append(:notifications, todo) # append row + bump count + clear empty-state
1875
+ reply.append(todo, to: :notifications) # append row + bump count + clear empty-state
1829
1876
  end
1830
1877
 
1831
1878
  def dismiss(id:)
1832
- Todo.find(id).destroy!
1833
- reply.remove(:notifications, id) # remove row + bump count + restore empty-state at 0
1879
+ todo = Todo.find(id)
1880
+ todo.destroy!
1881
+ reply.remove(todo, from: :notifications) # remove row + bump count + restore empty-state at 0
1834
1882
  end
1835
1883
 
1836
1884
  # view_template renders the count, the container <ul>, and the empty-state on
@@ -1840,9 +1888,9 @@ end
1840
1888
 
1841
1889
  | Builder | Reply (one `Response`) |
1842
1890
  |---|---|
1843
- | `reply.append(name, model)` | append the row into the container + update the count + remove the empty-state when the list crosses 0→1 |
1844
- | `reply.prepend(name, model)` | as `append`, but the row goes to the top |
1845
- | `reply.remove(name, model)` | remove the row by its `dom_id` + update the count + append the empty-state back when the list crosses →0 |
1891
+ | `reply.append(model, to: name)` | append the row into the container + update the count + remove the empty-state when the list crosses 0→1 |
1892
+ | `reply.prepend(model, to: name)` | as `append`, but the row goes to the top |
1893
+ | `reply.remove(model, from: name)` | remove the row by its `dom_id` + update the count + append the empty-state back when the list crosses →0 |
1846
1894
 
1847
1895
  - **`size:` is the source of truth** — it's *re-counted* server-side after the
1848
1896
  mutation, so the badge and the empty-state are correct-by-construction (no
@@ -1854,11 +1902,11 @@ end
1854
1902
  (correct on the first click, silently rejected after); the helper bakes the
1855
1903
  refresh in so you never hit it.
1856
1904
  - **`remove` takes the record or its `dom_id` string** — a just-destroyed
1857
- ActiveRecord still answers `dom_id` correctly, so `reply.remove(:items, todo)`
1905
+ ActiveRecord still answers `dom_id` correctly, so `reply.remove(todo, from: :items)`
1858
1906
  works; pass the raw id only if your row `#id` matches `ActiveRecord::RecordIdentifier`.
1859
1907
  - **Reply governs the actor's HTTP response only.** For a *cross-tab* live list
1860
1908
  (other viewers see the row appear) keep broadcasting the row with
1861
- `NotificationRow.broadcast_append_to(..., exclude: reactive_connection_id)` —
1909
+ `NotificationRow.broadcast_to(..., append: model, exclude: reactive_connection_id)` —
1862
1910
  `reactive_collection` is the per-actor add/remove + count + empty-state wrapper,
1863
1911
  not a replacement for the broadcast.
1864
1912
 
@@ -2126,7 +2174,7 @@ end
2126
2174
 
2127
2175
  [pgbus](https://github.com/mhenrixon/pgbus) replaces Action Cable's transport
2128
2176
  with Postgres SSE and fixes its reliability gaps. With it installed,
2129
- `broadcast_*_to` and `turbo_stream_from` route over pgbus automatically:
2177
+ `broadcast_to` and `turbo_stream_from` route over pgbus automatically:
2130
2178
 
2131
2179
  ```ruby
2132
2180
  class Message < ApplicationRecord
@@ -2158,7 +2206,8 @@ events, all under the `phlex_reactive` namespace:
2158
2206
  |-------|-------|---------|
2159
2207
  | `action.phlex_reactive` | once per request | `component`, `action`, `outcome` (`ok`/`denied_undeclared`/`invalid_token`/`not_found`/`unauthorized`/`unverified`) |
2160
2208
  | `render.phlex_reactive` | per component render | `component`, `bytesize` |
2161
- | `broadcast.phlex_reactive` | per `broadcast_*_to` (Action Cable **and** pgbus) | `component`, `stream_action`, `streamables` |
2209
+ | `broadcast.phlex_reactive` | per `broadcast_to` call (Action Cable **and** pgbus) | `component`, `stream_action`, `streamables` |
2210
+ | `defer.phlex_reactive` | once per deferred render (the `reply.defer` pull/push endpoint) | `component`, `outcome` (`ok`/`no_content`/`invalid_token`/`not_found`/`unauthorized`) |
2162
2211
 
2163
2212
  Payloads carry **names, the outcome, and sizes only** — never the token, params,
2164
2213
  or state, so an event can't leak a secret. Subscribe from an initializer:
@@ -85,7 +85,7 @@ module Phlex
85
85
  end
86
86
 
87
87
  event[:outcome] = :ok
88
- stream = payload["m"] == "morph" ? component.to_stream_morph : component.to_stream_replace
88
+ stream = component.to_stream_replace(morph: payload["m"] == "morph")
89
89
  render turbo_stream: stream
90
90
  rescue Phlex::Reactive::InvalidToken => e
91
91
  event[:outcome] = :invalid_token
@@ -119,7 +119,7 @@ module Phlex
119
119
  payload = verified_payload
120
120
  component_class = resolve_component(payload["c"])
121
121
  event[:component] = component_class.name
122
- action_def = component_class.reactive_action(reactive_action_name)
122
+ action_def = component_class.reactive_actions[reactive_action_name.to_sym]
123
123
 
124
124
  # default-deny
125
125
  unless action_def
@@ -189,7 +189,9 @@ module Phlex
189
189
  return unless callable
190
190
 
191
191
  message = callable.call(kind)
192
- Phlex::Reactive::Response.flash_stream(:error, message, target: Phlex::Reactive.flash_target)
192
+ # flash_stream is a private Response builder (issue #182) the endpoint's
193
+ # error path is an internal caller, so reach it via send.
194
+ Phlex::Reactive::Response.send(:flash_stream, :error, message, target: Phlex::Reactive.flash_target)
193
195
  rescue => e # rubocop:disable Style/RescueStandardError
194
196
  ::Rails.logger&.warn("[phlex-reactive] error_flash raised: #{e.message}") if defined?(::Rails) &&
195
197
  ::Rails.respond_to?(:logger)
@@ -298,7 +300,7 @@ module Phlex
298
300
 
299
301
  streams = result.streams
300
302
 
301
- # Partial update (Response.streams / reply.streams, issue #30): the action
303
+ # Partial update (reply.streams, issue #30): the action
302
304
  # re-rendered only PART of the component and opted out of the full-self
303
305
  # replace. Append a tiny token-only stream so the signed token still rolls
304
306
  # forward WITHOUT re-rendering (and clobbering) the live inputs. Skip it
@@ -320,7 +322,7 @@ module Phlex
320
322
  # for replace AND update of self (both re-render the root via
321
323
  # render_component, carrying the token), and still adds the fallback
322
324
  # replace when a hand-built `with(...)` stream omits it. Idempotent: a
323
- # Response.replace(self)/update(self) already carries the token, so we
325
+ # reply.replace/update already carries the token, so we
324
326
  # don't double the self-render.
325
327
  #
326
328
  # GUARD 2 (issue #114): GLOBAL, un-scoped — "does ANY stream carry a
@@ -329,7 +331,7 @@ module Phlex
329
331
  # scan. Deliberately NOT rx_refreshes_token_for? (target-scoped): scoping
330
332
  # this guard would regress update/morph of self on an aliased id.
331
333
  #
332
- # A self-render reply (Response.replace/update/morph — subject_component
334
+ # A self-render reply (reply.replace/update/morph — subject_component
333
335
  # set) whose streams somehow carry no token gets the full self-replace
334
336
  # (defensive, unchanged). A companion-only reply.with (NO subject, NO
335
337
  # token_component) that doesn't re-render the root gets a token-ONLY
@@ -483,13 +485,31 @@ module Phlex
483
485
  # skipped — zero extra work on the production path.
484
486
  def coerce_params(action_def, component_class: nil, action_name: nil)
485
487
  dropped = Phlex::Reactive.verbose_errors ? [] : nil
486
- raw = params.fetch(:params, {})
488
+ raw = unwrap_scope(params.fetch(:params, {}), component_class)
487
489
 
488
490
  coerced = action_def.schema.coerce(raw, dropped)
489
491
  log_dropped_params(dropped, action_def.params, component_class, action_name)
490
492
  coerced
491
493
  end
492
494
 
495
+ # Issue #184: a scoped component's fields POST bracketed (todo[title]), which
496
+ # Rails expands to { "todo" => { "title" => … } } before the action runs. Peel
497
+ # exactly ONE scope level so the FLAT schema { title: :string } matches (the
498
+ # #67 bracket-drop footgun). Only when the component declares reactive_scope
499
+ # AND the raw params carry that single key mapping to a Hash — otherwise the
500
+ # raw params pass through untouched (unscoped components + nested_attributes
501
+ # shapes are unaffected).
502
+ def unwrap_scope(raw, component_class)
503
+ scope = component_class.reactive_scope if component_class.respond_to?(:reactive_scope)
504
+ return raw unless scope
505
+
506
+ # At the endpoint `raw` is ActionController::Parameters, so `raw[scope]` is
507
+ # too — NOT a Hash. Accept either shape (both answer to the schema's
508
+ # coerce): unwrap only when the scope key maps to a nested params/hash.
509
+ nested = raw[scope.to_s]
510
+ nested.is_a?(Hash) || nested.is_a?(ActionController::Parameters) ? nested : raw
511
+ end
512
+
493
513
  # ---- verbose_errors dropped-param logging --------------------------
494
514
  # ParamSchema collects the dropped entries; the controller formats the ONE
495
515
  # warn line (with the #16/#21 shape hints). Everything below runs ONLY when
@@ -577,7 +597,7 @@ module Phlex
577
597
  diagnostic: :unknown_class
578
598
  )
579
599
  end
580
- unless klass.respond_to?(:reactive_action?) && klass.include?(Phlex::Reactive::Component)
600
+ unless klass.respond_to?(:reactive_actions) && klass.include?(Phlex::Reactive::Component)
581
601
  raise Phlex::Reactive::InvalidToken.new(
582
602
  "#{name} resolved but does not include Phlex::Reactive::Component",
583
603
  diagnostic: :not_reactive_class
@@ -0,0 +1,52 @@
1
+ // The client-side confirm-predicate registry — the multi-field escape hatch for
2
+ // conditional confirmation (issue #179).
3
+ //
4
+ // confirm: { when: { total: 0 }, message: } handles the single-field 80% case
5
+ // declaratively (the reactive_show conditions language), with NO JS. But some
6
+ // soft-validation is multi-field — "warn if the end date precedes the start",
7
+ // "warn if two related totals disagree" — which a single field=value condition
8
+ // can't express. This registry is the seam for that logic: name a pure function
9
+ // with `confirm: { predicate: "name", message: }` (Ruby) and register it here.
10
+ //
11
+ // The seam mirrors compute.js (setComputeReducer) and confirm.js: a settable
12
+ // registry with a lookup the controller calls. Register once at boot:
13
+ //
14
+ // import { setConfirmPredicate } from "phlex/reactive/confirm_predicate"
15
+ // setConfirmPredicate("end_before_start", ({ starts_at, ends_at }) =>
16
+ // ends_at !== "" && ends_at < starts_at)
17
+ //
18
+ // The predicate's signature is (fields) => boolean:
19
+ //
20
+ // fields — a plain object of { name: value } over the trigger root's collected
21
+ // controls (the SAME snapshot reactive_compute reads — #collectFields).
22
+ // Values are the raw control values (strings; a checkbox is a boolean).
23
+ //
24
+ // It returns truthy to WARN (the confirm dialog fires with the declared message)
25
+ // or falsy to PROCEED with no dialog. The predicate is soft-validation UX, NOT
26
+ // authorization: a user can bypass it (devtools, an unregistered name) and the
27
+ // action still hits the endpoint's real authorize/default-deny — never let a
28
+ // predicate stand in for a server-side check.
29
+ //
30
+ // A missing predicate (name never registered) makes the gate a NO-OP: the
31
+ // controller proceeds WITHOUT a dialog and warns, exactly like compute.js's
32
+ // unknown-reducer posture — a stale/typo'd name must not break the page or
33
+ // (worse) block a legitimate action behind a dialog that can never resolve.
34
+
35
+ const predicates = new Map()
36
+
37
+ // Register (or replace) the predicate for `key`. `fn` is
38
+ // (fields: Record<string, unknown>) => boolean — truthy warns, falsy proceeds.
39
+ export function setConfirmPredicate(key, fn) {
40
+ predicates.set(key, fn)
41
+ }
42
+
43
+ // Look up a registered predicate; undefined when none — the controller then
44
+ // proceeds without a dialog (and warns) rather than throwing or blocking.
45
+ export function confirmPredicate(key) {
46
+ return predicates.get(key)
47
+ }
48
+
49
+ // Test seam: clear the registry so a predicate registered in one test can't leak.
50
+ export function __resetConfirmPredicateRegistryForTest() {
51
+ predicates.clear()
52
+ }
@@ -0,0 +1,4 @@
1
+ var j=new Map;function u(b,q){j.set(b,q)}function v(b){return j.get(b)}function w(){j.clear()}export{u as setConfirmPredicate,v as confirmPredicate,w as __resetConfirmPredicateRegistryForTest};
2
+
3
+ //# debugId=2F392605F11792F264756E2164756E21
4
+ //# sourceMappingURL=confirm_predicate.min.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["confirm_predicate.js"],
4
+ "sourcesContent": [
5
+ "// The client-side confirm-predicate registry — the multi-field escape hatch for\n// conditional confirmation (issue #179).\n//\n// confirm: { when: { total: 0 }, message: } handles the single-field 80% case\n// declaratively (the reactive_show conditions language), with NO JS. But some\n// soft-validation is multi-field — \"warn if the end date precedes the start\",\n// \"warn if two related totals disagree\" — which a single field=value condition\n// can't express. This registry is the seam for that logic: name a pure function\n// with `confirm: { predicate: \"name\", message: }` (Ruby) and register it here.\n//\n// The seam mirrors compute.js (setComputeReducer) and confirm.js: a settable\n// registry with a lookup the controller calls. Register once at boot:\n//\n// import { setConfirmPredicate } from \"phlex/reactive/confirm_predicate\"\n// setConfirmPredicate(\"end_before_start\", ({ starts_at, ends_at }) =>\n// ends_at !== \"\" && ends_at < starts_at)\n//\n// The predicate's signature is (fields) => boolean:\n//\n// fields — a plain object of { name: value } over the trigger root's collected\n// controls (the SAME snapshot reactive_compute reads — #collectFields).\n// Values are the raw control values (strings; a checkbox is a boolean).\n//\n// It returns truthy to WARN (the confirm dialog fires with the declared message)\n// or falsy to PROCEED with no dialog. The predicate is soft-validation UX, NOT\n// authorization: a user can bypass it (devtools, an unregistered name) and the\n// action still hits the endpoint's real authorize/default-deny — never let a\n// predicate stand in for a server-side check.\n//\n// A missing predicate (name never registered) makes the gate a NO-OP: the\n// controller proceeds WITHOUT a dialog and warns, exactly like compute.js's\n// unknown-reducer posture — a stale/typo'd name must not break the page or\n// (worse) block a legitimate action behind a dialog that can never resolve.\n\nconst predicates = new Map()\n\n// Register (or replace) the predicate for `key`. `fn` is\n// (fields: Record<string, unknown>) => boolean — truthy warns, falsy proceeds.\nexport function setConfirmPredicate(key, fn) {\n predicates.set(key, fn)\n}\n\n// Look up a registered predicate; undefined when none — the controller then\n// proceeds without a dialog (and warns) rather than throwing or blocking.\nexport function confirmPredicate(key) {\n return predicates.get(key)\n}\n\n// Test seam: clear the registry so a predicate registered in one test can't leak.\nexport function __resetConfirmPredicateRegistryForTest() {\n predicates.clear()\n}\n"
6
+ ],
7
+ "mappings": "AAkCA,IAAM,EAAa,IAAI,IAIhB,SAAS,CAAmB,CAAC,EAAK,EAAI,CAC3C,EAAW,IAAI,EAAK,CAAE,EAKjB,SAAS,CAAgB,CAAC,EAAK,CACpC,OAAO,EAAW,IAAI,CAAG,EAIpB,SAAS,CAAsC,EAAG,CACvD,EAAW,MAAM",
8
+ "debugId": "2F392605F11792F264756E2164756E21",
9
+ "names": []
10
+ }