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
- OPTIMISTIC_CLASS_OPS =
The declared optimistic-hint class ops (issue #98): the cosmetic class vocabulary the client applies instantly and reverts on failure. Enforced at build time in optimistic_hint_json (default-deny — a dead hint fails at render, not silently in the browser). Each carries a class string or array; hide/checked are flags with a fixed shape.
%w[toggle_class add_class remove_class].freeze
Instance Method Summary collapse
-
#busy_on(action) ⇒ Object
Scoped busy indicator (issue #99).
-
#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.
-
#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, listnav: nil, window: false, once: false, outside: false, optimistic: nil, loading: nil, disable_with: 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) ⇒ 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
Data attributes declaring a client-side compute for the root element.
-
#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, dirty: false, **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(input:, option:, 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, **attrs) ⇒ Object
Render an already bound to an action param (issue #23).
-
#reactive_listnav(option_selector) ⇒ 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, **attrs) ⇒ Object
Render a
-
#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).
-
#reply ⇒ Object
Subject-bound reply builder — the preferred way to control an action's reply.
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")
613 614 615 |
# File 'lib/phlex/reactive/component/helpers.rb', line 613 def busy_on(action) { data: { reactive_busy_on: action.to_s } } 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.
654 655 656 657 658 659 |
# File 'lib/phlex/reactive/component/helpers.rb', line 654 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.
645 646 647 |
# File 'lib/phlex/reactive/component/helpers.rb', line 645 def compute_mirror_param(definition) definition.mirror.transform_keys(&:to_s).to_json 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.
677 678 679 680 681 682 683 |
# File 'lib/phlex/reactive/component/helpers.rb', line 677 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
689 690 691 |
# File 'lib/phlex/reactive/component/helpers.rb', line 689 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, listnav: nil, window: false, once: false, outside: false, optimistic: nil, loading: nil, disable_with: 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" }
loading: / disable_with: (issue #99) — declarative per-trigger loading
states, Livewire's wire:loading + phx-disable-with without a Stimulus
controller. The moment the request is ENQUEUED (covering the queue wait,
not just the fetch), the trigger gets data-reactive-busy="<action>", an
optional loading class, an optional disabled + text swap; all revert in a
guarded finally. loading: is a hash:
* disable: true — disable the trigger while pending
* class: "…" / [ … ] — a loading class string/array on the trigger
(or a `to:` selector scoped to the root)
* text: "Saving…" — swap the trigger's textContent while pending
* to: :root / "sel" — target the class/text at the root or a selector
disable_with: "Saving…" is the shorthand for
{ disable: true, text: "Saving…" } and merges over an explicit loading:
(its text/disable win). Both become RESERVED on() kwargs (CHANGELOG note,
like #80's four and #98's optimistic:) — no longer free action-param names.
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, disable_with: "Saving…")) { "Save" }
button(**on(:destroy, confirm: "Sure?", loading: { class: "opacity-50" })) { "Delete" }
262 263 264 265 266 267 268 269 270 271 272 273 274 275 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 |
# File 'lib/phlex/reactive/component/helpers.rb', line 262 def on(action_name, event: "click", debounce: nil, throttle: nil, confirm: nil, listnav: nil, window: false, once: false, outside: false, optimistic: nil, loading: nil, disable_with: nil, **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}" action = "#{action} #{LISTNAV_ACTIONS.join(" ")}" if listnav 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 attrs[:data][:reactive_confirm_param] = confirm if confirm attrs[:data][:reactive_listnav_option_param] = listnav if listnav attrs[:data][:reactive_optimistic_param] = optimistic_hint_json(optimistic, action_name) if optimistic if (loading_hint = loading_hint_json(loading, disable_with, action_name)) attrs[:data][:reactive_loading_param] = loading_hint end # 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) ⇒ 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.
357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 |
# File 'lib/phlex/reactive/component/helpers.rb', line 357 def on_client(event, ops, window: false, once: false, outside: false) 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 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
Data attributes declaring a client-side compute for the root element. Spread ALONGSIDE reactive_root so the generic controller can find the reducer and the named input/output fields inside this root:
div(**mix(reactive_root, reactive_compute_attrs(:payment_split))) { … }
It emits the reducer key plus the input/output field names as JSON so the
client runs the reducer on input, writes the outputs with no round trip,
then the debounced POST reconciles from the server reply. Raises for an
undeclared compute — a silent no-op would leave the field wiring dead.
626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 |
# File 'lib/phlex/reactive/component/helpers.rb', line 626 def reactive_compute_attrs(name) definition = self.class.reactive_compute_def(name) raise Error, "#{self.class} has no reactive_compute #{name.inspect}" unless definition data = { 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 |
#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, dirty: false, **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: true (issue #103) wires the field to the generic controller's
trackDirty action, so a change re-scans this reactive root's owned fields
and marks the changed ones data-reactive-dirty (and the root with a
count). NO client state is shipped — the baseline is the DOM's own
defaultValue/defaultChecked/defaultSelected, i.e. the attributes from
the last server render; dirty = current ≠ default. It deep-merges the
descriptor via mix, so a caller's own data-action is token-joined, not
clobbered (CLAUDE.md Never-Do #8 — combining with other data:/on() still
needs mix at the call site).
400 401 402 403 404 405 |
# File 'lib/phlex/reactive/component/helpers.rb', line 400 def reactive_field(param, dirty: false, **attrs) binding_attrs = { name: param.to_s, **attrs } return binding_attrs unless dirty mix(binding_attrs, { data: { action: "input->reactive#trackDirty" } }) end |
#reactive_filter(input:, option:, 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:
div(**mix(reactive_root, reactive_filter(
input: "#exercise-search",
option: "[role=option]",
group: "[data-filter-group]", # optional: collapse empty group headers
empty: "#no-matches" # optional: reveal when 0 match
))) { … }
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.
518 519 520 521 522 523 524 525 526 527 |
# File 'lib/phlex/reactive/component/helpers.rb', line 518 def reactive_filter(input:, option:, group: nil, empty: nil) data = { reactive_filter_input: filter_selector!(:input, input), reactive_filter_option: filter_selector!(:option, option) } 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, **attrs) ⇒ Object
Render an already bound to an action param (issue #23). Sugar for input(**reactive_field(param, **attrs)); the value/type/etc. pass through. reactive_input(:value, value: @record.name, type: "text")
410 411 412 |
# File 'lib/phlex/reactive/component/helpers.rb', line 410 def reactive_input(param, **attrs) input(**reactive_field(param, **attrs)) end |
#reactive_listnav(option_selector) ⇒ 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.
541 542 543 544 545 546 547 548 |
# File 'lib/phlex/reactive/component/helpers.rb', line 541 def reactive_listnav(option_selector) { 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").
track_dirty:/warn_unsaved: (issue #103) are CONSUMED here — deleted
from overrides BEFORE the mix — because reactive_root treats every leftover
kwarg as a literal HTML attribute override (only :id is special-cased), so
an unconsumed track_dirty: true would render a bogus track-dirty="true"
attribute. track_dirty mixes the trackDirty descriptor onto the root's
data-action (mix token-joins, so a caller's own data-action survives);
warn_unsaved emits the marker the client reads to arm the navigate-away
guard (STRING "true" — a boolean-true attr renders valueless, which the
client's param reader sees as "" → falsy).
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 |
# File 'lib/phlex/reactive/component/helpers.rb', line 122 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). root_id = overrides.delete(:id) root_id = id if root_id.nil? && respond_to?(:id) track_dirty = overrides.delete(:track_dirty) warn_unsaved = overrides.delete(:warn_unsaved) attrs = mix({ **reactive_attrs }, overrides) attrs = mix(attrs, { id: root_id }) unless root_id.nil? attrs = mix(attrs, { data: { action: "input->reactive#trackDirty" } }) if track_dirty attrs = mix(attrs, { data: { reactive_warn_unsaved: "true" } }) if warn_unsaved attrs end |
#reactive_select(param, **attrs) ⇒ Object
Render a
reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
665 666 667 |
# File 'lib/phlex/reactive/component/helpers.rb', line 665 def reactive_select(param, **attrs, &) select(**reactive_field(param, **attrs), &) 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.
474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 |
# File 'lib/phlex/reactive/component/helpers.rb', line 474 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] })
583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 |
# File 'lib/phlex/reactive/component/helpers.rb', line 583 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.
428 429 430 |
# File 'lib/phlex/reactive/component/helpers.rb', line 428 def reactive_text(name, initial = nil, **attrs) span(**mix({ data: { reactive_text: name.to_s } }, attrs)) { initial } end |
#reply ⇒ Object
Subject-bound reply builder — the preferred way to control an action's
reply. reply.replace.flash(:error, msg) reads cleaner than
Phlex::Reactive::Response.replace(self).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 |