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.



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

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

Instance Attribute Details

#opsObject (readonly)

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



99
100
101
# File 'lib/phlex/reactive/js.rb', line 99

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)


83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/phlex/reactive/js.rb', line 83

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



70
71
72
73
74
75
76
77
# File 'lib/phlex/reactive/js.rb', line 70

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

.normalize_target(to) ⇒ 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. :root -> the sentinel; a String passes through as a CSS selector. Shared by the js op builder AND the on() pending-state hint normalizer (issue #181) so BOTH resolve a to: target through one code path. Anything else (a stray symbol, nil) is a bug at the call site — raise at render time instead of silently matching nothing in the browser.

Raises:

  • (ArgumentError)


62
63
64
65
66
67
68
# File 'lib/phlex/reactive/js.rb', line 62

def self.normalize_target(to)
  return ROOT_SENTINEL if to == :root
  return to if to.is_a?(String)

  raise ArgumentError,
    "target must be :root or a CSS selector string, got #{to.inspect}"
end

Instance Method Details

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

--- Classes ---



128
129
130
# File 'lib/phlex/reactive/js.rb', line 128

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.



196
197
198
199
200
# File 'lib/phlex/reactive/js.rb', line 196

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)


207
208
209
# File 'lib/phlex/reactive/js.rb', line 207

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).



165
166
167
# File 'lib/phlex/reactive/js.rb', line 165

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

#focus_first(to, global: false) ⇒ Object



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

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

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



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

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

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



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

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

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



132
133
134
# File 'lib/phlex/reactive/js.rb', line 132

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.



147
148
149
# File 'lib/phlex/reactive/js.rb', line 147

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.



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

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).



181
182
183
184
185
# File 'lib/phlex/reactive/js.rb', line 181

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.



203
204
205
# File 'lib/phlex/reactive/js.rb', line 203

def to_json(*)
  @ops.to_json
end

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



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

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

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



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

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

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



136
137
138
# File 'lib/phlex/reactive/js.rb', line 136

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