phlex-reactive 0.12.3 → 0.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -408,6 +408,11 @@ module Phlex
408
408
  # on()'s flags above.
409
409
  attrs[:data][:reactive_outside_param] = "true" if outside
410
410
  attrs[:data][:reactive_window_param] = "true" if window_bound
411
+ # Issue #228: mark a clipboard-reading trigger so the client can gate
412
+ # its visibility on availability — on connect the controller sets
413
+ # hidden = !navigator.clipboard.readText. Render the trigger `hidden`
414
+ # for reveal-when-available (a dead paste button never shows).
415
+ attrs[:data][:reactive_clipboard] = "true" if ops.ops.any? { |name, _| name == "paste_into" }
411
416
  # Issue #178: confirm: gates the client-op chain behind the SAME
412
417
  # overridable confirmResolver on(:action, confirm:) uses (#52/#55). Emits
413
418
  # the identical data-reactive-confirm-param; the client's runOps prompts
@@ -185,6 +185,35 @@ module Phlex
185
185
  append("submit", target_args(to, global:))
186
186
  end
187
187
 
188
+ # --- Clipboard-source paste (issue #228) ---
189
+ #
190
+ # paste_into(to) — on a user gesture, read navigator.clipboard.readText()
191
+ # and feed the text into the target field through the normal `input`
192
+ # pipeline: set .value, dispatch a bubbling `input` event (so
193
+ # reactive_compute reducers, reactive_show, and reactive_on_complete all
194
+ # run exactly as if the user had typed it), then focus the field. The
195
+ # permission UX is the browser's own; a rejected or unavailable read is a
196
+ # SILENT NO-OP — page state must not change. The target is a FIELD: there
197
+ # is no :root default and an explicit :root is refused loudly (pasting
198
+ # into the root div is always a call-site bug). ACTOR-ONLY like
199
+ # focus/submit: allowed from on_client / reply.js / reactive_on_complete,
200
+ # refused in broadcast_to(js:) — a broadcast that reads every
201
+ # subscriber's clipboard would be hostile. on_client marks the trigger
202
+ # with data-reactive-clipboard so the client can hide it wherever the
203
+ # clipboard API is missing (no dead button). NOTE: the gate owns the
204
+ # trigger's `hidden` flag — don't also bind reactive_show to the trigger
205
+ # element (the two passes would fight over the same attribute).
206
+
207
+ def paste_into(to, global: false)
208
+ if to == :root
209
+ raise ArgumentError,
210
+ "#{self.class}: paste_into targets a field, not the component root — " \
211
+ "pass the input's CSS selector (e.g. js.paste_into(\"[name=code]\"))"
212
+ end
213
+
214
+ append("paste_into", target_args(to, global:))
215
+ end
216
+
188
217
  # --- Text content (issue #159) ---
189
218
  #
190
219
  # text(to, value) — set the target's textContent (stringified; nil clears).
@@ -35,10 +35,11 @@ module Phlex
35
35
 
36
36
  # Actor-only ops: a js broadcast rejects a broadcast that carries one.
37
37
  # Focus ops steal focus in every subscriber's tab (issue #96); submit
38
- # (issue #226) would force-submit every subscriber's form. Both belong to
38
+ # (issue #226) would force-submit every subscriber's form; paste_into
39
+ # (issue #228) would read every subscriber's clipboard. They belong to
39
40
  # the actor's own reply (reply.js) or gesture (on_client / a reducer's
40
41
  # $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
41
- BROADCAST_REFUSED_OPS = %w[focus focus_first submit].freeze
42
+ BROADCAST_REFUSED_OPS = %w[focus focus_first submit paste_into].freeze
42
43
 
43
44
  # The broadcast_to verb kwargs (issue #185) → their Turbo stream action.
44
45
  # SELF-TARGETING verbs derive the target from the component's #id (require a
@@ -124,22 +125,45 @@ module Phlex
124
125
  "append/prepend/remove), not client-op dispatches"
125
126
  end
126
127
  resolved_target = resolve_broadcast_target(verb, component, payload, target)
127
- # broadcast_js_ops_json is a private class method on the owner Streamable
128
- # class (reached via send); the instrumentation + pgbus thread-locals are
129
- # owned HERE so the class-level and module-level (plain-component) forms
130
- # share ONE path and neither is silently un-instrumented (issue #185).
128
+ # The instrumentation + pgbus thread-locals are owned HERE so the
129
+ # class-level and module-level (plain-component) forms share ONE path
130
+ # and neither is silently un-instrumented (issue #185). The js ops
131
+ # serializer lives HERE too (issue #228): when it was a private method
132
+ # on the includer class, the module-level owner (the Streamable module
133
+ # itself) crashed with NoMethodError instead of refusing — the
134
+ # actor-only gate must be reachable from BOTH broadcast doors.
131
135
  component_name = component ? component.class.name : owner.name
132
136
  Phlex::Reactive.instrument(
133
137
  "broadcast", { component: component_name, stream_action: BROADCAST_VERBS[verb], streamables: keys.size }
134
138
  ) do
135
139
  with_pgbus_broadcast_opts(exclude:, visible_to:) do
136
140
  html = verb == :js ? nil : render_broadcast_html(component)
137
- ops_json = verb == :js ? owner.send(:broadcast_js_ops_json, payload) : nil
141
+ ops_json = verb == :js ? broadcast_js_ops_json(payload) : nil
138
142
  keys.each { dispatch_broadcast(verb, it, resolved_target, html, ops_json, morph, effect) }
139
143
  end
140
144
  end
141
145
  end
142
146
 
147
+ # Validate + serialize broadcast ops: reject actor-only ops (focus steals
148
+ # focus in every tab; submit force-submits every subscriber's form;
149
+ # paste_into reads every subscriber's clipboard) and an empty chain (a
150
+ # dead broadcast), then return the JSON wire form. Works on a JS chain
151
+ # (inspect .ops) and a raw array. Lives on the module singleton — the
152
+ # ONE enforcement point both broadcast doors funnel through.
153
+ def broadcast_js_ops_json(ops)
154
+ pairs = ops.is_a?(Phlex::Reactive::JS) ? ops.ops : Array(ops)
155
+ refused = pairs.map { |name, _| name.to_s } & Phlex::Reactive::Streamable::BROADCAST_REFUSED_OPS
156
+ unless refused.empty?
157
+ raise ArgumentError,
158
+ "broadcast_to(js:) refuses actor-only op(s) #{refused.join(", ")} — broadcasting focus " \
159
+ "steals it in every subscriber's tab; broadcasting submit force-submits every " \
160
+ "subscriber's form; broadcasting paste_into reads every subscriber's clipboard. " \
161
+ "These are actor concerns (reply.js / on_client / $ops)."
162
+ end
163
+
164
+ Phlex::Reactive::Response.js_ops_json(ops)
165
+ end
166
+
143
167
  # Render a built component to HTML for a broadcast, ALWAYS instrumented
144
168
  # (issue #185): a Streamable renders through its own render_component (which
145
169
  # fires render.phlex_reactive); a plain component renders through the module
@@ -519,23 +543,6 @@ module Phlex
519
543
  # instrumentation + pgbus-threading path for the class-level and module-level
520
544
  # broadcast_to, so the transport logic has exactly one spelling.)
521
545
 
522
- # Validate + serialize broadcast ops: reject actor-only ops (focus steals
523
- # focus in every tab; submit force-submits every subscriber's form) and an
524
- # empty chain (a dead broadcast), then return the JSON wire form. Works on
525
- # a JS chain (inspect .ops) and a raw array.
526
- def broadcast_js_ops_json(ops)
527
- pairs = ops.is_a?(Phlex::Reactive::JS) ? ops.ops : Array(ops)
528
- refused = pairs.map { |name, _| name.to_s } & Phlex::Reactive::Streamable::BROADCAST_REFUSED_OPS
529
- unless refused.empty?
530
- raise ArgumentError,
531
- "broadcast_to(js:) refuses actor-only op(s) #{refused.join(", ")} — broadcasting focus " \
532
- "steals it in every subscriber's tab; broadcasting submit force-submits every " \
533
- "subscriber's form. These are actor concerns (reply.js / on_client / $ops)."
534
- end
535
-
536
- Phlex::Reactive::Response.js_ops_json(ops)
537
- end
538
-
539
546
  def build(model, options)
540
547
  new(**(model ? component_args(model, options) : options))
541
548
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.12.3"
5
+ VERSION = "0.12.4"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.3
4
+ version: 0.12.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson