Module: Phlex::Reactive::Component::Helpers
- Extended by:
- ActiveSupport::Concern
- Included in:
- Phlex::Reactive::Component
- 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_LITERAL_KEYS =
The declared reactive_show predicates: a literal match on the controlling field's current value. Exactly one per binding — enforced loudly at render (a dead binding must not no-op in the browser).
not/inare Ruby keywords, so they arrive via **options rather than named kwargs. Issue #161 shipped the string-literal trio; issue #176 adds the numeric-threshold quartet below. %i[equals not in].freeze
- SHOW_NUMERIC_KEYS =
The numeric-threshold predicates (issue #176 part B): gte/gt/lte/lt compare the field value coerced to a Number against a literal number. Still a declared literal — the RHS is a number baked into the attribute, never an expression — the only new capability is ordered comparison. The RHS must be an actual Numeric in Ruby (stricter, so a typo like gte: "10" fails at render, not silently in the browser).
%i[gte gt lte lt].freeze
- SHOW_PREDICATE_KEYS =
Every predicate key a reactive_show / reactive_show_targets binding may declare — the literal trio plus the numeric quartet. Exactly one of these decides a single binding; a compound term declares one too.
(SHOW_LITERAL_KEYS + SHOW_NUMERIC_KEYS).freeze
- SHOW_CONNECTIVE_KEYS =
The compound connectives (issue #176 part A): fold a list of per-field literal/numeric terms with ONE fixed keyword. Not an expression surface — the connective is one of two fixed keywords, each term is the same declared predicate vocabulary. Mutually exclusive with a single field and with each other (enforced loudly at render).
%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 #161) — the x-show / data-show / wire:show equivalent, entirely client-side.
-
#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")
598 599 600 |
# File 'lib/phlex/reactive/component/helpers.rb', line 598 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.
639 640 641 642 643 644 |
# File 'lib/phlex/reactive/component/helpers.rb', line 639 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.
630 631 632 |
# File 'lib/phlex/reactive/component/helpers.rb', line 630 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.
662 663 664 665 666 667 668 |
# File 'lib/phlex/reactive/component/helpers.rb', line 662 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
674 675 676 |
# File 'lib/phlex/reactive/component/helpers.rb', line 674 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" }
249 250 251 252 253 254 255 256 257 258 259 260 261 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 |
# File 'lib/phlex/reactive/component/helpers.rb', line 249 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.
344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 |
# File 'lib/phlex/reactive/component/helpers.rb', line 344 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 |
# File 'lib/phlex/reactive/component/helpers.rb', line 75 def reactive_attrs data = { controller: "reactive", reactive_token_value: reactive_token } # 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 { 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.
611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 |
# File 'lib/phlex/reactive/component/helpers.rb', line 611 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).
387 388 389 390 391 392 |
# File 'lib/phlex/reactive/component/helpers.rb', line 387 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.
505 506 507 508 509 510 511 512 513 514 |
# File 'lib/phlex/reactive/component/helpers.rb', line 505 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")
397 398 399 |
# File 'lib/phlex/reactive/component/helpers.rb', line 397 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.
528 529 530 531 532 533 534 535 |
# File 'lib/phlex/reactive/component/helpers.rb', line 528 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).
114 115 116 117 118 119 120 121 122 123 |
# File 'lib/phlex/reactive/component/helpers.rb', line 114 def reactive_root(**overrides) root_id = overrides.delete(:id) || id track_dirty = overrides.delete(:track_dirty) warn_unsaved = overrides.delete(:warn_unsaved) attrs = mix({ **reactive_attrs }, overrides, { id: root_id }) 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 } } }
650 651 652 |
# File 'lib/phlex/reactive/component/helpers.rb', line 650 def reactive_select(param, **attrs, &) select(**reactive_field(param, **attrs), &) end |
#reactive_show(field = nil, **options) ⇒ Object
Value-conditional visibility (issue #161) — the x-show / data-show /
wire:show equivalent, entirely client-side. Spread onto the element to
show/hide; it declares which OWNED field controls it plus ONE literal
predicate, and the generic controller toggles the hidden attribute
from the field's CURRENT value on every input/change — no round trip,
no token, no bespoke Stimulus controller:
div(**reactive_show(:mode, not: "off")) { "details" }
div(**reactive_show(:kind, equals: "premium")) { "premium panel" }
div(**reactive_show(:size, in: %w[l xl])) { "surcharge note" }
div(**reactive_show(:gift, equals: true)) { "gift message" }
Exactly one predicate — equals:, not:, or in: (a list) — and every
value is STRINGIFIED for a literal match against the field's value
(a checkbox compares its checked state as "true"/"false", so
equals: true is the checkbox form; a radio group reads the checked
radio's value). This is a DECLARED LITERAL MATCH, never an expression
— there is no eval surface (default-deny, like the op vocabulary).
Scope: presentational only, strictly less powerful than the js ops —
it reads an owned field (#15 ownership) and toggles hidden on an
owned element. The client seeds visibility at connect and reconciles
after a morph; render the initial hidden: yourself (from the same
server state that renders the field) to avoid a first-paint flash.
Extra attrs deep-merge over the binding (mix), like reactive_field.
Compound forms (issue #176 part A) — visibility that depends on MORE
THAN ONE field, or a threshold, staying inside the eval-free literal
contract. all:/any: fold a list of per-field terms (each the same
equals:/not:/in:/gte:/gt:/lte:/lt: vocabulary) with one fixed
connective; the compound has no single controlling field, so it
serializes as ONE JSON attr (data-reactive-show) like
reactive_show_targets rather than the flat data-reactive-show-* attrs:
# visible while type == "individual" AND country != "domestic"
div(**reactive_show(all: [
{ field: :type, equals: "individual" },
{ field: :country, not: "domestic" }
]))
# visible while director OR shareholder is checked
div(**reactive_show(any: [
{ field: :director, equals: true },
{ field: :shareholder, equals: true }
]))
# a numeric threshold, single field or as a compound term
div(**reactive_show(:quantity, gte: 10)) # visible while qty >= 10
div(**reactive_show(all: [{ field: :amount, gte: 5000 }, ...]))
all:/any: are additive and mutually exclusive with the single-field
form and with each other — enforced loudly at render, like the
one-predicate rule. A single-field all: with one term degrades to the
flat form's behaviour client-side.
470 471 472 473 474 475 |
# File 'lib/phlex/reactive/component/helpers.rb', line 470 def reactive_show(field = nil, **) connectives = .slice(*SHOW_CONNECTIVE_KEYS) return reactive_show_compound(field, connectives, ) if connectives.any? reactive_show_single(field, ) 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" => { equals: "advanced" },
"#advanced-panel" => { equals: "advanced" },
"#basic-note" => { not: "advanced" })))
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 predicate is the same
literal-only reactive_show vocabulary (exactly one of equals:/not:/
in:, 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).
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" => { equals: "advanced" } },
kind: { "#premium-note" => { not: "basic" } })
568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 |
# File 'lib/phlex/reactive/component/helpers.rb', line 568 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\" => { equals:/not:/in: ... }), 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.
415 416 417 |
# File 'lib/phlex/reactive/component/helpers.rb', line 415 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 |