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_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")
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).
725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 |
# 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 } # 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.
754 755 756 757 758 759 |
# File 'lib/phlex/reactive/component/helpers.rb', line 754 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.
745 746 747 |
# File 'lib/phlex/reactive/component/helpers.rb', line 745 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.
778 779 780 781 782 783 784 |
# File 'lib/phlex/reactive/component/helpers.rb', line 778 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
790 791 792 |
# File 'lib/phlex/reactive/component/helpers.rb', line 790 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.
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_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.
764 765 766 767 768 |
# File 'lib/phlex/reactive/component/helpers.rb', line 764 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] })
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=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 |