Class: Phlex::Reactive::JS

Inherits:
Object
  • Object
show all
Defined in:
lib/phlex/reactive/js.rb

Overview

An immutable, chainable builder of client-side DOM commands (issue #95) — the ops behind Component#on_client. Each verb returns a NEW frozen instance; #to_json emits the wire format the generic controller's runOps action interprets, entirely in the browser:

button(**on_client(:click, js.toggle("#menu"))) { "Menu" }
# -> data-reactive-ops-param='[["toggle",{"to":"#menu"}]]'

These are declarative DOM OPERATIONS, not state: nothing is shipped back to the server, nothing is trusted from the client, and any server re-render of the component resets whatever they toggled (the LiveView JS-commands caveat — by design; use a signed action for state that must survive re-renders).

Targets: a CSS selector string is resolved WITHIN the component's root by default (nested reactive roots excluded, issue #15 semantics); :root targets the root element itself; global: true opts a single op out of root scoping (document escape hatch, e.g. a page-level overlay).

The op vocabulary is a fixed whitelist mirrored by the client interpreter — an op name the client doesn't know is warn-and-skipped there (client-side default-deny). Validation here is deliberately LOUD: a bad target or an empty class list raises at render time rather than silently doing nothing in the browser.

Constant Summary collapse

ROOT_SENTINEL =

Serialized stand-in for "the component's own root element".

"@root"
URL_BEARING_ATTRS =

The attribute-name allowlist (issue #96) — the security-critical part of the attr ops, enforced HERE at build time AND again in the client interpreter (two-sided default-deny: a hand-built ops attr must not bypass it either). Rejected:

* /\Aon/i        — event-handler attributes (onclick, onmouseover) → XSS.
* the URL set     — href/src/srcdoc/action/formaction/xlink:href can carry
                  a `javascript:` payload; setting them from client ops
                  is a navigation/injection surface, not a UI toggle.
* style           — inline CSS injection; use classes (add_class/...).

The INTENDED surface is class ops plus boolean/state attributes:

hidden, disabled, open, selected, aria-*, data-*.
%w[href src srcdoc action formaction xlink:href].freeze
EVENT_HANDLER_ATTR =
/\Aon/i
ATTR_NAME_OPS =

The attr ops whose args carry a "name" the allowlist must gate. Used to re-validate a RAW [op, args] list (the js([...]) / broadcast_js_to([...]) escape hatch) that skips the builder's build-time attr_args check.

%w[set_attr remove_attr toggle_attr].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(ops = [].freeze) ⇒ JS

Returns a new instance of JS.



88
89
90
91
# File 'lib/phlex/reactive/js.rb', line 88

def initialize(ops = [].freeze)
  @ops = ops
  freeze
end

Instance Attribute Details

#opsObject (readonly)

The accumulated [name, args] op pairs, oldest first. Frozen.



86
87
88
# File 'lib/phlex/reactive/js.rb', line 86

def ops
  @ops
end

Class Method Details

.assert_allowed_attr(name) ⇒ Object

The attribute-name allowlist (issue #96), case-insensitive. Refuses event-handler (on*), URL-bearing, and style attributes. Enforced at build time by the instance builder AND on the raw-list escape hatch — two-sided default-deny with the client interpreter.

Raises:

  • (ArgumentError)


70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/phlex/reactive/js.rb', line 70

def self.assert_allowed_attr(name)
  lower = name.downcase
  if name.match?(EVENT_HANDLER_ATTR)
    raise ArgumentError,
      "#{self}: attribute #{name.inspect} is an event handler (on*) — refused (XSS). " \
      "Client attr ops target hidden/disabled/open/selected/aria-*/data-* and classes."
  end
  return unless URL_BEARING_ATTRS.include?(lower) || lower == "style"

  raise ArgumentError,
    "#{self}: attribute #{name.inspect} is refused — URL-bearing attributes " \
    "(#{URL_BEARING_ATTRS.join(", ")}) and `style` can't be set from client ops " \
    "(injection surface). Use classes for styling; target aria-*/data-*/boolean attrs."
end

.assert_ops_allowed!(list) ⇒ Object

Validate a raw ops list ([[op, args], ...] as passed to js/broadcast_js_to without the builder) against the attribute allowlist, so the escape hatch gets the SAME server-side default-deny as the JS chain (defense in depth; the client also enforces it). Non-attr ops and malformed entries pass through untouched — the client interpreter default-denies unknown ops.



57
58
59
60
61
62
63
64
# File 'lib/phlex/reactive/js.rb', line 57

def self.assert_ops_allowed!(list)
  Array(list).each do |op, args|
    next unless ATTR_NAME_OPS.include?(op.to_s) && args.is_a?(::Hash)

    name = args["name"] || args[:name]
    assert_allowed_attr(name.to_s) if name
  end
end

Instance Method Details

#add_class(to, *classes, global: false) ⇒ Object

--- Classes ---



115
116
117
# File 'lib/phlex/reactive/js.rb', line 115

def add_class(to, *classes, global: false)
  append("add_class", class_args(to, classes, global:))
end

#dispatch(name, to: nil, detail: {}, global: false) ⇒ Object

--- Dispatch a bubbling CustomEvent (issue #96) ---

dispatch(name, to: nil, detail: {}) — emit a bubbling CustomEvent so other components/controllers can react to a client-only interaction without a round trip. to: picks the element to dispatch ON (nil → the component root, serialized as the @root sentinel so the client resolves it uniformly); detail: is the event's detail payload. The client uses raw element.dispatchEvent — the shared controller SHADOWS Stimulus's this.dispatch helper, so the interpreter must not use it.



183
184
185
186
187
# File 'lib/phlex/reactive/js.rb', line 183

def dispatch(name, to: nil, detail: {}, global: false)
  args = { "name" => name.to_s, "to" => normalize_target(to.nil? ? :root : to), "detail" => detail }
  args["global"] = true if global
  append("dispatch", args.freeze)
end

#empty?Boolean

Returns:

  • (Boolean)


194
195
196
# File 'lib/phlex/reactive/js.rb', line 194

def empty?
  @ops.empty?
end

#focus(to, global: false) ⇒ Object

--- Focus (issue #96) ---

focus(to) — focus the FIRST match of the selector. focus_first(to) — focus the first FOCUSABLE DESCENDANT of the match (e.g. focus the first menuitem inside an opened menu).



152
153
154
# File 'lib/phlex/reactive/js.rb', line 152

def focus(to, global: false)
  append("focus", target_args(to, global:))
end

#focus_first(to, global: false) ⇒ Object



156
157
158
# File 'lib/phlex/reactive/js.rb', line 156

def focus_first(to, global: false)
  append("focus_first", target_args(to, global:))
end

#hide(to, global: false, transition: nil) ⇒ Object



105
106
107
# File 'lib/phlex/reactive/js.rb', line 105

def hide(to, global: false, transition: nil)
  append("hide", target_args(to, global:, transition:))
end

#remove_attr(to, name, global: false) ⇒ Object



138
139
140
# File 'lib/phlex/reactive/js.rb', line 138

def remove_attr(to, name, global: false)
  append("remove_attr", attr_args(to, name, global:))
end

#remove_class(to, *classes, global: false) ⇒ Object



119
120
121
# File 'lib/phlex/reactive/js.rb', line 119

def remove_class(to, *classes, global: false)
  append("remove_class", class_args(to, classes, global:))
end

#set_attr(to, name, value, global: false) ⇒ Object

--- Attributes (issue #96) — allowlisted names only ---

set_attr(to, name, value) / remove_attr(to, name) / toggle_attr(to, name). The value is stringified (a Phlex-style flag rides as the string "true", never a valueless attribute). The name is checked against the allowlist at build time — an event-handler, URL-bearing, or style name raises here.



134
135
136
# File 'lib/phlex/reactive/js.rb', line 134

def set_attr(to, name, value, global: false)
  append("set_attr", attr_args(to, name, global:, value:))
end

#show(to, global: false, transition: nil) ⇒ Object

--- Visibility (the hidden attribute) ---

transition: (issue #96) — an optional [during, from, to] class triple animated around the visibility flip: during+from are applied, then on the next frame fromto swaps, and the whole set is awaited via animationend (with a setTimeout fallback so a non-animated element never hangs the op chain). Omit it for the instant flip.



101
102
103
# File 'lib/phlex/reactive/js.rb', line 101

def show(to, global: false, transition: nil)
  append("show", target_args(to, global:, transition:))
end

#text(to, value, global: false) ⇒ Object

--- Text content (issue #159) ---

text(to, value) — set the target's textContent (stringified; nil clears). XSS-safe by construction: textContent only, NEVER innerHTML — strictly less powerful than set_attr. Pair with global: true to paint a value into a node OUTSIDE the component's root (the cross-root text escape, e.g. a read-only recap in another tab pane).



168
169
170
171
172
# File 'lib/phlex/reactive/js.rb', line 168

def text(to, value, global: false)
  args = { "to" => normalize_target(to), "value" => value.to_s }
  args["global"] = true if global
  append("text", args.freeze)
end

#to_jsonObject

The wire format: a JSON array of [op, args] pairs, applied in order.



190
191
192
# File 'lib/phlex/reactive/js.rb', line 190

def to_json(*)
  @ops.to_json
end

#toggle(to, global: false, transition: nil) ⇒ Object



109
110
111
# File 'lib/phlex/reactive/js.rb', line 109

def toggle(to, global: false, transition: nil)
  append("toggle", target_args(to, global:, transition:))
end

#toggle_attr(to, name, global: false) ⇒ Object



142
143
144
# File 'lib/phlex/reactive/js.rb', line 142

def toggle_attr(to, name, global: false)
  append("toggle_attr", attr_args(to, name, global:))
end

#toggle_class(to, *classes, global: false) ⇒ Object



123
124
125
# File 'lib/phlex/reactive/js.rb', line 123

def toggle_class(to, *classes, global: false)
  append("toggle_class", class_args(to, classes, global:))
end