phlex-reactive 0.4.8 → 0.9.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +868 -3
  3. data/README.md +986 -32
  4. data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
  5. data/app/javascript/phlex/reactive/compute.js +52 -7
  6. data/app/javascript/phlex/reactive/compute.min.js +4 -0
  7. data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
  8. data/app/javascript/phlex/reactive/confirm.min.js +4 -0
  9. data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
  10. data/app/javascript/phlex/reactive/reactive_controller.js +1487 -80
  11. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
  12. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
  13. data/lib/generators/phlex/reactive/component/USAGE +2 -1
  14. data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
  15. data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
  16. data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
  17. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
  18. data/lib/phlex/reactive/component/dsl.rb +249 -0
  19. data/lib/phlex/reactive/component/helpers.rb +595 -0
  20. data/lib/phlex/reactive/component/identity.rb +92 -0
  21. data/lib/phlex/reactive/component/registry.rb +200 -0
  22. data/lib/phlex/reactive/component.rb +30 -442
  23. data/lib/phlex/reactive/doctor.rb +333 -0
  24. data/lib/phlex/reactive/engine.rb +27 -9
  25. data/lib/phlex/reactive/js.rb +222 -0
  26. data/lib/phlex/reactive/log_subscriber.rb +64 -0
  27. data/lib/phlex/reactive/param_schema.rb +390 -0
  28. data/lib/phlex/reactive/reply.rb +5 -3
  29. data/lib/phlex/reactive/response.rb +156 -16
  30. data/lib/phlex/reactive/stream.rb +98 -0
  31. data/lib/phlex/reactive/streamable.rb +307 -43
  32. data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
  33. data/lib/phlex/reactive/test_helpers.rb +308 -0
  34. data/lib/phlex/reactive/version.rb +1 -1
  35. data/lib/phlex/reactive.rb +329 -14
  36. data/lib/tasks/phlex_reactive.rake +14 -0
  37. metadata +19 -1
@@ -0,0 +1,595 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # The view-side helper surface of Component (issue #115): the reply/js
7
+ # builders, the root-element attribute helpers (reactive_attrs/
8
+ # reactive_root), the trigger builders (on/on_client) with their hint
9
+ # vocabularies, the form-binding helpers (reactive_field/input/select/
10
+ # text/busy_on/reactive_compute_attrs), and the nested-attributes
11
+ # helpers. Everything a view_template spreads or calls — no registries,
12
+ # no signing (those live in DSL and Identity).
13
+ module Helpers
14
+ extend ActiveSupport::Concern
15
+
16
+ # The acting client's SSE connection id during the current action (nil
17
+ # outside an action, or when the client isn't subscribed to a stream).
18
+ # Pass it as `exclude:` when broadcasting from an action so the actor
19
+ # doesn't receive the echo of its own change — it already gets the
20
+ # action's HTTP response:
21
+ #
22
+ # def send_message(body:)
23
+ # msg = ChatMessage.create!(room: @room, body:)
24
+ # ChatMessage::Item.broadcast_append_to("chat", @room,
25
+ # target: "messages", model: msg, exclude: reactive_connection_id)
26
+ # end
27
+ def reactive_connection_id
28
+ Phlex::Reactive.current_connection_id
29
+ end
30
+
31
+ # Subject-bound reply builder — the preferred way to control an action's
32
+ # reply. `reply.replace.flash(:error, msg)` reads cleaner than
33
+ # `Phlex::Reactive::Response.replace(self).flash(:error, msg)`: the
34
+ # component is the implicit subject (no `self` to thread) and there's no
35
+ # constant to qualify (reply is a method, so a namespaced component needs
36
+ # no `Response = …` alias). It returns the same immutable Response the
37
+ # endpoint reads, so chaining and the legacy return-value contract are
38
+ # unchanged. See Phlex::Reactive::Reply.
39
+ #
40
+ # def archive = reply.remove
41
+ # def go_home = reply.redirect("/todos")
42
+ # def update(name:) = (@account.update!(name:); reply.morph)
43
+ def reply
44
+ Phlex::Reactive::Reply.new(self)
45
+ end
46
+
47
+ # An empty client-side op chain (issue #95) — the starting point for
48
+ # on_client's DOM commands, mirroring how `reply` starts a Response chain:
49
+ # button(**on_client(:click, js.toggle("#menu"))) { "Menu" }
50
+ # Immutable: each verb returns a new chain, so reuse never leaks ops.
51
+ def js
52
+ Phlex::Reactive::JS.new
53
+ end
54
+
55
+ # Root-element attributes: marks the element reactive and carries the
56
+ # signed identity token. Spread onto the root:
57
+ # div(id:, **reactive_attrs) { ... }
58
+ def reactive_attrs
59
+ data = {
60
+ controller: "reactive",
61
+ reactive_token_value: reactive_token
62
+ }
63
+ # Client debug mode (issue #108): stamp the flag so the generic controller
64
+ # console.groups every dispatch. STRING "true", not boolean — Phlex renders
65
+ # a boolean-true attr VALUELESS, which getAttribute reads as "" (falsy in
66
+ # JS), so the client's attr check would never fire (the on()/warn_unsaved
67
+ # precedent). Off by default → no key, no string, zero client surface.
68
+ data[:reactive_debug] = "true" if Phlex::Reactive.debug
69
+ { data: }
70
+ end
71
+
72
+ # The WHOLE reactive root in one spread (issue #48). reactive_attrs alone
73
+ # doesn't emit `id:`, so `id:` and `data-controller="reactive"` can land on
74
+ # DIFFERENT elements — putting `id:` on a child leaves the controller root's
75
+ # `id` empty, which silently breaks token threading (the client self-matches
76
+ # its next token by `this.element.id`) and 403s on the next action.
77
+ #
78
+ # reactive_root binds the id to the SAME element as reactive_attrs, so the
79
+ # footgun is unbuildable:
80
+ # div(**reactive_root) { ... } # id + controller + token
81
+ # div(**reactive_root(class: "card")) { ... } # add your own attrs
82
+ #
83
+ # mix deep-merges, so overrides add `class:`/`data:` without clobbering the
84
+ # controller/token data: (a bare data: would). The id is resolved separately
85
+ # (an explicit override wins as a clean replace, not a `mix` string-concat —
86
+ # mix would join two String ids into "default override").
87
+ #
88
+ # `track_dirty:`/`warn_unsaved:` (issue #103) are CONSUMED here — deleted
89
+ # from overrides BEFORE the mix — because reactive_root treats every leftover
90
+ # kwarg as a literal HTML attribute override (only :id is special-cased), so
91
+ # an unconsumed `track_dirty: true` would render a bogus `track-dirty="true"`
92
+ # attribute. track_dirty mixes the trackDirty descriptor onto the root's
93
+ # data-action (mix token-joins, so a caller's own data-action survives);
94
+ # warn_unsaved emits the marker the client reads to arm the navigate-away
95
+ # guard (STRING "true" — a boolean-true attr renders valueless, which the
96
+ # client's param reader sees as "" → falsy).
97
+ def reactive_root(**overrides)
98
+ root_id = overrides.delete(:id) || id
99
+ track_dirty = overrides.delete(:track_dirty)
100
+ warn_unsaved = overrides.delete(:warn_unsaved)
101
+
102
+ attrs = mix({ **reactive_attrs }, overrides, { id: root_id })
103
+ attrs = mix(attrs, { data: { action: "input->reactive#trackDirty" } }) if track_dirty
104
+ attrs = mix(attrs, { data: { reactive_warn_unsaved: "true" } }) if warn_unsaved
105
+ attrs
106
+ end
107
+
108
+ # Attributes for an element that triggers an action.
109
+ # button(**on(:toggle)) { "○" }
110
+ # form(**on(:save, event: "submit")) { ... }
111
+ # input(**on(:update, event: "input", debounce: 300)) # live-as-you-type
112
+ #
113
+ # Extra keyword args become explicit params merged over collected form
114
+ # fields. For click triggers we force type="button" so a bare button
115
+ # inside a <form> can't submit it and cause a full-page navigation.
116
+ #
117
+ # `debounce:` (milliseconds) coalesces rapid events (e.g. keystrokes on an
118
+ # "input" trigger) into ONE round trip fired after the quiet period — so
119
+ # live-update-as-you-type doesn't POST per keystroke. A blur flushes a
120
+ # pending dispatch so the last edit is never dropped. Omit it for the
121
+ # immediate-dispatch default.
122
+ #
123
+ # `confirm:` (a message string) gates the action behind a confirmation
124
+ # prompt (issue #52). Destructive reactive triggers can't use Hotwire's
125
+ # `data-turbo-confirm` — the reactive controller calls preventDefault and
126
+ # enqueues the POST itself, so Turbo's confirm handling never runs. The
127
+ # client shows window.confirm(message) FIRST and bails before any
128
+ # enqueue/debounce if the user declines (and prevents the native default so
129
+ # a `submit` trigger can't navigate on cancel). Omit it for no prompt.
130
+ # button(**on(:destroy, confirm: "Really delete this item?")) { "Delete" }
131
+ #
132
+ # `event:` is interpolated verbatim into the Stimulus action descriptor
133
+ # (`#{event}->reactive#dispatch`), so any Stimulus event string works —
134
+ # including its native KEYBOARD FILTERS. Pass `event: "keydown.enter"` for
135
+ # Enter-to-submit or `event: "keydown.esc"` for Escape-to-cancel, and the
136
+ # action fires only on that key — no separate option, no client code, and
137
+ # `key` stays free as an ordinary action-param name (on(:switch, key: …)):
138
+ # input(**on(:add, event: "keydown.enter")) # Enter submits
139
+ # button(**on(:cancel, event: "keydown.esc")) # Escape cancels
140
+ #
141
+ # `listnav:` (a CSS selector for the option elements) adds keyboard list
142
+ # navigation to a search/combobox trigger (issue #72). It appends Stimulus
143
+ # keyboard filters to the SAME element's data-action so Arrow Up/Down move a
144
+ # client-side highlight among the options, Enter picks the highlighted one
145
+ # (clicking its own reactive trigger — so selection stays a signed action),
146
+ # and Escape clears — all with NO server round trip for the highlight (the
147
+ # controller's listnav* handlers, like #recompute). Omit it for no nav.
148
+ # input(**on(:search, event: "input", debounce: 300, listnav: "[role=option]"))
149
+ # The verbatim JSON for an empty explicit-params payload. The common
150
+ # trigger (on(:increment), no params) hits this on EVERY render — skipping
151
+ # params.to_json (which re-serializes {} to the same "{}" each time) avoids
152
+ # a per-render allocation while keeping the wire format byte-identical.
153
+ EMPTY_PARAMS_JSON = "{}"
154
+
155
+ # The keyboard filters appended to a listnav trigger's data-action. Each is
156
+ # a client-only handler (no POST) except Enter, which clicks the highlighted
157
+ # option's own reactive trigger. Stimulus binds these natively.
158
+ LISTNAV_ACTIONS = [
159
+ "keydown.down->reactive#listnavNext",
160
+ "keydown.up->reactive#listnavPrev",
161
+ "keydown.enter->reactive#listnavPick",
162
+ "keydown.esc->reactive#listnavClose"
163
+ ].freeze
164
+
165
+ # Event modifiers (issue #80) — window:, once:, outside:, throttle: are
166
+ # RESERVED keyword names on on() (no longer usable as free action params):
167
+ #
168
+ # `window: true` binds the trigger to the window (Stimulus's native
169
+ # `@window` descriptor suffix) — for page-level events like scroll/resize.
170
+ # `once: true` appends Stimulus's `:once` option, so the trigger fires at
171
+ # most one round trip and then unbinds. Both are pure descriptor
172
+ # composition. A window-bound trigger is NOT preventDefault-ed by the
173
+ # client (it would kill every native click/submit on the page), and it
174
+ # skips the forced type="button" (it isn't an in-form button trigger).
175
+ #
176
+ # `outside: true` fires the action only for events OUTSIDE this
177
+ # component's ROOT (containment against the reactive root element) — the
178
+ # close-a-dropdown-on-outside-click pattern. It implies `window: true`;
179
+ # an event inside the root is a complete client-side no-op:
180
+ # div(**mix(reactive_root, on(:close_menu, outside: true))) { ... }
181
+ #
182
+ # `throttle:` (milliseconds) rate-limits a hot trigger LEADING-EDGE: the
183
+ # first event fires immediately, further events are suppressed until the
184
+ # window elapses (scroll/mousemove). Mutually exclusive with `debounce:`
185
+ # (trailing-edge) — passing both raises ArgumentError.
186
+ # div(**mix(reactive_root, on(:track, event: "scroll", window: true, throttle: 250)))
187
+ #
188
+ # `optimistic:` (issue #98) — a small, ALWAYS-REVERSIBLE vocabulary of
189
+ # COSMETIC hints the client applies the instant the trigger fires and
190
+ # REVERTS if the round trip fails, so a click/toggle gives instant feedback
191
+ # instead of waiting a full round trip. Hints are visual only — never data,
192
+ # never computed values (that would be client state). Supported ops in the
193
+ # hint hash:
194
+ # * toggle_class:/add_class:/remove_class: — a class string or array,
195
+ # applied to the TRIGGER (default) or to a `to:` selector scoped to the
196
+ # root (`to: :root` targets the root element itself).
197
+ # * checked: :keep — for a click-bound checkbox/radio, the client SKIPS
198
+ # its unconditional preventDefault so the native flip happens now
199
+ # (today the morph never even lets it flip). On failure, the flip is
200
+ # reverted.
201
+ # * hide: true — hides the target immediately (the `hide: true` + a
202
+ # `reply.remove` action is the instant delete-a-row recipe: the hint
203
+ # hides it, the reply removes it; a failure snaps it back).
204
+ # Success does NO cleanup: a reply that re-renders the root overwrites the
205
+ # hint with server truth; a reply that does NOT re-render the root
206
+ # (reply.remove / streams-only) LEAVES the hint standing — that's the
207
+ # instant-delete working as intended.
208
+ # input(type: "checkbox", checked: @todo.done,
209
+ # **mix(on(:toggle, event: "change", optimistic: { checked: :keep }), name: "done"))
210
+ # button(**on(:destroy, confirm: "Delete?", optimistic: { hide: true, to: :root })) { "Delete" }
211
+ # `loading:` / `disable_with:` (issue #99) — declarative per-trigger loading
212
+ # states, Livewire's wire:loading + phx-disable-with without a Stimulus
213
+ # controller. The moment the request is ENQUEUED (covering the queue wait,
214
+ # not just the fetch), the trigger gets `data-reactive-busy="<action>"`, an
215
+ # optional loading class, an optional disabled + text swap; all revert in a
216
+ # guarded finally. `loading:` is a hash:
217
+ # * disable: true — disable the trigger while pending
218
+ # * class: "…" / [ … ] — a loading class string/array on the trigger
219
+ # (or a `to:` selector scoped to the root)
220
+ # * text: "Saving…" — swap the trigger's textContent while pending
221
+ # * to: :root / "sel" — target the class/text at the root or a selector
222
+ # `disable_with: "Saving…"` is the shorthand for
223
+ # `{ disable: true, text: "Saving…" }` and merges over an explicit `loading:`
224
+ # (its text/disable win). Both become RESERVED on() kwargs (CHANGELOG note,
225
+ # like #80's four and #98's optimistic:) — no longer free action-param names.
226
+ # The trigger/root also always carry `data-reactive-busy` for the whole
227
+ # pending window regardless of these hints, so an app styles a spinner with
228
+ # `[data-reactive-busy] .spinner { display: block }` and zero Ruby; see
229
+ # busy_on for scoped indicators.
230
+ # button(**on(:save, disable_with: "Saving…")) { "Save" }
231
+ # button(**on(:destroy, confirm: "Sure?", loading: { class: "opacity-50" })) { "Delete" }
232
+ def on(action_name, event: "click", debounce: nil, throttle: nil, confirm: nil, listnav: nil,
233
+ window: false, once: false, outside: false, optimistic: nil, loading: nil, disable_with: nil,
234
+ **params)
235
+ # A typo'd or forgotten action renders fine and only surfaces as an
236
+ # opaque 403 at CLICK time (the endpoint's default-deny). Under
237
+ # verbose_errors (dev + test), fail loudly at RENDER time instead —
238
+ # listing the declared actions — the same courtesy reactive_compute_attrs
239
+ # gives an undeclared compute (issue #105). Placed FIRST, before any attr
240
+ # building. Production (flag off) keeps the permissive emit: a stale page
241
+ # after a deploy that removed an action must not 500 on render. This is a
242
+ # dev-time aid, NOT the security boundary — default-deny stays the
243
+ # SERVER's enforcement. on_client triggers are not declared actions (no
244
+ # registry), so they are never checked here.
245
+ #
246
+ # The check applies ONLY to a component that declares actions of its own.
247
+ # A component with an EMPTY registry is a cross-component dispatch helper
248
+ # — a child row that renders a trigger for its CONTAINER's action and
249
+ # sends the container's token (e.g. NotificationRowComponent → the list's
250
+ # :dismiss). It can't self-validate against a registry it doesn't own, so
251
+ # the guard would false-positive; skipping the empty case keeps the
252
+ # pattern working while still catching a typo in a component that DOES
253
+ # declare actions (the issue's target — on(:togle) where :toggle exists).
254
+ # verbose_errors is checked FIRST so production (flag off) short-circuits
255
+ # before touching the registry — zero added cost on the hot path.
256
+ if Phlex::Reactive.verbose_errors &&
257
+ (actions = self.class.reactive_actions).any? && !actions.key?(action_name.to_sym)
258
+ raise Phlex::Reactive::Error,
259
+ "#{self.class} has no declared action #{action_name.to_sym.inspect} " \
260
+ "(declared: #{actions.keys.inspect})"
261
+ end
262
+
263
+ if debounce && throttle
264
+ raise ArgumentError,
265
+ "on(#{action_name.inspect}) got both debounce: and throttle: — they are mutually " \
266
+ "exclusive (debounce is trailing-edge, throttle is leading-edge); pick one"
267
+ end
268
+
269
+ window_bound = window || outside
270
+ action = "#{event}#{"@window" if window_bound}->reactive#dispatch#{":once" if once}"
271
+ action = "#{action} #{LISTNAV_ACTIONS.join(" ")}" if listnav
272
+ attrs = {
273
+ data: {
274
+ action:,
275
+ reactive_action_param: action_name.to_s,
276
+ reactive_params_param: params.empty? ? EMPTY_PARAMS_JSON : params.to_json
277
+ }
278
+ }
279
+ attrs[:data][:reactive_debounce_param] = debounce if debounce
280
+ attrs[:data][:reactive_throttle_param] = throttle if throttle
281
+ attrs[:data][:reactive_confirm_param] = confirm if confirm
282
+ attrs[:data][:reactive_listnav_option_param] = listnav if listnav
283
+ attrs[:data][:reactive_optimistic_param] = optimistic_hint_json(optimistic, action_name) if optimistic
284
+ if (loading_hint = loading_hint_json(loading, disable_with, action_name))
285
+ attrs[:data][:reactive_loading_param] = loading_hint
286
+ end
287
+ # STRING "true", not boolean: Phlex renders a `true` attribute VALUELESS
288
+ # (data-reactive-outside-param), which Stimulus's param reader sees as ""
289
+ # — falsy in JS, so the guard silently never fires. The explicit ="true"
290
+ # typecasts to a real boolean on the client.
291
+ attrs[:data][:reactive_outside_param] = "true" if outside
292
+ # The client decides preventDefault behavior from event.params (never by
293
+ # sniffing the descriptor), so EVERY window binding flags the param.
294
+ attrs[:data][:reactive_window_param] = "true" if window_bound
295
+ # Force type="button" for click triggers so a bare button inside a <form>
296
+ # can't submit it — EXCEPT when checked: :keep is declared: that hint's
297
+ # whole point is to let a click-bound checkbox/radio flip natively, and a
298
+ # forced type="button" would destroy the very control being toggled
299
+ # (issue #98). The caller supplies the real type="checkbox"/"radio".
300
+ attrs[:type] = "button" if event == "click" && !window_bound && !optimistic_keeps_native?(optimistic)
301
+ attrs
302
+ end
303
+
304
+ # Attributes for a CLIENT-ONLY trigger (issue #95): binds a DOM event to a
305
+ # chain of declarative DOM ops (Phlex::Reactive::JS) that the generic
306
+ # controller's runOps action applies in the browser — NO token, NO params,
307
+ # NO POST, ever. The zero-round-trip sibling of on():
308
+ #
309
+ # button(**on_client(:click, js.toggle("#menu"))) { "Menu" }
310
+ # # tabs, one line per tab, no Stimulus controller:
311
+ # button(**on_client(:click, js.hide(".panel").show("#panel-2")))
312
+ #
313
+ # `window:`, `once:`, and `outside:` compose exactly like on()'s event
314
+ # modifiers (#80): outside-click-to-close a dropdown is
315
+ # div(**mix(reactive_root, on_client(:click, js.hide("#menu"), outside: true)))
316
+ # Window-bound triggers are never preventDefault-ed by the client and skip
317
+ # the forced type="button".
318
+ #
319
+ # Ops are EPHEMERAL UI: any server re-render of the component (an action
320
+ # reply, a broadcast, a morph) rebuilds from server state and resets
321
+ # whatever they toggled — by design (the LiveView JS-commands caveat). Use
322
+ # a signed action for state that must survive re-renders.
323
+ #
324
+ # Validation is loud: only a non-empty Phlex::Reactive::JS chain is
325
+ # accepted — a dead trigger should fail at render, not no-op in the
326
+ # browser.
327
+ def on_client(event, ops, window: false, once: false, outside: false)
328
+ unless ops.is_a?(Phlex::Reactive::JS)
329
+ raise ArgumentError,
330
+ "on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
331
+ "got #{ops.class}"
332
+ end
333
+ raise ArgumentError, "on_client(#{event.inspect}) got no ops — a dead trigger" if ops.empty?
334
+
335
+ event = event.to_s
336
+ window_bound = window || outside
337
+ attrs = {
338
+ data: {
339
+ action: "#{event}#{"@window" if window_bound}->reactive#runOps#{":once" if once}",
340
+ reactive_ops_param: ops.to_json
341
+ }
342
+ }
343
+ # STRING "true", not boolean — same Phlex valueless-attribute trap as
344
+ # on()'s flags above.
345
+ attrs[:data][:reactive_outside_param] = "true" if outside
346
+ attrs[:data][:reactive_window_param] = "true" if window_bound
347
+ attrs[:type] = "button" if event == "click" && !window_bound
348
+ attrs
349
+ end
350
+
351
+ # Bind a form control's `name` to an action param so its value travels with
352
+ # the action — instead of hand-writing the magic `name: "value"` on every
353
+ # input and silently getting no params when you forget it (issue #23).
354
+ # Returns a Phlex attributes hash to spread onto any control:
355
+ # input(**reactive_field(:value, value: @record.name))
356
+ # select(**reactive_field(:status)) { ... }
357
+ # Extra attrs merge over the binding; an explicit name: still wins (escape
358
+ # hatch). The trigger (on(:save)) stays on the button, not the field — so
359
+ # focusing the input doesn't dispatch and collapse edit mode.
360
+ #
361
+ # `dirty: true` (issue #103) wires the field to the generic controller's
362
+ # trackDirty action, so a change re-scans this reactive root's owned fields
363
+ # and marks the changed ones `data-reactive-dirty` (and the root with a
364
+ # count). NO client state is shipped — the baseline is the DOM's own
365
+ # `defaultValue`/`defaultChecked`/`defaultSelected`, i.e. the attributes from
366
+ # the last server render; dirty = current ≠ default. It deep-merges the
367
+ # descriptor via mix, so a caller's own data-action is token-joined, not
368
+ # clobbered (CLAUDE.md Never-Do #8 — combining with other data:/on() still
369
+ # needs mix at the call site).
370
+ def reactive_field(param, dirty: false, **attrs)
371
+ binding_attrs = { name: param.to_s, **attrs }
372
+ return binding_attrs unless dirty
373
+
374
+ mix(binding_attrs, { data: { action: "input->reactive#trackDirty" } })
375
+ end
376
+
377
+ # Render an <input> already bound to an action param (issue #23). Sugar for
378
+ # input(**reactive_field(param, **attrs)); the value/type/etc. pass through.
379
+ # reactive_input(:value, value: @record.name, type: "text")
380
+ def reactive_input(param, **attrs)
381
+ input(**reactive_field(param, **attrs))
382
+ end
383
+
384
+ # Mirror a compute output (or a declared input) into a TEXT NODE — a live
385
+ # preview heading, a character counter, a "Hello, {name}" greeting (issue
386
+ # #104). The text sibling of reactive_field: reactive_field binds a FORM
387
+ # CONTROL; reactive_text binds a plain span the client writes via
388
+ # textContent (XSS-safe by construction — never innerHTML).
389
+ #
390
+ # h2 { reactive_text(:title_preview, @post.title) }
391
+ # small { reactive_text(:char_count) }
392
+ #
393
+ # The span carries data-reactive-text=<name> and NO `name` attribute, so
394
+ # #collectFields never sweeps it into the POSTed params. `initial` seeds the
395
+ # first paint — the SERVER render must seed the same derived value the
396
+ # reducer would, or a morph repaints stale text (same reconcile contract
397
+ # reactive_compute documents). Extra attrs merge over the binding.
398
+ def reactive_text(name, initial = nil, **attrs)
399
+ span(**mix({ data: { reactive_text: name.to_s } }, attrs)) { initial }
400
+ end
401
+
402
+ # Scoped busy indicator (issue #99). Marks an element so the generic
403
+ # controller toggles `data-reactive-busy` on it ONLY while `action` is in
404
+ # flight — the scoped sibling of the always-on `data-reactive-busy` the
405
+ # trigger and root carry. Spread onto any element inside the reactive root;
406
+ # style it with `[data-reactive-busy] { … }` and zero Ruby:
407
+ # span(**busy_on(:save), class: "spinner hidden")
408
+ def busy_on(action)
409
+ { data: { reactive_busy_on: action.to_s } }
410
+ end
411
+
412
+ # Data attributes declaring a client-side compute for the root element.
413
+ # Spread ALONGSIDE reactive_root so the generic controller can find the
414
+ # reducer and the named input/output fields inside this root:
415
+ # div(**mix(reactive_root, reactive_compute_attrs(:payment_split))) { … }
416
+ #
417
+ # It emits the reducer key plus the input/output field names as JSON so the
418
+ # client runs the reducer on `input`, writes the outputs with no round trip,
419
+ # then the debounced POST reconciles from the server reply. Raises for an
420
+ # undeclared compute — a silent no-op would leave the field wiring dead.
421
+ def reactive_compute_attrs(name)
422
+ definition = self.class.reactive_compute_def(name)
423
+ raise Error, "#{self.class} has no reactive_compute #{name.inspect}" unless definition
424
+
425
+ {
426
+ data: {
427
+ reactive_compute_reducer_param: definition.reducer,
428
+ reactive_compute_inputs_param: compute_inputs_param(definition),
429
+ reactive_compute_outputs_param: definition.outputs.map(&:to_s).to_json
430
+ }
431
+ }
432
+ end
433
+
434
+ # The inputs param wire (issue #104). Untyped (array form) → a JSON ARRAY of
435
+ # names, byte-identical to the shipped wire so the client keeps its numeric
436
+ # coercion. Typed (hash form) → a JSON OBJECT of name→type
437
+ # ({"title":"string","qty":"number"}) so the client reads a :string raw and
438
+ # coerces a :number through Number.
439
+ def compute_inputs_param(definition)
440
+ types = definition.input_types
441
+ return definition.inputs.map(&:to_s).to_json if types.nil?
442
+
443
+ definition.inputs.to_h { [it.to_s, types[it].to_s] }.to_json
444
+ end
445
+
446
+ # Render a <select> bound to an action param (issue #23). The options block
447
+ # is the element's content, so the awkward FormBuilder positional split
448
+ # (where name: lands after the options/html-options args) goes away:
449
+ # reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
450
+ def reactive_select(param, **attrs, &)
451
+ select(**reactive_field(param, **attrs), &)
452
+ end
453
+
454
+ # Map a declared nested param onto Rails' <assoc>_attributes, carrying the
455
+ # existing associated record's id so accepts_nested_attributes_for matches
456
+ # it IN PLACE instead of building a second one (issue #24). Returns the
457
+ # update hash; pass it to update!:
458
+ # def save(address:) = nested_update!(:address, address)
459
+ # The id is only added when the association already exists, so the first
460
+ # save (no associated record yet) creates one cleanly. The given attrs are
461
+ # not mutated.
462
+ def nested_attributes(association, attrs)
463
+ merged = attrs.dup
464
+ existing = reactive_record_for_nested.public_send(association)
465
+ merged[:id] = existing.id if existing
466
+
467
+ { "#{association}_attributes": merged }
468
+ end
469
+
470
+ # Map a nested param onto <assoc>_attributes (with id preservation) AND
471
+ # apply it to the component's record in one call (issue #24). Extra keyword
472
+ # attributes update alongside the association.
473
+ # def save(address:, name:) = nested_update!(:address, address, name:)
474
+ def nested_update!(association, attrs, **extra)
475
+ reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
476
+ end
477
+
478
+ # The declared optimistic-hint class ops (issue #98): the cosmetic class
479
+ # vocabulary the client applies instantly and reverts on failure. Enforced
480
+ # at build time in optimistic_hint_json (default-deny — a dead hint fails
481
+ # at render, not silently in the browser). Each carries a class string or
482
+ # array; hide/checked are flags with a fixed shape.
483
+ OPTIMISTIC_CLASS_OPS = %w[toggle_class add_class remove_class].freeze
484
+
485
+ private
486
+
487
+ # True when the hint declares checked: :keep — the click-bound
488
+ # checkbox/radio case that must SKIP the forced type="button" so the native
489
+ # control (and its native flip) survives (issue #98). Accepts symbol or
490
+ # string keys/values; nil-safe for the no-hint hot path.
491
+ def optimistic_keeps_native?(optimistic)
492
+ return false unless optimistic.is_a?(Hash)
493
+
494
+ value = optimistic[:checked] || optimistic["checked"]
495
+ value.to_s == "keep"
496
+ end
497
+
498
+ # Normalize + validate the optimistic hint hash, returning its JSON wire
499
+ # form (data-reactive-optimistic-param). `to: :root` becomes the same
500
+ # "@root" sentinel the js op builder uses so the client resolves it
501
+ # uniformly. Unknown keys, a bad `checked:` value, or a non-hash raise —
502
+ # a hint that can't apply must fail loudly at render.
503
+ def optimistic_hint_json(optimistic, action_name)
504
+ unless optimistic.is_a?(Hash)
505
+ raise ArgumentError,
506
+ "on(#{action_name.inspect}) optimistic: must be a Hash of visual hints " \
507
+ "(e.g. { checked: :keep } or { hide: true, to: :root }), got #{optimistic.class}"
508
+ end
509
+
510
+ hint = {}
511
+ optimistic.each do |key, value|
512
+ case key.to_s
513
+ when *OPTIMISTIC_CLASS_OPS
514
+ hint[key.to_s] = Array(value).map(&:to_s)
515
+ when "hide"
516
+ hint["hide"] = value ? true : false
517
+ when "checked"
518
+ unless value.to_s == "keep"
519
+ raise ArgumentError,
520
+ "on(#{action_name.inspect}) optimistic checked: only supports :keep " \
521
+ "(flip the native control, revert on failure), got #{value.inspect}"
522
+ end
523
+ hint["checked"] = "keep"
524
+ when "to"
525
+ hint["to"] = value == :root ? Phlex::Reactive::JS::ROOT_SENTINEL : value.to_s
526
+ else
527
+ raise ArgumentError,
528
+ "on(#{action_name.inspect}) got an unknown optimistic hint #{key.inspect} — " \
529
+ "supported: toggle_class/add_class/remove_class, checked: :keep, hide: true, to:"
530
+ end
531
+ end
532
+
533
+ hint.to_json
534
+ end
535
+
536
+ # Normalize + validate the loading hint (issue #99), returning its JSON wire
537
+ # form (data-reactive-loading-param) or nil when neither loading: nor
538
+ # disable_with: is given (the bare on() hot path stays untouched).
539
+ # disable_with: "Saving…" expands to { disable: true, text: … } and MERGES
540
+ # over an explicit loading: hash — its text/disable win. `to: :root` becomes
541
+ # the "@root" sentinel the js op builder uses so the client resolves targets
542
+ # uniformly. An unknown key or a non-hash loading: raises — a dead hint must
543
+ # fail loudly at render, not silently in the browser (default-deny).
544
+ def loading_hint_json(loading, disable_with, action_name)
545
+ return nil if loading.nil? && disable_with.nil?
546
+
547
+ source = loading || {}
548
+ unless source.is_a?(Hash)
549
+ raise ArgumentError,
550
+ "on(#{action_name.inspect}) loading: must be a Hash of loading hints " \
551
+ "(e.g. { disable: true, class: \"opacity-50\", text: \"Saving…\" }), got #{loading.class}"
552
+ end
553
+
554
+ hint = {}
555
+ source.each do |key, value|
556
+ case key.to_s
557
+ when "class"
558
+ hint["class"] = Array(value).map(&:to_s)
559
+ when "disable"
560
+ hint["disable"] = value ? true : false
561
+ when "text"
562
+ hint["text"] = value.to_s
563
+ when "to"
564
+ hint["to"] = value == :root ? Phlex::Reactive::JS::ROOT_SENTINEL : value.to_s
565
+ else
566
+ raise ArgumentError,
567
+ "on(#{action_name.inspect}) got an unknown loading hint #{key.inspect} — " \
568
+ "supported: disable:, class:, text:, to:"
569
+ end
570
+ end
571
+
572
+ # disable_with: "Saving…" ≡ { disable: true, text: … }, applied AFTER the
573
+ # explicit loading: hash so it wins on both keys (the shorthand is the
574
+ # more specific intent when a caller passes both).
575
+ if disable_with
576
+ hint["disable"] = true
577
+ hint["text"] = disable_with.to_s
578
+ end
579
+
580
+ hint.to_json
581
+ end
582
+
583
+ # The component's record, for the nested-attributes helpers. Requires a
584
+ # declared reactive_record (the nested helper only makes sense for a
585
+ # record-backed component).
586
+ def reactive_record_for_nested
587
+ key = self.class.reactive_record_key
588
+ raise Error, "#{self.class} must declare `reactive_record` to use nested_update!/nested_attributes" unless key
589
+
590
+ instance_variable_get(:"@#{key}")
591
+ end
592
+ end
593
+ end
594
+ end
595
+ end