Class: Phlex::Reactive::JS
- Inherits:
-
Object
- Object
- Phlex::Reactive::JS
- 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:
(**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
-
#ops ⇒ Object
readonly
The accumulated [name, args] op pairs, oldest first.
Class Method Summary collapse
-
.assert_allowed_attr(name) ⇒ Object
The attribute-name allowlist (issue #96), case-insensitive.
-
.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).
Instance Method Summary collapse
-
#add_class(to, *classes, global: false) ⇒ Object
--- Classes ---.
-
#dispatch(name, to: nil, detail: {}, global: false) ⇒ Object
--- Dispatch a bubbling CustomEvent (issue #96) ---.
- #empty? ⇒ Boolean
-
#focus(to, global: false) ⇒ Object
--- Focus (issue #96) ---.
- #focus_first(to, global: false) ⇒ Object
- #hide(to, global: false, transition: nil) ⇒ Object
-
#initialize(ops = [].freeze) ⇒ JS
constructor
A new instance of JS.
- #remove_attr(to, name, global: false) ⇒ Object
- #remove_class(to, *classes, global: false) ⇒ Object
-
#set_attr(to, name, value, global: false) ⇒ Object
--- Attributes (issue #96) — allowlisted names only ---.
-
#show(to, global: false, transition: nil) ⇒ Object
--- Visibility (the
hiddenattribute) ---. -
#text(to, value, global: false) ⇒ Object
--- Text content (issue #159) ---.
-
#to_json ⇒ Object
The wire format: a JSON array of [op, args] pairs, applied in order.
- #toggle(to, global: false, transition: nil) ⇒ Object
- #toggle_attr(to, name, global: false) ⇒ Object
- #toggle_class(to, *classes, global: false) ⇒ Object
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
#ops ⇒ Object (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.
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
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 from→to 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_json ⇒ Object
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 |