phlex-reactive 0.9.5 → 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
@@ -73,16 +73,24 @@ module Phlex
73
73
  # signed identity token. Spread onto the root:
74
74
  # div(id:, **reactive_attrs) { ... }
75
75
  def reactive_attrs
76
- data = {
77
- controller: "reactive",
78
- reactive_token_value: reactive_token
79
- }
76
+ data = { controller: "reactive" }
77
+ # A CLIENT-ONLY component (Phlex::Reactive::ClientBindings, issue #180)
78
+ # has no Identity, so no token — the root is tokenless (show/filter/
79
+ # compute need no signed round trip). reactive_token is private, so the
80
+ # include-private `respond_to?` mirrors to_stream_token's guard.
81
+ data[:reactive_token_value] = reactive_token if respond_to?(:reactive_token, true)
80
82
  # Client debug mode (issue #108): stamp the flag so the generic controller
81
83
  # console.groups every dispatch. STRING "true", not boolean — Phlex renders
82
84
  # a boolean-true attr VALUELESS, which getAttribute reads as "" (falsy in
83
85
  # JS), so the client's attr check would never fire (the on()/warn_unsaved
84
86
  # precedent). Off by default → no key, no string, zero client surface.
85
87
  data[:reactive_debug] = "true" if Phlex::Reactive.debug
88
+ # Field-name scope (issue #180): the client prefixes bare binding field
89
+ # names with `scope[...]`. Omitted entirely when undeclared — byte-stable
90
+ # wire for unscoped components.
91
+ if self.class.respond_to?(:reactive_scope) && (scope = self.class.reactive_scope)
92
+ data[:reactive_scope] = scope.to_s
93
+ end
86
94
  { data: }
87
95
  end
88
96
 
@@ -102,26 +110,49 @@ module Phlex
102
110
  # (an explicit override wins as a clean replace, not a `mix` string-concat —
103
111
  # mix would join two String ids into "default override").
104
112
  #
105
- # `track_dirty:`/`warn_unsaved:` (issue #103) are CONSUMED here deleted
106
- # from overrides BEFORE the mix — because reactive_root treats every leftover
107
- # kwarg as a literal HTML attribute override (only :id is special-cased), so
108
- # an unconsumed `track_dirty: true` would render a bogus `track-dirty="true"`
109
- # attribute. track_dirty mixes the trackDirty descriptor onto the root's
110
- # data-action (mix token-joins, so a caller's own data-action survives);
111
- # warn_unsaved emits the marker the client reads to arm the navigate-away
112
- # guard (STRING "true" a boolean-true attr renders valueless, which the
113
- # 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.
114
121
  def reactive_root(**overrides)
115
- root_id = overrides.delete(:id) || id
116
- track_dirty = overrides.delete(:track_dirty)
117
- warn_unsaved = overrides.delete(:warn_unsaved)
118
-
119
- attrs = mix({ **reactive_attrs }, overrides, { id: root_id })
120
- attrs = mix(attrs, { data: { action: "input->reactive#trackDirty" } }) if track_dirty
121
- attrs = mix(attrs, { data: { reactive_warn_unsaved: "true" } }) if warn_unsaved
122
+ # A CLIENT-ONLY component (ClientBindings, issue #180) needs no #id
123
+ # there's no token to self-match by id. Use an explicit override, else
124
+ # #id when the component defines one, else nothing (no id attr).
125
+ reject_removed_dirty_kwargs!(overrides)
126
+ root_id = overrides.delete(:id)
127
+ root_id = id if root_id.nil? && respond_to?(:id)
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)
134
+
135
+ attrs = mix({ **reactive_attrs }, overrides)
136
+ attrs = mix(attrs, { id: root_id }) unless root_id.nil?
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
122
142
  attrs
123
143
  end
124
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
+
125
156
  # Attributes for an element that triggers an action.
126
157
  # button(**on(:toggle)) { "○" }
127
158
  # form(**on(:save, event: "submit")) { ... }
@@ -155,14 +186,10 @@ module Phlex
155
186
  # input(**on(:add, event: "keydown.enter")) # Enter submits
156
187
  # button(**on(:cancel, event: "keydown.esc")) # Escape cancels
157
188
  #
158
- # `listnav:` (a CSS selector for the option elements) adds keyboard list
159
- # navigation to a search/combobox trigger (issue #72). It appends Stimulus
160
- # keyboard filters to the SAME element's data-action so Arrow Up/Down move a
161
- # client-side highlight among the options, Enter picks the highlighted one
162
- # (clicking its own reactive trigger — so selection stays a signed action),
163
- # and Escape clears — all with NO server round trip for the highlight (the
164
- # controller's listnav* handlers, like #recompute). Omit it for no nav.
165
- # 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))
166
193
  # The verbatim JSON for an empty explicit-params payload. The common
167
194
  # trigger (on(:increment), no params) hits this on EVERY render — skipping
168
195
  # params.to_json (which re-serializes {} to the same "{}" each time) avoids
@@ -225,30 +252,31 @@ module Phlex
225
252
  # input(type: "checkbox", checked: @todo.done,
226
253
  # **mix(on(:toggle, event: "change", optimistic: { checked: :keep }), name: "done"))
227
254
  # button(**on(:destroy, confirm: "Delete?", optimistic: { hide: true, to: :root })) { "Delete" }
228
- # `loading:` / `disable_with:` (issue #99) — declarative per-trigger loading
229
- # states, Livewire's wire:loading + phx-disable-with without a Stimulus
230
- # controller. The moment the request is ENQUEUED (covering the queue wait,
231
- # not just the fetch), the trigger gets `data-reactive-busy="<action>"`, an
232
- # optional loading class, an optional disabled + text swap; all revert in a
233
- # 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: … }
234
263
  # * disable: true — disable the trigger while pending
235
- # * class: "…" / [ … ] a loading class string/array on the trigger
236
- # (or a `to:` selector scoped to the root)
237
- # * text: "Saving…" swap the trigger's textContent while pending
238
- # * to: :root / "sel" target the class/text at the root or a selector
239
- # `disable_with: "Saving…"` is the shorthand for
240
- # `{ disable: true, text: "Saving…" }` and merges over an explicit `loading:`
241
- # (its text/disable win). Both become RESERVED on() kwargs (CHANGELOG note,
242
- # like #80's four and #98's optimistic:) no longer free action-param names.
243
- # The trigger/root also always carry `data-reactive-busy` for the whole
244
- # pending window regardless of these hints, so an app styles a spinner with
245
- # `[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
246
273
  # busy_on for scoped indicators.
247
- # button(**on(:save, disable_with: "Saving…")) { "Save" }
248
- # button(**on(:destroy, confirm: "Sure?", loading: { class: "opacity-50" })) { "Delete" }
249
- def on(action_name, event: "click", debounce: nil, throttle: nil, confirm: nil, listnav: nil,
250
- 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,
251
278
  **params)
279
+ reject_removed_on_kwargs!(action_name, params)
252
280
  # A typo'd or forgotten action renders fine and only surfaces as an
253
281
  # opaque 403 at CLICK time (the endpoint's default-deny). Under
254
282
  # verbose_errors (dev + test), fail loudly at RENDER time instead —
@@ -285,7 +313,6 @@ module Phlex
285
313
 
286
314
  window_bound = window || outside
287
315
  action = "#{event}#{"@window" if window_bound}->reactive#dispatch#{":once" if once}"
288
- action = "#{action} #{LISTNAV_ACTIONS.join(" ")}" if listnav
289
316
  attrs = {
290
317
  data: {
291
318
  action:,
@@ -295,12 +322,9 @@ module Phlex
295
322
  }
296
323
  attrs[:data][:reactive_debounce_param] = debounce if debounce
297
324
  attrs[:data][:reactive_throttle_param] = throttle if throttle
298
- attrs[:data][:reactive_confirm_param] = confirm if confirm
299
- attrs[:data][:reactive_listnav_option_param] = listnav if listnav
300
- attrs[:data][:reactive_optimistic_param] = optimistic_hint_json(optimistic, action_name) if optimistic
301
- if (loading_hint = loading_hint_json(loading, disable_with, action_name))
302
- attrs[:data][:reactive_loading_param] = loading_hint
303
- 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
304
328
  # STRING "true", not boolean: Phlex renders a `true` attribute VALUELESS
305
329
  # (data-reactive-outside-param), which Stimulus's param reader sees as ""
306
330
  # — falsy in JS, so the guard silently never fires. The explicit ="true"
@@ -341,7 +365,7 @@ module Phlex
341
365
  # Validation is loud: only a non-empty Phlex::Reactive::JS chain is
342
366
  # accepted — a dead trigger should fail at render, not no-op in the
343
367
  # browser.
344
- 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)
345
369
  unless ops.is_a?(Phlex::Reactive::JS)
346
370
  raise ArgumentError,
347
371
  "on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
@@ -361,6 +385,14 @@ module Phlex
361
385
  # on()'s flags above.
362
386
  attrs[:data][:reactive_outside_param] = "true" if outside
363
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
364
396
  attrs[:type] = "button" if event == "click" && !window_bound
365
397
  attrs
366
398
  end
@@ -375,27 +407,67 @@ module Phlex
375
407
  # hatch). The trigger (on(:save)) stays on the button, not the field — so
376
408
  # focusing the input doesn't dispatch and collapse edit mode.
377
409
  #
378
- # `dirty: true` (issue #103) wires the field to the generic controller's
379
- # trackDirty action, so a change re-scans this reactive root's owned fields
380
- # and marks the changed ones `data-reactive-dirty` (and the root with a
381
- # count). NO client state is shipped the baseline is the DOM's own
382
- # `defaultValue`/`defaultChecked`/`defaultSelected`, i.e. the attributes from
383
- # the last server render; dirty = current default. It deep-merges the
384
- # descriptor via mix, so a caller's own data-action is token-joined, not
385
- # clobbered (CLAUDE.md Never-Do #8 combining with other data:/on() still
386
- # needs mix at the call site).
387
- def reactive_field(param, dirty: false, **attrs)
388
- binding_attrs = { name: param.to_s, **attrs }
389
- 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)
390
434
 
391
435
  mix(binding_attrs, { data: { action: "input->reactive#trackDirty" } })
392
436
  end
393
437
 
394
- # Render an <input> already bound to an action param (issue #23). Sugar for
395
- # input(**reactive_field(param, **attrs)); the value/type/etc. pass through.
396
- # reactive_input(:value, value: @record.name, type: "text")
397
- def reactive_input(param, **attrs)
398
- 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)."
399
471
  end
400
472
 
401
473
  # Mirror a compute output (or a declared input) into a TEXT NODE — a live
@@ -413,65 +485,82 @@ module Phlex
413
485
  # reducer would, or a morph repaints stale text (same reconcile contract
414
486
  # reactive_compute documents). Extra attrs merge over the binding.
415
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?
416
493
  span(**mix({ data: { reactive_text: name.to_s } }, attrs)) { initial }
417
494
  end
418
495
 
419
- # Value-conditional visibility (issue #161) the x-show / data-show /
420
- # wire:show equivalent, entirely client-side. Spread onto the element to
421
- # show/hide; it declares which OWNED field controls it plus ONE literal
422
- # predicate, and the generic controller toggles the `hidden` attribute
423
- # from the field's CURRENT value on every input/change — no round trip,
424
- # no token, no bespoke Stimulus controller:
425
- #
426
- # div(**reactive_show(:mode, not: "off")) { "details" }
427
- # div(**reactive_show(:kind, equals: "premium")) { "premium panel" }
428
- # div(**reactive_show(:size, in: %w[l xl])) { "surcharge note" }
429
- # div(**reactive_show(:gift, equals: true)) { "gift message" }
430
- #
431
- # Exactly one predicateequals:, not:, or in: (a list) — and every
432
- # value is STRINGIFIED for a literal match against the field's value
433
- # (a checkbox compares its checked state as "true"/"false", so
434
- # `equals: true` is the checkbox form; a radio group reads the checked
435
- # radio's value). This is a DECLARED LITERAL MATCH, never an expression
436
- # — there is no eval surface (default-deny, like the op vocabulary).
437
- #
438
- # Scope: presentational only, strictly less powerful than the js ops —
439
- # it reads an owned field (#15 ownership) and toggles `hidden` on an
440
- # owned element. The client seeds visibility at connect and reconciles
441
- # after a morph; render the initial `hidden:` yourself (from the same
442
- # server state that renders the field) to avoid a first-paint flash.
443
- # Extra attrs deep-merge over the binding (mix), like reactive_field.
444
- # Compound forms (issue #176 part A) — visibility that depends on MORE
445
- # THAN ONE field, or a threshold, staying inside the eval-free literal
446
- # contract. `all:`/`any:` fold a list of per-field terms (each the same
447
- # equals:/not:/in:/gte:/gt:/lte:/lt: vocabulary) with one fixed
448
- # connective; the compound has no single controlling field, so it
449
- # serializes as ONE JSON attr (data-reactive-show) like
450
- # reactive_show_targets rather than the flat data-reactive-show-* attrs:
451
- #
452
- # # visible while type == "individual" AND country != "domestic"
453
- # div(**reactive_show(all: [
454
- # { field: :type, equals: "individual" },
455
- # { field: :country, not: "domestic" }
456
- # ]))
457
- # # visible while director OR shareholder is checked
458
- # div(**reactive_show(any: [
459
- # { field: :director, equals: true },
460
- # { field: :shareholder, equals: true }
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
+
508
+ # Value-conditional visibility (issue #180) the x-show / data-show /
509
+ # wire:show equivalent, entirely client-side, in ONE Ruby-native
510
+ # conditions language. Spread onto the element to show/hide; declare an
511
+ # if:/if_any:/unless: condition with where-style values, and the generic
512
+ # controller toggles `hidden` from the fields' CURRENT values on every
513
+ # input/change no round trip, no token, no bespoke Stimulus controller.
514
+ #
515
+ # THE VALUE LANGUAGE (Phlex::Reactive::ShowConditions):
516
+ # Hash = AND (multiple keys ANDed) if: { a: "x", b: "y" }
517
+ # Array = membership if: { size: %w[l xl] }
518
+ # Range = threshold (10.. / ..10 / ...10 / 10..20) if: { qty: 10.. }
519
+ # true/false = checkbox checked-state if: { gift: true }
520
+ # nil = blank if: { note: nil }
521
+ # unless: = negation (composes with if:/if_any:)
522
+ #
523
+ # div(**reactive_show(unless: { mode: "off" })) { "details" }
524
+ # div(**reactive_show(if: { size: %w[l xl] })) { "surcharge" }
525
+ # div(**reactive_show(if: { qty: 10.. })) { "bulk note" }
526
+ # # OR-of-AND director OR (shareholder AND role == "individual"):
527
+ # div(**reactive_show(if_any: [
528
+ # { director: true },
529
+ # { shareholder: true, role: "individual" }
461
530
  # ]))
462
- # # a numeric threshold, single field or as a compound term
463
- # div(**reactive_show(:quantity, gte: 10)) # visible while qty >= 10
464
- # div(**reactive_show(all: [{ field: :amount, gte: 5000 }, ...]))
465
- #
466
- # `all:`/`any:` are additive and mutually exclusive with the single-field
467
- # form and with each other enforced loudly at render, like the
468
- # one-predicate rule. A single-field `all:` with one term degrades to the
469
- # flat form's behaviour client-side.
531
+ #
532
+ # There is no expression surface every term is a declared literal, so
533
+ # the same default-deny posture as before. Everything normalizes to ONE
534
+ # DNF wire attr (data-reactive-show='{"any":[[term,…],…]}').
535
+ #
536
+ # FIRST PAINT is computed for you: declare reactive_values (an instance
537
+ # method returning { field => value }) and every binding whose fields are
538
+ # all provided renders the correct initial `hidden:` server-side — no
539
+ # per-section mirror method, no flash. An explicit `hidden:` always wins;
540
+ # a per-call `values:` override merges over reactive_values.
541
+ #
542
+ # `disable: true` disables the section's OWNED controls while it is hidden
543
+ # so a switched-away value never submits. `reactive_scope :form` lets
544
+ # bindings use bare field symbols ([name="form[field]"] on the client).
545
+ #
546
+ # Scope: presentational only, strictly less powerful than the js ops — it
547
+ # reads owned fields (#15 ownership) and toggles `hidden` (+ optionally
548
+ # `disabled`) on owned elements. Extra attrs deep-merge over the binding
549
+ # (mix), like reactive_field.
470
550
  def reactive_show(field = nil, **options)
471
- connectives = options.slice(*SHOW_CONNECTIVE_KEYS)
472
- return reactive_show_compound(field, connectives, options) if connectives.any?
551
+ reject_legacy_show_surface!(field, options)
552
+
553
+ conditions = options.slice(*SHOW_CONDITION_KEYS)
554
+ disable = options.delete(:disable)
555
+ values_override = options.delete(:values)
556
+ attrs = options.except(*SHOW_CONDITION_KEYS)
557
+
558
+ groups = Phlex::Reactive::ShowConditions.normalize(**conditions)
559
+ data = { reactive_show: { "any" => groups }.to_json }
560
+ data[:reactive_show_disable] = "true" if disable
473
561
 
474
- reactive_show_single(field, options)
562
+ result = mix({ data: }, attrs)
563
+ apply_first_paint_hidden(result, groups, values_override)
475
564
  end
476
565
 
477
566
  # Client-side option filtering for the searchable combobox (issue #163)
@@ -482,12 +571,15 @@ module Phlex
482
571
  # keystroke by substring-matching each option's haystack — no round
483
572
  # trip, no token, no bespoke per-feature controller:
484
573
  #
485
- # div(**mix(reactive_root, reactive_filter(
486
- # input: "#exercise-search",
487
- # option: "[role=option]",
488
- # group: "[data-filter-group]", # optional: collapse empty group headers
489
- # empty: "#no-matches" # optional: reveal when 0 match
490
- # ))) { … }
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
491
583
  #
492
584
  # Each option's haystack is its `data-reactive-filter-text` attribute
493
585
  # (server-rendered — pack in synonyms/categories), falling back to the
@@ -502,11 +594,25 @@ module Phlex
502
594
  # hidden options) and each option's own on(:select, …) trigger —
503
595
  # selection still round-trips as a signed action; only FILTERING is
504
596
  # local. Blank selectors raise: a dead binding must fail at render.
505
- 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
+
506
607
  data = {
507
- reactive_filter_input: filter_selector!(:input, input),
508
- 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]"
509
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).
510
616
  data[:reactive_filter_group] = filter_selector!(:group, group) if group
511
617
  data[:reactive_filter_empty] = filter_selector!(:empty, empty) if empty
512
618
 
@@ -525,7 +631,7 @@ module Phlex
525
631
  # Enter picks the highlighted one by clicking its own reactive trigger
526
632
  # (selection stays a signed action), Escape clears. Combine with other
527
633
  # attrs via mix so a caller's data-action token-joins, not clobbers.
528
- def reactive_listnav(option_selector)
634
+ def reactive_listnav(option_selector = "[role=option]")
529
635
  {
530
636
  data: {
531
637
  action: LISTNAV_ACTIONS.join(" "),
@@ -544,33 +650,35 @@ module Phlex
544
650
  # reactive_root — the client reads it off the controller element):
545
651
  #
546
652
  # div(**mix(reactive_root, reactive_show_targets(:mode,
547
- # "#advanced-tab" => { equals: "advanced" },
548
- # "#advanced-panel" => { equals: "advanced" },
549
- # "#basic-note" => { not: "advanced" })))
653
+ # "#advanced-tab" => "advanced", # equals
654
+ # "#advanced-panel" => "advanced",
655
+ # "#premium-note" => %w[gold platinum]))) # membership
550
656
  #
551
657
  # Same posture as mirror: — opt-in and declared, never implicit (a plain
552
658
  # reactive_show stays root-isolated); targets are SINGLE ID SELECTORS
553
659
  # only, enforced here at declare time AND warn-and-skipped by the client
554
- # interpreter (two-sided default-deny); the predicate is the same
555
- # literal-only reactive_show vocabulary (exactly one of equals:/not:/
556
- # in:, no expressions); and the toggle is `hidden` only — no innerHTML,
557
- # no attribute freedom. The FIELD read stays owned (#15): you can only
558
- # drive outside visibility from a field this root owns. A target id not
559
- # on the page is silently skipped (an unrendered tab pane is normal).
660
+ # interpreter (two-sided default-deny); the value uses the same where-
661
+ # style conditions vocabulary (scalar/Array/Range, no expressions); and
662
+ # the toggle is `hidden` only — no innerHTML, no attribute freedom. The
663
+ # FIELD read stays owned (#15): you can only drive outside visibility from
664
+ # a field this root owns. A target id not on the page is silently skipped
665
+ # (an unrendered tab pane is normal). A target value is positive-only (no
666
+ # per-target unless:) — express "not X" as a membership Array over the
667
+ # remaining options.
560
668
  #
561
669
  # ONE call per root. Phlex `mix` space-joins duplicate STRING data
562
670
  # values, so a second call's JSON would concatenate into an unparseable
563
671
  # attr and the client would drop BOTH maps (it warns when that
564
672
  # happens). Several fields therefore go in ONE call via the hash form:
565
673
  #
566
- # reactive_show_targets(mode: { "#advanced-tab" => { equals: "advanced" } },
567
- # kind: { "#premium-note" => { not: "basic" } })
674
+ # reactive_show_targets(mode: { "#advanced-tab" => "advanced" },
675
+ # kind: { "#premium-note" => %w[gold platinum] })
568
676
  def reactive_show_targets(field, targets = nil)
569
677
  field_maps = targets.nil? ? field : { field => targets }
570
678
  unless field_maps.is_a?(Hash) && field_maps.any?
571
679
  raise ArgumentError,
572
680
  "reactive_show_targets needs at least one target " \
573
- "(:field, \"#id\" => { equals:/not:/in: ... }), got #{field_maps.inspect}"
681
+ "(:field, \"#id\" => value), got #{field_maps.inspect}"
574
682
  end
575
683
 
576
684
  normalized = field_maps.to_h do |name, map|
@@ -599,20 +707,27 @@ module Phlex
599
707
  { data: { reactive_busy_on: action.to_s } }
600
708
  end
601
709
 
602
- # Data attributes declaring a client-side compute for the root element.
603
- # Spread ALONGSIDE reactive_root so the generic controller can find the
604
- # reducer and the named input/output fields inside this root:
605
- # div(**mix(reactive_root, reactive_compute_attrs(:payment_split))) { … }
606
- #
607
- # It emits the reducer key plus the input/output field names as JSON so the
608
- # client runs the reducer on `input`, writes the outputs with no round trip,
609
- # then the debounced POST reconciles from the server reply. Raises for an
610
- # 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.
611
715
  def reactive_compute_attrs(name)
612
- 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]
613
727
  raise Error, "#{self.class} has no reactive_compute #{name.inspect}" unless definition
614
728
 
615
729
  data = {
730
+ action: "input->reactive#recompute",
616
731
  reactive_compute_reducer_param: definition.reducer,
617
732
  reactive_compute_inputs_param: compute_inputs_param(definition),
618
733
  reactive_compute_outputs_param: definition.outputs.map(&:to_s).to_json
@@ -643,12 +758,13 @@ module Phlex
643
758
  definition.inputs.to_h { [it.to_s, types[it].to_s] }.to_json
644
759
  end
645
760
 
646
- # Render a <select> bound to an action param (issue #23). The options block
647
- # is the element's content, so the awkward FormBuilder positional split
648
- # (where name: lands after the options/html-options args) goes away:
649
- # reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
650
- def reactive_select(param, **attrs, &)
651
- 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)."
652
768
  end
653
769
 
654
770
  # Map a declared nested param onto Rails' <assoc>_attributes, carrying the
@@ -675,40 +791,17 @@ module Phlex
675
791
  reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
676
792
  end
677
793
 
678
- # The declared reactive_show predicates: a literal match on the
679
- # controlling field's current value. Exactly one per binding — enforced
680
- # loudly at render (a dead binding must not no-op in the browser).
681
- # `not`/`in` are Ruby keywords, so they arrive via **options rather than
682
- # named kwargs. Issue #161 shipped the string-literal trio; issue #176
683
- # adds the numeric-threshold quartet below.
684
- SHOW_LITERAL_KEYS = %i[equals not in].freeze
685
-
686
- # The numeric-threshold predicates (issue #176 part B): gte/gt/lte/lt
687
- # compare the field value coerced to a Number against a literal number.
688
- # Still a declared literal — the RHS is a number baked into the
689
- # attribute, never an expression — the only new capability is ordered
690
- # comparison. The RHS must be an actual Numeric in Ruby (stricter, so a
691
- # typo like gte: "10" fails at render, not silently in the browser).
692
- SHOW_NUMERIC_KEYS = %i[gte gt lte lt].freeze
693
-
694
- # Every predicate key a reactive_show / reactive_show_targets binding may
695
- # declare — the literal trio plus the numeric quartet. Exactly one of
696
- # these decides a single binding; a compound term declares one too.
697
- SHOW_PREDICATE_KEYS = (SHOW_LITERAL_KEYS + SHOW_NUMERIC_KEYS).freeze
698
-
699
- # The compound connectives (issue #176 part A): fold a list of per-field
700
- # literal/numeric terms with ONE fixed keyword. Not an expression surface
701
- # — the connective is one of two fixed keywords, each term is the same
702
- # declared predicate vocabulary. Mutually exclusive with a single field
703
- # and with each other (enforced loudly at render).
704
- SHOW_CONNECTIVE_KEYS = %i[all any].freeze
705
-
706
- # The declared optimistic-hint class ops (issue #98): the cosmetic class
707
- # vocabulary the client applies instantly and reverts on failure. Enforced
708
- # at build time in optimistic_hint_json (default-deny — a dead hint fails
709
- # at render, not silently in the browser). Each carries a class string or
710
- # array; hide/checked are flags with a fixed shape.
711
- OPTIMISTIC_CLASS_OPS = %w[toggle_class add_class remove_class].freeze
794
+ # The conditions-language kwargs (issue #180): if:/if_any:/unless:
795
+ # compiled by Phlex::Reactive::ShowConditions into the DNF wire. The ONE
796
+ # vocabulary; there are no predicate kwargs any more.
797
+ SHOW_CONDITION_KEYS = %i[if if_any unless].freeze
798
+
799
+ # The removed 0.9.5 surface (issue #180 clean break): each of these
800
+ # kwargs and a positional field — now raises a GUIDED error printing
801
+ # the if:/if_any:/unless: rewrite. Kept only to detect the legacy call
802
+ # shape; nothing here reaches the wire.
803
+ LEGACY_SHOW_PREDICATE_KEYS = %i[equals not in gte gt lte lt].freeze
804
+ LEGACY_SHOW_CONNECTIVE_KEYS = %i[all any].freeze
712
805
 
713
806
  private
714
807
 
@@ -726,154 +819,157 @@ module Phlex
726
819
  selector
727
820
  end
728
821
 
729
- # Normalize + validate ONE field's target map (issue #164): each key a
730
- # single id selector (loud raise — the declare-time half of the
731
- # two-sided default-deny), each value one literal predicate. Shared by
732
- # both reactive_show_targets call forms.
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
+
869
+ # Normalize + validate ONE field's target map (issue #180): each key a
870
+ # single id selector (loud raise — the declare-time half of the two-sided
871
+ # default-deny), each value a where-style condition value (scalar/Array/
872
+ # Range) that compiles to ONE DNF group (terms ANDed). Shared by both
873
+ # reactive_show_targets call forms.
733
874
  def normalize_show_target_map(field, targets)
734
875
  unless targets.is_a?(Hash) && targets.any?
735
876
  raise ArgumentError,
736
877
  "reactive_show_targets(#{field.inspect}) needs at least one target " \
737
- "(\"#id\" => { equals:/not:/in: ... }), got #{targets.inspect}"
878
+ "(\"#id\" => value), got #{targets.inspect}"
738
879
  end
739
880
 
740
- targets.to_h do |selector, predicate|
881
+ targets.to_h do |selector, value|
741
882
  selector = selector.to_s
742
883
  unless selector.match?(DSL::MIRROR_ID_SELECTOR)
743
884
  raise ArgumentError,
744
885
  "reactive_show_targets(#{field.inspect}) target #{selector.inspect} must be a single " \
745
886
  "ID selector (\"#id\") — cross-root visibility is id-allowlisted, like mirror: (#159)"
746
887
  end
888
+ if value.is_a?(Hash)
889
+ raise ArgumentError,
890
+ "reactive_show_targets(#{field.inspect}) target #{selector.inspect}: the { equals: ... } " \
891
+ "predicate form was removed — pass a bare value (#{selector.inspect} => \"advanced\", " \
892
+ "=> %w[a b] for a set, => 10.. for a threshold)"
893
+ end
747
894
 
748
- context = "reactive_show_targets(#{field.inspect}) target #{selector.inspect}"
749
- [selector, normalize_show_predicate(predicate.is_a?(Hash) ? predicate : {}, context)]
750
- end
751
- end
752
-
753
- # Validate ONE declared show predicate (issues #161/#164/#176) and
754
- # return its wire form — { "equals" => "v" } / { "not" => "v" } /
755
- # { "in" => ["a", …] } / { "gte" => 10 } (a numeric RHS stays a real
756
- # Number so the wire carries a JSON number, not a string). reactive_show
757
- # JSON-encodes an in: array into its own attr; the reactive_show_targets
758
- # map and a compound term embed the predicate directly. Shared by every
759
- # helper so the vocabulary and the loud validation can never drift.
760
- # `context` names the call site in the error.
761
- def normalize_show_predicate(predicates, context)
762
- unless predicates.size == 1 && SHOW_PREDICATE_KEYS.include?(predicates.keys.first)
763
- raise ArgumentError,
764
- "#{context} needs exactly one predicate — equals:, not:, in:, or " \
765
- "gte:/gt:/lte:/lt: — got #{predicates.keys.inspect}"
895
+ # A target compiles to ONE group: the field-vs-value condition.
896
+ groups = Phlex::Reactive::ShowConditions.normalize(if: { field => value })
897
+ [selector, groups.first]
766
898
  end
767
-
768
- key, value = predicates.first
769
- return normalize_show_numeric(key, value, context) if SHOW_NUMERIC_KEYS.include?(key)
770
- return { key.to_s => value.to_s } unless key == :in
771
-
772
- list = Array(value).map(&:to_s)
773
- raise ArgumentError, "#{context} in: needs at least one value" if list.empty?
774
-
775
- { "in" => list }
776
899
  end
777
900
 
778
- # Validate a numeric threshold predicate (issue #176 part B): the RHS
779
- # must be an actual Numeric (a String "10" is a typo caught here, not a
780
- # silent NaN in the browser). It rides the wire as a JSON number so the
781
- # client compares Number(value) against it directly.
782
- def normalize_show_numeric(key, value, context)
783
- unless value.is_a?(Numeric)
901
+ # Reject the removed 0.9.5 reactive_show surface (issue #180 clean break)
902
+ # with a GUIDED error printing the if:/if_any:/unless: rewrite. A
903
+ # positional field, a predicate kwarg (equals:/not:/in:/gte:/…), or a
904
+ # connective (all:/any:) all land here before any conditions parsing.
905
+ def reject_legacy_show_surface!(field, options)
906
+ unless field.nil?
784
907
  raise ArgumentError,
785
- "#{context} #{key}: needs a number (an ordered comparison against a literal), " \
786
- "got #{value.inspect}"
908
+ "reactive_show no longer takes a positional field the conditions language is " \
909
+ "keyword-only: reactive_show(if: { #{field}: <value> }) (a Range is a threshold, " \
910
+ "an Array is a set, unless: negates). See the 0.10 upgrade notes."
787
911
  end
788
912
 
789
- { key.to_s => value }
790
- end
791
-
792
- # The single-field reactive_show form (issue #161, extended for numeric
793
- # thresholds in #176): one owned field + one predicate → flat
794
- # data-reactive-show-* attrs. An in: list JSON-encodes into its own attr;
795
- # a numeric threshold or a literal stringifies into the flat attr and the
796
- # client Number()-coerces the numeric case back on read.
797
- def reactive_show_single(field, options)
798
- if field.nil?
913
+ if (pred = options.keys & LEGACY_SHOW_PREDICATE_KEYS).any?
799
914
  raise ArgumentError,
800
- "reactive_show needs a field (reactive_show(:mode, equals: …)) or a compound " \
801
- "connective (reactive_show(all: [...]) / any:) got neither"
915
+ "reactive_show(#{pred.first}: ...) was removed use the conditions language: " \
916
+ "reactive_show(if: { field: value }). equals:/not: → if:/unless:, in: an Array value, " \
917
+ "gte:/gt:/lte:/lt: → a Range value (10.., ..10, ...10)."
802
918
  end
803
-
804
- predicates = options.slice(*SHOW_PREDICATE_KEYS)
805
- attrs = options.except(*SHOW_PREDICATE_KEYS)
806
- unless predicates.size == 1
919
+ if (conn = options.keys & LEGACY_SHOW_CONNECTIVE_KEYS).any?
920
+ replacement = conn.first == :all ? "if: { … }" : "if_any: [{ … }, { … }]"
807
921
  raise ArgumentError,
808
- "reactive_show(#{field.inspect}) needs exactly one predicate equals:, not:, in:, " \
809
- "or gte:/gt:/lte:/lt: got #{predicates.keys.inspect}"
922
+ "reactive_show(#{conn.first}: [...]) was removeduse #{replacement}. " \
923
+ "all: → if: (one AND group); any: → if_any: (OR of AND groups). Terms are now " \
924
+ "field => value pairs, not { field:, equals: } hashes."
810
925
  end
811
-
812
- key, value = normalize_show_predicate(predicates, "reactive_show(#{field.inspect})").first
813
- data = { reactive_show_field: field.to_s }
814
- # in: → JSON array; a numeric threshold or a literal → the value as-is.
815
- # Phlex stringifies it into the flat attr; the client re-reads via
816
- # getAttribute (always a string) and Number()-coerces the numeric case.
817
- data[:"reactive_show_#{key}"] = key == "in" ? value.to_json : value.to_s
818
-
819
- mix({ data: }, attrs)
820
926
  end
821
927
 
822
- # The compound reactive_show form (issue #176 part A): `all:`/`any:` fold
823
- # a list of per-field terms with one fixed connective, serialized as one
824
- # JSON attr (data-reactive-show). A field alongside a connective, or two
825
- # connectives, is a contradiction raise. Each term is validated through
826
- # the SAME predicate normalizer, so a term's vocabulary never drifts from
827
- # the single-field form.
828
- def reactive_show_compound(field, connectives, options)
829
- unless field.nil?
830
- raise ArgumentError,
831
- "reactive_show got a field AND all:/any: — the compound and single-field forms are " \
832
- "mutually exclusive; use one flat binding OR a compound list, not both"
833
- end
834
- unless connectives.size == 1
835
- raise ArgumentError,
836
- "reactive_show needs exactly one of all:/any: (one fixed connective), " \
837
- "got #{connectives.keys.inspect}"
838
- end
928
+ # Compute the first-paint `hidden:` from reactive_values (issue #180) so
929
+ # the author never restates the predicate as a Ruby mirror method. Fires
930
+ # only when EVERY field the binding references is provided (by
931
+ # reactive_values, merged under a per-call `values:` override); otherwise
932
+ # the attrs are returned untouched. An explicit `hidden:` in the caller's
933
+ # attrs always wins (it survives the mix, so this is a no-op then).
934
+ def apply_first_paint_hidden(attrs, groups, values_override)
935
+ return attrs if attrs.key?(:hidden)
839
936
 
840
- connective, terms = connectives.first
841
- # A top-level predicate alongside a connective (reactive_show(all: [...],
842
- # equals: "x")) is a misuse — predicates belong INSIDE terms. Catch it
843
- # loudly at render rather than leaking `equals="x"` as a bogus HTML attr
844
- # (the mix at the tail treats every leftover kwarg as a literal
845
- # attribute). Same default-deny posture as the single-field path.
846
- if (stray = options.slice(*SHOW_PREDICATE_KEYS)).any?
847
- raise ArgumentError,
848
- "reactive_show #{connective}: got a top-level predicate #{stray.keys.inspect} — " \
849
- "predicates belong INSIDE each term ({ field: …, #{stray.keys.first}: … }), not beside the connective"
850
- end
937
+ provided = show_values(values_override)
938
+ return attrs if provided.nil?
851
939
 
852
- attrs = options.except(*SHOW_CONNECTIVE_KEYS)
853
- unless terms.is_a?(Array) && terms.any?
854
- raise ArgumentError,
855
- "reactive_show #{connective}: needs at least one term " \
856
- "({ field: …, equals:/not:/in:/gte:/… }), got #{terms.inspect}"
857
- end
940
+ referenced = Phlex::Reactive::ShowConditions.fields(groups)
941
+ return attrs unless referenced.all? { provided.key?(it) }
858
942
 
859
- normalized = terms.map { normalize_show_term(connective, it) }
860
- mix({ data: { reactive_show: { connective.to_s => normalized }.to_json } }, attrs)
943
+ visible = Phlex::Reactive::ShowConditions.match?(groups, provided)
944
+ attrs.merge(hidden: !visible)
861
945
  end
862
946
 
863
- # Normalize + validate ONE compound term (issue #176 part A): a Hash with
864
- # a :field and exactly one predicate. The predicate goes through the
865
- # shared normalizer; the field is stringified into the term. Returns
866
- # { "field" => name, <predicate> } so the wire is uniform with the
867
- # reactive_show_targets embedded-predicate shape.
868
- def normalize_show_term(connective, term)
869
- context = "reactive_show #{connective}: term"
870
- unless term.is_a?(Hash) && term.key?(:field)
871
- raise ArgumentError, "#{context} each term needs a field (got #{term.inspect})"
872
- end
947
+ # The { field => current-string-value } map the first-paint evaluator
948
+ # reads: reactive_values (if the component declares it) merged under a
949
+ # per-call values: override, both stringified the way the client reads a
950
+ # field (checkbox → "true"/"false", nil ""). nil when neither source
951
+ # exists — first paint then no-ops (no flash guarantee is the author's,
952
+ # exactly as before).
953
+ def show_values(values_override)
954
+ base = respond_to?(:reactive_values) ? reactive_values : nil
955
+ return nil if base.nil? && values_override.nil?
956
+
957
+ merged = {}
958
+ merged.merge!(base) if base
959
+ merged.merge!(values_override) if values_override
960
+ merged.to_h { |name, value| [name.to_s, show_value_string(value)] }
961
+ end
873
962
 
874
- field = term[:field]
875
- predicate = normalize_show_predicate(term.except(:field), "#{context} (field #{field.inspect})")
876
- { "field" => field.to_s }.merge(predicate)
963
+ # Stringify a reactive_values entry the way the client's #showFieldValue
964
+ # reports the live field: a boolean is the checkbox checked-state string,
965
+ # nil is blank, everything else is to_s.
966
+ def show_value_string(value)
967
+ case value
968
+ when true then "true"
969
+ when false then "false"
970
+ when nil then ""
971
+ else value.to_s
972
+ end
877
973
  end
878
974
 
879
975
  # True when the hint declares checked: :keep — the click-bound
@@ -887,89 +983,90 @@ module Phlex
887
983
  value.to_s == "keep"
888
984
  end
889
985
 
890
- # Normalize + validate the optimistic hint hash, returning its JSON wire
891
- # form (data-reactive-optimistic-param). `to: :root` becomes the same
892
- # "@root" sentinel the js op builder uses so the client resolves it
893
- # uniformly. Unknown keys, a bad `checked:` value, or a non-hash raise
894
- # a hint that can't apply must fail loudly at render.
895
- def optimistic_hint_json(optimistic, action_name)
896
- unless optimistic.is_a?(Hash)
897
- raise ArgumentError,
898
- "on(#{action_name.inspect}) optimistic: must be a Hash of visual hints " \
899
- "(e.g. { checked: :keep } or { hide: true, to: :root }), got #{optimistic.class}"
900
- end
901
-
902
- hint = {}
903
- optimistic.each do |key, value|
904
- case key.to_s
905
- when *OPTIMISTIC_CLASS_OPS
906
- hint[key.to_s] = Array(value).map(&:to_s)
907
- when "hide"
908
- hint["hide"] = value ? true : false
909
- when "checked"
910
- unless value.to_s == "keep"
911
- raise ArgumentError,
912
- "on(#{action_name.inspect}) optimistic checked: only supports :keep " \
913
- "(flip the native control, revert on failure), got #{value.inspect}"
914
- end
915
- hint["checked"] = "keep"
916
- when "to"
917
- hint["to"] = value == :root ? Phlex::Reactive::JS::ROOT_SENTINEL : value.to_s
918
- else
919
- raise ArgumentError,
920
- "on(#{action_name.inspect}) got an unknown optimistic hint #{key.inspect} — " \
921
- "supported: toggle_class/add_class/remove_class, checked: :keep, hide: true, to:"
922
- end
923
- end
924
-
925
- 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]}"
926
1011
  end
927
1012
 
928
- # Normalize + validate the loading hint (issue #99), returning its JSON wire
929
- # form (data-reactive-loading-param) or nil when neither loading: nor
930
- # disable_with: is given (the bare on() hot path stays untouched).
931
- # disable_with: "Saving…" expands to { disable: true, text: } and MERGES
932
- # over an explicit loading: hash its text/disable win. `to: :root` becomes
933
- # the "@root" sentinel the js op builder uses so the client resolves targets
934
- # uniformly. An unknown key or a non-hash loading: raises a dead hint must
935
- # fail loudly at render, not silently in the browser (default-deny).
936
- def loading_hint_json(loading, disable_with, action_name)
937
- return nil if loading.nil? && disable_with.nil?
938
-
939
- 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)
940
1024
  unless source.is_a?(Hash)
941
1025
  raise ArgumentError,
942
- "on(#{action_name.inspect}) loading: must be a Hash of loading hints " \
943
- "(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}"
944
1028
  end
945
1029
 
946
1030
  hint = {}
947
1031
  source.each do |key, value|
948
1032
  case key.to_s
949
- when "class"
950
- hint["class"] = Array(value).map(&:to_s)
951
- when "disable"
952
- 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
953
1037
  when "text"
954
1038
  hint["text"] = value.to_s
955
1039
  when "to"
956
- 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)
957
1043
  else
958
1044
  raise ArgumentError,
959
- "on(#{action_name.inspect}) got an unknown loading hint #{key.inspect} — " \
960
- "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}"
961
1048
  end
962
1049
  end
963
1050
 
964
- # disable_with: "Saving…" ≡ { disable: true, text: … }, applied AFTER the
965
- # explicit loading: hash so it wins on both keys (the shorthand is the
966
- # more specific intent when a caller passes both).
967
- if disable_with
968
- hint["disable"] = true
969
- 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}"
970
1067
  end
971
1068
 
972
- hint.to_json
1069
+ "keep"
973
1070
  end
974
1071
 
975
1072
  # The component's record, for the nested-attributes helpers. Requires a