phlex-reactive 0.16.13 → 0.16.14
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 +16 -0
- data/README.md +14 -3
- data/app/javascript/phlex/reactive/core.min.js +6 -6
- data/app/javascript/phlex/reactive/core.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/app/javascript/phlex/reactive/runtime.js +12 -1
- data/lib/phlex/reactive/component/editable.rb +3 -0
- data/lib/phlex/reactive/component/helpers.rb +18 -0
- data/lib/phlex/reactive/js.rb +51 -0
- data/lib/phlex/reactive/response.rb +4 -1
- data/lib/phlex/reactive/streamable.rb +10 -3
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -1416,6 +1416,12 @@ const CLIENT_OPS = Object.freeze({
|
|
|
1416
1416
|
show_modal: (el) => showModal(el),
|
|
1417
1417
|
close_dialog: (el) => closeDialog(el),
|
|
1418
1418
|
|
|
1419
|
+
// Click op (issue #471): HTMLElement#click(), the browser's own activation
|
|
1420
|
+
// (a link visit through Turbo Drive, a form submit, a checkbox toggle).
|
|
1421
|
+
// FIRST match only (FIRST_MATCH_OPS). Gesture-only: the server refuses it in
|
|
1422
|
+
// every pushed op (JS::GESTURE_OPS).
|
|
1423
|
+
click: (el) => el.click?.(),
|
|
1424
|
+
|
|
1419
1425
|
// Text op (issue #159): set textContent — XSS-safe by construction (never
|
|
1420
1426
|
// innerHTML), strictly less powerful than set_attr. Change-guarded like
|
|
1421
1427
|
// #mirrorText. With global: true it is the cross-root text escape: paint a
|
|
@@ -2114,6 +2120,10 @@ function bindingsAlreadyRan(event, controller) {
|
|
|
2114
2120
|
return false
|
|
2115
2121
|
}
|
|
2116
2122
|
|
|
2123
|
+
// Ops that act on the first match only (issue #471): clicking every match
|
|
2124
|
+
// would follow several links or submit several forms.
|
|
2125
|
+
const FIRST_MATCH_OPS = new Set(["click"])
|
|
2126
|
+
|
|
2117
2127
|
// Interpret a [[name, args], ...] op list against the frozen CLIENT_OPS
|
|
2118
2128
|
// whitelist (issues #95/#96/#97). `resolveTargets(args)` returns the element(s)
|
|
2119
2129
|
// an op applies to — the controller scopes to its root (excluding nested
|
|
@@ -2130,8 +2140,9 @@ function applyOps(list, resolveTargets, onZeroTargets) {
|
|
|
2130
2140
|
console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(name)} — skipped`)
|
|
2131
2141
|
continue
|
|
2132
2142
|
}
|
|
2133
|
-
|
|
2143
|
+
let targets = resolveTargets(args)
|
|
2134
2144
|
if (targets.length === 0 && onZeroTargets) onZeroTargets(name, args)
|
|
2145
|
+
if (targets.length > 1 && FIRST_MATCH_OPS.has(name)) targets = targets.slice(0, 1)
|
|
2135
2146
|
if (targets.length > 0) args = readArgs(name, args, resolveTargets, onZeroTargets)
|
|
2136
2147
|
if (!args) continue
|
|
2137
2148
|
for (const el of targets) {
|
|
@@ -64,6 +64,9 @@ module Phlex
|
|
|
64
64
|
end
|
|
65
65
|
raise ArgumentError, "reactive_editable on_enter: got a JS chain with no ops — a dead Enter" if on_enter.empty?
|
|
66
66
|
|
|
67
|
+
# Issue #471: the Enter runs root-scoped, with no trigger element —
|
|
68
|
+
# js.click's default (:trigger) would resolve to nothing.
|
|
69
|
+
Phlex::Reactive::JS.refuse_trigger!(on_enter, "a reactive_editable on_enter: chain")
|
|
67
70
|
on_enter.ops.to_json
|
|
68
71
|
end
|
|
69
72
|
|
|
@@ -547,6 +547,15 @@ module Phlex
|
|
|
547
547
|
"submit event this trigger is bound to. Bind the submit op to another event " \
|
|
548
548
|
"(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
|
|
549
549
|
end
|
|
550
|
+
# Issue #471: el.click() dispatches the very click this trigger is
|
|
551
|
+
# bound to, and the binding prevents its default — the activation
|
|
552
|
+
# would be lost.
|
|
553
|
+
if event == "click" && self_click?(ops)
|
|
554
|
+
raise ArgumentError,
|
|
555
|
+
"on_client(:click, js.click) on its own trigger cancels itself — the synthetic click " \
|
|
556
|
+
"re-enters this binding, which prevents its default. Bind it to another event " \
|
|
557
|
+
"(\"keydown.space\"), or click another element (js.click(\"#target\"))."
|
|
558
|
+
end
|
|
550
559
|
shortcut = client_shortcut!(event, shortcut, window:, outside:)
|
|
551
560
|
keeps_default!("on_client(#{event.inspect})", prevent, shortcut) unless prevent == true
|
|
552
561
|
window_bound = window || outside || !shortcut.nil?
|
|
@@ -1660,6 +1669,15 @@ module Phlex
|
|
|
1660
1669
|
record
|
|
1661
1670
|
end
|
|
1662
1671
|
|
|
1672
|
+
# Issue #471: a click op aimed at the trigger. A hand-built chain may
|
|
1673
|
+
# carry Symbol names and keys, or no args Hash.
|
|
1674
|
+
def self_click?(ops)
|
|
1675
|
+
ops.ops.any? do |name, args|
|
|
1676
|
+
name.to_s == "click" && args.is_a?(Hash) &&
|
|
1677
|
+
(args["to"] || args[:to]) == Phlex::Reactive::JS::TRIGGER_SENTINEL
|
|
1678
|
+
end
|
|
1679
|
+
end
|
|
1680
|
+
|
|
1663
1681
|
# Issue #442: validate on_client's within: — a non-empty CSS selector,
|
|
1664
1682
|
# on an element-bound binding that can survive a miss.
|
|
1665
1683
|
def client_within!(event, within, window:, outside:, shortcut:, once:)
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -145,6 +145,29 @@ module Phlex
|
|
|
145
145
|
end
|
|
146
146
|
end
|
|
147
147
|
|
|
148
|
+
# Ops the server never pushes (issue #471): click activates an element
|
|
149
|
+
# (follows a link, submits a form), so a reply or a broadcast that lands
|
|
150
|
+
# later must not carry it. Stricter than the broadcast-only refusal of
|
|
151
|
+
# focus/submit (Streamable::BROADCAST_REFUSED_OPS).
|
|
152
|
+
GESTURE_OPS = %w[click].freeze
|
|
153
|
+
|
|
154
|
+
# Raise when `list` (a JS chain or a raw [[op, args]] list) carries a
|
|
155
|
+
# gesture-only op. Called for every op the server pushes
|
|
156
|
+
# (Response.js_ops_json: reply.js, a settle's js and broadcast_to(js:)).
|
|
157
|
+
# A pushed dispatch("click") stays allowed (it may run an on(:action)
|
|
158
|
+
# click binding, as it always could), so it can reach a :click binding
|
|
159
|
+
# that holds js.click — the developer's wiring, like set_value's input.
|
|
160
|
+
def self.refuse_gesture_ops!(list)
|
|
161
|
+
pairs = list.is_a?(JS) ? list.ops : Array(list)
|
|
162
|
+
refused = pairs.filter_map { |op, _| op.to_s if GESTURE_OPS.include?(op.to_s) }.uniq
|
|
163
|
+
return if refused.empty?
|
|
164
|
+
|
|
165
|
+
raise ArgumentError,
|
|
166
|
+
"#{self}: a server-pushed op (reply.js, broadcast_to(js:)) refuses #{refused.join(", ")} — " \
|
|
167
|
+
"click activates an element (follows a link, submits a form), so the server never pushes " \
|
|
168
|
+
"it. Answer with reply.redirect for a visit, or bind js.click with on_client."
|
|
169
|
+
end
|
|
170
|
+
|
|
148
171
|
# Raise when an op in `list` (a JS chain or a raw [[op, args]] list)
|
|
149
172
|
# names @trigger (a target, from:, or dispatch's detail_from:, which
|
|
150
173
|
# reads off it). Called for every op the server pushes
|
|
@@ -322,6 +345,34 @@ module Phlex
|
|
|
322
345
|
append("blur", target_args(to, global:))
|
|
323
346
|
end
|
|
324
347
|
|
|
348
|
+
# --- Click (issue #471) ---
|
|
349
|
+
#
|
|
350
|
+
# click(to = :trigger) — HTMLElement#click() on the FIRST match: the
|
|
351
|
+
# browser's own activation, as a user click would run it. A link follows
|
|
352
|
+
# its href (through Turbo Drive, with its own data-turbo-* attributes), a
|
|
353
|
+
# submit button submits its form, a checkbox toggles. The default target
|
|
354
|
+
# is the trigger, so a link that should also answer Space reads
|
|
355
|
+
# a(href: item_path(item), **on_client("keydown.space", js.click)) { item.name }
|
|
356
|
+
# (the binding prevents the keydown's default, so Space does not scroll).
|
|
357
|
+
#
|
|
358
|
+
# The synthetic click bubbles like a real one: a click binding on the
|
|
359
|
+
# target or an ancestor prevents its default (and so the activation)
|
|
360
|
+
# unless it was bound with prevent: false. on_client(:click, js.click) on
|
|
361
|
+
# its own trigger is refused at render for that reason.
|
|
362
|
+
#
|
|
363
|
+
# NEVER SERVER-PUSHED, stricter than focus: refused in every server-pushed
|
|
364
|
+
# op (reply.js, a settle's js, broadcast_to(js:)) — a reply that lands
|
|
365
|
+
# later would navigate without a gesture. on_client, a raw ["click", …] pair in a reducer's $ops and
|
|
366
|
+
# reactive_on_complete may carry it. The last two run on an input event,
|
|
367
|
+
# and a reply's set_value fires one too, so a reply can reach a click
|
|
368
|
+
# there — the same exposure submit has; so can a pushed
|
|
369
|
+
# dispatch("click") reaching a :click binding that holds it. A held key repeats its keydown,
|
|
370
|
+
# and each repeat clicks again.
|
|
371
|
+
|
|
372
|
+
def click(to = :trigger, global: false)
|
|
373
|
+
append("click", target_args(to, global:))
|
|
374
|
+
end
|
|
375
|
+
|
|
325
376
|
# --- Modal dialogs (issue #365) ---
|
|
326
377
|
#
|
|
327
378
|
# show_modal(to) — HTMLDialogElement#showModal(): the top layer, a
|
|
@@ -304,12 +304,14 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
|
|
|
304
304
|
# site), so fail loudly rather than emit an inert tag.
|
|
305
305
|
# Every op the server pushes (reply.js, broadcast_to(js:)) passes here,
|
|
306
306
|
# so this is where @trigger is refused for a chain too (issue #442): a
|
|
307
|
-
# pushed op has no element whose binding fired.
|
|
307
|
+
# pushed op has no element whose binding fired. So is a gesture-only op
|
|
308
|
+
# (click, issue #471).
|
|
308
309
|
def js_ops_json(ops)
|
|
309
310
|
if ops.is_a?(Phlex::Reactive::JS)
|
|
310
311
|
raise ArgumentError, "js(...) got no ops — a dead reactive:js stream" if ops.empty?
|
|
311
312
|
|
|
312
313
|
Phlex::Reactive::JS.refuse_trigger!(ops)
|
|
314
|
+
Phlex::Reactive::JS.refuse_gesture_ops!(ops)
|
|
313
315
|
ops.to_json
|
|
314
316
|
else
|
|
315
317
|
list = Array(ops)
|
|
@@ -319,6 +321,7 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
|
|
|
319
321
|
# list skips that, so re-apply the allowlist here (defense in depth —
|
|
320
322
|
# the client interpreter also refuses on*/URL/style attrs).
|
|
321
323
|
Phlex::Reactive::JS.assert_ops_allowed!(list)
|
|
324
|
+
Phlex::Reactive::JS.refuse_gesture_ops!(list)
|
|
322
325
|
list.to_json
|
|
323
326
|
end
|
|
324
327
|
end
|
|
@@ -44,14 +44,19 @@ module Phlex
|
|
|
44
44
|
# and close_dialog (issue #365) would open or close a modal, and move
|
|
45
45
|
# focus, on every screen at once; set_value (issue #374) would overwrite
|
|
46
46
|
# every subscriber's field; preview_file (issue #375) would read every
|
|
47
|
-
# subscriber's file input
|
|
47
|
+
# subscriber's file input; click (issue #471) would follow a link or
|
|
48
|
+
# submit a form on every subscriber's page. They
|
|
48
49
|
# belong to the actor's own reply (reply.js) or gesture (on_client / a
|
|
49
|
-
# reducer's $ops), never a broadcast
|
|
50
|
+
# reducer's $ops), never a broadcast — except click, which reply.js
|
|
51
|
+
# refuses too (Phlex::Reactive::JS::GESTURE_OPS): bind it with on_client
|
|
52
|
+
# (or a raw ["click", …] pair in a reducer's $ops).
|
|
53
|
+
# Names mirror Phlex::Reactive::JS's verbs.
|
|
50
54
|
BROADCAST_REFUSED_OPS = %w[
|
|
51
55
|
focus focus_first blur submit paste_into persist_state persist_clear check_group presence
|
|
52
56
|
show_modal close_dialog
|
|
53
57
|
preview_file
|
|
54
58
|
set_value
|
|
59
|
+
click
|
|
55
60
|
].freeze
|
|
56
61
|
# Refused as a FLAG, on any op (issue #369): keep: records client state in
|
|
57
62
|
# the tab that ran the op, so a broadcast carrying it would plant that
|
|
@@ -241,7 +246,9 @@ module Phlex
|
|
|
241
246
|
"broadcasting show_modal or close_dialog opens or closes a modal (and moves focus) on " \
|
|
242
247
|
"every screen — render the dialog open or closed, with data: { reactive_modal: true }, instead. " \
|
|
243
248
|
"Broadcasting preview_file reads every subscriber's chosen file onto their page. " \
|
|
244
|
-
"
|
|
249
|
+
"Broadcasting click follows a link or submits a form on every subscriber's page. " \
|
|
250
|
+
"These are actor concerns (reply.js / on_client / $ops); click is never server-pushed — " \
|
|
251
|
+
"reply.js refuses it too: bind it with on_client (or a raw [\"click\", …] pair in a reducer's $ops)."
|
|
245
252
|
end
|
|
246
253
|
refuse_kept_broadcast_ops!(pairs)
|
|
247
254
|
|