Module: Phlex::Reactive::Component::Helpers

Extended by:
ActiveSupport::Concern
Included in:
Phlex::Reactive::ClientBindings
Defined in:
lib/phlex/reactive/component/helpers.rb

Overview

The view-side helper surface of Component (issue #115): the reply/js builders, the root-element attribute helpers (reactive_attrs/ reactive_root), the trigger builders (on/on_client) with their hint vocabularies, the form-binding helpers (reactive_field/input/select/ text/busy_on/reactive_compute_attrs), and the nested-attributes helpers. Everything a view_template spreads or calls — no registries, no signing (those live in DSL and Identity).

Constant Summary collapse

EMPTY_PARAMS_JSON =

Attributes for an element that triggers an action. button(**on(:toggle)) { "○" } form(**on(:save, event: "submit")) { ... } input(**on(:update, event: "input", debounce: 300)) # live-as-you-type

Extra keyword args become explicit params merged over collected form fields. For click triggers we force type="button" so a bare button inside a

can't submit it and cause a full-page navigation.

debounce: (milliseconds) coalesces rapid events (e.g. keystrokes on an "input" trigger) into ONE round trip fired after the quiet period — so live-update-as-you-type doesn't POST per keystroke. A blur flushes a pending dispatch so the last edit is never dropped. Omit it for the immediate-dispatch default.

confirm: (a message string) gates the action behind a confirmation prompt (issue #52). Destructive reactive triggers can't use Hotwire's data-turbo-confirm — the reactive controller calls preventDefault and enqueues the POST itself, so Turbo's confirm handling never runs. The client shows window.confirm(message) FIRST and bails before any enqueue/debounce if the user declines (and prevents the native default so a submit trigger can't navigate on cancel). Omit it for no prompt. button(**on(:destroy, confirm: "Really delete this item?")) { "Delete" }

event: is interpolated verbatim into the Stimulus action descriptor (#{event}->reactive#dispatch), so any Stimulus event string works — including its native KEYBOARD FILTERS. Pass event: "keydown.enter" for Enter-to-submit or event: "keydown.esc" for Escape-to-cancel, and the action fires only on that key — no separate option, no client code, and key stays free as an ordinary action-param name (on(:switch, key: …)):

input(**on(:add, event: "keydown.enter"))      # Enter submits
button(**on(:cancel, event: "keydown.esc"))     # Escape cancels

Combobox keyboard navigation is the standalone reactive_listnav (issue #181 removed the on(…, listnav:) kwarg — it duplicated reactive_listnav while skipping its blank-selector validation). Compose it via mix:

input(**mix(on(:search, event: "input", debounce: 300), reactive_listnav))

The verbatim JSON for an empty explicit-params payload. The common trigger (on(:increment), no params) hits this on EVERY render — skipping params.to_json (which re-serializes {} to the same "{}" each time) avoids a per-render allocation while keeping the wire format byte-identical.

"{}"
LISTNAV_ACTIONS =

The keyboard filters appended to a listnav trigger's data-action. Each is a client-only handler (no POST) except Enter, which clicks the highlighted option's own reactive trigger. Stimulus binds these natively.

[
  "keydown.down->reactive#listnavNext",
  "keydown.up->reactive#listnavPrev",
  "keydown.enter->reactive#listnavPick",
  "keydown.esc->reactive#listnavClose"
].freeze
SHOW_CONDITION_KEYS =

The conditions-language kwargs (issue #180): if:/if_any:/unless: — compiled by Phlex::Reactive::ShowConditions into the DNF wire. The ONE vocabulary; there are no predicate kwargs any more.

%i[if if_any unless].freeze
LEGACY_SHOW_PREDICATE_KEYS =

The removed 0.9.5 surface (issue #180 clean break): each of these kwargs — and a positional field — now raises a GUIDED error printing the if:/if_any:/unless: rewrite. Kept only to detect the legacy call shape; nothing here reaches the wire.

%i[equals not in gte gt lte lt].freeze
LEGACY_SHOW_CONNECTIVE_KEYS =
%i[all any].freeze

Instance Method Summary collapse

Instance Method Details

#busy_on(action) ⇒ Object

Scoped busy indicator (issue #99). Marks an element so the generic controller toggles data-reactive-busy on it ONLY while action is in flight — the scoped sibling of the always-on data-reactive-busy the trigger and root carry. Spread onto any element inside the reactive root; style it with [data-reactive-busy] { … } and zero Ruby:

span(**busy_on(:save), class: "spinner hidden")


706
707
708
# File 'lib/phlex/reactive/component/helpers.rb', line 706

def busy_on(action)
  { data: { reactive_busy_on: action.to_s } }
end

#compute_binding(name) ⇒ Object

The root's compute descriptors + the recompute delegation (issue #183). Emits the same data-reactive-compute-* attrs as before PLUS the input->reactive#recompute action, all on the root element. Raises for an undeclared name (fail fast, not a silent no-op).

Raises:



725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
# File 'lib/phlex/reactive/component/helpers.rb', line 725

def compute_binding(name)
  definition = self.class.reactive_computes[name.to_sym]
  raise Error, "#{self.class} has no reactive_compute #{name.inspect}" unless definition

  data = {
    action: "input->reactive#recompute",
    reactive_compute_reducer_param: definition.reducer,
    reactive_compute_inputs_param: compute_inputs_param(definition),
    reactive_compute_outputs_param: definition.outputs.map(&:to_s).to_json,
    # Issue #199: the client self-seeds the derived fields on connect from
    # this marker, so a freshly-rendered compute root computes its outputs
    # + mirrors on first paint — no wait for the first user input, and no
    # synthetic seed `input` for an app to race. STRING "true" (a valueless
    # boolean attr renders "" → falsy client-side; the client reads == "true").
    reactive_compute_seed: "true"
  }
  # Declared cross-root text mirrors (issue #159) ride as a JSON object of
  # name → [id selectors]; omitted entirely when undeclared so the shipped
  # wire stays byte-identical.
  data[:reactive_compute_mirror_param] = compute_mirror_param(definition) if definition.mirror

  { data: }
end

#compute_inputs_param(definition) ⇒ Object

The inputs param wire (issue #104). Untyped (array form) → a JSON ARRAY of names, byte-identical to the shipped wire so the client keeps its numeric coercion. Typed (hash form) → a JSON OBJECT of name→type ("title":"string","qty":"number") so the client reads a :string raw and coerces a :number through Number.



760
761
762
763
764
765
# File 'lib/phlex/reactive/component/helpers.rb', line 760

def compute_inputs_param(definition)
  types = definition.input_types
  return definition.inputs.map(&:to_s).to_json if types.nil?

  definition.inputs.to_h { [it.to_s, types[it].to_s] }.to_json
end

#compute_mirror_param(definition) ⇒ Object

The mirror param wire (issue #159): { "sum_a" => ["#sum_a"], … } — the values are ALWAYS arrays so the client parses one uniform shape.



751
752
753
# File 'lib/phlex/reactive/component/helpers.rb', line 751

def compute_mirror_param(definition)
  definition.mirror.transform_keys(&:to_s).to_json
end

#field_dirty_tracked?(param) ⇒ Boolean

True when reactive_dirty only: names this field, so it carries its own trackDirty descriptor (issue #184).

Returns:

  • (Boolean)


449
450
451
452
453
454
# File 'lib/phlex/reactive/component/helpers.rb', line 449

def field_dirty_tracked?(param)
  return false unless self.class.respond_to?(:reactive_dirty_config)

  only = self.class.reactive_dirty_config&.dig(:only)
  only&.include?(param.to_sym) || false
end

#jsObject

An empty client-side op chain (issue #95) — the starting point for on_client's DOM commands, mirroring how reply starts a Response chain:

button(**on_client(:click, js.toggle("#menu"))) { "Menu" }

Immutable: each verb returns a new chain, so reuse never leaks ops.



68
69
70
# File 'lib/phlex/reactive/component/helpers.rb', line 68

def js
  Phlex::Reactive::JS.new
end

#mark_authorized!Object

Manually satisfy the verify_authorized guard (issue #168) for a bespoke authorization check the interceptor can't see — a hand-rolled policy, a feature flag, an ownership comparison that doesn't go through one of Phlex::Reactive.authorization_methods:

def publish
raise NotAllowed unless @post.author == Current.user
mark_authorized!
@post.update!(published: true)
end

Call it only AFTER your check passes — it asserts "I have authorized this action." A no-op when verify_authorized is off.



44
45
46
# File 'lib/phlex/reactive/component/helpers.rb', line 44

def mark_authorized!
  Phlex::Reactive::Authorization.mark!
end

#nested_attributes(association, attrs) ⇒ Object

Map a declared nested param onto Rails' _attributes, carrying the existing associated record's id so accepts_nested_attributes_for matches it IN PLACE instead of building a second one (issue #24). Returns the update hash; pass it to update!:

def save(address:) = nested_update!(:address, address)

The id is only added when the association already exists, so the first save (no associated record yet) creates one cleanly. The given attrs are not mutated.



784
785
786
787
788
789
790
# File 'lib/phlex/reactive/component/helpers.rb', line 784

def nested_attributes(association, attrs)
  merged = attrs.dup
  existing = reactive_record_for_nested.public_send(association)
  merged[:id] = existing.id if existing

  { "#{association}_attributes": merged }
end

#nested_update!(association, attrs, **extra) ⇒ Object

Map a nested param onto _attributes (with id preservation) AND apply it to the component's record in one call (issue #24). Extra keyword attributes update alongside the association. def save(address:, name:) = nested_update!(:address, address, name:)



796
797
798
# File 'lib/phlex/reactive/component/helpers.rb', line 796

def nested_update!(association, attrs, **extra)
  reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
end

#on(action_name, event: "click", debounce: nil, throttle: nil, confirm: nil, window: false, once: false, outside: false, optimistic: nil, busy: nil, **params) ⇒ Object

Event modifiers (issue #80) — window:, once:, outside:, throttle: are RESERVED keyword names on on() (no longer usable as free action params):

window: true binds the trigger to the window (Stimulus's native @window descriptor suffix) — for page-level events like scroll/resize. once: true appends Stimulus's :once option, so the trigger fires at most one round trip and then unbinds. Both are pure descriptor composition. A window-bound trigger is NOT preventDefault-ed by the client (it would kill every native click/submit on the page), and it skips the forced type="button" (it isn't an in-form button trigger).

outside: true fires the action only for events OUTSIDE this component's ROOT (containment against the reactive root element) — the close-a-dropdown-on-outside-click pattern. It implies window: true; an event inside the root is a complete client-side no-op:

div(**mix(reactive_root, on(:close_menu, outside: true))) { ... }

throttle: (milliseconds) rate-limits a hot trigger LEADING-EDGE: the first event fires immediately, further events are suppressed until the window elapses (scroll/mousemove). Mutually exclusive with debounce: (trailing-edge) — passing both raises ArgumentError. div(**mix(reactive_root, on(:track, event: "scroll", window: true, throttle: 250)))

optimistic: (issue #98) — a small, ALWAYS-REVERSIBLE vocabulary of COSMETIC hints the client applies the instant the trigger fires and REVERTS if the round trip fails, so a click/toggle gives instant feedback instead of waiting a full round trip. Hints are visual only — never data, never computed values (that would be client state). Supported ops in the hint hash:

* toggle_class:/add_class:/remove_class: — a class string or array,
applied to the TRIGGER (default) or to a `to:` selector scoped to the
root (`to: :root` targets the root element itself).
* checked: :keep — for a click-bound checkbox/radio, the client SKIPS
its unconditional preventDefault so the native flip happens now
(today the morph never even lets it flip). On failure, the flip is
reverted.
* hide: true — hides the target immediately (the `hide: true` + a
`reply.remove` action is the instant delete-a-row recipe: the hint
hides it, the reply removes it; a failure snaps it back).

Success does NO cleanup: a reply that re-renders the root overwrites the hint with server truth; a reply that does NOT re-render the root (reply.remove / streams-only) LEAVES the hint standing — that's the instant-delete working as intended. input(type: "checkbox", checked: @todo.done, **mix(on(:toggle, event: "change", optimistic: { checked: :keep }), name: "done")) button(**on(:destroy, confirm: "Delete?", optimistic: { hide: true, to: :root })) { "Delete" } busy: (issue #181) — declarative per-trigger pending states, Livewire's wire:loading + phx-disable-with without a Stimulus controller. It shares optimistic:'s key vocabulary and normalizer; the ONLY difference is the lifecycle: a busy: hint applies the moment the request is ENQUEUED (covering the queue wait, not just the fetch) and reverts on SETTLE (any completion), where optimistic: reverts only on FAILURE. busy: is a String or a Hash:

* "Saving…"             — String shorthand for { disable: true, text: … }
* disable: true         — disable the trigger while pending
* add_class:/remove_class:/toggle_class: "…" / [ … ] — class op on the
                        trigger (or a `to:` selector scoped to the root)
* hide:/show: true      — hide/show the target while pending
* text: "Saving…"       — swap the trigger's innerHTML while pending
* to: :root / "sel"     — target the ops at the root or a selector

checked: :keep is optimistic-ONLY (a native flip has no settle-revert meaning). The trigger/root also always carry data-reactive-busy for the whole pending window regardless of these hints, so an app styles a spinner with [data-reactive-busy] .spinner { display: block } and zero Ruby; see busy_on for scoped indicators. button(**on(:save, busy: "Saving…")) { "Save" } button(**on(:destroy, confirm: "Sure?", busy: { add_class: "opacity-50" })) { "Delete" }



276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# File 'lib/phlex/reactive/component/helpers.rb', line 276

def on(action_name, event: "click", debounce: nil, throttle: nil, confirm: nil,
       window: false, once: false, outside: false, optimistic: nil, busy: nil,
       **params)
  reject_removed_on_kwargs!(action_name, params)
  # A typo'd or forgotten action renders fine and only surfaces as an
  # opaque 403 at CLICK time (the endpoint's default-deny). Under
  # verbose_errors (dev + test), fail loudly at RENDER time instead —
  # listing the declared actions — the same courtesy reactive_compute_attrs
  # gives an undeclared compute (issue #105). Placed FIRST, before any attr
  # building. Production (flag off) keeps the permissive emit: a stale page
  # after a deploy that removed an action must not 500 on render. This is a
  # dev-time aid, NOT the security boundary — default-deny stays the
  # SERVER's enforcement. on_client triggers are not declared actions (no
  # registry), so they are never checked here.
  #
  # The check applies ONLY to a component that declares actions of its own.
  # A component with an EMPTY registry is a cross-component dispatch helper
  # — a child row that renders a trigger for its CONTAINER's action and
  # sends the container's token (e.g. NotificationRowComponent → the list's
  # :dismiss). It can't self-validate against a registry it doesn't own, so
  # the guard would false-positive; skipping the empty case keeps the
  # pattern working while still catching a typo in a component that DOES
  # declare actions (the issue's target — on(:togle) where :toggle exists).
  # verbose_errors is checked FIRST so production (flag off) short-circuits
  # before touching the registry — zero added cost on the hot path.
  if Phlex::Reactive.verbose_errors &&
     (actions = self.class.reactive_actions).any? && !actions.key?(action_name.to_sym)
    raise Phlex::Reactive::Error,
      "#{self.class} has no declared action #{action_name.to_sym.inspect} " \
      "(declared: #{actions.keys.inspect})"
  end

  if debounce && throttle
    raise ArgumentError,
      "on(#{action_name.inspect}) got both debounce: and throttle: — they are mutually " \
      "exclusive (debounce is trailing-edge, throttle is leading-edge); pick one"
  end

  window_bound = window || outside
  action = "#{event}#{"@window" if window_bound}->reactive#dispatch#{":once" if once}"
  attrs = {
    data: {
      action:,
      reactive_action_param: action_name.to_s,
      reactive_params_param: params.empty? ? EMPTY_PARAMS_JSON : params.to_json
    }
  }
  attrs[:data][:reactive_debounce_param] = debounce if debounce
  attrs[:data][:reactive_throttle_param] = throttle if throttle
  apply_confirm!(attrs[:data], confirm) if confirm
  attrs[:data][:reactive_optimistic_param] = pending_hint_json(optimistic, action_name, :optimistic) if optimistic
  attrs[:data][:reactive_busy_param] = pending_hint_json(busy, action_name, :busy) if busy
  # STRING "true", not boolean: Phlex renders a `true` attribute VALUELESS
  # (data-reactive-outside-param), which Stimulus's param reader sees as ""
  # — falsy in JS, so the guard silently never fires. The explicit ="true"
  # typecasts to a real boolean on the client.
  attrs[:data][:reactive_outside_param] = "true" if outside
  # The client decides preventDefault behavior from event.params (never by
  # sniffing the descriptor), so EVERY window binding flags the param.
  attrs[:data][:reactive_window_param] = "true" if window_bound
  # Force type="button" for click triggers so a bare button inside a <form>
  # can't submit it — EXCEPT when checked: :keep is declared: that hint's
  # whole point is to let a click-bound checkbox/radio flip natively, and a
  # forced type="button" would destroy the very control being toggled
  # (issue #98). The caller supplies the real type="checkbox"/"radio".
  attrs[:type] = "button" if event == "click" && !window_bound && !optimistic_keeps_native?(optimistic)
  attrs
end

#on_client(event, ops, window: false, once: false, outside: false, confirm: nil) ⇒ Object

Attributes for a CLIENT-ONLY trigger (issue #95): binds a DOM event to a chain of declarative DOM ops (Phlex::Reactive::JS) that the generic controller's runOps action applies in the browser — NO token, NO params, NO POST, ever. The zero-round-trip sibling of on():

button(**on_client(:click, js.toggle("#menu"))) { "Menu" }
# tabs, one line per tab, no Stimulus controller:
button(**on_client(:click, js.hide(".panel").show("#panel-2")))

window:, once:, and outside: compose exactly like on()'s event modifiers (#80): outside-click-to-close a dropdown is div(**mix(reactive_root, on_client(:click, js.hide("#menu"), outside: true))) Window-bound triggers are never preventDefault-ed by the client and skip the forced type="button".

Ops are EPHEMERAL UI: any server re-render of the component (an action reply, a broadcast, a morph) rebuilds from server state and resets whatever they toggled — by design (the LiveView JS-commands caveat). Use a signed action for state that must survive re-renders.

Validation is loud: only a non-empty Phlex::Reactive::JS chain is accepted — a dead trigger should fail at render, not no-op in the browser.

Raises:

  • (ArgumentError)


368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
# File 'lib/phlex/reactive/component/helpers.rb', line 368

def on_client(event, ops, window: false, once: false, outside: false, confirm: nil)
  unless ops.is_a?(Phlex::Reactive::JS)
    raise ArgumentError,
      "on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
      "got #{ops.class}"
  end
  raise ArgumentError, "on_client(#{event.inspect}) got no ops — a dead trigger" if ops.empty?

  event = event.to_s
  window_bound = window || outside
  attrs = {
    data: {
      action: "#{event}#{"@window" if window_bound}->reactive#runOps#{":once" if once}",
      reactive_ops_param: ops.to_json
    }
  }
  # STRING "true", not boolean — same Phlex valueless-attribute trap as
  # on()'s flags above.
  attrs[:data][:reactive_outside_param] = "true" if outside
  attrs[:data][:reactive_window_param] = "true" if window_bound
  # Issue #178: confirm: gates the client-op chain behind the SAME
  # overridable confirmResolver on(:action, confirm:) uses (#52/#55). Emits
  # the identical data-reactive-confirm-param; the client's runOps prompts
  # via confirmResolver BEFORE applying the ops (a falsy resolve cancels
  # the chain), so a destructive client op gets the themed dialog with no
  # round trip. Issue #179: a Hash confirm: is CONDITIONAL — same shared
  # apply_confirm! branches String vs Hash for both on and on_client.
  apply_confirm!(attrs[:data], confirm) if confirm
  attrs[:type] = "button" if event == "click" && !window_bound
  attrs
end

#reactive_attrsObject

Root-element attributes: marks the element reactive and carries the signed identity token. Spread onto the root:

div(id:, **reactive_attrs) { ... }


75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/phlex/reactive/component/helpers.rb', line 75

def reactive_attrs
  data = { controller: "reactive" }
  # A CLIENT-ONLY component (Phlex::Reactive::ClientBindings, issue #180)
  # has no Identity, so no token — the root is tokenless (show/filter/
  # compute need no signed round trip). reactive_token is private, so the
  # include-private `respond_to?` mirrors to_stream_token's guard.
  data[:reactive_token_value] = reactive_token if respond_to?(:reactive_token, true)
  # Client debug mode (issue #108): stamp the flag so the generic controller
  # console.groups every dispatch. STRING "true", not boolean — Phlex renders
  # a boolean-true attr VALUELESS, which getAttribute reads as "" (falsy in
  # JS), so the client's attr check would never fire (the on()/warn_unsaved
  # precedent). Off by default → no key, no string, zero client surface.
  data[:reactive_debug] = "true" if Phlex::Reactive.debug
  # Field-name scope (issue #180): the client prefixes bare binding field
  # names with `scope[...]`. Omitted entirely when undeclared — byte-stable
  # wire for unscoped components.
  if self.class.respond_to?(:reactive_scope) && (scope = self.class.reactive_scope)
    data[:reactive_scope] = scope.to_s
  end
  { data: }
end

#reactive_compute_attrs(name) ⇒ Object

REMOVED in issue #183 — the compute binding moved to reactive_root(compute: :name), which emits the descriptors AND the recompute delegation at the root, so no field carries per-field wiring:

div(**reactive_root(compute: :payment_split)) {  }

This helper now raises a guided ArgumentError printing that rewrite.

Raises:

  • (ArgumentError)


715
716
717
718
719
# File 'lib/phlex/reactive/component/helpers.rb', line 715

def reactive_compute_attrs(name)
  raise ArgumentError,
    "reactive_compute_attrs(#{name.inspect}) was removed in issue #183 — " \
    "pass reactive_root(compute: #{name.inspect}) instead (bind + listen at the root)"
end

#reactive_connection_idObject

The acting client's SSE connection id during the current action (nil outside an action, or when the client isn't subscribed to a stream). Pass it as exclude: when broadcasting from an action so the actor doesn't receive the echo of its own change — it already gets the action's HTTP response:

def send_message(body:)
msg = ChatMessage.create!(room: @room, body:)
ChatMessage::Item.broadcast_append_to("chat", @room,
  target: "messages", model: msg, exclude: reactive_connection_id)
end


27
28
29
# File 'lib/phlex/reactive/component/helpers.rb', line 27

def reactive_connection_id
  Phlex::Reactive.current_connection_id
end

#reactive_field(param, **attrs) ⇒ Object

Bind a form control's name to an action param so its value travels with the action — instead of hand-writing the magic name: "value" on every input and silently getting no params when you forget it (issue #23). Returns a Phlex attributes hash to spread onto any control:

input(**reactive_field(:value, value: @record.name))
select(**reactive_field(:status)) { ... }

Extra attrs merge over the binding; an explicit name: still wins (escape hatch). The trigger (on(:save)) stays on the button, not the field — so focusing the input doesn't dispatch and collapse edit mode.

Dirty tracking is class-level now (issue #184): the per-field dirty: kwarg is REMOVED (reject_removed_field_dirty! raises a guided error). A field carries the trackDirty descriptor only when reactive_dirty only: names it (field_dirty_tracked?) — otherwise the root delegates for the whole subtree via reactive_root. The client behavior is unchanged (issue #103): a change re-scans this root's owned fields and marks the changed ones data-reactive-dirty (the root gets a count); NO client state ships — the baseline is the DOM's own defaultValue/defaultChecked/ defaultSelected from the last server render (dirty = current ≠ default). The descriptor deep-merges via mix, so a caller's own data-action is token-joined, not clobbered (CLAUDE.md Never-Do #8).



421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
# File 'lib/phlex/reactive/component/helpers.rb', line 421

def reactive_field(param, **attrs)
  # Issue #184: the removed dirty: kwarg lands in **attrs — catch it and
  # print the reactive_dirty rewrite.
  reject_removed_field_dirty!(attrs)
  # Under reactive_scope, emit the SCOPED wire name (name="invoice[date]")
  # so the POST arrives bracketed (the endpoint unwraps one level) AND the
  # field matches the client show/compute resolvers, which already query
  # [name="scope[x]"]. An explicit name: in attrs still wins via the spread
  # (a third-party wire name, never re-scoped) — the escape hatch.
  binding_attrs = { name: scoped_field_name(param), **attrs }
  # Per-field dirty descriptor when reactive_dirty only: names this field
  # (issue #184) — otherwise the root delegates for the whole subtree.
  return binding_attrs unless field_dirty_tracked?(param)

  mix(binding_attrs, { data: { action: "input->reactive#trackDirty" } })
end

#reactive_filter(field = nil, input: :__removed, option: nil, group: nil, empty: nil) ⇒ Object

Client-side option filtering for the searchable combobox (issue #163) — the "preload + type to narrow" half of #72's keyboard nav, entirely client-side. Spread onto the ROOT (mix with reactive_root); it names the input whose value drives the filter and the option elements to show/hide, and the generic controller toggles hidden on every keystroke by substring-matching each option's haystack — no round trip, no token, no bespoke per-feature controller:

Issue #186: name the FIELD that drives the filter — reactive_filter(:q) compiles :q to [name="q"] (scope-aware) and defaults option to the [role=option] convention. group:/empty: stay opt-in; any selector kwarg overrides a convention:

div(**mix(reactive_root, reactive_filter(:q, empty: "#no-matches"))) do
input(name: "q", type: "search", **reactive_listnav)  # listnav → [role=option]

end

Each option's haystack is its data-reactive-filter-text attribute (server-rendered — pack in synonyms/categories), falling back to the option's own text. Matching is a case-folded substring test — a DECLARED literal match, never an expression (no eval surface, the reactive_show posture). group: hides any group element whose every contained option is hidden; empty: reveals the no-matches node when 0 options are visible. The client seeds at connect and re-syncs after a morph; selectors resolve WITHIN this root only (#15 ownership).

Filtering composes with reactive_listnav (Arrow/Enter/Escape skip hidden options) and each option's own on(:select, …) trigger — selection still round-trips as a signed action; only FILTERING is local. Blank selectors raise: a dead binding must fail at render.

Raises:

  • (ArgumentError)


597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
# File 'lib/phlex/reactive/component/helpers.rb', line 597

def reactive_filter(field = nil, input: :__removed, option: nil, group: nil, empty: nil)
  # Issue #186: the four-selector kwarg form is removed — name the FIELD that
  # drives the filter instead. A leftover input: means the old call shape.
  unless input == :__removed
    raise ArgumentError,
      "reactive_filter(input:) was removed in issue #186 — name the driving field: " \
      "reactive_filter(:q) (compiles to [name=\"q\"], scope-aware; option defaults to [role=option])."
  end
  raise ArgumentError, "reactive_filter needs a field name — reactive_filter(:q)" if field.nil?

  data = {
    # Compile the field to a scoped [name="…"] selector (same scope convention
    # reactive_field uses, so the filter input aligns with its own field).
    reactive_filter_input: %([name="#{scoped_field_name(field)}"]),
    # option defaults to the [role=option] convention; a kwarg overrides it.
    reactive_filter_option: option ? filter_selector!(:option, option) : "[role=option]"
  }
  # group/empty stay OPT-IN (no convention default — a default would change the
  # byte-stable wire and always-emit an attribute the client would then query).
  data[:reactive_filter_group] = filter_selector!(:group, group) if group
  data[:reactive_filter_empty] = filter_selector!(:empty, empty) if empty

  { data: }
end

#reactive_input(param) ⇒ Object

REMOVED in issue #184 — one binding helper (reactive_field); the element is the caller's: input(**reactive_field(:value, value: @record.name)). Raises a guided error printing that rewrite.

Raises:

  • (ArgumentError)


467
468
469
470
471
# File 'lib/phlex/reactive/component/helpers.rb', line 467

def reactive_input(param, **)
  raise ArgumentError,
    "reactive_input was removed in issue #184 — use " \
    "input(**reactive_field(#{param.inspect}, …)) (one binding helper; the element is yours)."
end

#reactive_listnav(option_selector = "[role=option]") ⇒ Object

STANDALONE combobox keyboard navigation (issue #163) — the same Arrow/Enter/Escape wiring on(…, listnav:) appends, without the dispatch descriptor. A preload-and-filter combobox input fires NO action (filtering is pure client), so it can't carry on(); spread this onto the input instead:

input(id: "search", type: "search", **reactive_listnav("[role=option]"))

Arrow keys move the client-side highlight among the (visible) options, Enter picks the highlighted one by clicking its own reactive trigger (selection stays a signed action), Escape clears. Combine with other attrs via mix so a caller's data-action token-joins, not clobbers.



634
635
636
637
638
639
640
641
# File 'lib/phlex/reactive/component/helpers.rb', line 634

def reactive_listnav(option_selector = "[role=option]")
  {
    data: {
      action: LISTNAV_ACTIONS.join(" "),
      reactive_listnav_option_param: filter_selector!(:selector, option_selector)
    }
  }
end

#reactive_root(**overrides) ⇒ Object

The WHOLE reactive root in one spread (issue #48). reactive_attrs alone doesn't emit id:, so id: and data-controller="reactive" can land on DIFFERENT elements — putting id: on a child leaves the controller root's id empty, which silently breaks token threading (the client self-matches its next token by this.element.id) and 403s on the next action.

reactive_root binds the id to the SAME element as reactive_attrs, so the footgun is unbuildable:

div(**reactive_root) { ... }                       # id + controller + token
div(**reactive_root(class: "card")) { ... }        # add your own attrs

mix deep-merges, so overrides add class:/data: without clobbering the controller/token data: (a bare data: would). The id is resolved separately (an explicit override wins as a clean replace, not a mix string-concat — mix would join two String ids into "default override").

Dirty tracking (issue #103, #184) is now a CLASS-LEVEL reactive_dirty declaration, not a reactive_root kwarg. reactive_root reads self.class.reactive_dirty_config and emits the SAME DOM as before: the trackDirty descriptor on the root's data-action (mix token-joins, so a caller's own data-action survives) UNLESS only: scoped tracking to named fields, and — for warn_unsaved: true — the navigate-away marker (STRING "true", since a boolean-true attr renders valueless → "" → falsy client- side). The removed track_dirty:/warn_unsaved: kwargs raise a guided error.



121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/phlex/reactive/component/helpers.rb', line 121

def reactive_root(**overrides)
  # A CLIENT-ONLY component (ClientBindings, issue #180) needs no #id —
  # there's no token to self-match by id. Use an explicit override, else
  # #id when the component defines one, else nothing (no id attr).
  reject_removed_dirty_kwargs!(overrides)
  root_id = overrides.delete(:id)
  root_id = id if root_id.nil? && respond_to?(:id)
  # Issue #183: bind a client-side compute AT THE ROOT — the descriptors +
  # the recompute delegation ride here so no field needs per-field wiring.
  # nil (the conditional-binding collapse) emits nothing.
  compute = overrides.delete(:compute)
  # Issue #184: dirty tracking is a class-level reactive_dirty declaration.
  dirty = self.class.reactive_dirty_config if self.class.respond_to?(:reactive_dirty_config)

  attrs = mix({ **reactive_attrs }, overrides)
  attrs = mix(attrs, { id: root_id }) unless root_id.nil?
  # Root-level delegation tracks the whole subtree UNLESS only: scoped it to
  # named fields (those carry their own descriptor via reactive_field).
  attrs = mix(attrs, { data: { action: "input->reactive#trackDirty" } }) if dirty && dirty[:only].nil?
  attrs = mix(attrs, { data: { reactive_warn_unsaved: "true" } }) if dirty&.dig(:warn_unsaved)
  attrs = mix(attrs, compute_binding(compute)) if compute
  attrs
end

#reactive_select(param) ⇒ Object

REMOVED in issue #184 — one binding helper (reactive_field); the element is the caller's: select(**reactive_field(:status)) { status_options }. Raises a guided error printing that rewrite.

Raises:

  • (ArgumentError)


770
771
772
773
774
# File 'lib/phlex/reactive/component/helpers.rb', line 770

def reactive_select(param, **, &)
  raise ArgumentError,
    "reactive_select was removed in issue #184 — use " \
    "select(**reactive_field(#{param.inspect})) { … } (one binding helper; the element is yours)."
end

#reactive_show(field = nil, **options) ⇒ Object

Value-conditional visibility (issue #180) — the x-show / data-show / wire:show equivalent, entirely client-side, in ONE Ruby-native conditions language. Spread onto the element to show/hide; declare an if:/if_any:/unless: condition with where-style values, and the generic controller toggles hidden from the fields' CURRENT values on every input/change — no round trip, no token, no bespoke Stimulus controller.

THE VALUE LANGUAGE (Phlex::Reactive::ShowConditions):

Hash    = AND (multiple keys ANDed)          if: { a: "x", b: "y" }
Array   = membership                          if: { size: %w[l xl] }
Range   = threshold (10.. / ..10 / ...10 / 10..20)  if: { qty: 10.. }
true/false = checkbox checked-state           if: { gift: true }
nil     = blank                               if: { note: nil }
unless: = negation (composes with if:/if_any:)

div(**reactive_show(unless: { mode: "off" }))          { "details" }
div(**reactive_show(if: { size: %w[l xl] }))           { "surcharge" }
div(**reactive_show(if: { qty: 10.. }))                { "bulk note" }
# OR-of-AND — director OR (shareholder AND role == "individual"):
div(**reactive_show(if_any: [
{ director: true },
{ shareholder: true, role: "individual" }
]))

There is no expression surface — every term is a declared literal, so the same default-deny posture as before. Everything normalizes to ONE DNF wire attr (data-reactive-show='"any":[[term,…],…]').

FIRST PAINT is computed for you: declare reactive_values (an instance method returning { field => value }) and every binding whose fields are all provided renders the correct initial hidden: server-side — no per-section mirror method, no flash. An explicit hidden: always wins; a per-call values: override merges over reactive_values.

disable: true disables the section's OWNED controls while it is hidden so a switched-away value never submits. reactive_scope :form lets bindings use bare field symbols ([name="form[field]"] on the client).

Scope: presentational only, strictly less powerful than the js ops — it reads owned fields (#15 ownership) and toggles hidden (+ optionally disabled) on owned elements. Extra attrs deep-merge over the binding (mix), like reactive_field.



550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
# File 'lib/phlex/reactive/component/helpers.rb', line 550

def reactive_show(field = nil, **options)
  reject_legacy_show_surface!(field, options)

  conditions = options.slice(*SHOW_CONDITION_KEYS)
  disable = options.delete(:disable)
  values_override = options.delete(:values)
  attrs = options.except(*SHOW_CONDITION_KEYS)

  groups = Phlex::Reactive::ShowConditions.normalize(**conditions)
  data = { reactive_show: { "any" => groups }.to_json }
  data[:reactive_show_disable] = "true" if disable

  result = mix({ data: }, attrs)
  apply_first_paint_hidden(result, groups, values_override)
end

#reactive_show_targets(field, targets = nil) ⇒ Object

CROSS-ROOT value-conditional visibility (issue #164) — the visibility parallel to reactive_compute's mirror: (#159). A plain reactive_show is root-scoped by design (#15), so it can't express "this control drives elements ELSEWHERE on the page" — a nav tab, a panel in another tab pane, a sidebar note. reactive_show_targets is the declared, id-allowlisted escape: the component that OWNS the field declares which outside ids it governs. Spread it on the ROOT (mix alongside reactive_root — the client reads it off the controller element):

div(**mix(reactive_root, reactive_show_targets(:mode,
"#advanced-tab"   => "advanced",          # equals
"#advanced-panel" => "advanced",
"#premium-note"   => %w[gold platinum]))) # membership

Same posture as mirror: — opt-in and declared, never implicit (a plain reactive_show stays root-isolated); targets are SINGLE ID SELECTORS only, enforced here at declare time AND warn-and-skipped by the client interpreter (two-sided default-deny); the value uses the same where- style conditions vocabulary (scalar/Array/Range, no expressions); and the toggle is hidden only — no innerHTML, no attribute freedom. The FIELD read stays owned (#15): you can only drive outside visibility from a field this root owns. A target id not on the page is silently skipped (an unrendered tab pane is normal). A target value is positive-only (no per-target unless:) — express "not X" as a membership Array over the remaining options.

ONE call per root. Phlex mix space-joins duplicate STRING data values, so a second call's JSON would concatenate into an unparseable attr and the client would drop BOTH maps (it warns when that happens). Several fields therefore go in ONE call via the hash form:

reactive_show_targets(mode: { "#advanced-tab" => "advanced" },
                    kind: { "#premium-note" => %w[gold platinum] })


676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
# File 'lib/phlex/reactive/component/helpers.rb', line 676

def reactive_show_targets(field, targets = nil)
  field_maps = targets.nil? ? field : { field => targets }
  unless field_maps.is_a?(Hash) && field_maps.any?
    raise ArgumentError,
      "reactive_show_targets needs at least one target " \
      "(:field, \"#id\" => value), got #{field_maps.inspect}"
  end

  normalized = field_maps.to_h do |name, map|
    # Catch the forgotten-field-name misuse — reactive_show_targets(
    # "#id" => {…}) — before the per-target validation turns it into a
    # baffling "predicate" error.
    if name.to_s.start_with?("#")
      raise ArgumentError,
        "reactive_show_targets: #{name.inspect} looks like a target selector, not a field " \
        "name — call reactive_show_targets(:field, #{name.inspect} => { ... })"
    end

    [name.to_s, normalize_show_target_map(name, map)]
  end

  { data: { reactive_show_targets: normalized.to_json } }
end

#reactive_text(name, initial = nil, **attrs) ⇒ Object

Mirror a compute output (or a declared input) into a TEXT NODE — a live preview heading, a character counter, a "Hello, name" greeting (issue #104). The text sibling of reactive_field: reactive_field binds a FORM CONTROL; reactive_text binds a plain span the client writes via textContent (XSS-safe by construction — never innerHTML).

h2 { reactive_text(:title_preview, @post.title) }
small { reactive_text(:char_count) }

The span carries data-reactive-text= and NO name attribute, so #collectFields never sweeps it into the POSTed params. initial seeds the first paint — the SERVER render must seed the same derived value the reducer would, or a morph repaints stale text (same reconcile contract reactive_compute documents). Extra attrs merge over the binding.



487
488
489
490
491
492
493
494
# File 'lib/phlex/reactive/component/helpers.rb', line 487

def reactive_text(name, initial = nil, **attrs)
  # Issue #183: with no explicit initial, seed the first paint from
  # reactive_values (Phase A) when the component declares it and covers this
  # name — so the server render matches what the reducer would paint (the
  # same no-flash reconcile contract reactive_show's first paint uses).
  initial = reactive_text_seed(name) if initial.nil?
  span(**mix({ data: { reactive_text: name.to_s } }, attrs)) { initial }
end

#reactive_text_seed(name) ⇒ Object

The reactive_values first-paint seed for a reactive_text name, stringified the way the client reports a field (via show_value_string), or nil when the component declares no reactive_values or doesn't cover the name.



499
500
501
502
503
504
505
506
# File 'lib/phlex/reactive/component/helpers.rb', line 499

def reactive_text_seed(name)
  return nil unless respond_to?(:reactive_values)

  values = reactive_values
  return nil unless values.is_a?(Hash) && values.key?(name.to_sym)

  show_value_string(values[name.to_sym])
end

#reject_removed_dirty_kwargs!(overrides) ⇒ Object

Issue #184: track_dirty:/warn_unsaved: on reactive_root are removed in favor of the class-level reactive_dirty macro. Guided error naming it.

Raises:

  • (ArgumentError)


147
148
149
150
151
152
153
154
# File 'lib/phlex/reactive/component/helpers.rb', line 147

def reject_removed_dirty_kwargs!(overrides)
  removed = overrides.keys & %i[track_dirty warn_unsaved]
  return if removed.empty?

  raise ArgumentError,
    "reactive_root(#{removed.first}:) was removed in issue #184 — declare " \
    "`reactive_dirty warn_unsaved: true` (class-level) instead."
end

#reject_removed_field_dirty!(attrs) ⇒ Object

The removed reactive_field(dirty:) kwarg (issue #184) — now a guided error.

Raises:

  • (ArgumentError)


439
440
441
442
443
444
445
# File 'lib/phlex/reactive/component/helpers.rb', line 439

def reject_removed_field_dirty!(attrs)
  return unless attrs.key?(:dirty)

  raise ArgumentError,
    "reactive_field(dirty:) was removed in issue #184 — declare " \
    "`reactive_dirty only: %i[...]` (class-level) instead."
end

#replyObject

Subject-bound reply builder — the preferred way to control an action's reply. reply.replace.flash(:error, msg) reads cleaner than reply.replace.flash(:error, msg): the component is the implicit subject (no self to thread) and there's no constant to qualify (reply is a method, so a namespaced component needs no Response = … alias). It returns the same immutable Response the endpoint reads, so chaining and the legacy return-value contract are unchanged. See Phlex::Reactive::Reply.

def archive       = reply.remove
def go_home       = reply.redirect("/todos")
def update(name:) = (@account.update!(name:); reply.morph)


60
61
62
# File 'lib/phlex/reactive/component/helpers.rb', line 60

def reply
  Phlex::Reactive::Reply.new(self)
end

#scoped_field_name(param) ⇒ Object

The wire name for a bare field param: scope[param] when the component declares reactive_scope, else the bare param. Read self.class.reactive_scope the way reactive_attrs does (helpers.rb) so scoped + unscoped stay aligned.



459
460
461
462
# File 'lib/phlex/reactive/component/helpers.rb', line 459

def scoped_field_name(param)
  scope = self.class.reactive_scope if self.class.respond_to?(:reactive_scope)
  scope ? "#{scope}[#{param}]" : param.to_s
end