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

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

Overview

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

Constant Summary collapse

EMPTY_PARAMS_JSON =

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

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

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

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

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

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

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

listnav: (a CSS selector for the option elements) adds keyboard list navigation to a search/combobox trigger (issue #72). It appends Stimulus keyboard filters to the SAME element's data-action so Arrow Up/Down move a client-side highlight among the options, Enter picks the highlighted one (clicking its own reactive trigger — so selection stays a signed action), and Escape clears — all with NO server round trip for the highlight (the controller's listnav* handlers, like #recompute). Omit it for no nav. input(**on(:search, event: "input", debounce: 300, listnav: "[role=option]")) The verbatim JSON for an empty explicit-params payload. The common trigger (on(:increment), no params) hits this on EVERY render — skipping params.to_json (which re-serializes {} to the same "{}" each time) avoids a per-render allocation while keeping the wire format byte-identical.

"{}"
LISTNAV_ACTIONS =

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

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

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

%i[if if_any unless].freeze
LEGACY_SHOW_PREDICATE_KEYS =

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

%i[equals not in gte gt lte lt].freeze
LEGACY_SHOW_CONNECTIVE_KEYS =
%i[all any].freeze
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



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