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.
@@ -47,7 +47,7 @@ module Phlex
47
47
 
48
48
  # Subject-bound reply builder — the preferred way to control an action's
49
49
  # reply. `reply.replace.flash(:error, msg)` reads cleaner than
50
- # `Phlex::Reactive::Response.replace(self).flash(:error, msg)`: the
50
+ # `reply.replace.flash(:error, msg)`: the
51
51
  # component is the implicit subject (no `self` to thread) and there's no
52
52
  # constant to qualify (reply is a method, so a namespaced component needs
53
53
  # no `Response = …` alias). It returns the same immutable Response the
@@ -110,31 +110,49 @@ module Phlex
110
110
  # (an explicit override wins as a clean replace, not a `mix` string-concat —
111
111
  # mix would join two String ids into "default override").
112
112
  #
113
- # `track_dirty:`/`warn_unsaved:` (issue #103) are CONSUMED here deleted
114
- # from overrides BEFORE the mix — because reactive_root treats every leftover
115
- # kwarg as a literal HTML attribute override (only :id is special-cased), so
116
- # an unconsumed `track_dirty: true` would render a bogus `track-dirty="true"`
117
- # attribute. track_dirty mixes the trackDirty descriptor onto the root's
118
- # data-action (mix token-joins, so a caller's own data-action survives);
119
- # warn_unsaved emits the marker the client reads to arm the navigate-away
120
- # guard (STRING "true" a boolean-true attr renders valueless, which the
121
- # client's param reader sees as "" → falsy).
113
+ # Dirty tracking (issue #103, #184) is now a CLASS-LEVEL reactive_dirty
114
+ # declaration, not a reactive_root kwarg. reactive_root reads
115
+ # self.class.reactive_dirty_config and emits the SAME DOM as before: the
116
+ # trackDirty descriptor on the root's data-action (mix token-joins, so a
117
+ # caller's own data-action survives) UNLESS `only:` scoped tracking to named
118
+ # fields, and for warn_unsaved: true the navigate-away marker (STRING
119
+ # "true", since a boolean-true attr renders valueless "" falsy client-
120
+ # side). The removed track_dirty:/warn_unsaved: kwargs raise a guided error.
122
121
  def reactive_root(**overrides)
123
122
  # A CLIENT-ONLY component (ClientBindings, issue #180) needs no #id —
124
123
  # there's no token to self-match by id. Use an explicit override, else
125
124
  # #id when the component defines one, else nothing (no id attr).
125
+ reject_removed_dirty_kwargs!(overrides)
126
126
  root_id = overrides.delete(:id)
127
127
  root_id = id if root_id.nil? && respond_to?(:id)
128
- track_dirty = overrides.delete(:track_dirty)
129
- warn_unsaved = overrides.delete(:warn_unsaved)
128
+ # Issue #183: bind a client-side compute AT THE ROOT — the descriptors +
129
+ # the recompute delegation ride here so no field needs per-field wiring.
130
+ # nil (the conditional-binding collapse) emits nothing.
131
+ compute = overrides.delete(:compute)
132
+ # Issue #184: dirty tracking is a class-level reactive_dirty declaration.
133
+ dirty = self.class.reactive_dirty_config if self.class.respond_to?(:reactive_dirty_config)
130
134
 
131
135
  attrs = mix({ **reactive_attrs }, overrides)
132
136
  attrs = mix(attrs, { id: root_id }) unless root_id.nil?
133
- attrs = mix(attrs, { data: { action: "input->reactive#trackDirty" } }) if track_dirty
134
- attrs = mix(attrs, { data: { reactive_warn_unsaved: "true" } }) if warn_unsaved
137
+ # Root-level delegation tracks the whole subtree UNLESS only: scoped it to
138
+ # named fields (those carry their own descriptor via reactive_field).
139
+ attrs = mix(attrs, { data: { action: "input->reactive#trackDirty" } }) if dirty && dirty[:only].nil?
140
+ attrs = mix(attrs, { data: { reactive_warn_unsaved: "true" } }) if dirty&.dig(:warn_unsaved)
141
+ attrs = mix(attrs, compute_binding(compute)) if compute
135
142
  attrs
136
143
  end
137
144
 
145
+ # Issue #184: track_dirty:/warn_unsaved: on reactive_root are removed in
146
+ # favor of the class-level reactive_dirty macro. Guided error naming it.
147
+ def reject_removed_dirty_kwargs!(overrides)
148
+ removed = overrides.keys & %i[track_dirty warn_unsaved]
149
+ return if removed.empty?
150
+
151
+ raise ArgumentError,
152
+ "reactive_root(#{removed.first}:) was removed in issue #184 — declare " \
153
+ "`reactive_dirty warn_unsaved: true` (class-level) instead."
154
+ end
155
+
138
156
  # Attributes for an element that triggers an action.
139
157
  # button(**on(:toggle)) { "○" }
140
158
  # form(**on(:save, event: "submit")) { ... }
@@ -168,14 +186,10 @@ module Phlex
168
186
  # input(**on(:add, event: "keydown.enter")) # Enter submits
169
187
  # button(**on(:cancel, event: "keydown.esc")) # Escape cancels
170
188
  #
171
- # `listnav:` (a CSS selector for the option elements) adds keyboard list
172
- # navigation to a search/combobox trigger (issue #72). It appends Stimulus
173
- # keyboard filters to the SAME element's data-action so Arrow Up/Down move a
174
- # client-side highlight among the options, Enter picks the highlighted one
175
- # (clicking its own reactive trigger — so selection stays a signed action),
176
- # and Escape clears — all with NO server round trip for the highlight (the
177
- # controller's listnav* handlers, like #recompute). Omit it for no nav.
178
- # input(**on(:search, event: "input", debounce: 300, listnav: "[role=option]"))
189
+ # Combobox keyboard navigation is the standalone reactive_listnav (issue
190
+ # #181 removed the `on(…, listnav:)` kwarg it duplicated reactive_listnav
191
+ # while skipping its blank-selector validation). Compose it via mix:
192
+ # input(**mix(on(:search, event: "input", debounce: 300), reactive_listnav))
179
193
  # The verbatim JSON for an empty explicit-params payload. The common
180
194
  # trigger (on(:increment), no params) hits this on EVERY render — skipping
181
195
  # params.to_json (which re-serializes {} to the same "{}" each time) avoids
@@ -238,30 +252,31 @@ module Phlex
238
252
  # input(type: "checkbox", checked: @todo.done,
239
253
  # **mix(on(:toggle, event: "change", optimistic: { checked: :keep }), name: "done"))
240
254
  # button(**on(:destroy, confirm: "Delete?", optimistic: { hide: true, to: :root })) { "Delete" }
241
- # `loading:` / `disable_with:` (issue #99) — declarative per-trigger loading
242
- # states, Livewire's wire:loading + phx-disable-with without a Stimulus
243
- # controller. The moment the request is ENQUEUED (covering the queue wait,
244
- # not just the fetch), the trigger gets `data-reactive-busy="<action>"`, an
245
- # optional loading class, an optional disabled + text swap; all revert in a
246
- # guarded finally. `loading:` is a hash:
255
+ # `busy:` (issue #181) — declarative per-trigger pending states, Livewire's
256
+ # wire:loading + phx-disable-with without a Stimulus controller. It shares
257
+ # optimistic:'s key vocabulary and normalizer; the ONLY difference is the
258
+ # lifecycle: a busy: hint applies the moment the request is ENQUEUED
259
+ # (covering the queue wait, not just the fetch) and reverts on SETTLE (any
260
+ # completion), where optimistic: reverts only on FAILURE. `busy:` is a
261
+ # String or a Hash:
262
+ # * "Saving…" — String shorthand for { disable: true, text: … }
247
263
  # * disable: true — disable the trigger while pending
248
- # * class: "…" / [ … ] a loading class string/array on the trigger
249
- # (or a `to:` selector scoped to the root)
250
- # * text: "Saving…" swap the trigger's textContent while pending
251
- # * to: :root / "sel" target the class/text at the root or a selector
252
- # `disable_with: "Saving…"` is the shorthand for
253
- # `{ disable: true, text: "Saving…" }` and merges over an explicit `loading:`
254
- # (its text/disable win). Both become RESERVED on() kwargs (CHANGELOG note,
255
- # like #80's four and #98's optimistic:) no longer free action-param names.
256
- # The trigger/root also always carry `data-reactive-busy` for the whole
257
- # pending window regardless of these hints, so an app styles a spinner with
258
- # `[data-reactive-busy] .spinner { display: block }` and zero Ruby; see
264
+ # * add_class:/remove_class:/toggle_class: "…" / [ … ] — class op on the
265
+ # trigger (or a `to:` selector scoped to the root)
266
+ # * hide:/show: true hide/show the target while pending
267
+ # * text: "Saving…" swap the trigger's innerHTML while pending
268
+ # * to: :root / "sel" target the ops at the root or a selector
269
+ # checked: :keep is optimistic-ONLY (a native flip has no settle-revert
270
+ # meaning). The trigger/root also always carry `data-reactive-busy` for the
271
+ # whole pending window regardless of these hints, so an app styles a spinner
272
+ # with `[data-reactive-busy] .spinner { display: block }` and zero Ruby; see
259
273
  # busy_on for scoped indicators.
260
- # button(**on(:save, disable_with: "Saving…")) { "Save" }
261
- # button(**on(:destroy, confirm: "Sure?", loading: { class: "opacity-50" })) { "Delete" }
262
- def on(action_name, event: "click", debounce: nil, throttle: nil, confirm: nil, listnav: nil,
263
- window: false, once: false, outside: false, optimistic: nil, loading: nil, disable_with: nil,
274
+ # button(**on(:save, busy: "Saving…")) { "Save" }
275
+ # button(**on(:destroy, confirm: "Sure?", busy: { add_class: "opacity-50" })) { "Delete" }
276
+ def on(action_name, event: "click", debounce: nil, throttle: nil, confirm: nil,
277
+ window: false, once: false, outside: false, optimistic: nil, busy: nil,
264
278
  **params)
279
+ reject_removed_on_kwargs!(action_name, params)
265
280
  # A typo'd or forgotten action renders fine and only surfaces as an
266
281
  # opaque 403 at CLICK time (the endpoint's default-deny). Under
267
282
  # verbose_errors (dev + test), fail loudly at RENDER time instead —
@@ -298,7 +313,6 @@ module Phlex
298
313
 
299
314
  window_bound = window || outside
300
315
  action = "#{event}#{"@window" if window_bound}->reactive#dispatch#{":once" if once}"
301
- action = "#{action} #{LISTNAV_ACTIONS.join(" ")}" if listnav
302
316
  attrs = {
303
317
  data: {
304
318
  action:,
@@ -308,12 +322,9 @@ module Phlex
308
322
  }
309
323
  attrs[:data][:reactive_debounce_param] = debounce if debounce
310
324
  attrs[:data][:reactive_throttle_param] = throttle if throttle
311
- attrs[:data][:reactive_confirm_param] = confirm if confirm
312
- attrs[:data][:reactive_listnav_option_param] = listnav if listnav
313
- attrs[:data][:reactive_optimistic_param] = optimistic_hint_json(optimistic, action_name) if optimistic
314
- if (loading_hint = loading_hint_json(loading, disable_with, action_name))
315
- attrs[:data][:reactive_loading_param] = loading_hint
316
- end
325
+ apply_confirm!(attrs[:data], confirm) if confirm
326
+ attrs[:data][:reactive_optimistic_param] = pending_hint_json(optimistic, action_name, :optimistic) if optimistic
327
+ attrs[:data][:reactive_busy_param] = pending_hint_json(busy, action_name, :busy) if busy
317
328
  # STRING "true", not boolean: Phlex renders a `true` attribute VALUELESS
318
329
  # (data-reactive-outside-param), which Stimulus's param reader sees as ""
319
330
  # — falsy in JS, so the guard silently never fires. The explicit ="true"
@@ -354,7 +365,7 @@ module Phlex
354
365
  # Validation is loud: only a non-empty Phlex::Reactive::JS chain is
355
366
  # accepted — a dead trigger should fail at render, not no-op in the
356
367
  # browser.
357
- def on_client(event, ops, window: false, once: false, outside: false)
368
+ def on_client(event, ops, window: false, once: false, outside: false, confirm: nil)
358
369
  unless ops.is_a?(Phlex::Reactive::JS)
359
370
  raise ArgumentError,
360
371
  "on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
@@ -374,6 +385,14 @@ module Phlex
374
385
  # on()'s flags above.
375
386
  attrs[:data][:reactive_outside_param] = "true" if outside
376
387
  attrs[:data][:reactive_window_param] = "true" if window_bound
388
+ # Issue #178: confirm: gates the client-op chain behind the SAME
389
+ # overridable confirmResolver on(:action, confirm:) uses (#52/#55). Emits
390
+ # the identical data-reactive-confirm-param; the client's runOps prompts
391
+ # via confirmResolver BEFORE applying the ops (a falsy resolve cancels
392
+ # the chain), so a destructive client op gets the themed dialog with no
393
+ # round trip. Issue #179: a Hash confirm: is CONDITIONAL — same shared
394
+ # apply_confirm! branches String vs Hash for both on and on_client.
395
+ apply_confirm!(attrs[:data], confirm) if confirm
377
396
  attrs[:type] = "button" if event == "click" && !window_bound
378
397
  attrs
379
398
  end
@@ -388,27 +407,67 @@ module Phlex
388
407
  # hatch). The trigger (on(:save)) stays on the button, not the field — so
389
408
  # focusing the input doesn't dispatch and collapse edit mode.
390
409
  #
391
- # `dirty: true` (issue #103) wires the field to the generic controller's
392
- # trackDirty action, so a change re-scans this reactive root's owned fields
393
- # and marks the changed ones `data-reactive-dirty` (and the root with a
394
- # count). NO client state is shipped the baseline is the DOM's own
395
- # `defaultValue`/`defaultChecked`/`defaultSelected`, i.e. the attributes from
396
- # the last server render; dirty = current default. It deep-merges the
397
- # descriptor via mix, so a caller's own data-action is token-joined, not
398
- # clobbered (CLAUDE.md Never-Do #8 combining with other data:/on() still
399
- # needs mix at the call site).
400
- def reactive_field(param, dirty: false, **attrs)
401
- binding_attrs = { name: param.to_s, **attrs }
402
- return binding_attrs unless dirty
410
+ # Dirty tracking is class-level now (issue #184): the per-field `dirty:`
411
+ # kwarg is REMOVED (reject_removed_field_dirty! raises a guided error). A
412
+ # field carries the trackDirty descriptor only when `reactive_dirty only:`
413
+ # names it (field_dirty_tracked?) otherwise the root delegates for the
414
+ # whole subtree via reactive_root. The client behavior is unchanged (issue
415
+ # #103): a change re-scans this root's owned fields and marks the changed
416
+ # ones `data-reactive-dirty` (the root gets a count); NO client state ships
417
+ # the baseline is the DOM's own `defaultValue`/`defaultChecked`/
418
+ # `defaultSelected` from the last server render (dirty = current ≠ default).
419
+ # The descriptor deep-merges via mix, so a caller's own data-action is
420
+ # token-joined, not clobbered (CLAUDE.md Never-Do #8).
421
+ def reactive_field(param, **attrs)
422
+ # Issue #184: the removed dirty: kwarg lands in **attrs — catch it and
423
+ # print the reactive_dirty rewrite.
424
+ reject_removed_field_dirty!(attrs)
425
+ # Under reactive_scope, emit the SCOPED wire name (name="invoice[date]")
426
+ # so the POST arrives bracketed (the endpoint unwraps one level) AND the
427
+ # field matches the client show/compute resolvers, which already query
428
+ # [name="scope[x]"]. An explicit name: in attrs still wins via the spread
429
+ # (a third-party wire name, never re-scoped) — the escape hatch.
430
+ binding_attrs = { name: scoped_field_name(param), **attrs }
431
+ # Per-field dirty descriptor when reactive_dirty only: names this field
432
+ # (issue #184) — otherwise the root delegates for the whole subtree.
433
+ return binding_attrs unless field_dirty_tracked?(param)
403
434
 
404
435
  mix(binding_attrs, { data: { action: "input->reactive#trackDirty" } })
405
436
  end
406
437
 
407
- # Render an <input> already bound to an action param (issue #23). Sugar for
408
- # input(**reactive_field(param, **attrs)); the value/type/etc. pass through.
409
- # reactive_input(:value, value: @record.name, type: "text")
410
- def reactive_input(param, **attrs)
411
- input(**reactive_field(param, **attrs))
438
+ # The removed reactive_field(dirty:) kwarg (issue #184) now a guided error.
439
+ def reject_removed_field_dirty!(attrs)
440
+ return unless attrs.key?(:dirty)
441
+
442
+ raise ArgumentError,
443
+ "reactive_field(dirty:) was removed in issue #184 — declare " \
444
+ "`reactive_dirty only: %i[...]` (class-level) instead."
445
+ end
446
+
447
+ # True when reactive_dirty only: names this field, so it carries its own
448
+ # trackDirty descriptor (issue #184).
449
+ def field_dirty_tracked?(param)
450
+ return false unless self.class.respond_to?(:reactive_dirty_config)
451
+
452
+ only = self.class.reactive_dirty_config&.dig(:only)
453
+ only&.include?(param.to_sym) || false
454
+ end
455
+
456
+ # The wire name for a bare field param: `scope[param]` when the component
457
+ # declares reactive_scope, else the bare param. Read self.class.reactive_scope
458
+ # the way reactive_attrs does (helpers.rb) so scoped + unscoped stay aligned.
459
+ def scoped_field_name(param)
460
+ scope = self.class.reactive_scope if self.class.respond_to?(:reactive_scope)
461
+ scope ? "#{scope}[#{param}]" : param.to_s
462
+ end
463
+
464
+ # REMOVED in issue #184 — one binding helper (reactive_field); the element
465
+ # is the caller's: input(**reactive_field(:value, value: @record.name)).
466
+ # Raises a guided error printing that rewrite.
467
+ def reactive_input(param, **)
468
+ raise ArgumentError,
469
+ "reactive_input was removed in issue #184 — use " \
470
+ "input(**reactive_field(#{param.inspect}, …)) (one binding helper; the element is yours)."
412
471
  end
413
472
 
414
473
  # Mirror a compute output (or a declared input) into a TEXT NODE — a live
@@ -426,9 +485,26 @@ module Phlex
426
485
  # reducer would, or a morph repaints stale text (same reconcile contract
427
486
  # reactive_compute documents). Extra attrs merge over the binding.
428
487
  def reactive_text(name, initial = nil, **attrs)
488
+ # Issue #183: with no explicit initial, seed the first paint from
489
+ # reactive_values (Phase A) when the component declares it and covers this
490
+ # name — so the server render matches what the reducer would paint (the
491
+ # same no-flash reconcile contract reactive_show's first paint uses).
492
+ initial = reactive_text_seed(name) if initial.nil?
429
493
  span(**mix({ data: { reactive_text: name.to_s } }, attrs)) { initial }
430
494
  end
431
495
 
496
+ # The reactive_values first-paint seed for a reactive_text name, stringified
497
+ # the way the client reports a field (via show_value_string), or nil when
498
+ # the component declares no reactive_values or doesn't cover the name.
499
+ def reactive_text_seed(name)
500
+ return nil unless respond_to?(:reactive_values)
501
+
502
+ values = reactive_values
503
+ return nil unless values.is_a?(Hash) && values.key?(name.to_sym)
504
+
505
+ show_value_string(values[name.to_sym])
506
+ end
507
+
432
508
  # Value-conditional visibility (issue #180) — the x-show / data-show /
433
509
  # wire:show equivalent, entirely client-side, in ONE Ruby-native
434
510
  # conditions language. Spread onto the element to show/hide; declare an
@@ -495,12 +571,15 @@ module Phlex
495
571
  # keystroke by substring-matching each option's haystack — no round
496
572
  # trip, no token, no bespoke per-feature controller:
497
573
  #
498
- # div(**mix(reactive_root, reactive_filter(
499
- # input: "#exercise-search",
500
- # option: "[role=option]",
501
- # group: "[data-filter-group]", # optional: collapse empty group headers
502
- # empty: "#no-matches" # optional: reveal when 0 match
503
- # ))) { … }
574
+ # Issue #186: name the FIELD that drives the filter — reactive_filter(:q)
575
+ # compiles :q to [name="q"] (scope-aware) and defaults option to the
576
+ # [role=option] convention. group:/empty: stay opt-in; any selector kwarg
577
+ # overrides a convention:
578
+ #
579
+ # div(**mix(reactive_root, reactive_filter(:q, empty: "#no-matches"))) do
580
+ # input(name: "q", type: "search", **reactive_listnav) # listnav → [role=option]
581
+ # …
582
+ # end
504
583
  #
505
584
  # Each option's haystack is its `data-reactive-filter-text` attribute
506
585
  # (server-rendered — pack in synonyms/categories), falling back to the
@@ -515,11 +594,25 @@ module Phlex
515
594
  # hidden options) and each option's own on(:select, …) trigger —
516
595
  # selection still round-trips as a signed action; only FILTERING is
517
596
  # local. Blank selectors raise: a dead binding must fail at render.
518
- def reactive_filter(input:, option:, group: nil, empty: nil)
597
+ def reactive_filter(field = nil, input: :__removed, option: nil, group: nil, empty: nil)
598
+ # Issue #186: the four-selector kwarg form is removed — name the FIELD that
599
+ # drives the filter instead. A leftover input: means the old call shape.
600
+ unless input == :__removed
601
+ raise ArgumentError,
602
+ "reactive_filter(input:) was removed in issue #186 — name the driving field: " \
603
+ "reactive_filter(:q) (compiles to [name=\"q\"], scope-aware; option defaults to [role=option])."
604
+ end
605
+ raise ArgumentError, "reactive_filter needs a field name — reactive_filter(:q)" if field.nil?
606
+
519
607
  data = {
520
- reactive_filter_input: filter_selector!(:input, input),
521
- reactive_filter_option: filter_selector!(:option, option)
608
+ # Compile the field to a scoped [name="…"] selector (same scope convention
609
+ # reactive_field uses, so the filter input aligns with its own field).
610
+ reactive_filter_input: %([name="#{scoped_field_name(field)}"]),
611
+ # option defaults to the [role=option] convention; a kwarg overrides it.
612
+ reactive_filter_option: option ? filter_selector!(:option, option) : "[role=option]"
522
613
  }
614
+ # group/empty stay OPT-IN (no convention default — a default would change the
615
+ # byte-stable wire and always-emit an attribute the client would then query).
523
616
  data[:reactive_filter_group] = filter_selector!(:group, group) if group
524
617
  data[:reactive_filter_empty] = filter_selector!(:empty, empty) if empty
525
618
 
@@ -538,7 +631,7 @@ module Phlex
538
631
  # Enter picks the highlighted one by clicking its own reactive trigger
539
632
  # (selection stays a signed action), Escape clears. Combine with other
540
633
  # attrs via mix so a caller's data-action token-joins, not clobbers.
541
- def reactive_listnav(option_selector)
634
+ def reactive_listnav(option_selector = "[role=option]")
542
635
  {
543
636
  data: {
544
637
  action: LISTNAV_ACTIONS.join(" "),
@@ -614,20 +707,27 @@ module Phlex
614
707
  { data: { reactive_busy_on: action.to_s } }
615
708
  end
616
709
 
617
- # Data attributes declaring a client-side compute for the root element.
618
- # Spread ALONGSIDE reactive_root so the generic controller can find the
619
- # reducer and the named input/output fields inside this root:
620
- # div(**mix(reactive_root, reactive_compute_attrs(:payment_split))) { … }
621
- #
622
- # It emits the reducer key plus the input/output field names as JSON so the
623
- # client runs the reducer on `input`, writes the outputs with no round trip,
624
- # then the debounced POST reconciles from the server reply. Raises for an
625
- # undeclared compute — a silent no-op would leave the field wiring dead.
710
+ # REMOVED in issue #183 the compute binding moved to
711
+ # reactive_root(compute: :name), which emits the descriptors AND the
712
+ # recompute delegation at the root, so no field carries per-field wiring:
713
+ # div(**reactive_root(compute: :payment_split)) { … }
714
+ # This helper now raises a guided ArgumentError printing that rewrite.
626
715
  def reactive_compute_attrs(name)
627
- definition = self.class.reactive_compute_def(name)
716
+ raise ArgumentError,
717
+ "reactive_compute_attrs(#{name.inspect}) was removed in issue #183 — " \
718
+ "pass reactive_root(compute: #{name.inspect}) instead (bind + listen at the root)"
719
+ end
720
+
721
+ # The root's compute descriptors + the recompute delegation (issue #183).
722
+ # Emits the same data-reactive-compute-* attrs as before PLUS the
723
+ # input->reactive#recompute action, all on the root element. Raises for an
724
+ # undeclared name (fail fast, not a silent no-op).
725
+ def compute_binding(name)
726
+ definition = self.class.reactive_computes[name.to_sym]
628
727
  raise Error, "#{self.class} has no reactive_compute #{name.inspect}" unless definition
629
728
 
630
729
  data = {
730
+ action: "input->reactive#recompute",
631
731
  reactive_compute_reducer_param: definition.reducer,
632
732
  reactive_compute_inputs_param: compute_inputs_param(definition),
633
733
  reactive_compute_outputs_param: definition.outputs.map(&:to_s).to_json
@@ -658,12 +758,13 @@ module Phlex
658
758
  definition.inputs.to_h { [it.to_s, types[it].to_s] }.to_json
659
759
  end
660
760
 
661
- # Render a <select> bound to an action param (issue #23). The options block
662
- # is the element's content, so the awkward FormBuilder positional split
663
- # (where name: lands after the options/html-options args) goes away:
664
- # reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
665
- def reactive_select(param, **attrs, &)
666
- select(**reactive_field(param, **attrs), &)
761
+ # REMOVED in issue #184 one binding helper (reactive_field); the element
762
+ # is the caller's: select(**reactive_field(:status)) { status_options }.
763
+ # Raises a guided error printing that rewrite.
764
+ def reactive_select(param, **, &)
765
+ raise ArgumentError,
766
+ "reactive_select was removed in issue #184 — use " \
767
+ "select(**reactive_field(#{param.inspect})) { … } (one binding helper; the element is yours)."
667
768
  end
668
769
 
669
770
  # Map a declared nested param onto Rails' <assoc>_attributes, carrying the
@@ -702,13 +803,6 @@ module Phlex
702
803
  LEGACY_SHOW_PREDICATE_KEYS = %i[equals not in gte gt lte lt].freeze
703
804
  LEGACY_SHOW_CONNECTIVE_KEYS = %i[all any].freeze
704
805
 
705
- # The declared optimistic-hint class ops (issue #98): the cosmetic class
706
- # vocabulary the client applies instantly and reverts on failure. Enforced
707
- # at build time in optimistic_hint_json (default-deny — a dead hint fails
708
- # at render, not silently in the browser). Each carries a class string or
709
- # array; hide/checked are flags with a fixed shape.
710
- OPTIMISTIC_CLASS_OPS = %w[toggle_class add_class remove_class].freeze
711
-
712
806
  private
713
807
 
714
808
  # A reactive_filter/reactive_listnav selector, validated non-blank and
@@ -725,6 +819,53 @@ module Phlex
725
819
  selector
726
820
  end
727
821
 
822
+ # Issue #179: apply a confirm: gate to a trigger's data hash. A String is
823
+ # the static #52/#55 form (data-reactive-confirm-param, unchanged). A Hash
824
+ # is the CONDITIONAL form (data-reactive-confirm-when-param, JSON) — warn
825
+ # only when field values look suspect:
826
+ # { when: { total: 0 }, message: } — reactive_show conditions language
827
+ # (scalar=equals, Range=threshold, Array=set); prompts when it MATCHES.
828
+ # { predicate: "name", message: } — a JS fn registered with
829
+ # setConfirmPredicate, evaluated over collected fields (multi-field logic).
830
+ # Shared by on and on_client so both paths speak the same three forms. The
831
+ # predicate is soft-validation UX, NOT authorization — a user can bypass it;
832
+ # the action still hits the endpoint's real authorize/default-deny.
833
+ def apply_confirm!(data, confirm)
834
+ case confirm
835
+ when String
836
+ data[:reactive_confirm_param] = confirm
837
+ when Hash
838
+ data[:reactive_confirm_when_param] = compile_conditional_confirm(confirm).to_json
839
+ else
840
+ raise ArgumentError,
841
+ "confirm: takes a String (static) or a Hash ({ when: {…}, message: } / " \
842
+ "{ predicate: \"name\", message: }), got #{confirm.class}"
843
+ end
844
+ end
845
+
846
+ # Validate + compile a Hash confirm: into its wire payload. Exactly one of
847
+ # when:/predicate:, message: required — a dead binding fails loudly at
848
+ # render (the reactive_show posture), never silently in the browser.
849
+ def compile_conditional_confirm(confirm)
850
+ message = confirm[:message]
851
+ has_when = confirm.key?(:when)
852
+ has_predicate = confirm.key?(:predicate)
853
+
854
+ if has_when == has_predicate
855
+ raise ArgumentError,
856
+ "confirm: needs exactly one of when: (a conditions hash) or predicate: " \
857
+ "(a registered name), not both/neither — got #{confirm.except(:message).inspect}"
858
+ end
859
+ raise ArgumentError, "confirm: Hash needs a message: to show when it fires" if message.nil?
860
+
861
+ if has_predicate
862
+ { "predicate" => confirm[:predicate].to_s, "message" => message }
863
+ else
864
+ groups = Phlex::Reactive::ShowConditions.normalize(if: confirm[:when])
865
+ { "groups" => { "any" => groups }, "message" => message }
866
+ end
867
+ end
868
+
728
869
  # Normalize + validate ONE field's target map (issue #180): each key a
729
870
  # single id selector (loud raise — the declare-time half of the two-sided
730
871
  # default-deny), each value a where-style condition value (scalar/Array/
@@ -842,89 +983,90 @@ module Phlex
842
983
  value.to_s == "keep"
843
984
  end
844
985
 
845
- # Normalize + validate the optimistic hint hash, returning its JSON wire
846
- # form (data-reactive-optimistic-param). `to: :root` becomes the same
847
- # "@root" sentinel the js op builder uses so the client resolves it
848
- # uniformly. Unknown keys, a bad `checked:` value, or a non-hash raise
849
- # a hint that can't apply must fail loudly at render.
850
- def optimistic_hint_json(optimistic, action_name)
851
- unless optimistic.is_a?(Hash)
852
- raise ArgumentError,
853
- "on(#{action_name.inspect}) optimistic: must be a Hash of visual hints " \
854
- "(e.g. { checked: :keep } or { hide: true, to: :root }), got #{optimistic.class}"
855
- end
856
-
857
- hint = {}
858
- optimistic.each do |key, value|
859
- case key.to_s
860
- when *OPTIMISTIC_CLASS_OPS
861
- hint[key.to_s] = Array(value).map(&:to_s)
862
- when "hide"
863
- hint["hide"] = value ? true : false
864
- when "checked"
865
- unless value.to_s == "keep"
866
- raise ArgumentError,
867
- "on(#{action_name.inspect}) optimistic checked: only supports :keep " \
868
- "(flip the native control, revert on failure), got #{value.inspect}"
869
- end
870
- hint["checked"] = "keep"
871
- when "to"
872
- hint["to"] = value == :root ? Phlex::Reactive::JS::ROOT_SENTINEL : value.to_s
873
- else
874
- raise ArgumentError,
875
- "on(#{action_name.inspect}) got an unknown optimistic hint #{key.inspect} — " \
876
- "supported: toggle_class/add_class/remove_class, checked: :keep, hide: true, to:"
877
- end
878
- end
879
-
880
- hint.to_json
986
+ # The removed on() pending-state kwargs (issue #181). loading:/disable_with:
987
+ # collapsed into busy:; listnav: into the standalone reactive_listnav.
988
+ # They now land in **params, so catch them there and raise a guided error
989
+ # showing the rewrite a clean break (pre-1.0), not a silent shim that
990
+ # would ship a stale call to the browser wrong.
991
+ REMOVED_ON_KWARGS = {
992
+ disable_with: 'busy: "…" (String shorthand for { disable: true, text: "…" })',
993
+ loading: "busy: { disable:, add_class:, text:, to: } (same keys as optimistic:)",
994
+ listnav: "reactive_listnav — mix(on(:search, event: \"input\"), reactive_listnav)"
995
+ }.freeze
996
+
997
+ # The unified pending-state hint vocabulary (issue #181): the js.* op names
998
+ # shared by optimistic: and busy:, plus disable:/to:. checked: :keep is
999
+ # optimistic-ONLY (a native control flip has no settle-revert meaning).
1000
+ PENDING_CLASS_OPS = %w[toggle_class add_class remove_class].freeze
1001
+
1002
+ private_constant :REMOVED_ON_KWARGS, :PENDING_CLASS_OPS
1003
+
1004
+ def reject_removed_on_kwargs!(action_name, params)
1005
+ removed = params.keys & REMOVED_ON_KWARGS.keys
1006
+ return if removed.empty?
1007
+
1008
+ key = removed.first
1009
+ raise ArgumentError,
1010
+ "on(#{action_name.inspect}) #{key}: was removed in issue #181 — use #{REMOVED_ON_KWARGS[key]}"
881
1011
  end
882
1012
 
883
- # Normalize + validate the loading hint (issue #99), returning its JSON wire
884
- # form (data-reactive-loading-param) or nil when neither loading: nor
885
- # disable_with: is given (the bare on() hot path stays untouched).
886
- # disable_with: "Saving…" expands to { disable: true, text: } and MERGES
887
- # over an explicit loading: hash its text/disable win. `to: :root` becomes
888
- # the "@root" sentinel the js op builder uses so the client resolves targets
889
- # uniformly. An unknown key or a non-hash loading: raises a dead hint must
890
- # fail loudly at render, not silently in the browser (default-deny).
891
- def loading_hint_json(loading, disable_with, action_name)
892
- return nil if loading.nil? && disable_with.nil?
893
-
894
- source = loading || {}
1013
+ # Normalize + validate a pending-state hint (issue #181), returning its
1014
+ # JSON wire form. ONE vocabulary + ONE normalizer for both optimistic: (kind
1015
+ # :optimistic, reverts on FAILURE) and busy: (kind :busy, reverts on
1016
+ # SETTLE); they differ only in client lifecycle, not in shape. The String
1017
+ # shorthand `busy: "Saving…"` expands to { disable: true, text: … }. `to:`
1018
+ # resolves through the SAME JS.normalize_target the js op builder uses (a
1019
+ # :root sentinel or a verbatim CSS selector). checked: :keep is accepted
1020
+ # only for :optimistic. An unknown key, a bad value, or the wrong type
1021
+ # raises — a dead hint fails loudly at render, never silently on the client.
1022
+ def pending_hint_json(source, action_name, kind)
1023
+ source = { disable: true, text: source } if kind == :busy && source.is_a?(String)
895
1024
  unless source.is_a?(Hash)
896
1025
  raise ArgumentError,
897
- "on(#{action_name.inspect}) loading: must be a Hash of loading hints " \
898
- "(e.g. { disable: true, class: \"opacity-50\", text: \"Saving…\" }), got #{loading.class}"
1026
+ "on(#{action_name.inspect}) #{kind}: must be a #{"String or " if kind == :busy}Hash of visual " \
1027
+ "hints (e.g. { disable: true, text: \"Saving…\" }), got #{source.class}"
899
1028
  end
900
1029
 
901
1030
  hint = {}
902
1031
  source.each do |key, value|
903
1032
  case key.to_s
904
- when "class"
905
- hint["class"] = Array(value).map(&:to_s)
906
- when "disable"
907
- hint["disable"] = value ? true : false
1033
+ when *PENDING_CLASS_OPS
1034
+ hint[key.to_s] = Array(value).map(&:to_s)
1035
+ when "hide", "show", "disable"
1036
+ hint[key.to_s] = value ? true : false
908
1037
  when "text"
909
1038
  hint["text"] = value.to_s
910
1039
  when "to"
911
- hint["to"] = value == :root ? Phlex::Reactive::JS::ROOT_SENTINEL : value.to_s
1040
+ hint["to"] = Phlex::Reactive::JS.normalize_target(value)
1041
+ when "checked"
1042
+ hint["checked"] = pending_checked!(value, action_name, kind)
912
1043
  else
913
1044
  raise ArgumentError,
914
- "on(#{action_name.inspect}) got an unknown loading hint #{key.inspect} — " \
915
- "supported: disable:, class:, text:, to:"
1045
+ "on(#{action_name.inspect}) got an unknown #{kind} hint #{key.inspect} — supported: " \
1046
+ "add_class/remove_class/toggle_class, hide:, show:, disable:, text:, to:" \
1047
+ "#{", checked: :keep" if kind == :optimistic}"
916
1048
  end
917
1049
  end
918
1050
 
919
- # disable_with: "Saving…" ≡ { disable: true, text: … }, applied AFTER the
920
- # explicit loading: hash so it wins on both keys (the shorthand is the
921
- # more specific intent when a caller passes both).
922
- if disable_with
923
- hint["disable"] = true
924
- hint["text"] = disable_with.to_s
1051
+ hint.to_json
1052
+ end
1053
+
1054
+ # checked: :keep flips a native checkbox/radio and reverts on failure — it
1055
+ # only makes sense for optimistic: (a settle-revert busy: hint has no native
1056
+ # control to keep). Reject it for :busy, and reject any value but :keep.
1057
+ def pending_checked!(value, action_name, kind)
1058
+ if kind != :optimistic
1059
+ raise ArgumentError,
1060
+ "on(#{action_name.inspect}) #{kind}: does not support checked: — the native-control " \
1061
+ "flip is an optimistic-only hint (it reverts on failure, not on settle)"
1062
+ end
1063
+ unless value.to_s == "keep"
1064
+ raise ArgumentError,
1065
+ "on(#{action_name.inspect}) optimistic checked: only supports :keep " \
1066
+ "(flip the native control, revert on failure), got #{value.inspect}"
925
1067
  end
926
1068
 
927
- hint.to_json
1069
+ "keep"
928
1070
  end
929
1071
 
930
1072
  # The component's record, for the nested-attributes helpers. Requires a