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

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_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/in are 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



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