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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +29 -6
- data/app/javascript/phlex/reactive/compute.js +7 -1
- data/app/javascript/phlex/reactive/compute.min.js.map +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.js +105 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/helpers.rb +5 -0
- data/lib/phlex/reactive/js.rb +29 -0
- data/lib/phlex/reactive/streamable.rb +31 -24
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -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
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
128
|
-
# class
|
|
129
|
-
#
|
|
130
|
-
#
|
|
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 ?
|
|
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
|