phlex-reactive 0.16.8 → 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.
@@ -1972,13 +1972,15 @@ function bindingsAlreadyRan(event, controller) {
1972
1972
  function applyOps(list, resolveTargets, onZeroTargets) {
1973
1973
  for (const entry of list) {
1974
1974
  if (!Array.isArray(entry)) continue
1975
- const [name, args = {}] = entry
1975
+ let [name, args = {}] = entry
1976
1976
  if (!Object.hasOwn(CLIENT_OPS, name)) {
1977
1977
  console.warn(`[phlex-reactive] unknown client op ${JSON.stringify(name)} — skipped`)
1978
1978
  continue
1979
1979
  }
1980
1980
  const targets = resolveTargets(args)
1981
1981
  if (targets.length === 0 && onZeroTargets) onZeroTargets(name, args)
1982
+ if (targets.length > 0) args = readArgs(name, args, resolveTargets, onZeroTargets)
1983
+ if (!args) continue
1982
1984
  for (const el of targets) {
1983
1985
  CLIENT_OPS[name](el, args, resolveTargets)
1984
1986
  if (args?.keep === true) keepResult(el, name, args, resolveTargets)
@@ -1986,6 +1988,48 @@ function applyOps(list, resolveTargets, onZeroTargets) {
1986
1988
  }
1987
1989
  }
1988
1990
 
1991
+ // --- Reads (issue #442) ----------------------------------------------------
1992
+ // set_value, text and set_attr with `from` + `attr` take their value off an
1993
+ // element instead of a literal; dispatch's detail_from builds the detail off
1994
+ // the trigger. The read runs ONCE per op, before any target is written, with
1995
+ // the op's own scoping ("@trigger", "@root", a selector, global:), first
1996
+ // match. Returns the args with the value (or detail) in place, or null to skip
1997
+ // the op: a source that resolves to nothing (the zero-target diagnostic) or a
1998
+ // read attr off the data-*/text rule (warned). copy_media's `from` is an
1999
+ // element it copies, not a read, so it never comes here.
2000
+ const READ_OPS = new Set(["set_value", "text", "set_attr"])
2001
+
2002
+ function readArgs(name, args, resolveTargets, onZeroTargets) {
2003
+ const detail = name === "dispatch"
2004
+ const reads = detail ? args.detail_from : READ_OPS.has(name) && args.from != null && { value: args.attr }
2005
+ if (!reads) return args
2006
+ const from = detail ? "@trigger" : args.from
2007
+ let source
2008
+ // A from that does not parse is a miss like one that matches nothing (the
2009
+ // copy_media precedent): the op is skipped, its siblings still run.
2010
+ try {
2011
+ source = resolveTargets({ ...args, to: from })[0]
2012
+ } catch {}
2013
+ if (!source) return onZeroTargets?.(name, { ...args, to: from }), null
2014
+ const values = detail ? { ...args.detail } : {}
2015
+ for (const [key, attr] of Object.entries(reads)) {
2016
+ const value = readValue(source, attr)
2017
+ if (value == null) return null
2018
+ values[key] = value
2019
+ }
2020
+ return detail ? { ...args, detail: values } : { ...args, ...values }
2021
+ }
2022
+
2023
+ // What a read may take off an element: a data-* attribute (absent reads "",
2024
+ // so it clears) or "@text", its textContent. Anything else warns and is null
2025
+ // — the interpret-time side of JS::READABLE_ATTR (two-sided default-deny).
2026
+ function readValue(el, attr) {
2027
+ if (attr === "@text") return el.textContent ?? ""
2028
+ if (typeof attr === "string" && /^data-[a-z][a-z0-9-]*$/i.test(attr)) return el.getAttribute(attr) ?? ""
2029
+ console.warn(`[phlex-reactive] refused client read of ${JSON.stringify(attr)} — skipped`)
2030
+ return null
2031
+ }
2032
+
1989
2033
  // Resolve a reactive:js op's targets against its `target` root (issue #97).
1990
2034
  // "@root" is the root element itself; a selector resolves WITHIN it; a bare
1991
2035
  // selector with no root (no `target` attr on the stream) resolves document-wide
@@ -1997,6 +2041,9 @@ function applyOps(list, resolveTargets, onZeroTargets) {
1997
2041
  // documents for the controller path).
1998
2042
  function streamOpTargets(args, root) {
1999
2043
  const to = args.to
2044
+ // A server-pushed op has no trigger (issue #442): the server refuses
2045
+ // "@trigger", and a hand-built one resolves to nothing.
2046
+ if (to === "@trigger") return []
2000
2047
  if (root) {
2001
2048
  if (to === "@root") return [root]
2002
2049
  if (typeof to !== "string" || to === "") return []
@@ -3520,6 +3567,9 @@ export default class extends Controller {
3520
3567
  // A detail miss (issue #346) is a complete no-op — checked before the
3521
3568
  // once record is spent, preventDefault and the confirm gate.
3522
3569
  if (!detailMatches(record, event)) continue
3570
+ // So is a within: miss (issue #442); a hit's row is the ops' @trigger.
3571
+ const row = record.within == null ? trigger : this.#delegatedTrigger(record.within, event)
3572
+ if (record.within != null && !row) continue
3523
3573
  // So is a shortcut that is not this platform's twin or fired in an
3524
3574
  // editable target (issue #357).
3525
3575
  if (record.shortcut != null && !shortcutApplies(record.shortcut, event, this.application?.schema?.keyMappings))
@@ -3531,15 +3581,35 @@ export default class extends Controller {
3531
3581
  this.#runBinding(
3532
3582
  record.legacy ? { ...params, ops: record.ops, debounce: undefined, throttle: undefined, prevent: undefined } : record,
3533
3583
  event,
3534
- trigger,
3584
+ row,
3535
3585
  records.indexOf(record),
3586
+ trigger,
3536
3587
  )
3537
3588
  }
3538
3589
  }
3539
3590
 
3591
+ // Issue #442: a within: record runs for an event from inside an element
3592
+ // matching the selector, strictly under the bound element and owned by this
3593
+ // root (issue #15: a nested root's row is that root's). That element is
3594
+ // returned as the ops' @trigger; anything else is a miss (null). So is a
3595
+ // selector that does not parse (Ruby cannot check CSS), warned under
3596
+ // verbose_errors so the dead binding is never silent in development.
3597
+ #delegatedTrigger(within, event) {
3598
+ const bound = event.currentTarget
3599
+ let row = null
3600
+ try {
3601
+ row = event.target?.closest?.(within)
3602
+ } catch {
3603
+ if (this.#verboseEnabled()) console.warn(`[phlex-reactive] on_client within: ${JSON.stringify(within)} is not a selector — nothing ran`)
3604
+ }
3605
+ return row && row !== bound && bound?.contains?.(row) && this.#ownsField(row) ? row : null
3606
+ }
3607
+
3540
3608
  // One on_client binding (issue #95): its outside guard, preventDefault rule,
3541
3609
  // confirm gate, then its ops — every flag read from the binding itself.
3542
- #runBinding(record, event, trigger, ordinal) {
3610
+ // `trigger` is the ops' @trigger and the confirm ctx.el (with within:, the
3611
+ // row); `bound`, the element the binding is on, keys its wait (issue #442).
3612
+ #runBinding(record, event, trigger, ordinal, bound = trigger) {
3543
3613
  const { ops, confirm, confirmWhen, outside, window: windowBound, shortcut, prevent } = record
3544
3614
  // Outside guard FIRST — identical semantics to dispatch() (issue #80): an
3545
3615
  // outside: trigger is a COMPLETE no-op for events inside this root, before
@@ -3564,7 +3634,7 @@ export default class extends Controller {
3564
3634
  // which must NEVER prompt. The same confirmResolver gate on(:action,
3565
3635
  // confirm:) uses (issues #52/#55/#178), so a themed dialog covers both.
3566
3636
  const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
3567
- const apply = this.#waitedOps(record, ordinal, trigger, () => this.#applyOps(this.#parseOps(ops)))
3637
+ const apply = this.#waitedOps(record, ordinal, bound, () => this.#applyOps(this.#parseOps(ops), trigger))
3568
3638
  if (!message) return apply()
3569
3639
  if (message instanceof Promise) {
3570
3640
  return message.then((resolved) => (resolved ? this.#confirmThen(resolved, trigger, apply) : apply()))
@@ -4751,10 +4821,10 @@ export default class extends Controller {
4751
4821
  // nested-reactive-root ownership filter, issue #15). The whitelist + skip
4752
4822
  // logic lives in the shared applyOps so runOps and the reactive:js stream
4753
4823
  // action interpret the SAME vocabulary the SAME way (client-side default-deny).
4754
- #applyOps(list) {
4824
+ #applyOps(list, trigger) {
4755
4825
  applyOps(
4756
4826
  list,
4757
- (args) => this.#opTargets(args),
4827
+ (args) => this.#opTargets(args, trigger),
4758
4828
  (name, args) => this.#diagnoseZeroTargets(`client op "${name}"`, args),
4759
4829
  )
4760
4830
  }
@@ -4783,13 +4853,17 @@ export default class extends Controller {
4783
4853
  if (__SPLIT__) withFeature("devtools", use)
4784
4854
  }
4785
4855
 
4786
- // Resolve an op's targets: "@root" is this element; a selector resolves
4787
- // WITHIN this root and excludes nested reactive roots' subtrees (issue #15
4788
- // semantics — the same nearest-root ownership check the field walk uses);
4789
- // global: true opts a single op out to document-wide.
4790
- #opTargets(args) {
4856
+ // Resolve an op's targets: "@root" is this element; "@trigger" (issue #442)
4857
+ // the element whose on_client binding fired, when there is one and it is an
4858
+ // element (a window-bound binding's is the window; a reducer's $ops, a
4859
+ // feature's ops and a stream have none); a selector resolves WITHIN this
4860
+ // root and excludes nested reactive roots' subtrees (issue #15 semantics —
4861
+ // the same nearest-root ownership check the field walk uses); global: true
4862
+ // opts a single op out to document-wide.
4863
+ #opTargets(args, trigger) {
4791
4864
  const to = args.to
4792
4865
  if (to === "@root") return [this.element]
4866
+ if (to === "@trigger") return trigger?.nodeType === 1 ? [trigger] : []
4793
4867
  if (typeof to !== "string" || to === "") return []
4794
4868
  if (args.global) return [...document.querySelectorAll(to)]
4795
4869
  return [...this.element.querySelectorAll(to)].filter((el) => this.#ownsField(el))
@@ -657,6 +657,8 @@ module Phlex
657
657
  end
658
658
  raise ArgumentError, "reactive_on_complete #{name.inspect} got no ops — a dead binding" if pairs.empty?
659
659
 
660
+ # Issue #442: a completion has no element whose binding fired.
661
+ Phlex::Reactive::JS.refuse_trigger!(pairs, "a reactive_on_complete run")
660
662
  Phlex::Reactive::JS.assert_ops_allowed!(pairs) unless run.is_a?(Phlex::Reactive::JS)
661
663
  pairs
662
664
  end
@@ -504,6 +504,23 @@ module Phlex
504
504
  # on a window-bound binding, an ArgumentError with shortcut:.
505
505
  # div(**on_client(:drop, js.remove_class("#zone", "is-over"), prevent: false))
506
506
  #
507
+ # Issue #442: `within:` makes ONE binding serve a whole subtree — a list
508
+ # of N rows binds once instead of N times. The record runs only for an
509
+ # event from inside an element matching the selector under the bound
510
+ # element (owned by this root: a row of a nested reactive root is that
511
+ # root's), and that element is the ops' `:trigger`; read each row's
512
+ # value off it with from:/attr: (see Phlex::Reactive::JS#set_value):
513
+ # ul(**on_client(:click, js.set_value("#item-value", from: :trigger, attr: "data-value")
514
+ # .set_attr(:trigger, "aria-selected", "true"),
515
+ # within: "[role=option]"))
516
+ # Anywhere else (the list's padding) is a complete no-op: no
517
+ # preventDefault, no confirm, no ops. The bound element is a container,
518
+ # so a click binding is not forced to type="button". window:, outside:
519
+ # and shortcut: raise (a delegated binding is element-bound), and so
520
+ # does once: (Stimulus would drop the listener on the first click, a
521
+ # miss included). A window-bound binding has no trigger element: its
522
+ # `:trigger` ops resolve to nothing (warned under verbose_errors).
523
+ #
507
524
  # Ops are EPHEMERAL UI: any server re-render of the component (an action
508
525
  # reply, a broadcast, a morph) rebuilds from server state and resets
509
526
  # whatever they toggled — by design (the LiveView JS-commands caveat). Use
@@ -513,7 +530,7 @@ module Phlex
513
530
  # accepted — a dead trigger should fail at render, not no-op in the
514
531
  # browser.
515
532
  def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil,
516
- shortcut: nil, debounce: nil, throttle: nil, prevent: true)
533
+ shortcut: nil, debounce: nil, throttle: nil, prevent: true, within: nil)
517
534
  unless ops.is_a?(Phlex::Reactive::JS)
518
535
  raise ArgumentError,
519
536
  "on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
@@ -533,8 +550,10 @@ module Phlex
533
550
  shortcut = client_shortcut!(event, shortcut, window:, outside:)
534
551
  keeps_default!("on_client(#{event.inspect})", prevent, shortcut) unless prevent == true
535
552
  window_bound = window || outside || !shortcut.nil?
553
+ client_within!(event, within, window:, outside:, shortcut:, once:) unless within.nil?
536
554
  keep_default = !prevent && !window_bound
537
- record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:, keep_default:)
555
+ record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:, keep_default:,
556
+ within:)
538
557
  client_binding_wait!(record, event, debounce, throttle) unless debounce.nil? && throttle.nil?
539
558
  # The record's `on` stays the bare keydown: the client reads a key
540
559
  # filter from `on`, and `mod` is not a key.
@@ -553,7 +572,7 @@ module Phlex
553
572
  # hidden = !navigator.clipboard.readText. Render the trigger `hidden`
554
573
  # for reveal-when-available (a dead paste button never shows).
555
574
  attrs[:data][:reactive_clipboard] = "true" if ops.ops.any? { |name, _| name == "paste_into" }
556
- attrs[:type] = "button" if event == "click" && !window_bound
575
+ attrs[:type] = "button" if event == "click" && !window_bound && within.nil?
557
576
  attrs
558
577
  end
559
578
 
@@ -1596,7 +1615,8 @@ module Phlex
1596
1615
  # overridable confirmResolver on(:action, confirm:) uses (#52/#55); the
1597
1616
  # client prompts BEFORE applying the ops. Issue #179: a Hash confirm: is
1598
1617
  # CONDITIONAL, compiled by the shared compile_conditional_confirm.
1599
- def client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:, keep_default:)
1618
+ def client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:, keep_default:,
1619
+ within: nil)
1600
1620
  record = { "on" => event }
1601
1621
  # Issue #433: RIGHT AFTER "on" — phlex/reactive/early reads the pair
1602
1622
  # `"on":"<event>","prevent":false` off the raw attribute before connect.
@@ -1607,6 +1627,9 @@ module Phlex
1607
1627
  # this binding's :once listener — the client skips a spent once record.
1608
1628
  record["once"] = true if once
1609
1629
  record["detail"] = client_binding_detail(detail, once:) unless detail.nil?
1630
+ # Issue #442: written only when given, so a record without it is
1631
+ # byte-identical to before.
1632
+ record["within"] = within unless within.nil?
1610
1633
  case confirm
1611
1634
  when nil, false then nil
1612
1635
  when String then record["confirm"] = confirm
@@ -1617,6 +1640,29 @@ module Phlex
1617
1640
  record
1618
1641
  end
1619
1642
 
1643
+ # Issue #442: validate on_client's within: — a non-empty CSS selector,
1644
+ # on an element-bound binding that can survive a miss.
1645
+ def client_within!(event, within, window:, outside:, shortcut:, once:)
1646
+ label = "on_client(#{event.inspect})"
1647
+ unless within.is_a?(String) && !within.strip.empty?
1648
+ raise ArgumentError,
1649
+ "#{label} within: takes a non-empty CSS selector String naming the elements to delegate " \
1650
+ "from (e.g. within: \"[role=option]\"), got #{within.inspect}"
1651
+ end
1652
+ { "window:" => window, "outside:" => outside, "shortcut:" => shortcut }.each do |name, value|
1653
+ next unless value
1654
+
1655
+ raise ArgumentError,
1656
+ "#{label} within: delegates from the bound element's own subtree — it cannot be " \
1657
+ "window-bound; drop #{name}"
1658
+ end
1659
+ return unless once
1660
+
1661
+ raise ArgumentError,
1662
+ "#{label} within: cannot combine with once: — Stimulus removes a :once listener on its " \
1663
+ "first event, so a first event outside every match would leave the binding dead"
1664
+ end
1665
+
1620
1666
  # Issue #393: the one message on() and on_client() raise when given both
1621
1667
  # waits. Built only on that error path (on() is a hot path).
1622
1668
  def both_waits_message(label)
@@ -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.8"
5
+ VERSION = "0.16.9"
6
6
  end
7
7
  end