phlex-reactive 0.16.7 → 0.16.9

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.
@@ -23,8 +23,16 @@ module Phlex
23
23
  #
24
24
  # Targets: a CSS selector string is resolved WITHIN the component's root by
25
25
  # default (nested reactive roots excluded, issue #15 semantics); `:root`
26
- # targets the root element itself; `global: true` opts a single op out of
27
- # root scoping (document escape hatch, e.g. a page-level overlay).
26
+ # targets the root element itself; `:trigger` (issue #442) targets the
27
+ # element whose on_client binding fired (with on_client's within:, the
28
+ # matching element under it); `global: true` opts a single op out of root
29
+ # scoping (document escape hatch, e.g. a page-level overlay).
30
+ #
31
+ # Reads (issue #442): set_value, text and set_attr take `from:` + `attr:`
32
+ # in place of a literal value — the value is read in the browser off the
33
+ # `from` element (a data-* attribute or its text), so one chain bound once
34
+ # serves every row of a list:
35
+ # js.set_value("#item-value", from: :trigger, attr: "data-value")
28
36
  #
29
37
  # The op vocabulary is a fixed whitelist mirrored by the client interpreter
30
38
  # — an op name the client doesn't know is warn-and-skipped there
@@ -35,6 +43,38 @@ module Phlex
35
43
  # Serialized stand-in for "the component's own root element".
36
44
  ROOT_SENTINEL = "@root"
37
45
 
46
+ # Serialized stand-in for "the element whose on_client binding fired"
47
+ # (issue #442). Only an on_client binding has one: a server-pushed op
48
+ # (reply.js, broadcast_to(js:)) or a reactive_on_complete run refuses it.
49
+ TRIGGER_SENTINEL = "@trigger"
50
+
51
+ # attr: :text's wire value — read the element's textContent.
52
+ TEXT_READ = "@text"
53
+
54
+ # What a from: read may take off an element (issue #442): a data-*
55
+ # attribute — state the server declared on the element — or its text.
56
+ # Two-sided like the write allowlist below: the client refuses anything
57
+ # else in a hand-built record. Narrow on purpose (widening later breaks
58
+ # nothing; narrowing would).
59
+ READABLE_ATTR = /\Adata-[a-z][a-z0-9-]*\z/i
60
+
61
+ # The ops whose value from: + attr: can read, and the arg keys that name
62
+ # an element (where a server-pushed op has no @trigger to put).
63
+ READ_OPS = %w[set_value text set_attr].freeze
64
+ TARGET_KEYS = %w[to from expanded submitter].freeze
65
+
66
+ # The field-writing ops write a field the author names: never the root
67
+ # (a container) nor the trigger (the element clicked).
68
+ FIELD_REFUSED = {
69
+ :root => "the component root", ROOT_SENTINEL => "the component root",
70
+ :trigger => "the trigger", TRIGGER_SENTINEL => "the trigger"
71
+ }.freeze
72
+
73
+ # The positional value set_value/text/set_attr default to, so nil keeps
74
+ # meaning "clear" and only from: may replace it.
75
+ NO_VALUE = Object.new.freeze
76
+ private_constant :NO_VALUE
77
+
38
78
  # The attribute-name allowlist (issue #96) — the security-critical part of
39
79
  # the attr ops, enforced HERE at build time AND again in the client
40
80
  # interpreter (two-sided default-deny: a hand-built ops attr must not
@@ -75,15 +115,73 @@ module Phlex
75
115
  "target must be :root or a CSS selector string, got #{to.inspect}"
76
116
  end
77
117
 
118
+ # The op builder's target: normalize_target plus :trigger (issue #442).
119
+ # Separate so a pending hint or a css-vars binding given :trigger keeps
120
+ # raising — neither has a trigger element.
121
+ def self.normalize_client_target(to)
122
+ return TRIGGER_SENTINEL if to == :trigger
123
+ return ROOT_SENTINEL if to == :root
124
+ return to if to.is_a?(String)
125
+
126
+ raise ArgumentError,
127
+ "target must be :trigger, :root or a CSS selector string, got #{to.inspect}"
128
+ end
129
+
130
+ # Also refuses @trigger (a raw list is server-pushed or a
131
+ # reactive_on_complete run: no trigger) and a read attr outside the
132
+ # data-*/text rule (issue #442).
78
133
  def self.assert_ops_allowed!(list)
134
+ refuse_trigger!(list)
79
135
  Array(list).each do |op, args|
80
- next unless ATTR_NAME_OPS.include?(op.to_s) && args.is_a?(::Hash)
136
+ next unless args.is_a?(::Hash)
137
+
138
+ name = arg(args, "name")
139
+ assert_allowed_attr(name.to_s) if name && ATTR_NAME_OPS.include?(op.to_s)
140
+ # A read: the client takes args.attr only on a read op with a from.
141
+ read_attr(arg(args, "attr"), op) if READ_OPS.include?(op.to_s) && arg(args, "from")
142
+ end
143
+ end
144
+
145
+ # Raise when an op in `list` (a JS chain or a raw [[op, args]] list)
146
+ # names @trigger (a target, from:, or dispatch's detail_from:, which
147
+ # reads off it). Called for every op the server pushes
148
+ # (Response.js_ops_json: reply.js and broadcast_to(js:)) and for a
149
+ # reactive_on_complete run.
150
+ def self.refuse_trigger!(list, where = "a server-pushed op (reply.js, broadcast_to(js:)) " \
151
+ "or a reactive_on_complete run")
152
+ op = trigger_op(list.is_a?(JS) ? list.ops : list)
153
+ return if op.nil?
154
+
155
+ raise ArgumentError,
156
+ "#{self}: #{op} names @trigger, the element whose on_client binding fired (a :trigger " \
157
+ "target, from: :trigger or detail_from:) — #{where} has no trigger. Name the element " \
158
+ "with a CSS selector."
159
+ end
81
160
 
82
- name = args["name"] || args[:name]
83
- assert_allowed_attr(name.to_s) if name
161
+ # The name of the first op that names @trigger, or nil. A raw list, or a
162
+ # hand-built JS.new([...]), may carry Symbol keys or no args Hash.
163
+ def self.trigger_op(list)
164
+ Array(list).each do |op, args|
165
+ next unless args.is_a?(::Hash)
166
+ return op.to_s if arg(args, "detail_from") || TARGET_KEYS.any? { arg(args, it) == TRIGGER_SENTINEL }
84
167
  end
168
+ nil
85
169
  end
86
170
 
171
+ # What attr: may read (issue #442): :text (or its "@text" wire form) or a
172
+ # data-* attribute name. Returns the wire value; raises otherwise.
173
+ def self.read_attr(attr, verb = "a read")
174
+ return TEXT_READ if [:text, TEXT_READ].include?(attr)
175
+ return attr if attr.is_a?(String) && attr.match?(READABLE_ATTR)
176
+
177
+ raise ArgumentError,
178
+ "#{self}: #{verb} attr: reads a data-* attribute or :text (textContent), got #{attr.inspect}"
179
+ end
180
+
181
+ # A raw list's args may carry String or Symbol keys.
182
+ def self.arg(args, key) = args.key?(key) ? args[key] : args[key.to_sym]
183
+ private_class_method :arg
184
+
87
185
  # The attribute-name allowlist (issue #96), case-insensitive. Refuses
88
186
  # event-handler (on*), URL-bearing, and style attributes. Enforced at build
89
187
  # time by the instance builder AND on the raw-list escape hatch — two-sided
@@ -172,9 +270,15 @@ module Phlex
172
270
  # instead of toggling presence — current == on ? off : on, so an absent
173
271
  # attribute becomes `on`. For aria-expanded/aria-pressed/data-state:
174
272
  # js.toggle_attr("#trigger", "aria-expanded", "true", "false")
273
+ #
274
+ # set_attr(to, name, from:, attr:) (issue #442) reads the value off an
275
+ # element instead (see #set_value); the name allowlist above still
276
+ # decides what may be WRITTEN:
277
+ # js.set_attr(:trigger, "aria-selected", "true")
278
+ # js.set_attr("#preview", "data-state", from: :trigger, attr: "data-state")
175
279
 
176
- def set_attr(to, name, value, global: false, keep: false)
177
- append("set_attr", attr_args(to, name, global:, keep:, value:))
280
+ def set_attr(to, name, value = NO_VALUE, from: nil, attr: nil, global: false, keep: false)
281
+ append("set_attr", attr_args(to, name, global:, keep:) { value_args(it, "set_attr", value, from:, attr:) })
178
282
  end
179
283
 
180
284
  def remove_attr(to, name, global: false, keep: false)
@@ -251,12 +355,13 @@ module Phlex
251
355
  args = target_args(to, global:)
252
356
  return append("submit", args) if submitter.nil?
253
357
 
254
- unless submitter.is_a?(String) && !submitter.empty?
358
+ unless submitter == :trigger || (submitter.is_a?(String) && !submitter.empty?)
255
359
  raise ArgumentError,
256
- "js.submit submitter: must be a CSS selector string naming a submit control, got #{submitter.inspect}"
360
+ "js.submit submitter: must be a CSS selector string naming a submit control " \
361
+ "(or :trigger), got #{submitter.inspect}"
257
362
  end
258
363
 
259
- append("submit", args.merge("submitter" => submitter).freeze)
364
+ append("submit", args.merge("submitter" => normalize_target(submitter)).freeze)
260
365
  end
261
366
 
262
367
  # --- Clipboard-source paste (issue #228) ---
@@ -279,12 +384,8 @@ module Phlex
279
384
  # element (the two passes would fight over the same attribute).
280
385
 
281
386
  def paste_into(to, global: false)
282
- if [:root, ROOT_SENTINEL].include?(to)
283
- raise ArgumentError,
284
- "#{self.class}: paste_into targets a field, not the component root — " \
285
- "pass the input's CSS selector (e.g. js.paste_into(\"[name=code]\"))"
286
- end
287
-
387
+ refuse_field_target!(to, "paste_into targets a field, not %s — " \
388
+ "pass the input's CSS selector (e.g. js.paste_into(\"[name=code]\"))")
288
389
  append("paste_into", target_args(to, global:))
289
390
  end
290
391
 
@@ -344,12 +445,8 @@ module Phlex
344
445
 
345
446
  def preview_file(to, into:, name: nil, global: false, valid: false, wrap: nil, max_bytes: nil, accept: nil,
346
447
  per_file: false)
347
- if [:root, ROOT_SENTINEL].include?(to)
348
- raise ArgumentError,
349
- "#{self.class}: preview_file reads a file input, not the component root — " \
350
- "pass the input's CSS selector (e.g. js.preview_file(\"#photo-file\", into: \"#photo\"))"
351
- end
352
-
448
+ refuse_field_target!(to, "preview_file reads a file input, not %s — pass the input's CSS selector " \
449
+ "(e.g. js.preview_file(\"#photo-file\", into: \"#photo\"))")
353
450
  args = { "to" => normalize_target(to), "into" => preview_selector(:into, into) }
354
451
  args["name"] = preview_selector(:name, name) unless name.nil?
355
452
  args["global"] = true if global
@@ -368,9 +465,14 @@ module Phlex
368
465
  # less powerful than set_attr. Pair with `global: true` to paint a value
369
466
  # into a node OUTSIDE the component's root (the cross-root text escape,
370
467
  # e.g. a read-only recap in another tab pane).
468
+ #
469
+ # text(to, from:, attr:) (issue #442) reads the text off an element
470
+ # instead (see #set_value) — the label of the row that was clicked:
471
+ # js.text("#item-label", from: :trigger, attr: "data-label")
371
472
 
372
- def text(to, value, global: false)
373
- args = { "to" => normalize_target(to), "value" => value.to_s }
473
+ def text(to, value = NO_VALUE, from: nil, attr: nil, global: false)
474
+ args = { "to" => normalize_target(to) }
475
+ value_args(args, "text", value, from:, attr:)
374
476
  args["global"] = true if global
375
477
  append("text", args.freeze)
376
478
  end
@@ -413,15 +515,21 @@ module Phlex
413
515
  # ACTOR-ONLY like paste_into: a broadcast that overwrote every
414
516
  # subscriber's field would be hostile (BROADCAST_REFUSED_OPS).
415
517
  # button(**on_client(:click, js.set_value("#notes", "Thanks"))) { "Thanks" }
416
-
417
- def set_value(to, value, global: false)
418
- if [:root, ROOT_SENTINEL].include?(to)
419
- raise ArgumentError,
420
- "#{self.class}: set_value targets a field, not the component root — " \
421
- "pass the field's CSS selector (e.g. js.set_value(\"#notes\", \"Thanks\"))"
422
- end
423
-
424
- args = { "to" => normalize_target(to), "value" => value.to_s }
518
+ #
519
+ # Reads (issue #442): `from:` + `attr:` in place of the value read it in
520
+ # the browser off an element — `from:` is :trigger, :root or a selector,
521
+ # resolved with the op's own scoping (global: included), first match;
522
+ # `attr:` is a data-* attribute or :text (textContent). A missing
523
+ # attribute reads "", so it clears; a `from:` matching nothing skips the
524
+ # op. Exactly one of the value or `from:`. The same on text and set_attr:
525
+ # ul(**on_client(:click, js.set_value("#item-value", from: :trigger, attr: "data-value"),
526
+ # within: "[role=option]"))
527
+
528
+ def set_value(to, value = NO_VALUE, from: nil, attr: nil, global: false)
529
+ refuse_field_target!(to, "set_value targets a field, not %s — " \
530
+ "pass the field's CSS selector (e.g. js.set_value(\"#notes\", \"Thanks\"))")
531
+ args = { "to" => normalize_target(to) }
532
+ value_args(args, "set_value", value, from:, attr:)
425
533
  args["global"] = true if global
426
534
  append("set_value", args.freeze)
427
535
  end
@@ -465,8 +573,15 @@ module Phlex
465
573
  # uniformly); `detail:` is the event's `detail` payload. The client uses raw
466
574
  # element.dispatchEvent — the shared controller SHADOWS Stimulus's
467
575
  # this.dispatch helper, so the interpreter must not use it.
468
- def dispatch(name, to: nil, detail: {}, global: false)
576
+ #
577
+ # detail_from: (issue #442) maps detail keys to what to read off the
578
+ # TRIGGER (a data-* attribute or :text), merged over `detail:` in the
579
+ # browser — every value a String. Only an on_client binding has a
580
+ # trigger: a server-pushed dispatch refuses it.
581
+ # js.dispatch("app:item-picked", detail_from: { id: "data-id", text: :text })
582
+ def dispatch(name, to: nil, detail: {}, detail_from: {}, global: false)
469
583
  args = { "name" => name.to_s, "to" => normalize_target(to.nil? ? :root : to), "detail" => detail }
584
+ args["detail_from"] = detail_reads(detail, detail_from) unless detail_from.nil? || detail_from == {}
470
585
  args["global"] = true if global
471
586
  append("dispatch", args.freeze)
472
587
  end
@@ -609,17 +724,72 @@ module Phlex
609
724
  [on, off].freeze
610
725
  end
611
726
 
612
- # An attr op's args: the target, the allowlisted name, an optional
613
- # (stringified) value. The name is validated LOUDLY at build time.
614
- def attr_args(to, name, global:, keep: false, value: :__none)
727
+ # An attr op's args: the target, the allowlisted name, then whatever the
728
+ # block adds (set_attr's value or read). The name is validated LOUDLY at
729
+ # build time.
730
+ def attr_args(to, name, global:, keep: false)
615
731
  name = name.to_s
616
732
  assert_allowed_attr(name)
617
733
  args = { "to" => normalize_target(to), "name" => name }
618
- args["value"] = value.to_s unless value == :__none
734
+ yield args if block_given?
619
735
  args["global"] = true if global
620
736
  with_keep(args, keep)
621
737
  end
622
738
 
739
+ # The value of set_value/text/set_attr (issue #442): the literal
740
+ # (stringified; nil clears) as "value", or a read as "from" + "attr".
741
+ # Exactly one — loud at render.
742
+ def value_args(args, verb, value, from:, attr:)
743
+ literal = !value.equal?(NO_VALUE)
744
+ raise ArgumentError, "#{self.class}: #{verb} takes a value or from:, not both" if literal && !from.nil?
745
+ if from.nil? && !attr.nil?
746
+ raise ArgumentError,
747
+ "#{self.class}: #{verb} attr: needs from: (the element to read it off)"
748
+ end
749
+ return args["value"] = value.to_s if literal
750
+ raise ArgumentError, "#{self.class}: #{verb} needs a value or from: + attr:" if from.nil?
751
+ raise ArgumentError, "#{self.class}: #{verb} from: needs attr: (a data-* attribute or :text)" if attr.nil?
752
+
753
+ source = normalize_target(from)
754
+ raise ArgumentError, "#{self.class}: #{verb} from: needs a CSS selector, got an empty string" if source.empty?
755
+
756
+ args["from"] = source
757
+ args["attr"] = self.class.read_attr(attr, verb)
758
+ end
759
+
760
+ # dispatch's detail_from: — detail key → read attr, the keys Strings.
761
+ # A key also in detail: would silently override it, so it raises.
762
+ def detail_reads(detail, reads)
763
+ unless reads.is_a?(Hash)
764
+ raise ArgumentError,
765
+ "#{self.class}: dispatch detail_from: takes a Hash of detail keys to data-* attributes " \
766
+ "(or :text), got #{reads.inspect}"
767
+ end
768
+
769
+ literal = detail.is_a?(Hash) ? detail.keys.map(&:to_s) : []
770
+ normalized = reads.to_h do |key, attr|
771
+ key = key.to_s
772
+ if literal.include?(key)
773
+ raise ArgumentError,
774
+ "#{self.class}: dispatch detail_from: key #{key.inspect} is also in detail: — one would override the other"
775
+ end
776
+
777
+ [key, self.class.read_attr(attr, "dispatch detail_from: #{key.inspect}")]
778
+ end
779
+ if normalized.size < reads.size
780
+ raise ArgumentError, "#{self.class}: dispatch detail_from: keys must be unique as strings"
781
+ end
782
+
783
+ normalized.freeze
784
+ end
785
+
786
+ # set_value/paste_into/preview_file write a field the author names:
787
+ # `message` has a %s for what was named instead (the root, the trigger).
788
+ def refuse_field_target!(to, message)
789
+ where = FIELD_REFUSED[to]
790
+ raise ArgumentError, "#{self.class}: #{format(message, where)}" if where
791
+ end
792
+
623
793
  # Build-time attr-name allowlist for the instance builder — delegates to the
624
794
  # shared class-method check (also used by the raw-list escape hatch).
625
795
  def assert_allowed_attr(name)
@@ -655,9 +825,9 @@ module Phlex
655
825
  with_keep(args, keep)
656
826
  end
657
827
 
658
- # Instance-side alias for the class method (used by the op builder). See
659
- # JS.normalize_target — the shared :root/selector translation.
660
- def normalize_target(to) = self.class.normalize_target(to)
828
+ # The op builder's target translation: :trigger, :root or a selector
829
+ # (JS.normalize_client_target; issue #442).
830
+ def normalize_target(to) = self.class.normalize_client_target(to)
661
831
  end
662
832
  end
663
833
  end
@@ -302,10 +302,14 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
302
302
  # Normalize `ops` to its JSON wire form, rejecting an empty chain — a
303
303
  # reactive:js stream with no ops is a dead stream (a mistake at the call
304
304
  # site), so fail loudly rather than emit an inert tag.
305
+ # Every op the server pushes (reply.js, broadcast_to(js:)) passes here,
306
+ # so this is where @trigger is refused for a chain too (issue #442): a
307
+ # pushed op has no element whose binding fired.
305
308
  def js_ops_json(ops)
306
309
  if ops.is_a?(Phlex::Reactive::JS)
307
310
  raise ArgumentError, "js(...) got no ops — a dead reactive:js stream" if ops.empty?
308
311
 
312
+ Phlex::Reactive::JS.refuse_trigger!(ops)
309
313
  ops.to_json
310
314
  else
311
315
  list = Array(ops)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.16.7"
5
+ VERSION = "0.16.9"
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.7
4
+ version: 0.16.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson