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
"{}"- 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
-
#busy_on(action) ⇒ Object
Scoped busy indicator (issue #99).
-
#compute_binding(name) ⇒ Object
The root's compute descriptors + the recompute delegation (issue #183).
-
#compute_inputs_param(definition) ⇒ Object
The inputs param wire (issue #104).
-
#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.
-
#field_dirty_tracked?(param) ⇒ Boolean
True when reactive_dirty only: names this field, so it carries its own trackDirty descriptor (issue #184).
-
#js ⇒ Object
An empty client-side op chain (issue #95) — the starting point for on_client's DOM commands, mirroring how
replystarts a Response chain: button(**on_client(:click, js.toggle("#menu"))) { "Menu" } Immutable: each verb returns a new chain, so reuse never leaks ops. -
#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:.
-
#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). -
#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). -
#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):.
-
#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.
-
#reactive_attrs ⇒ Object
Root-element attributes: marks the element reactive and carries the signed identity token.
-
#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.
-
#reactive_connection_id ⇒ Object
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).
-
#reactive_field(param, **attrs) ⇒ Object
Bind a form control's
nameto an action param so its value travels with the action — instead of hand-writing the magicname: "value"on every input and silently getting no params when you forget it (issue #23). -
#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.
-
#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)).
-
#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. -
#reactive_root(**overrides) ⇒ Object
The WHOLE reactive root in one spread (issue #48).
-
#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 }.
-
#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.
-
#reactive_show_targets(field, targets = nil) ⇒ Object
CROSS-ROOT value-conditional visibility (issue #164) — the visibility parallel to reactive_compute's
mirror:(#159). -
#reactive_tags(field = nil) ⇒ Object
Tag-chip input (issue #203) — the composed combobox/tags primitive.
-
#reactive_tags_add ⇒ Object
The Enter-to-add trigger for the tags query input (issue #203) — a CLIENT-ONLY keyboard action (no dispatch descriptor, no POST).
-
#reactive_tags_option(tag) ⇒ Object
A preloaded option row that ADDS its tag on click (issue #203) — the tags sibling of the combobox's on(:select) option, but CLIENT-ONLY (form state, no POST).
-
#reactive_tags_remove(tag = nil) ⇒ Object
A chip's remove button (issue #203) — client-only, no POST.
-
#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).
-
#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.
-
#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.
-
#reject_removed_field_dirty!(attrs) ⇒ Object
The removed reactive_field(dirty:) kwarg (issue #184) — now a guided error.
-
#reply ⇒ Object
Subject-bound reply builder — the preferred way to control an action's reply.
-
#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.
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")
790 791 792 |
# File 'lib/phlex/reactive/component/helpers.rb', line 790 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).
809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 |
# File 'lib/phlex/reactive/component/helpers.rb', line 809 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.
844 845 846 847 848 849 |
# File 'lib/phlex/reactive/component/helpers.rb', line 844 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.
835 836 837 |
# File 'lib/phlex/reactive/component/helpers.rb', line 835 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).
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 |
#js ⇒ Object
An empty client-side op chain (issue #95) — the starting point for
on_client's DOM commands, mirroring how reply starts a Response chain:
(**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. == Current.user
@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 Phlex::Reactive::Authorization.mark! end |
#nested_attributes(association, attrs) ⇒ Object
Map a declared nested param onto Rails'
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.
868 869 870 871 872 873 874 |
# File 'lib/phlex/reactive/component/helpers.rb', line 868 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
880 881 882 |
# File 'lib/phlex/reactive/component/helpers.rb', line 880 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():
(**on_client(:click, js.toggle("#menu"))) { "Menu" }
# tabs, one line per tab, no Stimulus controller:
(**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.
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_attrs ⇒ Object
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.
799 800 801 802 803 |
# File 'lib/phlex/reactive/component/helpers.rb', line 799 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_id ⇒ Object
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 (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.
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.
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.
854 855 856 857 858 |
# File 'lib/phlex/reactive/component/helpers.rb', line 854 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, **) reject_legacy_show_surface!(field, ) conditions = .slice(*SHOW_CONDITION_KEYS) disable = .delete(:disable) values_override = .delete(:values) attrs = .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] })
760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 |
# File 'lib/phlex/reactive/component/helpers.rb', line 760 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_tags(field = nil) ⇒ Object
Tag-chip input (issue #203) — the composed combobox/tags primitive.
Spread onto the ROOT (mix with reactive_root); it names the hidden
field that stores the COMMA-JOINED value, and the generic controller
maintains that field + the chip list entirely client-side. The value
is FORM state (like text in an input), never component state — so
add/remove round-trips nothing; the surrounding form submit carries
the joined value and the server splits it (tags.split(",")).
div(**mix(reactive_root, (:tags), reactive_filter(:tag_query))) do
input(type: :hidden, **reactive_field(:tags), value: @tags.join(","))
div(data: { reactive_tags_list: true }) { } # chips render here
template(data: { reactive_tags_template: true }) do # the chip markup (server-owned)
span(class: "chip") do
span(data: { reactive_tag_text: true }) # the client writes the tag here (textContent)
(**) { "×" } # the client fills the tag param per chip
end
end
input(name: "tag_query", **mix(reactive_listnav, ))
(**("Ruby")) { "Ruby" } # preloaded options, filter narrows them
end
The chip list is a CLIENT PROJECTION of the hidden field: every sync rebuilds the chips by cloning the (textContent writes only — never innerHTML), so the hidden value is the single source of truth and a server re-render/morph re-seeds cleanly. An option whose tag is already selected is hidden and marked data-reactive-tags-selected (reactive_filter keeps it hidden through re-filters). Tags dedupe case-insensitively, keeping the first casing.
Composes with reactive_filter (type to narrow — same driving input) and reactive_listnav (Arrow/Enter/Escape; Enter picks the highlighted option via its own tagsPick trigger, and reactive_tags_add only adds the TYPED text when nothing is highlighted — no double add).
676 677 678 679 680 681 682 683 684 685 |
# File 'lib/phlex/reactive/component/helpers.rb', line 676 def (field = nil) if field.nil? || field.to_s.strip.empty? raise ArgumentError, "reactive_tags needs a field name — reactive_tags(:tags)" end # Compile the field to a scoped [name="…"] selector, the reactive_filter # convention — so the hidden field written via reactive_field(:tags) # resolves under reactive_scope too. { data: { reactive_tags_field: %([name="#{scoped_field_name(field)}"]) } } end |
#reactive_tags_add ⇒ Object
The Enter-to-add trigger for the tags query input (issue #203) — a CLIENT-ONLY keyboard action (no dispatch descriptor, no POST). Mix it AFTER reactive_listnav so Enter prefers the highlighted option (listnavPick preventDefaults; tagsAdd then skips), and free text adds only when nothing is highlighted:
input(name: "tag_query", **mix(reactive_listnav, ))
Enter never submits the enclosing form — the client preventDefaults.
694 695 696 |
# File 'lib/phlex/reactive/component/helpers.rb', line 694 def { data: { action: "keydown.enter->reactive#tagsAdd" } } end |
#reactive_tags_option(tag) ⇒ Object
A preloaded option row that ADDS its tag on click (issue #203) — the tags sibling of the combobox's on(:select) option, but CLIENT-ONLY (form state, no POST). Emits the [role=option] convention (so reactive_filter/reactive_listnav see it), the forced type="button" (a bare button inside a
705 706 707 708 709 710 711 712 713 714 |
# File 'lib/phlex/reactive/component/helpers.rb', line 705 def (tag) { type: "button", role: "option", data: { action: "click->reactive#tagsPick", reactive_tag_param: (:reactive_tags_option, tag) } } end |
#reactive_tags_remove(tag = nil) ⇒ Object
A chip's remove button (issue #203) — client-only, no POST. Two forms: inside the chip, call it with NO tag (the client fills data-reactive-tag-param per cloned chip); on a server-rendered initial chip, pass the tag explicitly:
(**(tag)) { "×" }
721 722 723 724 725 |
# File 'lib/phlex/reactive/component/helpers.rb', line 721 def (tag = nil) attrs = { type: "button", data: { action: "click->reactive#tagsRemove" } } attrs[:data][:reactive_tag_param] = (:reactive_tags_remove, tag) unless tag.nil? attrs 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=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.
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.
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 |
#reply ⇒ Object
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 |