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.
@@ -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
- const targets = resolveTargets(args)
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:)
@@ -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. They
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. Names mirror Phlex::Reactive::JS's verbs.
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
- "These are actor concerns (reply.js / on_client / $ops)."
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.16.13"
5
+ VERSION = "0.16.14"
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.16.13
4
+ version: 0.16.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson